@gobing-ai/spur 0.3.73 → 0.3.74

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 (88) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +15 -4
  3. package/config/pipeline-budgets.json +4 -4
  4. package/config/rules/strict/runtime-boundaries.yaml +1 -1
  5. package/config/templates/AGENTS.md +5 -0
  6. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +2 -0
  7. package/config/workflows/basic.yaml +5 -2
  8. package/config/workflows/docs-pipeline.yaml +92 -55
  9. package/config/workflows/feature-dev.yaml +43 -20
  10. package/config/workflows/feature-lifecycle.yaml +1 -0
  11. package/config/workflows/history-anatomy.yaml +8 -4
  12. package/config/workflows/idea-pipeline.yaml +66 -33
  13. package/config/workflows/pr-review.yaml +31 -8
  14. package/config/workflows/task-lifecycle.yaml +1 -0
  15. package/config/workflows/task-pipeline.yaml +929 -905
  16. package/config/workflows/wayfinder-resolution.yaml +124 -90
  17. package/config/workflows/wrapup-pipeline.yaml +208 -83
  18. package/package.json +1 -1
  19. package/plugins/sp/plugin.json +1 -1
  20. package/plugins/sp/scripts/history-anatomy-cache.mjs +7 -1
  21. package/plugins/sp/scripts/history-anatomy-cache.ts +11 -2
  22. package/plugins/sp/scripts/pr-reviewing.mjs +5 -1
  23. package/plugins/sp/scripts/pr-reviewing.ts +7 -1
  24. package/plugins/sp/scripts/transition-shim-check.ts +6 -6
  25. package/plugins/sp/skills/pr-reviewing/SKILL.md +4 -1
  26. package/plugins/sp/skills/spur-cli/references/self.md +17 -3
  27. package/plugins/sp/skills/spur-cli/references/tasks.md +1 -1
  28. package/plugins/sp/skills/spur-cli/references/workflows/workflow-fit-and-tuning.md +5 -5
  29. package/plugins/sp/skills/spur-cli/references/workflows.md +4 -4
  30. package/plugins/sp/skills/spur-dev/SKILL.md +17 -0
  31. package/plugins/sp/skills/spur-dev/references/execution-batch.md +3 -3
  32. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -2
  33. package/plugins/sp/skills/wayfinder/SKILL.md +1 -1
  34. package/spur.js +1125 -784
  35. package/web/_astro/{BoardApp.BYCNkMOn.js → BoardApp.C60RJZRj.js} +78 -77
  36. package/web/_astro/BoardApp.ymfj1EHA.js +1 -0
  37. package/web/_astro/{TaskDetail.CgUreSP2.js → TaskDetail.Guk8VuNu.js} +1 -1
  38. package/web/_astro/{arc.BySSh34M.js → arc.CAZtlWJM.js} +1 -1
  39. package/web/_astro/{architectureDiagram-3BPJPVTR.DM46TS_h.js → architectureDiagram-3BPJPVTR.9-XbF_Tz.js} +1 -1
  40. package/web/_astro/{blockDiagram-GPEHLZMM.tZhvNUHA.js → blockDiagram-GPEHLZMM.DC4WLx3L.js} +1 -1
  41. package/web/_astro/{c4Diagram-AAUBKEIU.PT4Or4Nf.js → c4Diagram-AAUBKEIU.JgjUQMgG.js} +1 -1
  42. package/web/_astro/channel.Bxxe2Byc.js +1 -0
  43. package/web/_astro/{chunk-2J33WTMH.J9r0_Bbe.js → chunk-2J33WTMH.D_JFiXa-.js} +1 -1
  44. package/web/_astro/{chunk-4BX2VUAB.hzyeIvhR.js → chunk-4BX2VUAB.Bk_RxoeT.js} +1 -1
  45. package/web/_astro/{chunk-55IACEB6.B0rO7qVh.js → chunk-55IACEB6.a_Rj2VxL.js} +1 -1
  46. package/web/_astro/{chunk-727SXJPM.wE_Uk5D4.js → chunk-727SXJPM.C2NoS1U6.js} +1 -1
  47. package/web/_astro/{chunk-AQP2D5EJ.DqEEjQw7.js → chunk-AQP2D5EJ.D1Xn6CNa.js} +1 -1
  48. package/web/_astro/{chunk-FMBD7UC4.CDoD9sBX.js → chunk-FMBD7UC4.BhJrSBsX.js} +1 -1
  49. package/web/_astro/{chunk-ND2GUHAM.CtX5nF9P.js → chunk-ND2GUHAM.ByVGduYQ.js} +1 -1
  50. package/web/_astro/{chunk-QZHKN3VN.CK_EwfaT.js → chunk-QZHKN3VN.B47Paunq.js} +1 -1
  51. package/web/_astro/{classDiagram-4FO5ZUOK.DLt5a8Lh.js → classDiagram-4FO5ZUOK.BERMSD5C.js} +1 -1
  52. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DLt5a8Lh.js → classDiagram-v2-Q7XG4LA2.BERMSD5C.js} +1 -1
  53. package/web/_astro/{cose-bilkent-S5V4N54A.CMCWP49h.js → cose-bilkent-S5V4N54A.O_rHGlhU.js} +1 -1
  54. package/web/_astro/{cynefin-OW5HDTMX.HyXw_vdS.js → cynefin-OW5HDTMX.qCW2GdNp.js} +1 -1
  55. package/web/_astro/{dagre-BM42HDAG.BTuAzh01.js → dagre-BM42HDAG.C5Y4lH_u.js} +1 -1
  56. package/web/_astro/{diagram-2AECGRRQ.D9dr9wfT.js → diagram-2AECGRRQ.DxZmRXxF.js} +1 -1
  57. package/web/_astro/{diagram-5GNKFQAL.C4Rot0hj.js → diagram-5GNKFQAL.qSxZeYSK.js} +1 -1
  58. package/web/_astro/{diagram-KO2AKTUF.B_TK5uWC.js → diagram-KO2AKTUF.-7vl3bXs.js} +1 -1
  59. package/web/_astro/{diagram-LMA3HP47.JkXKK7CO.js → diagram-LMA3HP47.BHVV-UH3.js} +1 -1
  60. package/web/_astro/{diagram-OG6HWLK6.BzMN8Bd6.js → diagram-OG6HWLK6.DHAuW9sK.js} +1 -1
  61. package/web/_astro/{erDiagram-TEJ5UH35.DVZaWGUd.js → erDiagram-TEJ5UH35.DMRLQwPN.js} +1 -1
  62. package/web/_astro/{flowDiagram-I6XJVG4X.rjEiWUfR.js → flowDiagram-I6XJVG4X.B3EpPp_8.js} +1 -1
  63. package/web/_astro/{ganttDiagram-6RSMTGT7.C_EgAarK.js → ganttDiagram-6RSMTGT7.BgUrExXM.js} +1 -1
  64. package/web/_astro/{gitGraphDiagram-PVQCEYII.B-QQSDsK.js → gitGraphDiagram-PVQCEYII.CU-9yVN4.js} +1 -1
  65. package/web/_astro/{index.B5MTfe7k.css → index.BhvM3djt.css} +1 -1
  66. package/web/_astro/{infoDiagram-5YYISTIA.DlWesz7T.js → infoDiagram-5YYISTIA.CvVTCRLe.js} +1 -1
  67. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BUMZOawi.js → ishikawaDiagram-YF4QCWOH.BUzOfKPf.js} +1 -1
  68. package/web/_astro/{journeyDiagram-JHISSGLW.CWfkxfjY.js → journeyDiagram-JHISSGLW.BBTb7ziz.js} +1 -1
  69. package/web/_astro/{kanban-definition-UN3LZRKU.B-YpMwXf.js → kanban-definition-UN3LZRKU.CTQQr70i.js} +1 -1
  70. package/web/_astro/{linear.D7uqzENp.js → linear.CLKlJPiS.js} +1 -1
  71. package/web/_astro/{mermaid.core.CxrNppBD.js → mermaid.core.BA4wFhLP.js} +4 -4
  72. package/web/_astro/{mindmap-definition-RKZ34NQL.B4Qe7cM2.js → mindmap-definition-RKZ34NQL.t1vG2l5e.js} +1 -1
  73. package/web/_astro/{pieDiagram-4H26LBE5.Ds-5j2ro.js → pieDiagram-4H26LBE5.C8zJhyTu.js} +1 -1
  74. package/web/_astro/{quadrantDiagram-W4KKPZXB.tBd38uNC.js → quadrantDiagram-W4KKPZXB.DqYYvb7T.js} +1 -1
  75. package/web/_astro/{requirementDiagram-4Y6WPE33.sFENkWl3.js → requirementDiagram-4Y6WPE33.lzfDZ9nI.js} +1 -1
  76. package/web/_astro/{sankeyDiagram-5OEKKPKP.BeB-Hk7C.js → sankeyDiagram-5OEKKPKP.lm6N5ORM.js} +1 -1
  77. package/web/_astro/{sequenceDiagram-3UESZ5HK.DnTeaSpx.js → sequenceDiagram-3UESZ5HK.E6_IV4Dj.js} +1 -1
  78. package/web/_astro/{stateDiagram-AJRCARHV.B-8Jt5EJ.js → stateDiagram-AJRCARHV.Bnv6Ok2p.js} +1 -1
  79. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Br7xoqMW.js → stateDiagram-v2-BHNVJYJU.mJHH_Ng_.js} +1 -1
  80. package/web/_astro/{timeline-definition-PNZ67QCA.C-3WdOyi.js → timeline-definition-PNZ67QCA.BhRgqSLa.js} +1 -1
  81. package/web/_astro/{vennDiagram-CIIHVFJN.DCIs7Lc6.js → vennDiagram-CIIHVFJN.pjb5DMC9.js} +1 -1
  82. package/web/_astro/{wardleyDiagram-YWT4CUSO.rGAL-bbz.js → wardleyDiagram-YWT4CUSO.CW20KUng.js} +1 -1
  83. package/web/_astro/{xychartDiagram-2RQKCTM6.hkfQKiRl.js → xychartDiagram-2RQKCTM6.vX9_yuMl.js} +1 -1
  84. package/web/index.html +2 -2
  85. package/config/corpus-baseline.json +0 -1805
  86. package/config/workflow-composition-baseline.json +0 -615
  87. package/web/_astro/BoardApp.E12MFjOS.js +0 -1
  88. package/web/_astro/channel.5cYKr5cs.js +0 -1
