@gobing-ai/spur 0.3.74 → 0.3.76

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +6 -0
  3. package/config/rules/README.md +1 -1
  4. package/config/rules/strict/runtime-boundaries.yaml +1 -0
  5. package/config/rules/typescript/no-eslint-suppressions.yaml +30 -0
  6. package/config/workflows/docs-pipeline.yaml +26 -1
  7. package/config/workflows/feature-dev.yaml +143 -102
  8. package/config/workflows/idea-pipeline.yaml +58 -11
  9. package/config/workflows/task-pipeline.yaml +76 -40
  10. package/config/workflows/wrapup-pipeline.yaml +183 -111
  11. package/package.json +9 -9
  12. package/plugins/sp/agents/expert-spur.md +7 -4
  13. package/plugins/sp/commands/dev-idea.md +1 -1
  14. package/plugins/sp/commands/dev-refineall.md +2 -2
  15. package/plugins/sp/plugin.json +1 -1
  16. package/plugins/sp/skills/dogfood-testing/SKILL.md +7 -4
  17. package/plugins/sp/skills/spur-cli/references/tasks/section-editing.md +9 -3
  18. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +5 -3
  19. package/plugins/sp/skills/spur-cli/references/tasks.md +3 -1
  20. package/plugins/sp/skills/spur-cli/references/workflows.md +3 -3
  21. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +23 -8
  22. package/plugins/sp/skills/spur-dev/references/dev-operations.md +3 -3
  23. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +4 -1
  24. package/schemas/spur-config.schema.json +4 -0
  25. package/spur.js +3675 -838
  26. package/web/_astro/{BoardApp.C60RJZRj.js → BoardApp.CHQ1lycZ.js} +92 -92
  27. package/web/_astro/BoardApp.DV9kx0wo.js +1 -0
  28. package/web/_astro/{TaskDetail.Guk8VuNu.js → TaskDetail.GKfQJ60c.js} +1 -1
  29. package/web/_astro/{arc.CAZtlWJM.js → arc.DWEtA3Tx.js} +1 -1
  30. package/web/_astro/{architectureDiagram-3BPJPVTR.9-XbF_Tz.js → architectureDiagram-3BPJPVTR.DB42oWmP.js} +1 -1
  31. package/web/_astro/{blockDiagram-GPEHLZMM.DC4WLx3L.js → blockDiagram-GPEHLZMM.rhv-zNQV.js} +1 -1
  32. package/web/_astro/{c4Diagram-AAUBKEIU.JgjUQMgG.js → c4Diagram-AAUBKEIU.Ci4-4VvY.js} +1 -1
  33. package/web/_astro/channel.BAI6xLeV.js +1 -0
  34. package/web/_astro/{chunk-2J33WTMH.D_JFiXa-.js → chunk-2J33WTMH.Cc9veUgf.js} +1 -1
  35. package/web/_astro/{chunk-4BX2VUAB.Bk_RxoeT.js → chunk-4BX2VUAB.Bec9c4eI.js} +1 -1
  36. package/web/_astro/{chunk-55IACEB6.a_Rj2VxL.js → chunk-55IACEB6.DoV8S1iB.js} +1 -1
  37. package/web/_astro/{chunk-727SXJPM.C2NoS1U6.js → chunk-727SXJPM.DwR-Qlyj.js} +1 -1
  38. package/web/_astro/{chunk-AQP2D5EJ.D1Xn6CNa.js → chunk-AQP2D5EJ.ND_a81WY.js} +1 -1
  39. package/web/_astro/{chunk-FMBD7UC4.BhJrSBsX.js → chunk-FMBD7UC4.Wv_jwG48.js} +1 -1
  40. package/web/_astro/{chunk-ND2GUHAM.ByVGduYQ.js → chunk-ND2GUHAM.CXKXCMmp.js} +1 -1
  41. package/web/_astro/{chunk-QZHKN3VN.B47Paunq.js → chunk-QZHKN3VN.nkaoNYQq.js} +1 -1
  42. package/web/_astro/{classDiagram-4FO5ZUOK.BERMSD5C.js → classDiagram-4FO5ZUOK.cMQcVlQu.js} +1 -1
  43. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BERMSD5C.js → classDiagram-v2-Q7XG4LA2.cMQcVlQu.js} +1 -1
  44. package/web/_astro/{cose-bilkent-S5V4N54A.O_rHGlhU.js → cose-bilkent-S5V4N54A.OaDJ7Mr2.js} +1 -1
  45. package/web/_astro/{cynefin-OW5HDTMX.qCW2GdNp.js → cynefin-OW5HDTMX.Chi8IphF.js} +1 -1
  46. package/web/_astro/{dagre-BM42HDAG.C5Y4lH_u.js → dagre-BM42HDAG.CzK2t_Fp.js} +1 -1
  47. package/web/_astro/{diagram-2AECGRRQ.DxZmRXxF.js → diagram-2AECGRRQ.DRvxlVS7.js} +1 -1
  48. package/web/_astro/{diagram-5GNKFQAL.qSxZeYSK.js → diagram-5GNKFQAL.CnYvNdwA.js} +1 -1
  49. package/web/_astro/{diagram-KO2AKTUF.-7vl3bXs.js → diagram-KO2AKTUF.CpLpMw5R.js} +1 -1
  50. package/web/_astro/{diagram-LMA3HP47.BHVV-UH3.js → diagram-LMA3HP47.JTb78qUA.js} +1 -1
  51. package/web/_astro/{diagram-OG6HWLK6.DHAuW9sK.js → diagram-OG6HWLK6.Bk-1jDIb.js} +1 -1
  52. package/web/_astro/{erDiagram-TEJ5UH35.DMRLQwPN.js → erDiagram-TEJ5UH35.D8hN9GZq.js} +1 -1
  53. package/web/_astro/{flowDiagram-I6XJVG4X.B3EpPp_8.js → flowDiagram-I6XJVG4X.-6zQr6m5.js} +1 -1
  54. package/web/_astro/{ganttDiagram-6RSMTGT7.BgUrExXM.js → ganttDiagram-6RSMTGT7.DboLQ9ca.js} +1 -1
  55. package/web/_astro/{gitGraphDiagram-PVQCEYII.CU-9yVN4.js → gitGraphDiagram-PVQCEYII.4tYvJKGR.js} +1 -1
  56. package/web/_astro/index.Dcr_8fiK.css +1 -0
  57. package/web/_astro/{infoDiagram-5YYISTIA.CvVTCRLe.js → infoDiagram-5YYISTIA.Bd9rXpsB.js} +1 -1
  58. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BUzOfKPf.js → ishikawaDiagram-YF4QCWOH.CvMoaf67.js} +1 -1
  59. package/web/_astro/{journeyDiagram-JHISSGLW.BBTb7ziz.js → journeyDiagram-JHISSGLW.Ccy1CA7y.js} +1 -1
  60. package/web/_astro/{kanban-definition-UN3LZRKU.CTQQr70i.js → kanban-definition-UN3LZRKU.0MaMqHNS.js} +1 -1
  61. package/web/_astro/{linear.CLKlJPiS.js → linear.CHXgcIbN.js} +1 -1
  62. package/web/_astro/{mermaid.core.BA4wFhLP.js → mermaid.core.Ca-kcelG.js} +4 -4
  63. package/web/_astro/{mindmap-definition-RKZ34NQL.t1vG2l5e.js → mindmap-definition-RKZ34NQL.BUIDlHa0.js} +1 -1
  64. package/web/_astro/{pieDiagram-4H26LBE5.C8zJhyTu.js → pieDiagram-4H26LBE5.2dX3CU1s.js} +1 -1
  65. package/web/_astro/{quadrantDiagram-W4KKPZXB.DqYYvb7T.js → quadrantDiagram-W4KKPZXB.B3LBlRiv.js} +1 -1
  66. package/web/_astro/{requirementDiagram-4Y6WPE33.lzfDZ9nI.js → requirementDiagram-4Y6WPE33.X12I2uNx.js} +1 -1
  67. package/web/_astro/{sankeyDiagram-5OEKKPKP.lm6N5ORM.js → sankeyDiagram-5OEKKPKP.BXohIHqx.js} +1 -1
  68. package/web/_astro/{sequenceDiagram-3UESZ5HK.E6_IV4Dj.js → sequenceDiagram-3UESZ5HK.C37ZIUzg.js} +1 -1
  69. package/web/_astro/{stateDiagram-AJRCARHV.Bnv6Ok2p.js → stateDiagram-AJRCARHV.BRgz317z.js} +1 -1
  70. package/web/_astro/{stateDiagram-v2-BHNVJYJU.mJHH_Ng_.js → stateDiagram-v2-BHNVJYJU.7VYSXN9-.js} +1 -1
  71. package/web/_astro/{timeline-definition-PNZ67QCA.BhRgqSLa.js → timeline-definition-PNZ67QCA.BVNz_HiN.js} +1 -1
  72. package/web/_astro/{vennDiagram-CIIHVFJN.pjb5DMC9.js → vennDiagram-CIIHVFJN.CHVDkPX4.js} +1 -1
  73. package/web/_astro/{wardleyDiagram-YWT4CUSO.CW20KUng.js → wardleyDiagram-YWT4CUSO.EQQ_qT9v.js} +1 -1
  74. package/web/_astro/{xychartDiagram-2RQKCTM6.vX9_yuMl.js → xychartDiagram-2RQKCTM6.DrAT9WoP.js} +1 -1
  75. package/web/index.html +2 -2
  76. package/web/_astro/BoardApp.ymfj1EHA.js +0 -1
  77. package/web/_astro/channel.Bxxe2Byc.js +0 -1
  78. package/web/_astro/index.BhvM3djt.css +0 -1
