Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions autonomy/loki
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,10 @@ cmd_start() {
# Export for run.sh to access
export OPENSPEC_CHANGE_PATH="$openspec_change_path"

# NOTE: Do NOT delete the sentinel here. On crash-restart of the same
# change, the sentinel preserves progress (run.sh skips repopulation).
# run.sh's scoped comparison handles change-switching correctly.

# Ensure .loki directory exists for adapter output
mkdir -p "$LOKI_DIR"

Expand Down Expand Up @@ -940,6 +944,11 @@ cmd_start() {
fi
fi

# When --openspec is not provided, leave existing OpenSpec state untouched.
# OpenSpec tasks in the queue are only purged when --openspec is passed with
# a different change or different content (handled by run.sh sentinel logic).
# Not passing --openspec does not imply "undo previous openspec work."

# MiroFish market validation (optional, non-blocking)
if [[ -z "$mirofish_url" ]] && [[ -n "${LOKI_MIROFISH_URL:-}" ]] && [[ "$mirofish_disabled" != "true" ]]; then
mirofish_url="$LOKI_MIROFISH_URL"
Expand Down
10 changes: 7 additions & 3 deletions autonomy/openspec-adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -345,9 +345,13 @@ def _parse_scenario(name: str, body: str) -> Dict[str, Any]:

# -- Tasks Parsing ------------------------------------------------------------

def parse_tasks(tasks_path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
def parse_tasks(tasks_path: Path, change_name: str = "") -> Tuple[List[Dict[str, Any]], Dict[str, Dict[str, Any]]]:
"""Parse tasks.md into structured task list and source map.

Args:
tasks_path: Path to tasks.md
change_name: Name of the OpenSpec change (used to scope task IDs)

Returns:
(tasks_list, source_map)
tasks_list: list of task objects
Expand All @@ -373,7 +377,7 @@ def parse_tasks(tasks_path: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Dict[
checked = task_match.group(1).lower() == "x"
task_id_num = task_match.group(2)
description = task_match.group(3).strip()
task_id = f"openspec-{task_id_num}"
task_id = f"openspec-{change_name}-{task_id_num}" if change_name else f"openspec-{task_id_num}"

task = {
"id": task_id,
Expand Down Expand Up @@ -696,7 +700,7 @@ def run(
source_map: Dict[str, Dict[str, Any]] = {}
tasks_path = change_dir / "tasks.md"
if tasks_path.exists():
tasks_list, source_map = parse_tasks(tasks_path)
tasks_list, source_map = parse_tasks(tasks_path, change_name=change_name)

# -- Parse design.md (optional) --
design_data: Optional[Dict[str, str]] = None
Expand Down
61 changes: 56 additions & 5 deletions autonomy/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -8821,18 +8821,66 @@ bmad_write_back() {
# OpenSpec Task Queue Population
#===============================================================================

# Purge all tasks with source=="openspec" from a queue JSON file.
# Usage: purge_openspec_from_queue <queue_file>
# Returns: 0 on success (or file doesn't exist), 1 on error
purge_openspec_from_queue() {
local queue_file="$1"
[[ -f "$queue_file" ]] || return 0

local tmp_file="${queue_file}.tmp"
local err_file="${queue_file}.err"
if jq '[.[] | select(.source != "openspec")]' "$queue_file" > "$tmp_file" 2>"$err_file"; then
local before after
before=$(jq 'length' "$queue_file" 2>/dev/null || echo 0)
after=$(jq 'length' "$tmp_file" 2>/dev/null || echo 0)
mv "$tmp_file" "$queue_file"
rm -f "$err_file"
if [[ "$before" -ne "$after" ]]; then
log_info "Purged $((before - after)) OpenSpec tasks from $(basename "$queue_file")"
fi
return 0
else
log_warn "Failed to purge OpenSpec tasks from $(basename "$queue_file"): $(cat "$err_file" 2>/dev/null)"
rm -f "$tmp_file" "$err_file"
return 1
fi
}

# Populate the task queue from OpenSpec task artifacts
# Only runs once -- skips if queue was already populated from OpenSpec
populate_openspec_queue() {
# If --openspec was not passed this session, leave existing state untouched
if [[ -z "${OPENSPEC_CHANGE_PATH:-}" ]]; then
return 0
fi

# Skip if no OpenSpec tasks file
if [[ ! -f ".loki/openspec-tasks.json" ]]; then
return 0
fi

# Skip if already populated (marker file)
# Skip if already populated for the SAME change with SAME content
# Sentinel stores: line 1 = change path, line 2 = content hash
if [[ -f ".loki/queue/.openspec-populated" ]]; then
log_info "OpenSpec queue already populated, skipping"
return 0
local stored_change stored_hash current_hash
stored_change="$(sed -n '1p' ".loki/queue/.openspec-populated")"
stored_hash="$(sed -n '2p' ".loki/queue/.openspec-populated")"
current_hash="$(md5sum ".loki/openspec-tasks.json" 2>/dev/null | cut -d' ' -f1 || md5 -q ".loki/openspec-tasks.json" 2>/dev/null || echo "none")"
if [[ "$stored_change" == "$OPENSPEC_CHANGE_PATH" ]] && [[ "$stored_hash" == "$current_hash" ]]; then
log_info "OpenSpec queue already populated for this change (path and content match), skipping"
return 0
else
if [[ "$stored_change" != "$OPENSPEC_CHANGE_PATH" ]]; then
log_info "OpenSpec change switched (was: $stored_change, now: $OPENSPEC_CHANGE_PATH) -- purging stale tasks"
else
log_info "OpenSpec tasks.md content changed (hash mismatch) -- purging and reloading"
fi
# Purge stale OpenSpec tasks from all queue files before re-populating
purge_openspec_from_queue ".loki/queue/pending.json"
purge_openspec_from_queue ".loki/queue/completed.json"
purge_openspec_from_queue ".loki/queue/in-progress.json"
fi
fi

log_step "Populating task queue from OpenSpec tasks..."
Expand Down Expand Up @@ -8905,8 +8953,11 @@ OPENSPEC_QUEUE_EOF
return 0
fi

# Mark as populated so we don't re-add on restart
touch ".loki/queue/.openspec-populated"
# Mark as populated for this specific change so we don't re-add on restart
# Sentinel stores: line 1 = change path, line 2 = content hash of tasks file
local content_hash
content_hash="$(md5sum ".loki/openspec-tasks.json" 2>/dev/null | cut -d' ' -f1 || md5 -q ".loki/openspec-tasks.json" 2>/dev/null || echo "none")"
printf '%s\n%s\n' "$OPENSPEC_CHANGE_PATH" "$content_hash" > ".loki/queue/.openspec-populated"
log_info "OpenSpec queue population complete"
}

Expand Down
42 changes: 37 additions & 5 deletions skills/openspec-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Tasks are generated from OpenSpec `tasks.md` and loaded into `.loki/queue/pendin

```json
{
"id": "openspec-1.3",
"id": "openspec-session-hardening-1.3",
"title": "Implement session timeout change",
"description": "[OpenSpec] Authentication: Implement session timeout change",
"priority": "medium",
Expand All @@ -91,6 +91,38 @@ Tasks are generated from OpenSpec `tasks.md` and loaded into `.loki/queue/pendin

---

## Queue State Management

The sentinel file `.loki/queue/.openspec-populated` tracks which change was loaded and its content hash to prevent stale task contamination across runs.

### Sentinel Format

```
/path/to/openspec/change <- line 1: change directory path
a3f8b2c1d4e5f6789012345678 <- line 2: md5 hash of openspec-tasks.json
```

### State Transitions

| Scenario | Sentinel | Action |
|----------|----------|--------|
| First run with `--openspec A` | Missing | Populate queue, write sentinel |
| Crash-restart, same `--openspec A` | Path + hash match | Skip (progress preserved) |
| Switch to `--openspec B` | Path mismatch | Purge all queues, repopulate |
| Edit tasks.md, re-run same `--openspec A` | Hash mismatch | Purge all queues, repopulate |
| Run without `--openspec` | Untouched | No action (state left intact) |

### Purge Behavior

When a change switch or content edit is detected, OpenSpec tasks (`source: "openspec"`) are purged from all three queue files:
- `.loki/queue/pending.json`
- `.loki/queue/completed.json`
- `.loki/queue/in-progress.json`

Non-OpenSpec tasks (prd, bmad, mirofish) are preserved during purge.

---

## Scenario Verification

After implementing a requirement, verify its scenarios.
Expand Down Expand Up @@ -143,9 +175,9 @@ The verification map tracks each scenario with `"verified": false` initially. Af

```json
{
"openspec-1.1": { "file": "tasks.md", "line": 3, "group": "Authentication" },
"openspec-1.2": { "file": "tasks.md", "line": 4, "group": "Authentication" },
"openspec-2.1": { "file": "tasks.md", "line": 7, "group": "Dashboard" }
"openspec-session-hardening-1.1": { "file": "tasks.md", "line": 3, "group": "Authentication" },
"openspec-session-hardening-1.2": { "file": "tasks.md", "line": 4, "group": "Authentication" },
"openspec-session-hardening-2.1": { "file": "tasks.md", "line": 7, "group": "Dashboard" }
}
```

Expand All @@ -159,7 +191,7 @@ The adapter classifies complexity based on task count, spec file count, and desi

| Level | Condition | Agent Strategy |
|-------|-----------|----------------|
| enterprise | 20+ tasks OR 10+ spec files | Full agent team |
| enterprise | 21+ tasks OR 11+ spec files | Full agent team |
| complex | 11-20 tasks OR 6-10 spec files | Task tool parallelization |
| standard | 4-10 tasks OR 2-5 spec files OR design.md present | Parallel where possible |
| simple | 1-3 tasks, 1 spec file, no design | Single agent, sequential |
Expand Down
Loading
Loading