diff --git a/autonomy/loki b/autonomy/loki index 2c3bfd1e..a3086a03 100755 --- a/autonomy/loki +++ b/autonomy/loki @@ -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" @@ -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" diff --git a/autonomy/openspec-adapter.py b/autonomy/openspec-adapter.py index 290ce1ac..8e49e548 100644 --- a/autonomy/openspec-adapter.py +++ b/autonomy/openspec-adapter.py @@ -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 @@ -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, @@ -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 diff --git a/autonomy/run.sh b/autonomy/run.sh index f1314526..4bd57e3b 100755 --- a/autonomy/run.sh +++ b/autonomy/run.sh @@ -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 +# 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..." @@ -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" } diff --git a/skills/openspec-integration.md b/skills/openspec-integration.md index 5505db72..6c4e625f 100644 --- a/skills/openspec-integration.md +++ b/skills/openspec-integration.md @@ -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", @@ -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. @@ -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" } } ``` @@ -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 | diff --git a/tests/test-openspec-sentinel.sh b/tests/test-openspec-sentinel.sh new file mode 100755 index 00000000..59fda749 --- /dev/null +++ b/tests/test-openspec-sentinel.sh @@ -0,0 +1,527 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2034 # Variables may be unused in test context +# shellcheck disable=SC2155 # Declare and assign separately +# Test: OpenSpec Sentinel Scoping and Queue Purge Logic +# Tests all state transitions for sentinel-based task queue management +# Covers: fresh run, crash-restart, change switch, content edit, legacy compat + +set -uo pipefail +# Note: Not using -e to allow collecting all test results + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_DIR=$(mktemp -d) +PASSED=0 +FAILED=0 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASSED++)); } +log_fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAILED++)); } +log_test() { echo -e "${YELLOW}[TEST]${NC} $1"; } + +cleanup() { + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +cd "$TEST_DIR" || exit 1 + +echo "========================================" +echo "Loki Mode OpenSpec Sentinel Tests" +echo "========================================" +echo "" + +# Initialize structure +mkdir -p .loki/queue + +SENTINEL=".loki/queue/.openspec-populated" +PENDING=".loki/queue/pending.json" +COMPLETED=".loki/queue/completed.json" +IN_PROGRESS=".loki/queue/in-progress.json" +TASKS_FILE=".loki/openspec-tasks.json" + +echo '[]' > "$PENDING" +echo '[]' > "$COMPLETED" +echo '[]' > "$IN_PROGRESS" + +# --------------------------------------------------------------------------- +# Helpers (mirror the logic in autonomy/run.sh) +# --------------------------------------------------------------------------- + +# Compute content hash (cross-platform: Linux md5sum, macOS md5) +content_hash() { + md5sum "$1" 2>/dev/null | cut -d' ' -f1 || md5 -q "$1" 2>/dev/null || echo "none" +} + +# Simulate sentinel read logic from run.sh populate_openspec_queue() +check_sentinel() { + local change_path="$1" + if [[ -f "$SENTINEL" ]]; then + local stored_change stored_hash current_hash + stored_change="$(sed -n '1p' "$SENTINEL")" + stored_hash="$(sed -n '2p' "$SENTINEL")" + current_hash="$(content_hash "$TASKS_FILE")" + if [[ "$stored_change" == "$change_path" ]] && [[ "$stored_hash" == "$current_hash" ]]; then + echo "skip" + elif [[ "$stored_change" != "$change_path" ]]; then + echo "purge_change_switch" + else + echo "purge_content_changed" + fi + else + echo "populate" + fi +} + +# Write sentinel with path + content hash (mirrors run.sh) +write_sentinel() { + local change_path="$1" + local hash + hash="$(content_hash "$TASKS_FILE")" + printf '%s\n%s\n' "$change_path" "$hash" > "$SENTINEL" +} + +# Purge openspec tasks from a queue file using jq (mirrors run.sh) +# Outputs "purged N" to stdout for count verification +purge_openspec_from_queue() { + local queue_file="$1" + [[ -f "$queue_file" ]] || { echo "purged 0"; return 0; } + local tmp_file="${queue_file}.tmp" + if jq '[.[] | select(.source != "openspec")]' "$queue_file" > "$tmp_file" 2>&1; 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" + echo "purged $((before - after))" + return 0 + else + rm -f "$tmp_file" + echo "purged error" + return 1 + fi +} + +# --------------------------------------------------------------------------- +# Test 1: Fresh run -- no sentinel exists, should populate +# --------------------------------------------------------------------------- +log_test "Fresh run (no sentinel)" +echo '{"tasks": [{"id": "openspec-A-1.1"}]}' > "$TASKS_FILE" +CHANGE_PATH="/repo/openspec/changes/feature-a" + +result=$(check_sentinel "$CHANGE_PATH") +if [ "$result" = "populate" ]; then + log_pass "Fresh run triggers populate" +else + log_fail "Fresh run should trigger populate (got: $result)" +fi + +# Simulate population +echo '[{"id":"openspec-A-1.1","source":"openspec"},{"id":"openspec-A-1.2","source":"openspec"}]' > "$PENDING" +write_sentinel "$CHANGE_PATH" + +stored_path="$(sed -n '1p' "$SENTINEL")" +stored_hash="$(sed -n '2p' "$SENTINEL")" +if [ "$stored_path" = "$CHANGE_PATH" ]; then + log_pass "Sentinel stores change path" +else + log_fail "Sentinel path mismatch (expected: $CHANGE_PATH, got: $stored_path)" +fi + +expected_hash="$(content_hash "$TASKS_FILE")" +if [ "$stored_hash" = "$expected_hash" ]; then + log_pass "Sentinel stores content hash" +else + log_fail "Sentinel hash mismatch (expected: $expected_hash, got: $stored_hash)" +fi + +# --------------------------------------------------------------------------- +# Test 2: Crash-restart -- same change, same content, skip repopulation +# --------------------------------------------------------------------------- +log_test "Crash-restart same change (path + hash match)" + +result=$(check_sentinel "$CHANGE_PATH") +if [ "$result" = "skip" ]; then + log_pass "Same change + content skips repopulation" +else + log_fail "Should skip (got: $result)" +fi + +task_count=$(jq 'length' "$PENDING") +if [ "$task_count" -eq 2 ]; then + log_pass "Pending queue untouched (progress preserved)" +else + log_fail "Pending queue modified (count: $task_count, expected: 2)" +fi + +# --------------------------------------------------------------------------- +# Test 3: Switch to different change -- purge all 3 queues +# --------------------------------------------------------------------------- +log_test "Switch to different change" +echo '{"tasks": [{"id": "openspec-B-1.1"}]}' > "$TASKS_FILE" +NEW_CHANGE_PATH="/repo/openspec/changes/feature-b" + +# Populate all 3 queues with a mix of openspec and non-openspec tasks +echo '[{"id":"openspec-A-2.1","source":"openspec"},{"id":"prd-2","source":"prd"}]' > "$PENDING" +echo '[{"id":"openspec-A-1.1","source":"openspec"},{"id":"prd-1","source":"prd"}]' > "$COMPLETED" +echo '[{"id":"openspec-A-1.2","source":"openspec"}]' > "$IN_PROGRESS" + +result=$(check_sentinel "$NEW_CHANGE_PATH") +if [ "$result" = "purge_change_switch" ]; then + log_pass "Different change triggers purge" +else + log_fail "Should trigger purge (got: $result)" +fi + +# Execute purge on all 3 queues and verify counts +pending_purged=$(purge_openspec_from_queue "$PENDING") +completed_purged=$(purge_openspec_from_queue "$COMPLETED") +in_progress_purged=$(purge_openspec_from_queue "$IN_PROGRESS") + +if [ "$pending_purged" = "purged 1" ]; then + log_pass "Pending: 1 openspec task purged" +else + log_fail "Pending purge count wrong ($pending_purged)" +fi + +if [ "$completed_purged" = "purged 1" ]; then + log_pass "Completed: 1 openspec task purged" +else + log_fail "Completed purge count wrong ($completed_purged)" +fi + +if [ "$in_progress_purged" = "purged 1" ]; then + log_pass "In-progress: 1 openspec task purged" +else + log_fail "In-progress purge count wrong ($in_progress_purged)" +fi + +# Verify non-openspec tasks survived +pending_remaining=$(jq 'length' "$PENDING") +pending_source=$(jq -r '.[0].source' "$PENDING") +if [ "$pending_remaining" -eq 1 ] && [ "$pending_source" = "prd" ]; then + log_pass "Pending: non-openspec task preserved" +else + log_fail "Pending: unexpected state (count: $pending_remaining, source: $pending_source)" +fi + +completed_remaining=$(jq 'length' "$COMPLETED") +completed_source=$(jq -r '.[0].source' "$COMPLETED") +if [ "$completed_remaining" -eq 1 ] && [ "$completed_source" = "prd" ]; then + log_pass "Completed: non-openspec task preserved" +else + log_fail "Completed: unexpected state (count: $completed_remaining, source: $completed_source)" +fi + +in_progress_remaining=$(jq 'length' "$IN_PROGRESS") +if [ "$in_progress_remaining" -eq 0 ]; then + log_pass "In-progress: empty after purge (was all openspec)" +else + log_fail "In-progress should be empty (count: $in_progress_remaining)" +fi + +write_sentinel "$NEW_CHANGE_PATH" + +# --------------------------------------------------------------------------- +# Test 4: Same change but tasks.md edited -- hash mismatch triggers reload +# --------------------------------------------------------------------------- +log_test "Same change, tasks.md edited (hash mismatch)" +echo '{"tasks": [{"id": "openspec-B-1.1"}, {"id": "openspec-B-1.2", "new": true}]}' > "$TASKS_FILE" + +result=$(check_sentinel "$NEW_CHANGE_PATH") +if [ "$result" = "purge_content_changed" ]; then + log_pass "Content hash mismatch triggers reload" +else + log_fail "Should trigger content change purge (got: $result)" +fi + +# --------------------------------------------------------------------------- +# Test 5: No --openspec after previous run -- leave everything untouched +# --------------------------------------------------------------------------- +log_test "No --openspec after previous run (don't touch anything)" +echo '[{"id":"openspec-B-1.1","source":"openspec"},{"id":"prd-3","source":"prd"}]' > "$PENDING" +echo '[{"id":"openspec-B-2.1","source":"openspec"}]' > "$COMPLETED" +write_sentinel "$NEW_CHANGE_PATH" + +pending_before=$(jq 'length' "$PENDING") +completed_before=$(jq 'length' "$COMPLETED") +sentinel_before=$(cat "$SENTINEL") + +# Do nothing -- this IS the test. No --openspec means no cleanup. + +pending_after=$(jq 'length' "$PENDING") +completed_after=$(jq 'length' "$COMPLETED") +sentinel_after=$(cat "$SENTINEL") + +if [ "$pending_after" -eq "$pending_before" ]; then + log_pass "Pending untouched when no --openspec" +else + log_fail "Pending modified without --openspec (before: $pending_before, after: $pending_after)" +fi + +if [ "$completed_after" -eq "$completed_before" ]; then + log_pass "Completed untouched when no --openspec" +else + log_fail "Completed modified without --openspec" +fi + +if [ "$sentinel_after" = "$sentinel_before" ]; then + log_pass "Sentinel untouched when no --openspec" +else + log_fail "Sentinel modified without --openspec" +fi + +# --------------------------------------------------------------------------- +# Test 6: Direct run.sh invocation (bypass CLI) +# --------------------------------------------------------------------------- +log_test "Direct run.sh invocation (bypass CLI)" +echo '{"tasks": [{"id": "openspec-C-1.1"}]}' > "$TASKS_FILE" + +result=$(check_sentinel "/repo/openspec/changes/feature-c") +if [ "$result" = "purge_change_switch" ]; then + log_pass "Direct run.sh detects change switch" +else + log_fail "Should detect change switch (got: $result)" +fi + +# --------------------------------------------------------------------------- +# Test 7: Legacy sentinel (old format, path only, no hash line) +# --------------------------------------------------------------------------- +log_test "Legacy sentinel backward compatibility" +echo "/repo/openspec/changes/feature-c" > "$SENTINEL" +echo '{"tasks": [{"id": "openspec-C-1.1"}]}' > "$TASKS_FILE" + +stored_hash="$(sed -n '2p' "$SENTINEL")" +if [ -z "$stored_hash" ]; then + log_pass "Legacy sentinel has no hash line" +else + log_fail "Expected empty hash line (got: $stored_hash)" +fi + +result=$(check_sentinel "/repo/openspec/changes/feature-c") +if [ "$result" = "purge_content_changed" ]; then + log_pass "Legacy sentinel triggers reload (safe upgrade path)" +else + log_fail "Legacy sentinel should trigger reload (got: $result)" +fi + +# --------------------------------------------------------------------------- +# Test 8: jq purge on malformed JSON -- error handling +# --------------------------------------------------------------------------- +log_test "Malformed JSON error handling" +echo "this is not json" > "$PENDING" + +purge_result=0 +purge_openspec_from_queue "$PENDING" 2>/dev/null || purge_result=1 + +if [ "$purge_result" -eq 1 ]; then + log_pass "Malformed JSON returns error code" +else + log_fail "Should return error on malformed JSON" +fi + +content=$(cat "$PENDING") +if [ "$content" = "this is not json" ]; then + log_pass "Original file preserved on jq error" +else + log_fail "File was corrupted (content: $content)" +fi + +# --------------------------------------------------------------------------- +# Test 9: Purge on empty queue files +# --------------------------------------------------------------------------- +log_test "Empty queue files" +echo '[]' > "$PENDING" +echo '[]' > "$COMPLETED" +echo '[]' > "$IN_PROGRESS" + +p9=$(purge_openspec_from_queue "$PENDING") +c9=$(purge_openspec_from_queue "$COMPLETED") +i9=$(purge_openspec_from_queue "$IN_PROGRESS") + +if [ "$p9" = "purged 0" ]; then + log_pass "Empty pending: reports 0 purged" +else + log_fail "Empty pending: wrong count ($p9)" +fi +if [ "$c9" = "purged 0" ]; then + log_pass "Empty completed: reports 0 purged" +else + log_fail "Empty completed: wrong count ($c9)" +fi +if [ "$i9" = "purged 0" ]; then + log_pass "Empty in-progress: reports 0 purged" +else + log_fail "Empty in-progress: wrong count ($i9)" +fi + +# --------------------------------------------------------------------------- +# Test 10: Purge on nonexistent queue file +# --------------------------------------------------------------------------- +log_test "Nonexistent queue file" +rm -f "$COMPLETED" + +s10=$(purge_openspec_from_queue "$COMPLETED") +s10_rc=$? + +if [ "$s10_rc" -eq 0 ]; then + log_pass "Nonexistent file returns success" +else + log_fail "Should return success for nonexistent file" +fi +if [ "$s10" = "purged 0" ]; then + log_pass "Nonexistent file reports 0 purged" +else + log_fail "Should report 0 purged ($s10)" +fi +if [ ! -f "$COMPLETED" ]; then + log_pass "No file created for nonexistent queue" +else + log_fail "Should not create file" +fi + +# --------------------------------------------------------------------------- +# Test 11: Task ID scoping via adapter +# --------------------------------------------------------------------------- +log_test "Task ID scoping via adapter" +ADAPTER_PATH="$SCRIPT_DIR/../autonomy/openspec-adapter.py" + +if [ -f "$ADAPTER_PATH" ]; then + mkdir -p "$TEST_DIR/fake-change/specs/auth" + cat > "$TEST_DIR/fake-change/proposal.md" << 'MD' +# Test Change +## Why +Testing +## What Changes +Everything +MD + cat > "$TEST_DIR/fake-change/specs/auth/spec.md" << 'MD' +## ADDED Requirements +### Requirement: Test Auth +#### Scenario: Basic login +- GIVEN a user +- WHEN they login +- THEN they see dashboard +MD + cat > "$TEST_DIR/fake-change/tasks.md" << 'MD' +## 1. Auth +- [ ] 1.1 Implement login +- [ ] 1.2 Add session handling +## 2. Dashboard +- [ ] 2.1 Build main view +MD + + task_ids=$(python3 "$ADAPTER_PATH" "$TEST_DIR/fake-change" --json 2>/dev/null | python3 -c " +import json, sys +data = json.load(sys.stdin) +for t in data['tasks']: + print(t['id']) +" 2>/dev/null) + + id1=$(echo "$task_ids" | head -1) + if echo "$id1" | grep -q 'openspec-fake-change-'; then + log_pass "Task ID includes change name (openspec-fake-change-N.M)" + else + log_fail "Task ID missing change name (got: $id1)" + fi +else + log_fail "Adapter not found at $ADAPTER_PATH (skipped)" +fi + +# --------------------------------------------------------------------------- +# Test 12: Mixed-source queue preserves non-openspec tasks +# --------------------------------------------------------------------------- +log_test "Mixed-source queue preservation" +echo '[ + {"id":"openspec-X-1.1","source":"openspec","title":"OS task 1"}, + {"id":"prd-1","source":"prd","title":"PRD task"}, + {"id":"openspec-X-2.1","source":"openspec","title":"OS task 2"}, + {"id":"bmad-1","source":"bmad","title":"BMAD task"}, + {"id":"mirofish-1","source":"mirofish","title":"MiroFish task"} +]' > "$PENDING" + +mixed_purged=$(purge_openspec_from_queue "$PENDING") +if [ "$mixed_purged" = "purged 2" ]; then + log_pass "Reports 2 openspec tasks purged from mixed queue" +else + log_fail "Wrong purge count ($mixed_purged, expected: purged 2)" +fi + +remaining=$(jq 'length' "$PENDING") +if [ "$remaining" -eq 3 ]; then + log_pass "Keeps 3 non-openspec tasks" +else + log_fail "Wrong remaining count ($remaining, expected: 3)" +fi + +sources=$(jq -r '.[].source' "$PENDING" | sort | tr '\n' ',') +if [ "$sources" = "bmad,mirofish,prd," ]; then + log_pass "Preserves bmad, mirofish, prd sources" +else + log_fail "Wrong sources remaining ($sources)" +fi + +# --------------------------------------------------------------------------- +# Test 13: Empty/unset OPENSPEC_CHANGE_PATH with existing sentinel +# --------------------------------------------------------------------------- +log_test "Empty OPENSPEC_CHANGE_PATH triggers purge against stored path" + +# Set up sentinel with a real stored path +echo '{"tasks": [{"id": "openspec-D-1.1"}]}' > "$TASKS_FILE" +STORED_PATH="/repo/openspec/changes/feature-d" +write_sentinel "$STORED_PATH" + +# Verify sentinel was written with the stored path +stored_before="$(sed -n '1p' "$SENTINEL")" +if [ "$stored_before" = "$STORED_PATH" ]; then + log_pass "Sentinel has stored path before empty-path check" +else + log_fail "Sentinel setup failed (got: $stored_before)" +fi + +# Pass empty string as change_path -- simulates unset OPENSPEC_CHANGE_PATH +result=$(check_sentinel "") +if [ "$result" = "purge_change_switch" ]; then + log_pass "Empty change path vs stored path triggers purge_change_switch" +else + log_fail "Empty change path should trigger purge_change_switch (got: $result)" +fi + +# Populate queues and verify purge works end-to-end in this scenario +echo '[{"id":"openspec-D-1.1","source":"openspec"},{"id":"prd-5","source":"prd"}]' > "$PENDING" +echo '[{"id":"openspec-D-2.1","source":"openspec"}]' > "$COMPLETED" +echo '[]' > "$IN_PROGRESS" + +pending_purged=$(purge_openspec_from_queue "$PENDING") +completed_purged=$(purge_openspec_from_queue "$COMPLETED") + +if [ "$pending_purged" = "purged 1" ]; then + log_pass "Empty-path purge: 1 openspec task removed from pending" +else + log_fail "Empty-path purge pending count wrong ($pending_purged)" +fi + +if [ "$completed_purged" = "purged 1" ]; then + log_pass "Empty-path purge: 1 openspec task removed from completed" +else + log_fail "Empty-path purge completed count wrong ($completed_purged)" +fi + +prd_remaining=$(jq -r '.[0].source' "$PENDING") +if [ "$prd_remaining" = "prd" ]; then + log_pass "Empty-path purge: non-openspec task preserved" +else + log_fail "Empty-path purge: non-openspec task lost (source: $prd_remaining)" +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "========================================" +echo "Results: $PASSED passed, $FAILED failed" +echo "========================================" +[[ $FAILED -eq 0 ]] && exit 0 || exit 1 diff --git a/tests/test_openspec_adapter.py b/tests/test_openspec_adapter.py index b27445ac..563e82ef 100644 --- a/tests/test_openspec_adapter.py +++ b/tests/test_openspec_adapter.py @@ -163,16 +163,19 @@ def test_task_groups_extracted(self, output_dir): assert len(groups) >= 3, f"Expected at least 3 groups, got {groups}" def test_task_ids_hierarchical(self, output_dir): - """Task IDs should be in openspec-N.M format.""" + """Task IDs should be in openspec-{change_name}-N.M format.""" rc, stdout, _ = run_adapter(FIXTURES_DIR / "simple-feature", output_dir, "--json") assert rc == 0 data = json.loads(stdout) for task in data["tasks"]: assert task["id"].startswith("openspec-"), f"ID {task['id']} should start with openspec-" - # Should match openspec-N.M pattern - suffix = task["id"].replace("openspec-", "") - parts = suffix.split(".") - assert len(parts) == 2, f"ID suffix {suffix} should be N.M" + # Should match openspec-{change_name}-N.M pattern + prefix = "openspec-simple-feature-" + assert task["id"].startswith(prefix), \ + f"ID {task['id']} should start with {prefix}" + num_part = task["id"][len(prefix):] # e.g., "1.1" + parts = num_part.split(".") + assert len(parts) == 2, f"Numeric suffix {num_part} should be N.M" assert parts[0].isdigit() and parts[1].isdigit() def test_malformed_tasks_no_checkboxes(self, output_dir):