@@ -52,6 +52,19 @@
52
52
  # longer be silently re-interpreted as an empty list). All captures are
53
53
  # run-scoped under .spur/run/<runId>-wrapup-*; a lookup failure is
54
54
  # recorded, never silently omitted as success.
55
+ # - 0783 validated consumers (audit 0781 F-04): validation accepts only
56
+ # canonical four-digit WBS strings (whitespace is rejected, not trimmed);
57
+ # the route writer, every guard, model prompt and operator note after
58
+ # resolution read the run-scoped capture, never raw vars.tasks, and a
59
+ # missing/corrupted capture or status refuses progression. Metrics
60
+ # revalidate the capture, require well-shaped task lookups, serialize rows
61
+ # with jq (never interpolated printf), and write PASS only after every
62
+ # required append succeeds. Required feature sync succeeds only for a
63
+ # valid matching proposal with no gateBlocked/requiresConfirm condition
64
+ # whose target status is freshly observed; applied:false is a successful
65
+ # no-op only when from == to and that status is observed — nonzero,
66
+ # malformed, partial, blocked or unreadable outcomes fail explicitly, and
67
+ # an affected-feature check can never convert a failed sync into success.
55
68
  # - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer
56
69
  # defense) and consent-only: it records the decision, performs no git op.
57
70
  # - Wrap-up never mutates task status (consumes completed work only).
@@ -59,7 +72,7 @@
59
72
  "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
60
73
  kind: state-machine
61
74
  name: wrapup-pipeline
62
- version: "1"
75
+ version: "3"
63
76
  description: "Post-execution wrap-up: doc-sync (doc drift + learning capture), metrics, feature-transition, branch-cleanup"
64
77
  iterationBound: 10
65
78
  initialState: start
@@ -99,54 +112,33 @@ states:
99
112
  - id: task-resolve
100
113
  description: >
101
114
  Validate the task list ONCE and evaluate closed proportional routing (0758
102
- R1-R4). Validation (0770): vars.tasks must be a JSON array of non-empty
103
- WBS strings, deduplicated in first-seen order, and every member must
104
- resolve via `spur task show` to a completed status (done/cancelled).
105
- Invalid input or an unresolved/non-completed task records FAIL (reason
106
- into the run-scoped reason file) and routes to `failed`; siblings consume
107
- the normalized run-scoped artifact .spur/run/<runId>-wrapup-tasks.json
108
- and never re-parse raw input. A validated empty list -> skipped;
109
- tasks > 0 && mode == fast -> fast-path (metrics-record, bypassing
110
- doc-sync); missing/unknown/conflict -> doc-sync (safety-path). Writes
111
- bounded machine-readable reason to reasonFile.
115
+ R1-R4). Validation (0770 + 0783): vars.tasks must be a JSON array of
116
+ canonical four-digit WBS strings non-arrays, non-strings, whitespace or
117
+ other shapes are rejected, not trimmed deduplicated in first-seen
118
+ order, with __runId present, and every member must resolve via `spur task
119
+ show` to a completed status (done/cancelled); failed or malformed
120
+ lookups fail. Invalid input or an unresolved/non-completed task records
121
+ FAIL (reason into the run-scoped reason file) and routes to `failed`;
122
+ the route writer and every guard, prompt and note after resolution read
123
+ the normalized run-scoped artifact
124
+ .spur/run/<runId>-wrapup-tasks.json and never re-parse raw input — a
125
+ missing or corrupted capture refuses progression. A validated empty list
126
+ -> skipped; length > 0 && mode == fast -> fast-path (metrics-record,
127
+ bypassing doc-sync); missing/unknown/conflict -> doc-sync (safety-path).
128
+ Writes bounded machine-readable reason to reasonFile.
112
129
  onEnter:
