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:
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.
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).
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:
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:
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
Stamp _prd.md frontmatter:
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
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:
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>"
Commit — chore(specs): archive <slug> (Conventional Commits). Do not push unless asked.
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).
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.
1---2name: archive-spec3description: 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.4---56# Archive Spec78Move 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.910The 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.1112## Preconditions — verify, don't trust1314Check all three with fresh command evidence before touching anything:15161. **Every task completed.** Read each `task_NN.md` listed in `_tasks.md`; every17 `status` must be `completed`.1819 **Command:** run `grep -n '^status:' <each-task-file-listed-in-_tasks.md>` and20 retain its output. Any value other than `status: completed` blocks the21 archive and names the Task file.22232. **QA passed.** The newest report in `qa/` must pass the repository's QA24 verifier. Do not substitute a line grep for structured validation. In25 Roundfix, use the same `internal/spec.QAVerdict` contract as the Archive26 Command: select the newest report by the `qa-report-YYYY-MM-DD[-NN].md`27 filename contract, parse its YAML frontmatter, require a supported28 `verdict`, require both blocked-row fields to be non-negative integers when29 present, and reject `verdict: pass` when `rows_blocked_finding` is nonzero.30 Retain the verifier's report path and result as evidence. A missing `qa/`31 directory, malformed newest report, or non-passing verdict blocks the32 archive; proceed only if the user explicitly says "archive anyway", and33 record that override in the stamped frontmatter (`qa_override: true`).34353. **The Spec is self-contained.** Apply this precondition when36 `docs/specs/<slug>/references/_index.md` exists or is a symbolic link; a37 symbolic link, including a broken one, is invalid index state rather than a38 legacy Spec. Only a Spec where that path neither exists nor is a symbolic39 link predates this contract and passes without retrofitting historical40 artifacts. For an indexed Spec, every indexed `path` must exist relative to41 `_index.md`, every never-updated `source` path must be absent, and no42 Markdown link destination inside the Spec may point into `docs/_inbox/` or43 `docs/findings/`.4445 **Commands:** first run this link-destination check; its syntax deliberately46 matches inline or reference-style Markdown links, not prose that merely47 names either tree:4849 ```bash50 spec_dir=docs/specs/<slug>51 link_hits=$(grep --include='*.md' -RInE '(\]\([^)]*(docs/)?(_inbox|findings)/[^)]*\)|^[[:space:]]*\[[^]]+\]:[[:space:]]*<?[^[:space:]>]*(docs/)?(_inbox|findings)/)' "$spec_dir")52 link_status=$?53 if test "$link_status" -eq 0; then54 printf '%s\n' "$link_hits"55 echo "self-containment failed: rewrite each listed link at adoption step 8"56 exit 157 fi58 test "$link_status" -eq 1 || exit "$link_status"59 ```6061 Then parse and validate every data row. The index belongs to the current62 Spec: `owner` must equal its four-digit prefix, `type` must be `inbox`,63 `finding`, or `backlog`, and each `source` and `path` must appear only once. A `path` must64 be one basename relative to `_index.md`; reject absolute paths, `.`, `..`,65 path separators, and symbolic links instead of allowing traversal or a link66 outside `references/`. Run the following from the repository root and67 retain the normalized rows plus any diagnostic as evidence:6869 ```bash70 slug=<slug>71 index="docs/specs/$slug/references/_index.md"72 expected_owner=${slug%%-*}73 parsed_index=$(mktemp) || exit 174 trap 'rm -f "$parsed_index"' EXIT HUP INT TERM7576 if test -L "$(dirname "$index")"; then77 printf 'self-containment failed: references/ must not be a symbolic link\n' >&278 exit 179 fi8081 if test -L "$index" || test ! -f "$index"; then82 printf 'self-containment failed: references/_index.md must be a regular, non-symbolic-link file\n' >&283 exit 184 fi8586 awk -F '|' -v expected_owner="$expected_owner" '87 function trim(value) {88 gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)89 return value90 }91 function reject(message) {92 print "self-containment failed: " message > "/dev/stderr"93 invalid = 194 }95 function separator(value) {96 value = trim(value)97 return value ~ /^:?-{3,}:?$/98 }99 /^[[:space:]]*$/ || /^[[:space:]]*#/ { next }100 !header {101 if (NF != 7 || trim($2) != "source" || trim($3) != "type" ||102 trim($4) != "owner" || trim($5) != "adopted date" ||103 trim($6) != "path") {104 reject("_index.md must use the fixed source | type | owner | adopted date | path header")105 } else {106 header = 1107 }108 next109 }110 !divider {111 if (NF != 7 || !separator($2) || !separator($3) || !separator($4) ||112 !separator($5) || !separator($6)) {113 reject("_index.md has an invalid table separator")114 } else {115 divider = 1116 }117 next118 }119 {120 source = trim($2)121 type = trim($3)122 owner = trim($4)123 adopted = trim($5)124 path = trim($6)125 if (NF != 7 || source == "" || type == "" || owner == "" ||126 adopted == "" || path == "") {127 reject("invalid index row at line " NR ": " $0)128 next129 }130 if (type != "inbox" && type != "finding" && type != "backlog") {131 reject("type must be `inbox`, `finding`, or `backlog` at line " NR ": " type)132 }133 if (owner != expected_owner) {134 reject("owner must be " expected_owner " at line " NR ": " owner)135 }136 if (seen_source[source]++) {137 reject("duplicate source at line " NR ": " source)138 }139 if (seen_path[path]++) {140 reject("duplicate path at line " NR ": " path)141 }142 if (adopted !~ /^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]$/) {143 reject("adopted date must be YYYY-MM-DD at line " NR ": " adopted)144 }145 if ((type == "inbox" && source !~ /^docs\/_inbox\/[^\/]+\.md$/) ||146 (type == "finding" && source !~ /^docs\/findings\/[^\/]+\.md$/) ||147 (type == "backlog" && source !~ /^docs\/backlog\/[^\/]+\.md$/)) {148 reject("source does not match type at line " NR ": " source)149 }150 print source "|" type "|" owner "|" adopted "|" path151 }152 END {153 if (!header || !divider) {154 reject("_index.md is missing its fixed table header")155 }156 if (invalid) {157 exit 1158 }159 }160 ' "$index" > "$parsed_index" || exit $?161162 while IFS='|' read -r source type owner adopted path; do163 case "$path" in164 ""|.|..|/*|*/*|*\\*)165 printf 'self-containment failed: path must be one basename relative to `_index.md`: %s\n' "$path" >&2166 exit 1167 ;;168 esac169 source_basename=${source##*/}170 if test "$path" != "$source_basename"; then171 printf 'self-containment failed: path must equal source basename for %s: %s != %s\n' "$source" "$path" "$source_basename" >&2172 exit 1173 fi174 current="$(dirname "$index")/$path"175 if test -L "$current" || test ! -f "$current"; then176 printf 'self-containment failed: invalid or missing path %s; finish adoption step 7\n' "$path" >&2177 exit 1178 fi179 if test -e "$source" || test -L "$source"; then180 printf 'self-containment failed: source still exists at %s; finish adoption step 7\n' "$source" >&2181 exit 1182 fi183 done < "$parsed_index"184 cat "$parsed_index"185 ```186187`qa_override: true` overrides only failed or missing QA evidence in precondition1882. It never overrides self-containment: verification can be overridden by the189maintainer, but self-containment is a property of the artifact and must be190repaired by finishing adoption.191192A 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.193194If any check fails, stop and report the offending Task, report, source, or link195and the adoption step that fixes a self-containment failure — the Spec stays196active.197198## Steps1992001. **Stamp** `_prd.md` frontmatter:201202 ```yaml203 status: archived204 archived: YYYY-MM-DD205 qa_override: true # only when archiving despite failed/missing QA206 release: <tag or PR URL> # only when known — from --release or an already-merged PR/tag207 ```2082092. **Move** with history preserved. Resolve the configured Spec Root and its210 archive root first: the built-in root `docs/specs` archives to211 `docs/history/specs`; an external or non-default root `<spec-root>` archives212 beside the active root at `<spec-root>/_archived`. Then move the slug:213214 ```bash215 spec_root=docs/specs # or the configured non-default Spec Root216 if [ "$spec_root" = "docs/specs" ]; then217 archive_root="docs/history/specs"218 else219 archive_root="$spec_root/_archived"220 fi221 mkdir -p "$archive_root"222 git mv "$spec_root/<slug>" "$archive_root/<slug>"223 ```2242253. **Commit** — `chore(specs): archive <slug>` (Conventional Commits). Do not push unless asked.2262274. **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).2282295. **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.230231## Unarchive232233Rare, 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.234235## Anti-patterns236237- Archiving with failing or missing QA silently — the override must be the user's word, on the record.238- Blocking the archive on a merged PR or release tag — completion (tasks + QA) is the gate; publishing is a separate, user-driven step.239- Leaving a completed spec in the active set "until the PR merges" — the active folder is for live work only.240- Editing an archived spec — it is a record; new requirements get a new spec that links back.241- Deleting instead of archiving — the graveyard is where "didn't we already try this?" gets answered.