# Skill Todo

> Archive completed, abandoned, and expanded tasks with CHANGE_LOG.md updates and memory harvest suggestions

- Skill: `benbrastmckie/skill-todo-3` (Agent Skill)
- Install (CLI): `npx skillmds@latest add benbrastmckie/skill-todo-3`
- Raw SKILL.md: https://api.skillmd.com/api/skills/benbrastmckie/skill-todo-3/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: benbrastmckie (https://skillmd.com/u/benbrastmckie)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/benbrastmckie/skill-todo-3

---


# Todo Skill

Direct execution skill for archiving tasks, updating CHANGE_LOG.md, and suggesting memory harvesting.

<context>
  <system_context>OpenCode task archival with changelog tracking and memory suggestions.</system_context>
  <task_context>Archive completed/abandoned/expanded tasks and track changes.</task_context>
</context>

<role>Direct execution skill for task archival operations with automated CHANGE_LOG updates and memory harvest suggestions.</role>

<task>Parse arguments, scan for archivable tasks (completed, abandoned, expanded), update states, generate CHANGE_LOG entries, suggest memory harvesting from completed task artifacts.</task>

<execution>
  <stage id="1" name="ParseArguments">
    <action>Parse command arguments</action>
    <process>
      1. Check for --dry-run flag
      2. Set dry_run = true if present
      3. Validate no other arguments expected
    </process>
  </stage>
  
  <stage id="1.5" name="ReconcileScan">
    <action>Dry-run scan for status-stranded tasks whose artifact for the in-flight phase already
    exists on disk -- these are invisible to Stage 2's literal completed/abandoned match today and
    can never be archived until something promotes them</action>
    <process>
      This stage never auto-repairs status: `/todo` performs the system's most irreversible
      operations (moving directories, rewriting CHANGE_LOG.md), and a silent status promotion
      immediately before a silent archive move would compound two mutations with no visibility.
      Every call this stage makes is `--dry-run`; only a user-approved selection in Stage 9 ever
      calls the script live.

      1. Generate a session ID via `common_session_id` (skill-todo does not source
         `command-gate-in.sh` and has no session ID of its own):
         ```bash
         source .claude/scripts/lib/common.sh
         todo_session_id="$(common_session_id)"
         ```
      2. Select the same four reconcilable statuses used by the `/task --sync` and `/orchestrate`
         triggers (positive-match against the four statuses `reconcile-task-status.sh` knows how
         to reconcile -- no `!=`/negation selector needed):
         ```bash
         reconcile_scan_targets=$(jq -r '
           .active_projects[] |
           select(.status == "researching" or .status == "planning" or .status == "implementing" or .status == "partial") |
           .project_number
         ' specs/state.json)
         ```
      3. For each candidate, dry-run the script and collect any task whose output reports a
         would-promote line into `reconcile_candidates`, keeping each candidate's task number,
         current status, the artifact basename found, and the full dry-run output for display in
         Stage 8/9:
         ```bash
         reconcile_candidates=()
         for task_num in $reconcile_scan_targets; do
           recon_out=$(bash .claude/scripts/reconcile-task-status.sh "$task_num" "$todo_session_id" --dry-run 2>&1)
           if echo "$recon_out" | grep -q "Would promote"; then
             reconcile_candidates+=("$task_num")
             # associate $recon_out with $task_num (e.g. via an associative array) for Stage 9's
             # AskUserQuestion description text
           fi
         done
         ```
      4. If `reconcile_scan_targets` is empty, `reconcile_candidates` is `()` -- proceed straight
         to Stage 2. This stage is genuinely side-effect-free: `--dry-run` never writes
         `state.json` or any other file.
    </process>
  </stage>

  <stage id="2" name="ScanTasks">
    <action>Scan for archivable tasks</action>
    <process>
      1. Read specs/state.json
      2. Identify tasks with status = "completed"
      3. Identify tasks with status = "abandoned"
      4. Identify tasks with status = "expanded"
      5. Read specs/TODO.md and cross-reference (including entries marked [EXPANDED])
      6. Track counts: completed_count, abandoned_count, expanded_count

      **Subtasks-defer guard**: identical semantics to `commands/todo.md`'s Step 3 guard (see that
      file's "Prepare Archive List" section, which is the reference implementation this mirrors).
      Partition the tasks identified above into `archivable_tasks[]` (proceeds) and
      `deferred_expanded[]` (held back for a later `/todo` run) — an expanded parent is deferred
      while any task in its `subtasks[]` is still present in `active_projects` with a non-terminal
      status. Use a `case` statement for status classification (never `!=`):

      ```bash
      archivable_tasks=()
      deferred_expanded=()
      deferred_expanded_nums=()

      for task in "${candidate_tasks[@]}"; do
        status=$(echo "$task" | jq -r '.status')
        project_num=$(echo "$task" | jq -r '.project_number')

        case "$status" in
          expanded)
            # A missing, null, or empty subtasks array means nothing is blocking - archive normally.
            subtasks=$(echo "$task" | jq -c '.subtasks // []')
            subtask_count=$(echo "$subtasks" | jq 'length')
            if [ "$subtask_count" -eq 0 ]; then
              archivable_tasks+=("$task")
              continue
            fi

            blocking_count=0
            for subtask_num in $(echo "$subtasks" | jq -r '.[]'); do
              subtask_status=$(jq -r --argjson n "$subtask_num" \
                '.active_projects[] | select(.project_number == $n) | .status' \
                specs/state.json)

              # An empty result means the subtask is already archived - not blocking.
              if [ -z "$subtask_status" ]; then
                continue
              fi

              case "$subtask_status" in
                completed|abandoned|expanded)
                  # Terminal - not blocking.
                  ;;
                *)
                  # Any other status blocks.
                  ((blocking_count++))
                  ;;
              esac
            done

            if [ "$blocking_count" -gt 0 ]; then
              deferred_expanded+=("$task")
              deferred_expanded_nums+=("$project_num")
            else
              archivable_tasks+=("$task")
            fi
            ;;
          *)
            # Non-expanded tasks pass through untouched.
            archivable_tasks+=("$task")
            ;;
        esac
      done
      ```

      Track `deferred_expanded[]` and `deferred_count` (`= ${#deferred_expanded[@]}`). Stage 10
      (`ArchiveTasks`) consumes `archivable_tasks[]` — never a freshly-recomputed status match —
      so a deferred parent's `active_projects` entry survives this run.
    </process>
  </stage>
  
  <stage id="2.5" name="TopicRevision">
    <action>Optional: backfill topics on active tasks missing the topic field</action>
    <process>
      Detect active tasks without a topic:
      ```bash
      missing=$(jq -r '.active_projects[] |
        select(.status == "completed" | not) |
        select(.status == "abandoned" | not) |
        select(.status == "expanded" | not) |
        select(.topic == null or .topic == "") |
        "\(.project_number)|\(.project_name)"' specs/state.json)
      ```

      If no tasks need backfill, skip this stage.

      For each task needing a topic, follow the topic assignment pattern from
      @.claude/context/patterns/topic-assignment-pattern.md (Mode A, per-task backfill).
      Use header "Topic Backfill ({i} of {total})".

      After each selection:
      ```bash
      bash .claude/scripts/manage-topics.sh set "$task_num" "$topic"
      ```
    </process>
  </stage>

  <stage id="3" name="DetectOrphans">
    <action>Detect orphaned directories and TODO.md orphans</action>
    <process>
      1. Scan specs/ for directories not tracked in state files:
         ```bash
         for dir in specs/OC_[0-9]*_*/ specs/[0-9]*_*/; do
           [ -d "$dir" ] || continue
           basename_dir=$(basename "$dir")
           project_num=$(echo "$basename_dir" | sed 's/^OC_//' | cut -d_ -f1)

           in_active=$(jq -r --arg n "$project_num" \
             '.active_projects[] | select(.project_number == ($n | tonumber)) | .project_number' \
             specs/state.json 2>/dev/null)

           in_archive=$(jq -r --arg n "$project_num" \
             '.completed_projects[] | select(.project_number == ($num | tonumber)) | .project_number' \
             specs/archive/state.json 2>/dev/null)

           if [ -z "$in_active" ] && [ -z "$in_archive" ]; then
             orphaned_in_specs+=("$dir")
           fi
         done
         ```

      2. Scan specs/archive/ for orphaned directories:
         ```bash
         for dir in specs/archive/OC_[0-9]*_*/ specs/archive/[0-9]*_*/; do
           [ -d "$dir" ] || continue
           basename_dir=$(basename "$dir")
           project_num=$(echo "$basename_dir" | sed 's/^OC_//' | cut -d_ -f1)

           in_archive=$(jq -r --arg n "$project_num" \
             '.completed_projects[] | select(.project_number == ($num | tonumber)) | .project_number' \
             specs/archive/state.json 2>/dev/null)

           if [ -z "$in_archive" ]; then
             orphaned_in_archive+=("$dir")
           fi
         done
         ```

      3. Scan TODO.md for completed/abandoned tasks not tracked in state.json or archive:
         - Parse task headers (`### {N}.` or `### OC_{N}.`) and status lines (`[COMPLETED]`/`[ABANDONED]`)
         - Cross-reference each against active_projects and archive completed_projects
         - Collect as `todo_md_orphans[]` if: status is completed/abandoned, not in either state file, and has a directory in specs/
    </process>
  </stage>
  
  <stage id="4" name="DetectMisplaced">
    <action>Detect misplaced directories</action>
    <process>
      1. Scan specs/ for directories tracked in archive state:
         ```bash
         for dir in specs/OC_[0-9]*_*/ specs/[0-9]*_*/; do
           [ -d "$dir" ] || continue
           basename_dir=$(basename "$dir")
           project_num=$(echo "$basename_dir" | sed 's/^OC_//' | cut -d_ -f1)
           
           in_active=$(jq -r --arg n "$project_num" \
             '.active_projects[] | select(.project_number == ($num | tonumber)) | .project_number' \
             specs/state.json 2>/dev/null)
           
           in_archive=$(jq -r --arg n "$project_num" \
             '.completed_projects[] | select(.project_number == ($num | tonumber)) | .project_number' \
             specs/archive/state.json 2>/dev/null)
           
           if [ -z "$in_active" ] && [ -n "$in_archive" ]; then
             misplaced_in_specs+=("$dir")
           fi
         done
         ```
    </process>
  </stage>
  
  <stage id="5" name="ScanRoadmap">
    <action>Scan for roadmap references</action>
    <process>
      0. Ensure specs/ROADMAP.md exists. If the file does not exist, create it with the default template:
         ```markdown
         # Project Roadmap

         ## Phase 1: Current Priorities (High Priority)

         - [ ] (No items yet -- add roadmap items here)

         ## Success Metrics

         - (Define success metrics here)
         ```
      1. Partition `archivable_tasks[]` into roadmap-excluded (meta tasks, and expanded tasks —
         an expanded task has no `completion_summary` of its own by construction, since its
         subtasks carry the deliverables; do not "fix" this by requiring one) and
         roadmap-eligible tasks, exactly as `commands/todo.md`'s Step 3.5.1 does.
      2. This stage performs no matching of its own. Invoke `roadmap-integration.sh` parse-only
         (no `--annotate`) against `specs/ROADMAP.md`/`specs/state.json`, capturing
         `roadmap_structure`, `warnings`, and `roadmap_matches` from the payload. Filter
         `roadmap_matches` to only the roadmap-eligible tasks from step 1 before treating any
         match as an annotation candidate — this filter is where meta/expanded exclusion is
         enforced, since the script has no `task_type` filter of its own (see the script's header
         "Caller contract").
      3. **Error-handling contract** (identical to `commands/todo.md`'s Step 3.5 and
         `commands/review.md`'s Step 2.5): a missing script, a non-zero exit, or empty output all
         produce the same visible warning and the same fully-defined `parseable: false` fallback
         — never silence.
    </process>
  </stage>
  
  <stage id="6" name="ScanMetaSuggestions">
    <action>Scan meta tasks for README.md suggestions</action>
    <process>
      1. For each archived meta task:
         - Check completion_data.readme_suggestions
         - Filter out "none" values
         - Track actionable suggestions by type:
           * Add: Insert new content
           * Update: Replace existing content
           * Remove: Delete content
    </process>
  </stage>
  
  <stage id="7" name="HarvestMemories">
    <action>Collect, deduplicate, and classify memory candidates from state.json</action>
    <process>
      Note: this stage stays scoped to completed tasks and is not widened to expanded tasks —
      an expanded task's work product and memory candidates belong to its subtasks, which are
      harvested (or already were harvested) in their own right when they complete.

      1. Collect candidates from state.json:
         - For each completed task in the archival batch:
           - Read `memory_candidates // []` from the task's state.json entry
           - Flatten into a single list, tagging each candidate with `task_number` provenance
         - If no candidates across all tasks, set `harvest_candidates = []` and skip to Stage 8

      2. Deduplicate against existing memory-index.json:
         - Read `.memory/memory-index.json` (if missing or empty, skip dedup -- all candidates are CREATE)
         - For each candidate, compute keyword overlap against every index entry:
           ```
           overlap = |candidate.suggested_keywords INTERSECT entry.keywords| / |candidate.suggested_keywords|
           ```
         - Classify dedup action:
           - overlap > 90%: mark `dedup_action = "NOOP"` (exclude from prompt)
           - overlap > 60%: mark `dedup_action = "UPDATE"` (present with warning label)
           - overlap <= 60%: mark `dedup_action = "CREATE"` (standard new memory)
         - If ALL candidates are NOOP after dedup, set `harvest_candidates = []` and skip to Stage 8

      3. Apply three-tier classification:
         - **Tier 1** (pre-selected): category in [PATTERN, CONFIG] AND confidence >= 0.8
         - **Tier 2** (shown, not pre-selected): category in [WORKFLOW, TECHNIQUE] AND confidence >= 0.5
         - **Tier 3** (hidden by default): category == INSIGHT OR confidence < 0.5
         - Assign `tier` (1, 2, or 3) to each non-NOOP candidate

      4. Store the classified candidate list as `harvest_candidates`:
         Each entry contains: `task_number`, `content`, `category`, `source_artifact`, `confidence`, `suggested_keywords`, `tier`, `dedup_action`

      5. Collect completion-time reflections (read-only, parallel to memory candidates):
         - For each completed task in the archival batch:
           - Read `reflection // null` from the task's state.json entry
           - If present, append `{task_number, what_worked, what_was_hard, what_was_missed,
             successes}` to a `harvest_reflections` list; skip tasks with no reflection
         - No dedup or tiering is applied -- reflections are one-per-task, not vault-deduped
         - If no reflections across all tasks, set `harvest_reflections = []`
    </process>
  </stage>
  
  <stage id="8" name="DryRunOutput">
    <action>Display dry run preview if requested</action>
    <process>
      If dry_run = true:
      1. Display comprehensive preview:
         - Tasks to archive (completed/abandoned/expanded counts)
         - Deferred: one summary line from `deferred_expanded[]` (Stage 2's subtasks-defer guard)
           - Format: `Deferred: {N} expanded parent(s) held back (subtasks still active)`
           - If `deferred_expanded[]` is empty: `Deferred: none` (mirrors the neighbouring
             memory-candidate and status-reconciliation dry-run lines)
         - Orphaned directories count
         - Misplaced directories count
         - Roadmap updates needed: same three-way branch as `commands/todo.md`'s dry-run output —
           omit this line only when `roadmap_structure.parseable == true` and there are no
           eligible matches (legitimately nothing to do); **always** print
           `Warning: roadmap structure unrecognized (0 phases, 0 checkboxes, 0 table rows) -- see roadmap_structure in the payload`
           when `parseable == false`, regardless of match count; print
           `Warning: roadmap annotation no-op ({high_confidence_matches} high-confidence match(es), 0 applied) -- see skipped_reasons in the payload`
           when `silent_noop == true`. Invariant: omission is permitted only when the roadmap
           parsed successfully — an unparseable roadmap is never reportable as a successful
           annotation pass.
         - README.md suggestions count
         - Memory candidates: tiered breakdown from `harvest_candidates`
           - Format: `Memory candidates: {T1} Tier 1, {T2} Tier 2, {T3} Tier 3 ({after_dedup} after dedup, {noop_count} NOOP excluded)`
           - If no candidates: `Memory candidates: none`
         - Reflections: one summary line from `harvest_reflections`, shown only when non-empty
           - Format: `Reflections: {N} task(s) reported a completion-time reflection`
           - If empty, omit the line entirely (mirrors the memory-candidate dry-run line)
         - Status reconciliation: one summary line from `reconcile_candidates` (Stage 1.5)
           - Format: `Status reconciliation: {N} task(s) stranded with artifacts on disk`
           - If `reconcile_candidates` is empty: `Status reconciliation: none`
      2. Exit after display
    </process>
  </stage>
  
  <stage id="9" name="InteractivePrompts">
    <action>Handle interactive prompts</action>
    <process>
      Present AskUserQuestion prompts for each detected condition:
      1. **Orphaned directories**: track/skip options per directory
      2. **Misplaced directories**: move/skip options per directory
      3. **TODO.md orphans**: multiSelect list of completed/abandoned tasks not in state.json; store as `selected_todo_orphans`
      4. **Memory harvest candidates** (from `harvest_candidates`):
         - If `harvest_candidates` is empty (no candidates or all NOOP), skip this sub-step entirely
         - Build multiSelect option list, ordered by tier:
           a. **Tier 1 candidates first** (pre-selected): Format each as:
              `[PRE-SELECTED] [TIER 1] [{CATEGORY}] Task {N}: {content first 80 chars}... (confidence: {X.XX})`
              If `dedup_action == "UPDATE"`, append: ` [WARNING: similar memory exists]`
           b. **Tier 2 candidates** (shown, not pre-selected): Format each as:
              `[TIER 2] [{CATEGORY}] Task {N}: {content first 80 chars}... (confidence: {X.XX})`
              If `dedup_action == "UPDATE"`, append: ` [WARNING: similar memory exists]`
           c. **Tier 3 expansion option**: If Tier 3 candidates exist, add a final option:
              `Show {count} more candidates (Tier 3 -- low confidence/insight)`
         - Present AskUserQuestion with multiSelect
         - If user selected the Tier 3 expansion option:
           - Re-prompt with ALL tiers visible (Tier 1 + Tier 2 + Tier 3), Tier 1 still pre-selected
           - Tier 3 candidates formatted as:
             `[TIER 3] [{CATEGORY}] Task {N}: {content first 80 chars}... (confidence: {X.XX})`
         - Store user-approved candidates as `approved_memories` for Stage 14
         - **Read-only reflection augmentation**: when `harvest_reflections` is non-empty, append
           a per-task block to the same prompt's `description` text (not a new prompt, not new
           selectable options -- purely additional read-only context alongside the
           multiSelect options above):
           ```
           Completion-time reflections:
           Task {N}:
             What worked: {what_worked}
             What was hard: {what_was_hard}
             What was missed: {what_was_missed}
             Successes: {successes}
           ```
           (repeat per entry in `harvest_reflections`; omit any sub-field that is absent). Omit
           the entire "Completion-time reflections" section when `harvest_reflections` is empty.
           The multiSelect mechanics (tiers, dedup, NOOP) are unchanged by this augmentation.
      5. **Status reconciliation candidates** (from `reconcile_candidates`, Stage 1.5):
         - If `reconcile_candidates` is empty, skip this sub-step entirely (mirrors how the memory
           harvest sub-step above handles its empty case)
         - Build a multiSelect option list, one option per candidate, showing the task number, its
           current status, the artifact found, and the promotion that would result:
           `Task {N}: status={current_status}, artifact={artifact_basename} -- would promote to
           {target_status}`
         - Present AskUserQuestion with multiSelect (nothing pre-selected -- this stage never
           auto-repairs; every promotion here is an explicit opt-in)
         - Store user-approved candidates as `approved_reconciliations`
         - Only for `approved_reconciliations`: re-run `bash .claude/scripts/reconcile-task-status.sh
           "$task_num" "$todo_session_id"` **without** `--dry-run` to apply the promotion, echoing
           its `[reconcile]` output verbatim. Unselected candidates are left stranded and simply are
           not archived this run -- the correct conservative outcome, since a status promotion
           immediately before a directory move must never be inferred rather than chosen.
    </process>
  </stage>
  
  <stage id="10" name="ArchiveTasks" checkpoint="vault_check_complete">
    <action>Archive tasks to completed_projects (includes mandatory vault check)</action>
    <process>
      For each task in `archivable_tasks[]` (the guard-filtered list from Stage 2 — never a
      freshly-recomputed status match, so deferred expanded parents are excluded):
      1. Update specs/archive/state.json:
         - `completed` and `expanded` tasks add to completed_projects array (there is no third
           array for `expanded`); `abandoned` tasks add to archived_projects array
         - Include all task fields
         - Add archived timestamp
         - Bootstrap if this is the first archive operation (via `--init`), then apply the batch
           transform through `state-write.sh`'s `--state-file` flag, matching
           `commands/todo.md`'s Step 5A shape:
           ```bash
           [ -f specs/archive/state.json ] || bash .claude/scripts/state-write.sh \
             '{ "archived_projects": [], "completed_projects": [] }' \
             --init --state-file specs/archive/state.json --session-id "$todo_session_id"

           archivable_tasks_json=$(printf '%s\n' "${archivable_tasks[@]}" | jq -s '.')
           bash .claude/scripts/state-write.sh \
             '.completed_projects = ([$tasks[] | select(.status == "completed" or .status == "expanded") | .archived_at = $ts] + .completed_projects) |
              .archived_projects = ([$tasks[] | select(.status == "abandoned") | .archived_at = $ts] + .archived_projects)' \
             --state-file specs/archive/state.json \
             --session-id "$todo_session_id" \
             --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
             --argjson tasks "$archivable_tasks_json"
           ```

      2. Update specs/state.json:
         - Remove from active_projects array. As in `commands/todo.md`'s Step 5B, this removal
           must exclude any task listed in `deferred_expanded_nums[]` (Stage 2's guard) even
           though its status matches — the blanket status match alone would delete a deferred
           parent from state.json while its archive-list entry was held back, silently losing the
           task. Since Stage 10 iterates `archivable_tasks[]` directly rather than re-deriving a
           status match against the full `active_projects` array, deferred parents are naturally
           excluded from this removal as long as the removal is driven by the same
           `archivable_tasks[]` list — do not re-select by status here.

      3. Update specs/TODO.md:
         - Remove archived entries (both regular and TODO.md orphans) — the same
           `archivable_tasks[]` guard-filtered list from Stage 2; a deferred parent's entry stays
         - Pattern to match task entry start:
           ```lua
           -- Match both "### OC_N. " and "### N. " formats
           local task_start_pattern = "###%s+(OC_)?(%d+)%.%s+"
           ```
         - For each task to remove:
           a. Find entry start (header line)
           b. Find entry end (next task header or end of Active Tasks section)
           c. Extract complete entry including all lines
           d. Validate entry matches expected format before removal
         - Use Edit tool to remove validated entries:
           ```lua
           -- Remove the matched section
           edit_file("specs/TODO.md", old_entry_content, "")
           ```
         - Note: next_project_number should NOT be decremented when removing orphans
           (numbering continues from highest used number)

      4. Move project directories to specs/archive/ — again driven by `archivable_tasks[]`; a
         deferred parent's directory stays in place until a later `/todo` run archives it

      5. Track orphaned directories (if approved)

      7. Move misplaced directories (if approved)

      8. Archive TODO.md orphans:
         For each selected orphan in `selected_todo_orphans`:
         a. Build archive entry from TODO.md data:
            ```json
            {
              "project_number": orphan.project_number,
              "project_name": orphan.project_name,
              "status": orphan.status,  // "completed" or "abandoned"
              "created_at": "TODO.md_orphan",  // Marker indicating source
              "archived_at": "YYYY-MM-DDTHH:MM:SSZ"
            }
            ```
         b. Add entry to specs/archive/state.json completed_projects array, matching
            `commands/todo.md`'s Step 5E.2 shape (`$orphan` is the same JSON blob shown in
            step a):
            ```bash
            bash .claude/scripts/state-write.sh \
              '.completed_projects += [{
                project_number: $orphan.project_number,
                project_name: $orphan.project_name,
                status: $orphan.status,
                created_at: "TODO.md_orphan",
                archived_at: $ts
              }]' \
              --state-file specs/archive/state.json \
              --session-id "$todo_session_id" \
              --argjson orphan "$orphan" \
              --arg ts "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
            ```
         c. Move directory from specs/ to specs/archive/:
            ```bash
            source_dir="specs/OC_${orphan.project_number}_${orphan.project_name}/"
            if [ ! -d "$source_dir" ]; then
              source_dir="specs/${orphan.project_number}_${orphan.project_name}/"
            fi
            target_dir="specs/archive/$(basename "$source_dir")"
            mv "$source_dir" "$target_dir"
            ```
         d. Track orphan archival for CHANGE_LOG.md
         e. If no directory found, log warning:
            ```
            Warning: TODO.md orphan {N} has no directory in specs/
            Archive entry created but no files moved
            ```

      9. **Vault Threshold Check (MANDATORY)**

         **CRITICAL: ALWAYS EXECUTE - DO NOT SKIP**

         This sub-step MUST be executed unconditionally after archiving tasks.
         The bash block below produces output for BOTH vault-needed and vault-not-needed cases.

         Execute vault threshold detection:
         ```bash
         # UNCONDITIONAL VAULT CHECK - produces output in all cases
         PROJECT_ROOT="${PROJECT_ROOT:-.}"
         STATE_FILE="${PROJECT_ROOT}/specs/state.json"
         VAULT_THRESHOLD=1000

         next_num=$(jq -r '.next_project_number // 0' "$STATE_FILE")

         if [[ "$next_num" -gt "$VAULT_THRESHOLD" ]]; then
             echo ""
             echo "=============================================="
             echo "  VAULT THRESHOLD EXCEEDED"
             echo "=============================================="
             echo "  next_project_number: $next_num"
             echo "  threshold: $VAULT_THRESHOLD"
             echo "  status: VAULT OPERATION REQUIRED"
             echo "=============================================="
             echo ""
             vault_needed=true
         else
             echo ""
             echo "Vault check: next_project_number=$next_num (threshold: $VAULT_THRESHOLD) - OK"
             echo ""
             vault_needed=false
         fi
         ```

         **Decision Logic**:
         - If `vault_needed=true`: Proceed to sub-step 9.1 (VaultConfirmation)
         - If `vault_needed=false`: Skip sub-steps 9.1-9.4, continue to Stage 11 (UpdateRoadmap)

      9.1. **VaultConfirmation** (if vault_needed=true)

         Identify tasks requiring renumbering:
         ```bash
         # Find active tasks with project_number > 1000
         tasks_to_renumber=$(jq -r '
           .active_projects[] |
           select(.project_number > 1000) |
           {
             old_number: .project_number,
             new_number: (.project_number - 1000),
             project_name: .project_name,
             status: .status
           }
         ' specs/state.json)

         # Count tasks to renumber
         renumber_count=$(echo "$tasks_to_renumber" | jq -s 'length')

         # Build mapping array: [{old: 1001, new: 1}, {old: 1003, new: 3}, ...]
         renumber_mappings=$(jq -n --argjson tasks "$tasks_to_renumber" '
           [$tasks[] | {old: .old_number, new: .new_number, name: .project_name}]
         ')
         ```

         Build preview of renumbering:
         ```bash
         # Format preview of task renumbering
         renumber_preview=""
         for mapping in $(echo "$renumber_mappings" | jq -c '.[]'); do
           old=$(echo "$mapping" | jq -r '.old')
           new=$(echo "$mapping" | jq -r '.new')
           name=$(echo "$mapping" | jq -r '.name')
           renumber_preview="${renumber_preview}\n  - Task ${old} (${name}) -> Task ${new}"
         done
         ```

         Present AskUserQuestion for vault confirmation:
         ```json
         {
           "question": "Task numbering has exceeded 1000. Initiate vault archival?",
           "header": "Vault Operation",
           "description": "Current next_project_number: {next_num}\nActive tasks to renumber: {renumber_count}\n\nRenumbering preview:{renumber_preview}\n\nThis will:\n1. Move specs/archive/ to specs/vault/{NN-vault}/\n2. Renumber tasks > 1000 by subtracting 1000\n3. Reset next_project_number",
           "multiSelect": false,
           "options": [
             {"label": "Yes, proceed with vault operation", "value": "proceed"},
             {"label": "No, skip vault this time", "value": "skip"}
           ]
         }
         ```

         Handle user response:
         ```bash
         if [ "$user_response" = "proceed" ]; then
           vault_approved=true
           # Continue to sub-step 9.2
         else
           vault_approved=false
           # Skip to Stage 11 (UpdateRoadmap)
         fi
         ```

      9.2. **CreateVault** (if vault_approved=true)

         Calculate vault number:
         ```bash
         # Get current vault_count (or 0 if not set)
         vault_count=$(jq -r '.vault_count // 0' specs/state.json)
         new_vault_num=$((vault_count + 1))
         vault_dir_name=$(printf "%02d-vault" "$new_vault_num")
         vault_path="specs/vault/${vault_dir_name}"
         ```

         Create vault directory structure:
         ```bash
         mkdir -p "$vault_path"
         ```

         Move archive contents to vault:
         ```bash
         # Move archive directory to vault
         if [ -d "specs/archive" ]; then
           mv "specs/archive" "${vault_path}/archive"
         fi
         ```

         Move archive state.json to vault root (a file rename, not a state write -- correctly
         outside state-write.sh's remit):
         ```bash
         # Archive state.json becomes vault state.json
         if [ -f "${vault_path}/archive/state.json" ]; then
           mv "${vault_path}/archive/state.json" "${vault_path}/state.json"
         fi
         ```

         Create vault meta.json:
         ```bash
         # Calculate metadata
         current_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
         archived_count=$(jq -r '.completed_projects | length' "${vault_path}/state.json" 2>/dev/null || echo "0")
         task_range="1-$((next_num - renumber_count - 1))"

         # Create meta.json
         jq -n \
           --arg vault_num "$new_vault_num" \
           --arg created_at "$current_timestamp" \
           --arg task_range "$task_range" \
           --argjson archived_count "$archived_count" \
           --argjson final_task_num "$next_num" \
           '{
             vault_number: ($vault_num | tonumber),
             created_at: $created_at,
             task_range: $task_range,
             archived_count: $archived_count,
             final_task_number: $final_task_num,
             description: "Vault containing archived tasks from task numbering cycle"
           }' > "${vault_path}/meta.json"
         ```

         Reinitialize empty specs/archive/ with fresh state.json via `state-write.sh`'s `--init`
         mode. The timestamp is bound with `--arg`, not shell-interpolated into the filter:
         ```bash
         mkdir -p "specs/archive"

         # Create fresh archive state.json
         bash .claude/scripts/state-write.sh \
           '{
             "_comment": "Archive state for completed and abandoned tasks",
             "completed_projects": [],
             "archived_at": $ts
           }' \
           --init --state-file specs/archive/state.json --session-id "$todo_session_id" \
           --arg ts "$current_timestamp"
         ```

      9.3. **RenumberTasks** (if vault_approved=true)

         For each task in renumber_mappings, update state.json:
         ```bash
         # Update each task's project_number and artifact paths
         for mapping in $(echo "$renumber_mappings" | jq -c '.[]'); do
           old_num=$(echo "$mapping" | jq -r '.old')
           new_num=$(echo "$mapping" | jq -r '.new')
           task_name=$(echo "$mapping" | jq -r '.name')

           # Update project_number
           # Update artifact paths (4-digit dir -> 3-digit dir)
           old_padded=$(printf "%04d" "$old_num")
           new_padded=$(printf "%03d" "$new_num")

           # Use state-write.sh to update the task entry
           bash .claude/scripts/state-write.sh \
             '
             .active_projects |= map(
               if .project_number == $old then
                 .project_number = $new |
                 .artifacts |= (if . then map(
                   .path |= gsub("specs/\($old_pad)_"; "specs/\($new_pad)_") |
                   .path |= gsub("specs/\($old)_"; "specs/\($new_pad)_")
                 ) else . end)
               else . end
             )
           ' \
             --session-id "$todo_session_id" \
             --argjson old "$old_num" \
             --argjson new "$new_num" \
             --arg old_pad "$old_padded" \
             --arg new_pad "$new_padded"
         done
         ```

         Update dependencies arrays (task numbers > 1000):
         ```bash
         # Build mapping for all renumbered tasks
         bash .claude/scripts/state-write.sh \
           '
           # Create lookup from mappings
           ($mappings | map({(.old | tostring): .new}) | add) as $lookup |
           .active_projects |= map(
             .dependencies |= (if . then map(
               . as $dep |
               if $lookup[$dep | tostring] then
                 $lookup[$dep | tostring]
               else $dep end
             ) else . end)
           )
         ' \
           --session-id "$todo_session_id" \
           --argjson mappings "$renumber_mappings"
         ```

         Rename task directories:
         ```bash
         for mapping in $(echo "$renumber_mappings" | jq -c '.[]'); do
           old_num=$(echo "$mapping" | jq -r '.old')
           new_num=$(echo "$mapping" | jq -r '.new')
           task_name=$(echo "$mapping" | jq -r '.name')

           old_padded=$(printf "%04d" "$old_num")
           new_padded=$(printf "%03d" "$new_num")

           # Find source directory (could be 3-digit or 4-digit padded)
           source_dir=""
           if [ -d "specs/${old_padded}_${task_name}" ]; then
             source_dir="specs/${old_padded}_${task_name}"
           elif [ -d "specs/${old_num}_${task_name}" ]; then
             source_dir="specs/${old_num}_${task_name}"
           fi

           # Rename to 3-digit padded format
           if [ -n "$source_dir" ]; then
             target_dir="specs/${new_padded}_${task_name}"
             mv "$source_dir" "$target_dir"
           fi
         done
         ```

         Update TODO.md entries:
         ```bash
         for mapping in $(echo "$renumber_mappings" | jq -c '.[]'); do
           old_num=$(echo "$mapping" | jq -r '.old')
           new_num=$(echo "$mapping" | jq -r '.new')

           old_padded=$(printf "%04d" "$old_num")
           new_padded=$(printf "%03d" "$new_num")

           # Update task headers: ### 1001. Title -> ### 1. Title
           sed -i "s/^### ${old_num}\./### ${new_num}./" specs/TODO.md

           # Update artifact links with directory references
           sed -i "s|${old_padded}_|${new_padded}_|g" specs/TODO.md
           sed -i "s|${old_num}_|${new_padded}_|g" specs/TODO.md

           # Update dependency references
           sed -i "s|Task #${old_num}|Task #${new_num}|g" specs/TODO.md
         done
         ```

      9.4. **ResetState** (if vault_approved=true)

         Calculate new next_project_number:
         ```bash
         # Find maximum project_number in active_projects after renumbering
         max_active=$(jq -r '[.active_projects[].project_number] | max // 0' specs/state.json)
         new_next_num=$((max_active + 1))
         ```

         Update state.json with new next_project_number:
         ```bash
         bash .claude/scripts/state-write.sh \
            '.next_project_number = $new_next' \
            --session-id "$todo_session_id" \
            --argjson new_next "$new_next_num"
         ```

         Increment vault_count:
         ```bash
         bash .claude/scripts/state-write.sh \
           '.vault_count = (.vault_count // 0) + 1' \
           --session-id "$todo_session_id"
         ```

         Add entry to vault_history:
         ```bash
         current_timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
         archived_count=$(jq -r '.completed_projects | length' "${vault_path}/state.json" 2>/dev/null || echo "0")
         task_range="1-$((next_num - renumber_count - 1))"

         bash .claude/scripts/state-write.sh \
           '
           .vault_history = (.vault_history // []) + [{
             vault_number: $vault_num,
             vault_dir: $vault_dir,
             created_at: $created,
             task_range: $range,
             archived_count: $archived,
             final_task_number: $final
           }]
         ' \
           --session-id "$todo_session_id" \
           --arg vault_dir "$vault_path/" \
           --argjson vault_num "$new_vault_num" \
           --arg created "$current_timestamp" \
           --arg range "$task_range" 

…(truncated)