113
130
  - kind: note
114
131
  options:
115
132
  message: "Resolving task list: ${vars.tasks} (proportional mode: ${vars.mode})"
116
- # Run-id confinement (0758 R3). The run-scoped reason file is the only route artifact: a
117
- # second copy at the fixed path `.spur/run/wrapup-route-reason.txt` had no reader and was
118
- # overwritten by whichever run finished last, so a route claim read from it belonged to no
119
- # particular run. The log append carries the run id for the same reason — an unattributed
120
- # line is log scraping, which R5 rejects as evidence.
121
- - kind: shell
122
- options:
123
- command: >-
124
- mkdir -p .spur/run .spur/memory &&
125
- RUN_ID="$__runId" &&
126
- if [ -z "$RUN_ID" ]; then RUN_ID="wrapup"; fi &&
127
- REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
128
- if [ "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -eq 0 ]; then
129
- echo "skipped:empty task list" > "$REASON_FILE";
130
- elif [ "$mode" = "fast" ]; then
131
- echo "fast:evidence complete+consistent" > "$REASON_FILE";
132
- elif [ -z "$mode" ]; then
133
- echo "safety:missing evidence (mode empty)" > "$REASON_FILE";
134
- elif [ "$mode" = "unknown" ]; then
135
- echo "safety:unknown evidence quality" > "$REASON_FILE";
136
- elif [ "$mode" = "conflict" ]; then
137
- echo "safety:conflicting evidence" > "$REASON_FILE";
138
- else
139
- echo "safety:unrecognized evidence (mode=$mode)" > "$REASON_FILE";
140
- fi &&
141
- printf '%s %s\n' "$RUN_ID" "$(cat "$REASON_FILE")"
142
- >> .spur/memory/wrapup-routes.log &&
143
- exit 0
144
- # 0770 truthful input: parse and validate vars.tasks exactly once, here.
145
- # Malformed JSON, a non-array, a non-string or empty entry, an unresolved
146
- # task, or a task not at a completed status records FAIL (routed to
147
- # `failed` by the first declared edge) and is never re-interpreted as an
148
- # empty list by a later sibling guard. The normalized, deduplicated list
149
- # is persisted run-scoped; metrics/doc siblings only ever read it.
133
+ # 0770/0783 truthful input: parse and validate vars.tasks exactly once,
134
+ # here and only here. Malformed JSON, a non-array, a non-string entry,
135
+ # whitespace or a non-canonical WBS (anything but four digits), a missing
136
+ # __runId, an unresolved task, a failed or malformed lookup, or a task
137
+ # not at a completed status records FAIL (routed to `failed` by the first
138
+ # declared edge) and is never re-interpreted as an empty list by a later
139
+ # sibling guard. The normalized, deduplicated list is persisted
140
+ # run-scoped; the route writer, metrics, prompts, notes and guards only
141
+ # ever read that capture.
150
142
  - kind: shell
151
143
  options:
152
144
  command: >-
@@ -159,9 +151,9 @@ states:
159
151
  REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
160
152
  STATUS_FILE=".spur/run/$RUN_ID-wrapup-resolve.status" &&
161
153
  TASKS_FILE=".spur/run/$RUN_ID-wrapup-tasks.json" &&
162
- if ! printf '%s' "$tasks" | jq -e 'type == "array" and all(.[]; type == "string" and length > 0)' > /dev/null 2>&1; then
163
- echo "task-resolve: tasks must be a JSON array of non-empty WBS strings" >&2;
164
- echo "failed:tasks is not a JSON array of non-empty WBS strings" > "$REASON_FILE";
154
+ if ! printf '%s' "$tasks" | jq -e 'type == "array" and all(.[]; type == "string" and test("^[0-9]{4}$"))' > /dev/null 2>&1; then
155
+ echo "task-resolve: tasks must be a JSON array of canonical four-digit WBS strings (whitespace is rejected, not trimmed)" >&2;
156
+ echo "failed:tasks is not a JSON array of canonical four-digit WBS strings" > "$REASON_FILE";
165
157
  printf 'FAIL\n' > "$STATUS_FILE";
166
158
  else
167
159
  printf '%s' "$tasks" | jq -c 'reduce .[] as $w ([]; if any(.[]; . == $w) then . else . + [$w] end)' > "$TASKS_FILE";
@@ -184,6 +176,45 @@ states:
184
176
  fi;
185
177
  fi;
186
178
  exit 0
179
+ # Route reason writer (0758 R3/R4 run-attribution pins intact). Runs AFTER
180
+ # validation and reads ONLY the validated run-scoped capture — never raw
181
+ # vars.tasks (0783 R2/R5); the fixed-path fallback is gone because
182
+ # validation refuses an empty __runId above. A FAIL resolve exits without
183
+ # writing: the failed reason written by validation stands, and the skip
184
+ # reason can never mask it. A missing/corrupted capture yields N=-1 and
185
+ # no skipped claim; the failed defense edge owns the run.
186
+ - kind: shell
187
+ options:
188
+ command: >-
189
+ mkdir -p .spur/run .spur/memory &&
190
+ RUN_ID="$__runId" &&
191
+ if [ -z "$RUN_ID" ]; then
192
+ echo "task-resolve: __runId is empty — refusing to write a route reason" >&2;
193
+ exit 1;
194
+ fi &&
195
+ REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
196
+ STATUS_FILE=".spur/run/$RUN_ID-wrapup-resolve.status" &&
197
+ TASKS_FILE=".spur/run/$RUN_ID-wrapup-tasks.json" &&
198
+ if [ "$(cat "$STATUS_FILE" 2>/dev/null)" = FAIL ]; then
199
+ exit 0;
200
+ fi &&
201
+ N=$(jq length "$TASKS_FILE" 2>/dev/null || echo -1) &&
202
+ if [ "$N" -eq 0 ]; then
203
+ echo "skipped:empty task list" > "$REASON_FILE";
204
+ elif [ "$mode" = "fast" ]; then
205
+ echo "fast:evidence complete+consistent" > "$REASON_FILE";
206
+ elif [ -z "$mode" ]; then
207
+ echo "safety:missing evidence (mode empty)" > "$REASON_FILE";
208
+ elif [ "$mode" = "unknown" ]; then
209
+ echo "safety:unknown evidence quality" > "$REASON_FILE";
210
+ elif [ "$mode" = "conflict" ]; then
211
+ echo "safety:conflicting evidence" > "$REASON_FILE";
212
+ else
213
+ echo "safety:unrecognized evidence (mode=$mode)" > "$REASON_FILE";
214
+ fi &&
215
+ printf '%s %s\n' "$RUN_ID" "$(cat "$REASON_FILE")"
216
+ >> .spur/memory/wrapup-routes.log &&
217
+ exit 0
187
218
 
188
219
  - id: doc-sync
189
220
  description: >
@@ -200,9 +231,11 @@ states:
200
231
  options:
201
232
  agent: ${vars.agent}
202
233
  input: >-
203
- First run Skill(skill="sp:doc-evolve", args="wrapup tasks=${vars.tasks}") to repair drift in
234
+ First run Skill(skill="sp:doc-evolve", args="wrapup") to repair drift in
204
235
  docs/00_ADR.md, docs/03_ARCHITECTURE.md, docs/04_DESIGN.md, docs/design/* per constitution edit
205
- rules; do not write task/feature corpus. THEN extract working learnings from tasks ${vars.tasks}
236
+ rules; do not write task/feature corpus. The batch task list is the normalized, validated
237
+ capture at .spur/run/${vars.__runId}-wrapup-tasks.json — read it for the WBS ids; never
238
+ re-parse raw input. THEN extract working learnings from those tasks —
206
239
  conventions, errors fixed, patterns, gotchas, grouped by date and task WBS — as raw markdown
207
240
  (no fences) and END your final message with that markdown (it is captured to the
208
241
  run-scoped .spur/run/${vars.__runId}-wrapup-learnings.md).
@@ -228,9 +261,14 @@ states:
228
261
  - id: metrics-record
229
262
  description: >
230
263
  Append one JSONL row per task to .spur/memory/wrapup-metrics.jsonl deterministically.
231
- Reads ONLY the normalized run-scoped task list written by task-resolve (0770):
232
- a lookup failure for any member records FAIL and routes to `failed` — a
233
- missing metrics row is never silently absorbed as success.
264
+ Revalidates the normalized run-scoped capture from task-resolve (0770 + 0783):
265
+ a missing, corrupted or non-canonical capture records FAIL; every member must
266
+ produce a successful, well-shaped `task show` lookup (a missing metrics row is
267
+ never silently absorbed as success); rows are serialized with jq — never
268
+ interpolated printf JSON — so escaped fields stay parseable; and PASS is
269
+ written only after every required append succeeds. A missing per-task verdict
270
+ is UNKNOWN telemetry, never proof of completion; previously written valid rows
271
+ survive a failure.
234
272
  onEnter:
235
273
  - kind: shell
236
274
  options:
@@ -239,63 +277,69 @@ states:
239
277
  STATUS_FILE=".spur/run/$__runId-wrapup-metrics.status" &&
240
278
  TASKS_FILE=".spur/run/$__runId-wrapup-tasks.json" &&
241
279
  METRICS_RC=0 &&
242
- for wbs in $(jq -r '.[]' "$TASKS_FILE" 2>/dev/null); do
243
- task_json=$($spurBin task show "$wbs" --json 2>/dev/null || true);
244
- if [ -n "$task_json" ]; then
245
- feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // empty' 2>/dev/null || true);
246
- stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"' 2>/dev/null || true);
247
- verdict="UNKNOWN";
248
- if [ -f ".spur/run/$wbs-verdict.json" ]; then
249
- v=$(jq -r '.verdict // "UNKNOWN"' ".spur/run/$wbs-verdict.json" 2>/dev/null || true);
250
- if [ -n "$v" ]; then verdict="$v"; fi;
280
+ if ! jq -e 'type == "array" and all(.[]; type == "string" and test("^[0-9]{4}$"))' "$TASKS_FILE" > /dev/null 2>&1; then
281
+ echo "metrics-record: run-scoped task capture missing, corrupted or non-canonical — refusing to record metrics" >&2;
282
+ printf 'FAIL\n' > "$STATUS_FILE";
283
+ else
284
+ for wbs in $(jq -r '.[]' "$TASKS_FILE"); do
285
+ task_json=$($spurBin task show "$wbs" --json 2>/dev/null || true);
286
+ if printf '%s' "$task_json" | jq -e '(.frontmatter.status // .status) != null' > /dev/null 2>&1; then
287
+ feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // ""');
288
+ stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"');
289
+ verdict="UNKNOWN";
290
+ if [ -f ".spur/run/$wbs-verdict.json" ]; then
291
+ v=$(jq -r '.verdict // "UNKNOWN"' ".spur/run/$wbs-verdict.json" 2>/dev/null || true);
292
+ if [ -n "$v" ]; then verdict="$v"; fi;
293
+ fi;
294
+ ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
295
+ if ! jq -cn --arg wbs "$wbs" --arg feature_id "$feat" --arg status "$stat" --arg verdict "$verdict" --arg timestamp "$ts" '{wbs:$wbs, feature_id:$feature_id, status:$status, verdict:$verdict, timestamp:$timestamp}' >> .spur/memory/wrapup-metrics.jsonl; then
296
+ echo "metrics-record: metrics append failed for task $wbs — recording FAIL instead of claiming the row landed" >&2;
297
+ METRICS_RC=1;
298
+ fi;
299
+ else
300
+ echo "metrics-record: task $wbs lookup failed or was malformed — recording FAIL instead of silently omitting its metrics row" >&2;
301
+ METRICS_RC=1;
251
302
  fi;
252
- ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
253
- printf '{"wbs":"%s","feature_id":"%s","status":"%s","verdict":"%s","timestamp":"%s"}\n' "$wbs" "$feat" "$stat" "$verdict" "$ts" >> .spur/memory/wrapup-metrics.jsonl;
254
- else
255
- echo "metrics-record: task $wbs did not resolve — recording FAIL instead of silently omitting its metrics row" >&2;
256
- METRICS_RC=1;
257
- fi;
258
- done;
259
- if [ "$METRICS_RC" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi;
303
+ done;
304
+ if [ "$METRICS_RC" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi;
305
+ fi;
260
306
  exit 0
261
307
 
262
308
  - id: feature-transition
263
309
  description: >
264
310
  If vars.feature is set, sync feature status via bounded feature-sync-bounded
265
- (0411). Soft shell the run is never aborted after learnings/metrics
266
- landed; instead (0770) a failed required sync or a failed feature gate
267
- records FAIL in the run-scoped sync status and routes to `failed`, and a
268
- successful sync that applied no transition is reported as an explicit
269
- no-change. Empty vars.feature is NOT a blocked sync but a mis-invocation
311
+ (0411). The shell terminates `exit 0` so artifacts already landed are never
312
+ discarded, but the outcome is truthful (0770 + 0783 R4): sync succeeds only
313
+ with a valid proposal matching vars.feature, no gateBlocked/requiresConfirm
314
+ condition, and a freshly observed feature status equal to proposal.to;
315
+ applied:false is a successful explicit no-change only when from == to and
316
+ that status is observed. A nonzero, malformed, partial, blocked or
317
+ unreadable outcome records FAIL in the run-scoped sync status and routes
318
+ to `failed` — an affected-feature check cannot convert a failed sync into
319
+ success. Empty vars.feature is NOT a blocked sync but a mis-invocation
270
320
  (the engine only enters this state when vars.feature is set) and fails
271
- loud (dogfood 2026-08-15, feature I3: a silent exit 0 made the wrap look
272
- complete with no transition).
321
+ loud (dogfood 2026-08-15, feature I3).
273
322
  onEnter:
274
- # Genuinely soft: the terminating `exit 0` must be reached on BOTH
275
- # branches, so
276
- # the sync is chained with `;` with `&&` a non-zero sync skipped `exit
277
- # 0` and
278
- # aborted the run at this state, discarding the learnings/metrics that
279
- # already
280
- # landed and never reaching branch-cleanup/done (the exact failure the
281
- # state
282
- # description promises against). `feature-sync-bounded.ts` is a
283
- # Spur-monorepo
284
- # path (`spur init` does not scaffold `plugins/sp/`), so seeded projects
285
- # fall
286
- # back to the plain `spur feature sync` verb.
323
+ # The terminating `exit 0` must be reached on every branch — the sync is
324
+ # chained with `;`, because `&&` would abort the state, discarding the
325
+ # learnings/metrics that already landed. Soft TERMINATION is not soft
326
+ # TRUTH: the result is classified per 0783 R4 and only a verified sync
327
+ # writes PASS. `feature-sync-bounded.ts` is a Spur-monorepo path (`spur
328
+ # init` does not scaffold `plugins/sp/`), so seeded projects fall back to
329
+ # the plain `spur feature sync` verb.
287
330
  # R1 (0625): a feature transition that changed state may have left
288
- # cross-task fallout the per-task fast gates never see. Run the
289
- # affected-feature gate (featureGateCmd) after an
290
- # applied sync OR a non-zero exit that may follow a partial multi-hop
291
- # transition, then report PASS/FAIL. Soft by design: the operator
292
- # decides, and wrap-up never hard-fails here.
331
+ # cross-task fallout the per-task fast gates never see. The
332
+ # affected-feature gate (featureGateCmd) runs after an applied sync OR a
333
+ # non-zero exit that may follow a partial multi-hop transition. It is a
334
+ # diagnostic only: a gate PASS can never convert a failed sync into
335
+ # success, and a gate FAIL fails the step (0770 failure routing).
293
336
  # NOTE: do not add
294
337
  # `#` comments INSIDE the folded shell string — YAML `>-` folds them onto
295
338
  # the following statement line and comments it out.
296
339
  - kind: shell
297
340
  options:
298
341
  command: >-
342
+ mkdir -p .spur/run &&
299
343
  if [ -z "$feature" ]; then
300
344
  echo "feature-transition: vars.feature is empty — refusing no-op feature sync (mis-invocation, not a blocked sync)" >&2;
301
345
  exit 1;
@@ -309,10 +353,32 @@ states:
309
353
  fi);
310
354
  SYNC_RC=$?;
311
355
  printf '%s\n' "$SYNC_OUTPUT";
312
- if printf '%s' "$SYNC_OUTPUT" | jq -e 'has("applied")' > /dev/null 2>&1; then
313
- APPLIED=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.applied // false' 2>/dev/null || echo false);
356
+ OBSERVED=$($spurBin feature show "$feature" --json 2>/dev/null | jq -r '.status // .frontmatter.status // empty' 2>/dev/null || true);
357
+ if [ -z "$OBSERVED" ]; then OBSERVED=unreadable; fi;
358
+ APPLIED=unreadable;
359
+ SYNC_OK=0;
360
+ REASON="";
361
+ if [ "$SYNC_RC" -ne 0 ]; then
362
+ REASON="sync exited nonzero (rc=$SYNC_RC)";
363
+ elif ! printf '%s' "$SYNC_OUTPUT" | jq -e 'type == "object" and (.proposal | type == "object") and (.proposal.featureId | type == "string") and (.proposal.from | type == "string") and (.proposal.to | type == "string") and (.applied | type == "boolean")' > /dev/null 2>&1; then
364
+ REASON="malformed or unreadable sync result";
314
365
  else
315
- APPLIED="invalid";
366
+ P_FROM=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.from // ""');
367
+ P_TO=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.to // ""');
368
+ APPLIED=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.applied');
369
+ if [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.featureId // ""')" != "$feature" ]; then
370
+ REASON="sync proposal does not match feature $feature";
371
+ elif [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.gateBlocked // false')" = "true" ]; then
372
+ REASON="sync proposal is gate-blocked — a blocked sync is not a no-change success";
373
+ elif [ "$(printf '%s' "$SYNC_OUTPUT" | jq -r '.proposal.requiresConfirm // false')" = "true" ]; then
374
+ REASON="sync proposal requires operator confirmation";
375
+ elif [ "$APPLIED" = "true" ] && [ "$OBSERVED" != "$P_TO" ]; then
376
+ REASON="applied sync did not land on the proposal target (observed=$OBSERVED, to=$P_TO)";
377
+ elif [ "$APPLIED" = "false" ] && { [ "$P_FROM" != "$P_TO" ] || [ "$OBSERVED" != "$P_TO" ]; }; then
378
+ REASON="sync applied nothing without a from==to observed no-op (from=$P_FROM, to=$P_TO, observed=$OBSERVED)";
379
+ else
380
+ SYNC_OK=1;
381
+ fi;
316
382
  fi;
317
383
  GATE="skipped";
318
384
  if [ "$APPLIED" = "true" ] || [ "$SYNC_RC" -ne 0 ]; then
@@ -325,10 +391,17 @@ states:
325
391
  echo "feature-transition: feature gate FAIL for feature $feature — inspect findings before reporting the transition complete" >&2;
326
392
  fi;
327
393
  else
328
- echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — feature gate skipped (explicit no-change)";
394
+ echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — feature gate skipped";
329
395
  fi;
330
396
  SYNC_STATUS="PASS";
331
- if [ "$SYNC_RC" -ne 0 ] || [ "$GATE" = "FAIL" ] || [ "$APPLIED" = "invalid" ]; then SYNC_STATUS="FAIL"; fi;
397
+ if [ "$SYNC_OK" -ne 1 ] || [ "$GATE" = "FAIL" ]; then
398
+ SYNC_STATUS="FAIL";
399
+ echo "feature-transition: required synchronization failed for $feature — $REASON; gate=$GATE" >&2;
400
+ elif [ "$APPLIED" = "false" ]; then
401
+ echo "feature-transition: feature sync verified for $feature (from==to observed at $OBSERVED, gate=$GATE) — explicit no-change";
402
+ else
403
+ echo "feature-transition: feature sync verified for $feature (applied, observed=$OBSERVED, gate=$GATE)";
404
+ fi;
332
405
  if [ "$SYNC_STATUS" = "PASS" ]; then
333
406
  printf 'PASS\n' > ".spur/run/$__runId-wrapup-sync.status";
334
407
  else
@@ -348,7 +421,7 @@ states:
348
421
  onEnter:
349
422
  - kind: hitl.confirm
350
423
  options:
351
- prompt: "Branch cleanup for tasks ${vars.tasks}. This is IRREVERSIBLE (merge or delete). Confirm to proceed?"
424
+ prompt: "Branch cleanup for the validated task list (.spur/run/${vars.__runId}-wrapup-tasks.json). This is IRREVERSIBLE (merge or delete). Confirm to proceed?"
352
425
 
353
426
  - id: done
354
427
  description: >
@@ -358,12 +431,7 @@ states:
358
431
  onEnter:
359
432
  - kind: note
360
433
  options:
361
- message: "Wrap-up pipeline complete for tasks: ${vars.tasks}. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl. Branch cleanup: consent recorded only (merge=${vars.merge}); no git operation performed by wrap-up."
362
- # Checkpoint write: record session state for resume (Phase 4, task 0171
363
- # R3)
364
- - kind: shell
365
- options:
366
- command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: wrapup-pipeline done tasks=$tasks ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/wrapup-checkpoint.md'
434
+ message: "Wrap-up pipeline complete for the validated task list at .spur/run/${vars.__runId}-wrapup-tasks.json. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl. Branch cleanup: consent recorded only (merge=${vars.merge}); no git operation performed by wrap-up."
367
435
 
368
436
  - id: skipped
369
437
  description: Terminal — wrap-up skipped (empty task list or operator abort).
@@ -394,27 +462,31 @@ transitions:
394
462
  kind: shell
395
463
  options:
396
464
  command: 'test "$(cat .spur/run/$__runId-wrapup-resolve.status 2>/dev/null)" = FAIL'
465
+ # Route edges key on the validated run-scoped capture, never raw vars.tasks
466
+ # (0783 R2). A missing or corrupted capture yields -1, which satisfies none
467
+ # of the numeric edges, so the run falls through to the always-defense
468
+ # `failed` edge instead of claiming a skip or a route.
397
469
  - from: task-resolve
398
470
  to: skipped
399
- description: Task list is empty — skip wrap-up.
471
+ description: Validated task list is empty — skip wrap-up (only a validated [] may skip).
400
472
  guard:
401
473
  kind: shell
402
474
  options:
403
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -eq 0'
475
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -eq 0'
404
476
  - from: task-resolve
405
477
  to: metrics-record
406
478
  description: Proportional fast path — complete and consistent evidence bypasses doc-sync.
407
479
  guard:
408
480
  kind: shell
409
481
  options:
410
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" = fast'
482
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" = fast'
411
483
  - from: task-resolve
412
484
  to: doc-sync
413
485
  description: Proportional safety path — missing/unknown/conflicting evidence routes to full doc-sync.
414
486
  guard:
415
487
  kind: shell
416
488
  options:
417
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" != fast'
489
+ command: 'test "$(jq length .spur/run/$__runId-wrapup-tasks.json 2>/dev/null || echo -1)" -gt 0 && test "$mode" != fast'
418
490
  - from: task-resolve
419
491
  to: failed
420
492
  description: >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.74",
3
+ "version": "0.3.76",
4
4
  "description": "Spur CLI — local-first harness for mainstream coding agents: constraint checking, workflow orchestration, agent health, and history analytics. Bun-native; exposes the `spur` command.",
5
5
  "keywords": [
6
6
  "spur",
@@ -53,14 +53,14 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@commander-js/extra-typings": "^14.0.0",
56
- "@gobing-ai/ts-db": "^0.4.56",
57
- "@gobing-ai/ts-ai-runner": "^0.4.56",
58
- "@gobing-ai/ts-dual-workflow-engine": "^0.4.56",
59
- "@gobing-ai/ts-infra": "^0.4.56",
60
- "@gobing-ai/ts-llm-jsonl-importer": "^0.4.56",
61
- "@gobing-ai/ts-rule-engine": "^0.4.56",
62
- "@gobing-ai/ts-runtime": "^0.4.56",
63
- "@gobing-ai/ts-utils": "^0.4.56",
56
+ "@gobing-ai/ts-db": "^0.4.57",
57
+ "@gobing-ai/ts-ai-runner": "^0.4.57",
58
+ "@gobing-ai/ts-dual-workflow-engine": "^0.4.57",
59
+ "@gobing-ai/ts-infra": "^0.4.57",
60
+ "@gobing-ai/ts-llm-jsonl-importer": "^0.4.57",
61
+ "@gobing-ai/ts-rule-engine": "^0.4.57",
62
+ "@gobing-ai/ts-runtime": "^0.4.57",
63
+ "@gobing-ai/ts-utils": "^0.4.57",
64
64
  "@types/bun": "1.3.14",
65
65
  "@types/figlet": "^1.7.0",
66
66
  "@types/node-notifier": "8.0.5",
@@ -46,8 +46,10 @@ CLI nouns for direct use, but they are not this corpus specialist's scope.
46
46
  3. Run the noun's read/check/validate path before mutation where available.
47
47
  4. Mutate only through `spur`; parse `--json` output when the verb advertises it.
48
48
  5. Inspect each result before the next dependent operation; stop on structural or validation failure.
49
- 6. Run the scoped check/validate/refresh path after mutation. After task/feature batch writes, run
50
- `spur task check --corpus --json` once.
49
+ 6. Run affected-input checks after mutation (constitution T11): after task/feature batch writes,
50
+ run `spur task check <wbs>` / `spur feature check <id>` for each changed document and its
51
+ required linked evidence — not a corpus sweep. The explicit unsuppressed audit
52
+ (`spur task check --corpus --json`) is reserved for checker-policy changes (T10).
51
53
 
52
54
  Workflow fit, mode selection, simplicity budgets, authoring, and tuning live in the workflow
53
55
  references under `plugins/sp/skills/spur-cli/references/workflows/`; load them rather than copying
@@ -59,7 +61,7 @@ their runbook here.
59
61
 
60
62
  - Use the source-local CLI when working in the Spur repository.
61
63
  - Use `spur task update --section --from-file` for task section writes.
62
- - Keep check-before/write/check-after evidence and the final scoped refresh result.
64
+ - Keep check-before/write/check-after evidence and the final scoped validation result.
63
65
  - Preserve declaration order and currently executing runs when changing workflows.
64
66
 
65
67
  ### Never
@@ -87,7 +89,8 @@ their runbook here.
87
89
  ### Gates
88
90
  - pre-check: <result>
89
91
  - post-check/validate: <result>
90
- - refresh/corpus sweep: <result or n/a>
92
+ - scoped validation: <affected task/feature checks + linked evidence, or n/a; explicit T10
93
+ corpus audit only when checker policy changed>
91
94
  ```
92
95
 
93
96
  ## Platform Notes
@@ -46,4 +46,4 @@ vars as subsets of `--approve-taste` (`idea_approved` / `design_approved`). Pref
46
46
  - `auto`/name: launch `spur workflow run idea-pipeline.yaml --async`, observe with one `workflow trace --follow`, and only report cancellation as stopped when `workflow cancel --json` returns `killed: true`.
47
47
  - `Skill(skill="sp:spur-dev", args="idea $ARGUMENTS")`
48
48
  - Stage contract (discovery → idea-eval → feature-create → AC → feature-check → system-design →
49
- decompose → batch-create → handoff): `plugins/sp/skills/spur-dev/references/dev-operations.md` § idea.
49
+ decompose → batch-create → ready-prepare → handoff): `plugins/sp/skills/spur-dev/references/dev-operations.md` § idea.
@@ -23,7 +23,7 @@ operation, applied to a resolved set (typically every task under a feature). Pas
23
23
  | `--agent` `<inline\|auto\|name>` | Who runs the model-bearing refinement. | omit |
24
24
  | `--auto` | Skip objective HITL gates. | off |
25
25
  | `--keep-going` | Continue past per-task failures. | off |
26
- | `--status` `<s>` | Only refine tasks in a status. | backlog,todo |
26
+ | `--status` `<s>` | Only refine tasks in these statuses (applied in-agent to the frozen set). | `backlog` + `todo` |
27
27
  | `--json` | Emit structured JSON. | off |
28
28
  | `--worktree` `[<name>]` | Run the batch in an isolated git worktree; FF-merge on success, retain on failure. Bare `--worktree` creates a fresh tree; `--worktree <name>` adopts an existing worktree by name/path/branch. | off |
29
29
 
@@ -39,7 +39,7 @@ For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag
39
39
  Flags: `--feature` (sugar for `feature:<id>`), `--tasks <selector>`, shared refine flags
40
40
  (`--focus`, `--description`, `--depth`, `--agent`, `--auto`),
41
41
  plus `--keep-going`,
42
- `--status` (default `backlog,todo`),
42
+ `--status` (default `backlog` + `todo`),
43
43
  `--json`, `--worktree` `[<name>]` (run the batch in an isolated git worktree — FF-merge onto the base ref on
44
44
  full success, retain intact on any failure/halt/non-FF; bare form creates a fresh tree, `<name>`
45
45
  form adopts an existing worktree by name/path/branch; see `execution-batch.md` § Worktree
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.74",
3
+ "version": "0.3.76",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -75,8 +75,11 @@ The command forwards these via `$ARGUMENTS`:
75
75
  > independent mutation sources is present:
76
76
  >
77
77
  > - **Pipeline-driving testees** — tokens
78
- > [`--next`, `dev-runall`, `dev-wrapall`, `dev-run`, `dev-wrap`, `dev-idea`,
79
- > `runall`, `wrapall`, `run`, `wrap`, `idea`] matched as a **distinct hyphen-word**
78
+ > <!-- pipeline-tokens:start -->
79
+ > [`--next`, `dev-runall`, `dev-wrapall`, `dev-refineall`, `dev-verifyall`, `dev-run`, `dev-wrap`, `dev-idea`,
80
+ > `refineall`, `verifyall`, `runall`, `wrapall`, `run`, `wrap`, `idea`]
81
+ > <!-- pipeline-tokens:end -->
82
+ > matched as a **distinct hyphen-word**
80
83
  > (machine-checked by
81
84
  > [`detectPipelineDriving`](../../scripts/dogfood-testing/detect-pipeline-driving.ts);
82
85
  > see [§Pipeline-driving word-boundary contract](#pipeline-driving-word-boundary-contract))
@@ -335,8 +338,8 @@ node "$(superskill script path sp dogfood-testing/detect-pipeline-driving.mjs)"
335
338
 
336
339
  | Token shape | Examples | Matches | Rejects |
337
340
  |-------------|----------|---------|---------|
338
- | Flag / complete | `--next`, `dev-run`, `dev-runall`, `dev-wrap`, `dev-wrapall`, `dev-idea` | `/sp:dev-run 0125`, bare `--next` | `--next-gen`, `dev-runner` |
339
- | Bare noun | `run`, `runall`, `wrap`, `wrapall`, `idea` | `task run 0042` | `runaway`, `wrapper`, `idealist` |
341
+ | Flag / complete | <!-- pipeline-tokens:start -->`--next`, `dev-run`, `dev-runall`, `dev-wrap`, `dev-wrapall`, `dev-idea`, `dev-refineall`, `dev-verifyall`<!-- pipeline-tokens:end --> | `/sp:dev-run 0125`, bare `--next` | `--next-gen`, `dev-runner` |
342
+ | Bare noun | <!-- pipeline-tokens:start -->`run`, `runall`, `wrap`, `wrapall`, `idea`, `refineall`, `verifyall`<!-- pipeline-tokens:end --> | `task run 0042` | `runaway`, `wrapper`, `idealist` |
340
343
 
341
344
  `-` is a **word character** for boundaries: a token must be a distinct hyphen-word. Contract tests:
342
345
  `plugins/sp/tests/dogfood-testing/pipeline-detect.test.ts`. Helpers:
@@ -9,8 +9,8 @@ see_also:
9
9
 
10
10
  Task bodies are edited section-by-section through `spur task update --section <name> --from-file
11
11
  <path>`. The write is **file-wins and crash-safe** (atomic write): the named section's body is
12
- replaced wholesale from the file you point at. There is no inline-body flag always stage the new
13
- body in a file first.
12
+ replaced wholesale from the file you point at with one exception, `Q&A`, which appends (see
13
+ below). There is no inline-body flag — always stage the new body in a file first.
14
14
 
15
15
  For **pipeline output**, section authorship is one-writer-per-section (F92 0593 R1):
16
16
  `Testing` comes from `spur task record` (deterministic, from a verify verdict artifact — the
@@ -23,6 +23,8 @@ for `Plan`, `Acceptance Criteria`, hand-authored `Solution`, and any narrative s
23
23
 
24
24
  1. **Assemble the full section body** in a temp file. The body is everything *under* the `###`
25
25
  heading — do not include the heading line itself; the CLI owns the heading.
26
+ Sub-headings inside the body MUST be `####` or deeper: a `###` in a body parses as a new
27
+ top-level section and trips `L2.disallowed-section` (task 0787 grew 8 phantom sections this way).
26
28
 
27
29
  ```bash
28
30
  cat > /tmp/review.md <<'EOF'
@@ -42,7 +44,11 @@ for `Plan`, `Acceptance Criteria`, hand-authored `Solution`, and any narrative s
42
44
 
43
45
  3. The whole `### Review` body is now that file's contents. To amend rather than overwrite, read
44
46
  the current body (`spur task show 0040`), edit the temp file to the full desired state, and
45
- replace again — there is no append mode.
47
+ replace again — there is no append mode for ordinary sections.
48
+
49
+ **`Q&A` is the exception.** `--section "Q&A"` APPENDS a timestamped `#### Q&A entry — <ISO>`
50
+ block rather than replacing the section. Start the body with `<!-- qa:replace -->` to replace it
51
+ wholesale.
46
52
 
47
53
  `--section` **requires** `--from-file` (exit `2` otherwise). Section names match the DD-08 headings
48
54
  exactly: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Root Cause`, `Testing`, `Review`, `References`, `History`, `Notes` (universal sections are `History`, `References`, `Notes`; `Root Cause` is carried by the `issue` template variant).
@@ -61,8 +61,10 @@ frontmatter scalar.
61
61
  to avoid orphaned nested lifecycle runs). **It is not a guard bypass** — the `wip→testing` and
62
62
  `testing→done` `check` gates above still run; the CLI evaluates them inline when the FSM guard
63
63
  does not. `--force-done` waives the verify **verdict** only, never the section matrix.
64
- - **Section** (`--section` **requires** `--from-file`): replaces the entire named section body from
65
- the file. No inline-body flag. Section names: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Testing`, `Review`, `References`, `History`, `Notes`.
64
+ - **Section** (`--section` **requires** `--from-file`): writes the named section body from the file.
65
+ Most sections replace wholesale. Exception: `--section "Q&A"` APPENDS a timestamped
66
+ `#### Q&A entry — <ISO>` block; start the body with `<!-- qa:replace -->` to replace the section
67
+ wholesale. No inline-body flag. Section names: `Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Testing`, `Review`, `References`, `History`, `Notes`.
66
68
  - **Frontmatter** (`--feature <id>`, `--priority <p>`): sets the scalar frontmatter field on an
67
69
  existing task — the only post-create path, allow-listed to `feature_id` / `parent_wbs` / `priority`.
68
70
 
@@ -85,7 +87,7 @@ Flags: `--folder <path>`, `--json`. Exit codes: `0` success, `1` error, `2` usag
85
87
 
86
88
  ## `sections <wbs> <op> [name]`
87
89
 
88
- CLI-safe, matrix-enforced task section mutation. Section names are validated against canonical sections (`Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Root Cause`, `Testing`, `Review`, `References`, `History`, `Notes`). Universal sections (`History`, `References`, `Notes`) are always allowed; `Root Cause` is carried by the `issue` template variant.
90
+ CLI-safe, matrix-enforced task section mutation. Section names are validated against canonical sections (`Background`, `Requirements`, `Acceptance Criteria`, `Q&A`, `Design`, `Plan`, `Solution`, `Root Cause`, `Testing`, `Review`, `References`, `History`, `Notes`). Universal sections (`History`, `References`, `Notes`) are always allowed; `Root Cause` is carried by the `issue` template variant. `Q&A` on `update --section` appends rather than replacing (see the Section bullet above); `<!-- qa:replace -->` forces a wholesale replace.
89
91
 
90
92
  | Op | Usage | Description |
91
93
  | --- | --- | --- |