# Archive Spec

> Archive a completed spec — verify every task completed, QA passed, and indexed references are self-contained, then stamp the archive metadata and move <spec-root>/<slug>/ to the resolved archive root (<spec-root>/_archived/<slug>/, or docs/history/specs/<slug>/ for the built-in docs/specs root). Runs automatically at the end of the implement-spec loop after a QA pass, or whenever the user asks to archive a spec.

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

---


# Archive Spec

Move a completed spec out of the active set: `<resolved-spec-root>/<slug>/` → `<resolved-archive-root>/<slug>/`, with the completion stamped in its frontmatter. The source is the configured Spec Root; the destination is its resolved archive root — `docs/history/specs/` for the built-in `docs/specs` root, or `<spec-root>/_archived/` for an external or non-default root, matching the `roundfix archive` destination. Archived means _implemented, verified, and self-contained_ — every task done, QA passed, and every indexed reference owned by the Spec — after this, one `ls <spec-root>/` separates live work from history, and the archive stays greppable as the record of what was built and why.

The trigger is spec completion, not publication: run this automatically at the end of the `implement-spec` loop once the QA gate passes, or whenever the user asks. Merge and release are separate, user-driven steps — the archive commit simply travels with the branch and ships inside the feature's own PR.

## Preconditions — verify, don't trust

Check all three with fresh command evidence before touching anything:

1. **Every task completed.** Read each `task_NN.md` listed in `_tasks.md`; every
   `status` must be `completed`.

   **Command:** run `grep -n '^status:' <each-task-file-listed-in-_tasks.md>` and
   retain its output. Any value other than `status: completed` blocks the
   archive and names the Task file.

2. **QA passed.** The newest report in `qa/` must pass the repository's QA
   verifier. Do not substitute a line grep for structured validation. In
   Roundfix, use the same `internal/spec.QAVerdict` contract as the Archive
   Command: select the newest report by the `qa-report-YYYY-MM-DD[-NN].md`
   filename contract, parse its YAML frontmatter, require a supported
   `verdict`, require both blocked-row fields to be non-negative integers when
   present, and reject `verdict: pass` when `rows_blocked_finding` is nonzero.
   Retain the verifier's report path and result as evidence. A missing `qa/`
   directory, malformed newest report, or non-passing verdict blocks the
   archive; proceed only if the user explicitly says "archive anyway", and
   record that override in the stamped frontmatter (`qa_override: true`).

3. **The Spec is self-contained.** Apply this precondition when
   `docs/specs/<slug>/references/_index.md` exists or is a symbolic link; a
   symbolic link, including a broken one, is invalid index state rather than a
   legacy Spec. Only a Spec where that path neither exists nor is a symbolic
   link predates this contract and passes without retrofitting historical
   artifacts. For an indexed Spec, every indexed `path` must exist relative to
   `_index.md`, every never-updated `source` path must be absent, and no
   Markdown link destination inside the Spec may point into `docs/_inbox/` or
   `docs/findings/`.

   **Commands:** first run this link-destination check; its syntax deliberately
   matches inline or reference-style Markdown links, not prose that merely
   names either tree:

   ```bash
   spec_dir=docs/specs/<slug>
   link_hits=$(grep --include='*.md' -RInE '(\]\([^)]*(docs/)?(_inbox|findings)/[^)]*\)|^[[:space:]]*\[[^]]+\]:[[:space:]]*<?[^[:space:]>]*(docs/)?(_inbox|findings)/)' "$spec_dir")
   link_status=$?
   if test "$link_status" -eq 0; then
     printf '%s\n' "$link_hits"
     echo "self-containment failed: rewrite each listed link at adoption step 8"
     exit 1
   fi
   test "$link_status" -eq 1 || exit "$link_status"
   ```

   Then parse and validate every data row. The index belongs to the current
   Spec: `owner` must equal its four-digit prefix, `type` must be `inbox`,
   `finding`, or `backlog`, and each `source` and `path` must appear only once. A `path` must
   be one basename relative to `_index.md`; reject absolute paths, `.`, `..`,
   path separators, and symbolic links instead of allowing traversal or a link
   outside `references/`. Run the following from the repository root and
   retain the normalized rows plus any diagnostic as evidence:

   ```bash
   slug=<slug>
   index="docs/specs/$slug/references/_index.md"
   expected_owner=${slug%%-*}
   parsed_index=$(mktemp) || exit 1
   trap 'rm -f "$parsed_index"' EXIT HUP INT TERM

   if test -L "$(dirname "$index")"; then
     printf 'self-containment failed: references/ must not be a symbolic link\n' >&2
     exit 1
   fi

   if test -L "$index" || test ! -f "$index"; then
     printf 'self-containment failed: references/_index.md must be a regular, non-symbolic-link file\n' >&2
     exit 1
   fi

   awk -F '|' -v expected_owner="$expected_owner" '
   function trim(value) {
     gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
     return value
   }
   function reject(message) {
     print "self-containment failed: " message > "/dev/stderr"
     invalid = 1
   }
   function separator(value) {
     value = trim(value)
     return value ~ /^:?-{3,}:?$/
   }
   /^[[:space:]]*$/ || /^[[:space:]]*#/ { next }
   !header {
     if (NF != 7 || trim($2) != "source" || trim($3) != "type" ||
         trim($4) != "owner" || trim($5) != "adopted date" ||
         trim($6) != "path") {
       reject("_index.md must use the fixed source | type | owner | adopted date | path header")
     } else {
       header = 1
     }
     next
   }
   !divider {
     if (NF != 7 || !separator($2) || !separator($3) || !separator($4) ||
         !separator($5) || !separator($6)) {
       reject("_index.md has an invalid table separator")
     } else {
       divider = 1
     }
     next
   }
   {
     source = trim($2)
     type = trim($3)
     owner = trim($4)
     adopted = trim($5)
     path = trim($6)
     if (NF != 7 || source == "" || type == "" || owner == "" ||
         adopted == "" || path == "") {
       reject("invalid index row at line " NR ": " $0)
       next
     }
     if (type != "inbox" && type != "finding" && type != "backlog") {
       reject("type must be `inbox`, `finding`, or `backlog` at line " NR ": " type)
     }
     if (owner != expected_owner) {
       reject("owner must be " expected_owner " at line " NR ": " owner)
     }
     if (seen_source[source]++) {
       reject("duplicate source at line " NR ": " source)
     }
     if (seen_path[path]++) {
       reject("duplicate path at line " NR ": " path)
     }
     if (adopted !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) {
       reject("adopted date must be YYYY-MM-DD at line " NR ": " adopted)
     }
     if ((type == "inbox" && source !~ /^docs\/_inbox\/[^\/]+\.md$/) ||
         (type == "finding" && source !~ /^docs\/findings\/[^\/]+\.md$/) ||
         (type == "backlog" && source !~ /^docs\/backlog\/[^\/]+\.md$/)) {
       reject("source does not match type at line " NR ": " source)
     }
     print source "|" type "|" owner "|" adopted "|" path
   }
   END {
     if (!header || !divider) {
       reject("_index.md is missing its fixed table header")
     }
     if (invalid) {
       exit 1
     }
   }
   ' "$index" > "$parsed_index" || exit $?

   while IFS='|' read -r source type owner adopted path; do
     case "$path" in
       ""|.|..|/*|*/*|*\\*)
         printf 'self-containment failed: path must be one basename relative to `_index.md`: %s\n' "$path" >&2
         exit 1
         ;;
     esac
     source_basename=${source##*/}
     if test "$path" != "$source_basename"; then
       printf 'self-containment failed: path must equal source basename for %s: %s != %s\n' "$source" "$path" "$source_basename" >&2
       exit 1
     fi
     current="$(dirname "$index")/$path"
     if test -L "$current" || test ! -f "$current"; then
       printf 'self-containment failed: invalid or missing path %s; finish adoption step 7\n' "$path" >&2
       exit 1
     fi
     if test -e "$source" || test -L "$source"; then
       printf 'self-containment failed: source still exists at %s; finish adoption step 7\n' "$source" >&2
       exit 1
     fi
   done < "$parsed_index"
   cat "$parsed_index"
   ```