@@ -17,7 +17,10 @@
17
17
  # -> feature-transition (conditional: if vars.feature set)
18
18
  # -> branch-cleanup (conditional: if vars.merge=true)
19
19
  # -> done
20
- # (task-resolve with empty list short-circuits to `skipped`).
20
+ # task-resolve with a VALIDATED empty list short-circuits to `skipped`;
21
+ # invalid wrap input, an unresolved/non-completed task, a failed metrics
22
+ # capture, a failed required sync, or a failed feature gate routes to
23
+ # `failed` (0770) instead of being absorbed by an always-defensive skip.
21
24
  #
22
25
  # Vars (passed as a JSON object via `--vars`):
23
26
  # tasks — JSON array of WBS strings, passed as a JSON-encoded STRING value
@@ -39,21 +42,33 @@
39
42
  # a
40
43
  # single model query that both repairs doc drift (sp:doc-evolve) and captures
41
44
  # working learnings (task 0607 R2 — one query answers both; count 2 -> 1).
42
- # - feature-transition is a soft shell (always exit 0) so a blocked feature sync
43
- # does not abort wrap-up after learnings/metrics already landed.
45
+ # - feature-transition is a soft shell (always exit 0); a failed required sync
46
+ # or feature gate records FAIL and routes to `failed` instead of claiming
47
+ # success (0770).
48
+ # - 0770 truthful outcomes: vars.tasks is parsed and validated ONCE at
49
+ # task-resolve (JSON array of non-empty WBS strings, every member resolved
50
+ # via `task show` to a completed status); siblings consume the normalized
51
+ # run-scoped artifact and never re-parse raw input (malformed input can no
52
+ # longer be silently re-interpreted as an empty list). All captures are
53
+ # run-scoped under .spur/run/<runId>-wrapup-*; a lookup failure is
54
+ # recorded, never silently omitted as success.
44
55
  # - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer
45
- # defense).
56
+ # defense) and consent-only: it records the decision, performs no git op.
46
57
  # - Wrap-up never mutates task status (consumes completed work only).
47
58
 
48
59
  "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
49
60
  kind: state-machine
50
61
  name: wrapup-pipeline
62
+ version: "1"
51
63
  description: "Post-execution wrap-up: doc-sync (doc drift + learning capture), metrics, feature-transition, branch-cleanup"
52
64
  iterationBound: 10
53
65
  initialState: start
54
66
  terminalStates:
55
67
  - done
56
68
  - skipped
69
+ - failed
70
+ failureStates:
71
+ - failed
57
72
  vars:
58
73
  tasks: "[]"
59
74
  feature: ""
@@ -64,22 +79,11 @@ vars:
64
79
  __runId: ""
65
80
  mode: ""
66
81
  stepTimeoutMs: "1800000"
67
- # Corpus-aware quality gate for the feature-transition hop (R1, task 0625).
68
- # The per-task gate (`spur-check`) deliberately excludes corpus-check the
69
- # only
70
- # sweep that observes feature-level findings — so a transition that arms one
71
- # would go green. This gate is therefore the CORPUS SWEEP ALONE (~29 s), not
72
- # `spur-check-new`: every task in the feature already paid a full `spur-check`
73
- # in its own pipeline, and the states that run before this hop (`doc-sync`,
74
- # `metrics-record`) write only markdown and task sections, which Biome skips
75
- # (`ignoreUnknown: true`). Re-running the ~105 s per-task gate here
76
- # re-verifies
77
- # unchanged code and measures nothing new. Soft by design: the gate reports
78
- # PASS/FAIL and lets the operator decide; it never hard-fails the wrap-up
79
- # shell.
82
+ # Recheck the affected feature after sync (D61 / ADR-108), not the corpus
83
+ # or full code suite. Explicit caller overrides remain supported.
80
84
  # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (same surface as
81
85
  # task-pipeline's qualityGateCmd). Never interpolate untrusted input into it.
82
- featureGateCmd: "bun run corpus-check"
86
+ featureGateCmd: '$spurBin feature check "$feature"'
83
87
  __hitlAnswer: ""
84
88
 
85
89
  states:
@@ -94,10 +98,17 @@ states:
94
98
 
95
99
  - id: task-resolve
96
100
  description: >
97
- Validate the task list and evaluate closed proportional routing (0758 R1-R4).
98
- Empty list -> skipped; tasks > 0 && mode == fast -> fast-path (metrics-record,
99
- bypassing doc-sync); missing/unknown/conflict -> doc-sync (safety-path).
100
- Writes bounded machine-readable reason to reasonFile.
101
+ 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.
101
112
  onEnter:
102
113
  - kind: note
103
114
  options:
@@ -130,6 +141,49 @@ states:
130
141
  printf '%s %s\n' "$RUN_ID" "$(cat "$REASON_FILE")"
131
142
  >> .spur/memory/wrapup-routes.log &&
