Todo Skill
Direct execution skill for archiving tasks, updating CHANGE_LOG.md, and suggesting memory harvesting.
Direct execution skill for task archival operations with automated CHANGE_LOG updates and memory harvest suggestions.
Parse arguments, scan for archivable tasks (completed, abandoned, expanded), update states, generate CHANGE_LOG entries, suggest memory harvesting from completed task artifacts.
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>
**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>
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>
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>
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>
## 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>
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>
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)