`qa_override: true` overrides only failed or missing QA evidence in precondition
2. It never overrides self-containment: verification can be overridden by the
maintainer, but self-containment is a property of the artifact and must be
repaired by finishing adoption.

A merged PR or release tag is **not** a precondition. If the user passes `--release`, or a merged PR/tag is already known, stamp it as metadata — but never block the archive waiting for one.

If any check fails, stop and report the offending Task, report, source, or link
and the adoption step that fixes a self-containment failure — the Spec stays
active.

## Steps

1. **Stamp** `_prd.md` frontmatter:

   ```yaml
   status: archived
   archived: YYYY-MM-DD
   qa_override: true # only when archiving despite failed/missing QA
   release: <tag or PR URL> # only when known — from --release or an already-merged PR/tag
   ```

2. **Move** with history preserved. Resolve the configured Spec Root and its
   archive root first: the built-in root `docs/specs` archives to
   `docs/history/specs`; an external or non-default root `<spec-root>` archives
   beside the active root at `<spec-root>/_archived`. Then move the slug:

   ```bash
   spec_root=docs/specs          # or the configured non-default Spec Root
   if [ "$spec_root" = "docs/specs" ]; then
     archive_root="docs/history/specs"
   else
     archive_root="$spec_root/_archived"
   fi
   mkdir -p "$archive_root"
   git mv "$spec_root/<slug>" "$archive_root/<slug>"
   ```

3. **Commit** — `chore(specs): archive <slug>` (Conventional Commits). Do not push unless asked.

4. **Report** — the new path, the release reference when one was stamped, and anything carried over (open follow-ups from task `## Result` sections belong in new specs, not in the archive).

5. **Suggest the publish step** — when the work isn't merged yet, close by suggesting the PR (via `github-pr-workflow`). This is where that suggestion lives in the workflow — the implement loop ends at the archive and doesn't offer it. Suggest only: opening the PR is the user's call.

## Unarchive

Rare, explicit, reversed: `git mv` back, set `status: active`, remove `release`/`archived`. Reopening usually means new work — prefer a fresh spec that references the archived one.

## Anti-patterns

- Archiving with failing or missing QA silently — the override must be the user's word, on the record.
- Blocking the archive on a merged PR or release tag — completion (tasks + QA) is the gate; publishing is a separate, user-driven step.
- Leaving a completed spec in the active set "until the PR merges" — the active folder is for live work only.
- Editing an archived spec — it is a record; new requirements get a new spec that links back.
- Deleting instead of archiving — the graveyard is where "didn't we already try this?" gets answered.