132
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.
150
+ - kind: shell
151
+ options:
152
+ command: >-
153
+ mkdir -p .spur/run &&
154
+ RUN_ID="$__runId" &&
155
+ if [ -z "$RUN_ID" ]; then
156
+ echo "task-resolve: __runId is empty — refusing the legacy fixed-path fallback" >&2;
157
+ exit 1;
158
+ fi &&
159
+ REASON_FILE=".spur/run/$RUN_ID-route-reason.txt" &&
160
+ STATUS_FILE=".spur/run/$RUN_ID-wrapup-resolve.status" &&
161
+ 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";
165
+ printf 'FAIL\n' > "$STATUS_FILE";
166
+ else
167
+ printf '%s' "$tasks" | jq -c 'reduce .[] as $w ([]; if any(.[]; . == $w) then . else . + [$w] end)' > "$TASKS_FILE";
168
+ RESOLVE_RC=0;
169
+ for wbs in $(jq -r '.[]' "$TASKS_FILE"); do
170
+ TASK_JSON=$($spurBin task show "$wbs" --json 2>/dev/null || true);
171
+ TASK_STATUS=$(printf '%s' "$TASK_JSON" | jq -r '.frontmatter.status // .status // empty' 2>/dev/null || true);
172
+ if [ "$TASK_STATUS" != "done" ] && [ "$TASK_STATUS" != "cancelled" ]; then
173
+ STATUS_DISPLAY="$TASK_STATUS";
174
+ if [ -z "$STATUS_DISPLAY" ]; then STATUS_DISPLAY=unresolved; fi;
175
+ echo "task-resolve: task $wbs did not resolve to a completed status (status=$STATUS_DISPLAY)" >&2;
176
+ RESOLVE_RC=1;
177
+ fi;
178
+ done;
179
+ if [ "$RESOLVE_RC" -eq 0 ]; then
180
+ printf 'PASS\n' > "$STATUS_FILE";
181
+ else
182
+ echo "failed:unresolved or non-completed task (see $TASKS_FILE)" > "$REASON_FILE";
183
+ printf 'FAIL\n' > "$STATUS_FILE";
184
+ fi;
185
+ fi;
186
+ exit 0
133
187
 
134
188
  - id: doc-sync
135
189
  description: >
@@ -150,59 +204,72 @@ states:
150
204
  docs/00_ADR.md, docs/03_ARCHITECTURE.md, docs/04_DESIGN.md, docs/design/* per constitution edit
151
205
  rules; do not write task/feature corpus. THEN extract working learnings from tasks ${vars.tasks} —
152
206
  conventions, errors fixed, patterns, gotchas, grouped by date and task WBS — as raw markdown
153
- (no fences) and END your final message with that markdown (it is captured to .spur/run/wrapup-learnings.md).
207
+ (no fences) and END your final message with that markdown (it is captured to the
208
+ run-scoped .spur/run/${vars.__runId}-wrapup-learnings.md).
154
209
  # Declared Layer-1 role (0538 R2): routing reason beside the agent:
155
210
  # pin.
156
211
  role: coder
157
- answerFile: .spur/run/wrapup-learnings.md
158
- expectFile: .spur/run/wrapup-learnings.md
212
+ answerFile: .spur/run/${vars.__runId}-wrapup-learnings.md
213
+ expectFile: .spur/run/${vars.__runId}-wrapup-learnings.md
159
214
  timeoutMs: ${vars.stepTimeoutMs}
160
215
  - kind: shell
161
216
  options:
162
217
  command: >-
163
- mkdir -p .spur/memory &&
164
- if test -s .spur/run/wrapup-learnings.md; then
165
- cat .spur/run/wrapup-learnings.md >> .spur/memory/learnings.md &&
218
+ mkdir -p .spur/run .spur/memory &&
219
+ LEARNINGS_FILE=".spur/run/$__runId-wrapup-learnings.md" &&
220
+ if test -s "$LEARNINGS_FILE"; then
221
+ cat "$LEARNINGS_FILE" >> .spur/memory/learnings.md &&
166
222
  printf '\n' >> .spur/memory/learnings.md;
167
223
  else
168
- echo "wrapup: learning capture missing or empty at $PWD/.spur/run/wrapup-learnings.md - append skipped" >&2;
224
+ echo "wrapup: learning capture missing or empty at $PWD/$LEARNINGS_FILE - append skipped" >&2;
169
225
  fi &&
170
226
  exit 0
171
227
 
172
228
  - id: metrics-record
173
229
  description: >
174
230
  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.
175
234
  onEnter:
176
235
  - kind: shell
177
236
  options:
178
237
  command: >-
179
- mkdir -p .spur/memory &&
180
- printf '%s' "$tasks" | jq -r '(if type == "string" then (try fromjson catch .) else . end) | if type == "array" then .[] else empty end' |
181
- while IFS= read -r wbs; do
182
- if [ -n "$wbs" ]; then
183
- task_json=$($spurBin task show "$wbs" --json 2>/dev/null || true);
184
- if [ -n "$task_json" ]; then
185
- feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // empty' 2>/dev/null || true);
186
- stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"' 2>/dev/null || true);
187
- verdict="UNKNOWN";
188
- if [ -f ".spur/run/$wbs-verdict.json" ]; then
189
- v=$(jq -r '.verdict // "UNKNOWN"' ".spur/run/$wbs-verdict.json" 2>/dev/null || true);
190
- if [ -n "$v" ]; then verdict="$v"; fi;
191
- fi;
192
- ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
193
- printf '{"wbs":"%s","feature_id":"%s","status":"%s","verdict":"%s","timestamp":"%s"}\n' "$wbs" "$feat" "$stat" "$verdict" "$ts" >> .spur/memory/wrapup-metrics.jsonl;
238
+ mkdir -p .spur/run .spur/memory &&
239
+ STATUS_FILE=".spur/run/$__runId-wrapup-metrics.status" &&
240
+ TASKS_FILE=".spur/run/$__runId-wrapup-tasks.json" &&
241
+ 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;
194
251
  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;
195
257
  fi;
196
- done &&
258
+ done;
259
+ if [ "$METRICS_RC" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi;
197
260
  exit 0
198
261
 
199
262
  - id: feature-transition
200
263
  description: >
201
264
  If vars.feature is set, sync feature status via bounded feature-sync-bounded
202
- (0411). Soft shell — blocked/failed sync must not abort wrap-up after metrics.
203
- Empty vars.feature is NOT a blocked sync but a mis-invocation (the engine only
204
- enters this state when vars.feature is set) and fails loud (dogfood 2026-08-15,
205
- feature I3: a silent exit 0 made the wrap look complete with no transition).
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
270
+ (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).
206
273
  onEnter:
207
274
  # Genuinely soft: the terminating `exit 0` must be reached on BOTH
208
275
  # branches, so
@@ -217,11 +284,12 @@ states:
217
284
  # path (`spur init` does not scaffold `plugins/sp/`), so seeded projects
218
285
  # fall
219
286
  # back to the plain `spur feature sync` verb.
220
- # R1 (0625): a feature transition that changed state may have armed a
221
- # feature-level finding the per-task fast gate (spur-check) never sees.
222
- # Run the corpus-aware gate after an applied sync OR a non-zero exit that
223
- # may follow a partial multi-hop transition, then report PASS/FAIL. Soft
224
- # by design: the operator decides, and wrap-up never hard-fails here.
287
+ # 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.
225
293
  # NOTE: do not add
226
294
  # `#` comments INSIDE the folded shell string — YAML `>-` folds them onto
227
295
  # the following statement line and comments it out.
@@ -241,24 +309,41 @@ states:
241
309
  fi);
242
310
  SYNC_RC=$?;
243
311
  printf '%s\n' "$SYNC_OUTPUT";
244
- APPLIED=$(printf '%s' "$SYNC_OUTPUT" | jq -r '.applied // false' 2>/dev/null || echo false);
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);
314
+ else
315
+ APPLIED="invalid";
316
+ fi;
317
+ GATE="skipped";
245
318
  if [ "$APPLIED" = "true" ] || [ "$SYNC_RC" -ne 0 ]; then
246
- echo "feature-transition: sync applied or failed after a possible partial transition for $feature — running corpus-aware gate: $featureGateCmd";
319
+ echo "feature-transition: sync applied or failed after a possible partial transition for $feature — running feature gate: $featureGateCmd";
247
320
  if sh -c "$featureGateCmd"; then
248
- echo "feature-transition: corpus-aware gate PASS for feature $feature";
321
+ GATE="PASS";
322
+ echo "feature-transition: feature gate PASS for feature $feature";
249
323
  else
250
- echo "feature-transition: corpus-aware gate FAIL for feature $feature — inspect findings before reporting the transition complete" >&2;
324
+ GATE="FAIL";
325
+ echo "feature-transition: feature gate FAIL for feature $feature — inspect findings before reporting the transition complete" >&2;
251
326
  fi;
252
327
  else
253
- echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — corpus-aware gate skipped";
328
+ echo "feature-transition: sync did not apply a transition (rc=$SYNC_RC, applied=$APPLIED) — feature gate skipped (explicit no-change)";
329
+ fi;
330
+ SYNC_STATUS="PASS";
331
+ if [ "$SYNC_RC" -ne 0 ] || [ "$GATE" = "FAIL" ] || [ "$APPLIED" = "invalid" ]; then SYNC_STATUS="FAIL"; fi;
332
+ if [ "$SYNC_STATUS" = "PASS" ]; then
333
+ printf 'PASS\n' > ".spur/run/$__runId-wrapup-sync.status";
334
+ else
335
+ printf 'FAIL\n' > ".spur/run/$__runId-wrapup-sync.status";
254
336
  fi;
255
337
  exit 0
256
338
 
257
339
  - id: branch-cleanup
258
340
  description: >
259
- Irreversible HITL gate. If vars.merge=true, dispatch branch cleanup (merge or
260
- delete). This gate ALWAYS pauses even under --auto because branch operations
261
- are irreversible (Auto-Decision Principle #6). The operator must explicitly confirm.
341
+ Irreversible-operation HITL gate, consent-only (0770). If vars.merge=true,
342
+ branch cleanup (merge or delete) is a candidatebut this gate records
343
+ the operator decision and performs NO git operation itself; an operator
344
+ or a separate approved surface executes any actual merge/delete. This
345
+ gate ALWAYS pauses — even under --auto — because branch operations are
346
+ irreversible (Auto-Decision Principle #6).
262
347
  pause: true
263
348
  onEnter:
264
349
  - kind: hitl.confirm
@@ -267,12 +352,13 @@ states:
267
352
 
268
353
  - id: done
269
354
  description: >
270
- Terminal — wrap-up complete. Output summary: tasks wrapped, learnings captured,
271
- metrics recorded, feature advanced (if applicable), branch cleaned (if applicable).
355
+ Terminal — wrap-up complete. Summary: tasks wrapped, learnings captured,
356
+ metrics recorded, feature advanced or explicitly no-change (if applicable),
357
+ branch-cleanup decision recorded (consent-only; no git operation performed).
272
358
  onEnter:
273
359
  - kind: note
274
360
  options:
275
- message: "Wrap-up pipeline complete for tasks: ${vars.tasks}. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl."
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."
276
362
  # Checkpoint write: record session state for resume (Phase 4, task 0171
277
363
  # R3)
278
364
  - kind: shell
@@ -282,6 +368,13 @@ states:
282
368
  - id: skipped
283
369
  description: Terminal — wrap-up skipped (empty task list or operator abort).
284
370
 
371
+ - id: failed
372
+ description: >
373
+ Terminal — wrap-up failed (0770): invalid wrap input, an unresolved or
374
+ non-completed task, a failed metrics capture, a failed required sync, or
375
+ a failed feature gate. Already-written artifacts (learnings, metrics
376
+ rows, route reason) are preserved; nothing is rolled back.
377
+
285
378
  transitions:
286
379
  # ── start -> task-resolve ──
287
380
  - from: start
@@ -290,7 +383,17 @@ transitions:
290
383
  guard:
291
384
  kind: always
292
385
 
293
- # ── task-resolve: proportional route table (task 0758 R1-R4) ──
386
+ # ── task-resolve: validation fail + proportional route table (0758 R1-R4, 0770) ──
387
+ - from: task-resolve
388
+ to: failed
389
+ description: >
390
+ Validation FAIL (0770): malformed wrap input, a non-string/empty entry, an
391
+ unresolved or non-completed task, or an empty __runId. Declared before the
392
+ route edges so invalid input can never be absorbed by the empty-list skip.
393
+ guard:
394
+ kind: shell
395
+ options:
396
+ command: 'test "$(cat .spur/run/$__runId-wrapup-resolve.status 2>/dev/null)" = FAIL'
294
397
  - from: task-resolve
295
398
  to: skipped
296
399
  description: Task list is empty — skip wrap-up.
@@ -313,8 +416,10 @@ transitions:
313
416
  options:
314
417
  command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" != fast'
315
418
  - from: task-resolve
316
- to: skipped
317
- description: Defense — task list unparseable; skip rather than fail with no-passing-transition.
419
+ to: failed
420
+ description: >
421
+ Defense (0770) — no PASS/FAIL resolve status (shell crashed or never ran):
422
+ route to `failed` rather than claiming a skip.
318
423
  guard:
319
424
  kind: always
320
425
 
@@ -325,47 +430,67 @@ transitions:
325
430
  guard:
326
431
  kind: always
327
432
 
328
- # ── metrics-record: conditional routing based on feature and merge ──
329
- # Declaration order matters: feature-transition is tried first (if feature is
330
- # set),
331
- # then branch-cleanup (if merge=true but no feature), then done (neither).
433
+ # ── metrics-record: fail edge, then conditional routing (feature merge → done) ──
434
+ # Declaration order matters: the FAIL edge is tried first (0770), then
435
+ # feature-transition (if feature is set), then branch-cleanup (if merge=true
436
+ # but no feature), then done (neither). Routing edges additionally require
437
+ # the metrics status PASS so a missing status can never claim success.
438
+ - from: metrics-record
439
+ to: failed
440
+ description: >
441
+ Metrics capture failed for at least one task — a missing metrics row is
442
+ recorded as failure, never silently omitted as success (0770).
443
+ guard:
444
+ kind: shell
445
+ options:
446
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = FAIL'
332
447
  - from: metrics-record
333
448
  to: feature-transition
334
- description: Feature id is set — advance feature through legal lifecycle edges.
449
+ description: Metrics PASS and feature id is set — advance feature through legal lifecycle edges.
335
450
  guard:
336
451
  kind: shell
337
452
  options:
338
- command: 'test -n "$feature"'
453
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -n "$feature"'
339
454
  - from: metrics-record
340
455
  to: branch-cleanup
341
- description: No feature id, but merge is requested — go to branch cleanup.
456
+ description: Metrics PASS, no feature id, but merge is requested — go to branch cleanup.
342
457
  guard:
343
458
  kind: shell
344
459
  options:
345
- command: 'test -z "$feature" && test "$merge" = true'
460
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" = true'
346
461
  - from: metrics-record
347
462
  to: done
348
- description: No feature id and no merge — wrap-up is done.
463
+ description: Metrics PASS, no feature id and no merge — wrap-up is done.
349
464
  guard:
350
465
  kind: shell
351
466
  options:
352
- command: 'test -z "$feature" && test "$merge" != true'
467
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -z "$feature" && test "$merge" != true'
353
468
 
354
- # ── feature-transition: -> branch-cleanup (if merge) or done ──
469
+ # ── feature-transition: fail edge, then branch-cleanup (if merge) or done ──
470
+ - from: feature-transition
471
+ to: failed
472
+ description: >
473
+ Required synchronization failed (0770): non-zero sync exit, an invalid
474
+ sync result, or a failed feature gate. The failed status carries the
475
+ actual reason; already-written learnings/metrics stay on disk.
476
+ guard:
477
+ kind: shell
478
+ options:
479
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = FAIL'
355
480
  - from: feature-transition
356
481
  to: branch-cleanup
357
- description: Feature advanced — merge is requested.
482
+ description: Sync PASS — merge is requested.
358
483
  guard:
359
484
  kind: shell
360
485
  options:
361
- command: 'test "$merge" = true'
486
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" = true'
362
487
  - from: feature-transition
363
488
  to: done
364
- description: Feature advanced — no merge requested.
489
+ description: Sync PASS — no merge requested.
365
490
  guard:
366
491
  kind: shell
367
492
  options:
368
- command: 'test "$merge" != true'
493
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" != true'
369
494
 
370
495
  # ── branch-cleanup: HITL exhaustive routing (yes / no / cancel → done) ──
371
496
  # No irreversible git op is wired yet; confirmation is recorded, then wrap
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.73",
3
+ "version": "0.3.74",
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",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.73",
3
+ "version": "0.3.74",
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"]
@@ -198,6 +198,7 @@ function parseProvenance(reportMarkdown) {
198
198
  contractDigest: String(obj.contractDigest ?? ""),
199
199
  skillDigest: String(obj.skillDigest ?? ""),
200
200
  workflowDigest: String(obj.workflowDigest ?? ""),
201
+ helperDigest: String(obj.helperDigest ?? ""),
201
202
  coverage: coverage.map((c) => ({
202
203
  source: String(c.source ?? ""),
203
204
  status: String(c.status ?? ""),
@@ -244,6 +245,8 @@ function decideCache(cached, current, opts) {
244
245
  reasons.push("logic-changed:skill");
245
246
  if (cached.workflowDigest !== current.workflowDigest)
246
247
  reasons.push("logic-changed:workflow");
248
+ if (cached.helperDigest !== current.helperDigest)
249
+ reasons.push("logic-changed:helper");
247
250
  const currentSources = new Set(current.coverage.map((c) => c.source));
248
251
  if (cached.coverage.some((c) => !currentSources.has(c.source)))
249
252
  reasons.push("coverage-degraded");
@@ -473,6 +476,7 @@ function buildProvenance(opts) {
473
476
  contractDigest: logicDigest(opts.contractFile),
474
477
  skillDigest: logicDigest(opts.skillDir),
475
478
  workflowDigest: logicDigest(opts.workflowFile),
479
+ helperDigest: logicDigest(opts.helperFile),
476
480
  coverage,
477
481
  runId: opts.runId,
478
482
  currentArtifactPath: opts.artifact,
@@ -503,6 +507,7 @@ var YAML_KEYS = [
503
507
  "contractDigest",
504
508
  "skillDigest",
505
509
  "workflowDigest",
510
+ "helperDigest",
506
511
  "runId",
507
512
  "currentArtifactPath",
508
513
  "baselineArtifactPath",
@@ -595,7 +600,7 @@ function diffPorcelain(before, now, expects) {
595
600
  return [...porcelainPaths(now)].filter((p) => !beforePaths.has(p) && !expects.has(p)).sort();
596
601
  }
597
602
  var VALID_COMMANDS = "digest, check, paths, assert-clean, probe, stamp, refresh, publish";
598
- var PROBE_USAGE = "<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] " + "[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>]";
603
+ var PROBE_USAGE = "<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] " + "[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>] [--helper <f>]";
599
604
  function parseFlags(args) {
600
605
  const out = {};
601
606
  for (let i = 0;i < args.length; i++) {
@@ -729,6 +734,7 @@ ${result.problems.map((p) => `- ${p}
729
734
  skillDir: f["skill-dir"],
730
735
  contractFile: f.contract,
731
736
  workflowFile: f.workflow,
737
+ helperFile: f.helper,
732
738
  contractVersion: f["contract-version"],
733
739
  runId: f["run-id"],
734
740
  spurVersion: f["spur-version"]
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * ADR-079 makes cache validity a *derived* fact, not a stored claim: a cached report is reusable
6
6
  * only for its model-authored half, and only when a freshly derived semantic digest of the analyze
7
- * artifact plus the contract/skill/workflow logic digests all match what the cache recorded.
7
+ * artifact plus the contract/skill/workflow/helper logic digests all match what the cache
8
+ * recorded (helper digest: task 0771 — the deterministic half is part of cache identity).
8
9
  *
9
10
  * This script performs deterministic file, hash, and schema work only — no finding, remediation,
10
11
  * severity, or ranking logic (that is judgment, owned by the sp:history-anatomy skill). Jobs:
@@ -68,6 +69,8 @@ export interface CacheProvenance {
68
69
  contractDigest: string;
69
70
  skillDigest: string;
70
71
  workflowDigest: string;
72
+ /** Digest of the executing helper twin itself (0771): a changed deterministic half invalidates. */
73
+ helperDigest: string;
71
74
  coverage: Array<{ source: string; status: string; lastImportedAt: string | null }>;
72
75
  // 0660 R7 audit fields. Recorded in the published frontmatter for provenance; deliberately
73
76
  // NOT part of the invalidation matrix — a changed run id or executor is not stale evidence.
@@ -243,6 +246,7 @@ export function parseProvenance(reportMarkdown: string): CacheProvenance | null
243
246
  contractDigest: String(obj.contractDigest ?? ''),
244
247
  skillDigest: String(obj.skillDigest ?? ''),
245
248
  workflowDigest: String(obj.workflowDigest ?? ''),
249
+ helperDigest: String(obj.helperDigest ?? ''),
246
250
  coverage: coverage.map((c) => ({
247
251
  source: String(c.source ?? ''),
248
252
  status: String(c.status ?? ''),
@@ -300,6 +304,7 @@ export function decideCache(
300
304
  if (cached.contractDigest !== current.contractDigest) reasons.push('logic-changed:contract');
301
305
  if (cached.skillDigest !== current.skillDigest) reasons.push('logic-changed:skill');
302
306
  if (cached.workflowDigest !== current.workflowDigest) reasons.push('logic-changed:workflow');
307
+ if (cached.helperDigest !== current.helperDigest) reasons.push('logic-changed:helper');
303
308
 
304
309
  // Coverage cannot degrade: the cache must not claim broader coverage than the current
305
310
  // analyze covers. If the cached report covered a source the current analyze no longer does,
@@ -601,6 +606,7 @@ export interface ProbeOptions {
601
606
  skillDir?: string;
602
607
  contractFile?: string;
603
608
  workflowFile?: string;
609
+ helperFile?: string;
604
610
  contractVersion?: string;
605
611
  runId?: string;
606
612
  spurVersion?: string;
@@ -660,6 +666,7 @@ export function buildProvenance(opts: ProbeOptions): CacheProvenance {
660
666
  contractDigest: logicDigest(opts.contractFile),
661
667
  skillDigest: logicDigest(opts.skillDir),
662
668
  workflowDigest: logicDigest(opts.workflowFile),
669
+ helperDigest: logicDigest(opts.helperFile),
663
670
  coverage,
664
671
  runId: opts.runId,
665
672
  currentArtifactPath: opts.artifact,
@@ -697,6 +704,7 @@ const YAML_KEYS: Array<keyof CacheProvenance> = [
697
704
  'contractDigest',
698
705
  'skillDigest',
699
706
  'workflowDigest',
707
+ 'helperDigest',
700
708
  'runId',
701
709
  'currentArtifactPath',
702
710
  'baselineArtifactPath',
@@ -823,7 +831,7 @@ export function diffPorcelain(before: string, now: string, expects: Set<string>)
823
831
  const VALID_COMMANDS = 'digest, check, paths, assert-clean, probe, stamp, refresh, publish';
824
832
  const PROBE_USAGE =
825
833
  '<script> probe --artifact <a.json> --target <report.md> [--baseline <b.json>] [--mode daily|ad-hoc] ' +
826
- '[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>]';
834
+ '[--date <YYYY-MM-DD>] [--recompute true] [--out <prov.json>] [--skill-dir <d>] [--contract <f>] [--workflow <f>] [--helper <f>]';
827
835
 
828
836
  /** `--key value` / `--flag` → record. Bare flags become `"true"` so `--recompute` needs no value. */
829
837
  function parseFlags(args: string[]): Record<string, string | undefined> {
@@ -956,6 +964,7 @@ export function runCacheCli(argv: string[]): CacheCliResult {
956
964
  skillDir: f['skill-dir'],
957
965
  contractFile: f.contract,
958
966
  workflowFile: f.workflow,
967
+ helperFile: f.helper,
959
968
  contractVersion: f['contract-version'],
960
969
  runId: f['run-id'],
961
970
  spurVersion: f['spur-version'],
@@ -325,7 +325,11 @@ function hasPendingRequest(comments, head, login) {
325
325
  }
326
326
  function requireExpectedHead(args, pr) {
327
327
  const expected = args.flags.get("--head");
328
- if (expected && expected !== pr.headRefOid) {
328
+ if (!expected) {
329
+ writeStatus(args, "FAIL");
330
+ fail(args, "--head <sha> is required and must match the reviewed request HEAD", 2);
331
+ }
332
+ if (expected !== pr.headRefOid) {
329
333
  writeStatus(args, "FAIL");
330
334
  fail(args, `PR HEAD moved from ${expected.slice(0, 7)} to ${pr.headRefOid.slice(0, 7)} \u2014 request a new review`, 2);
331
335
  }
@@ -519,8 +519,14 @@ function hasPendingRequest(comments: readonly GhIssueComment[], head: string, lo
519
519
  }
520
520
 
521
521
  function requireExpectedHead(args: ParsedArgs, pr: GhPr): void {
522
+ // 0771: head pinning only protects when the flag actually carries a SHA — an empty or
523
+ // missing --head must fail loud instead of silently reviewing whatever HEAD is current.
522
524
  const expected = args.flags.get('--head');
523
- if (expected && expected !== pr.headRefOid) {
525
+ if (!expected) {
526
+ writeStatus(args, 'FAIL');
527
+ fail(args, '--head <sha> is required and must match the reviewed request HEAD', 2);
528
+ }
529
+ if (expected !== pr.headRefOid) {
524
530
  writeStatus(args, 'FAIL');
525
531
  fail(
526
532
  args,