@gobing-ai/spur 0.3.72 → 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 (101) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.example.yaml +48 -27
  3. package/config/pipeline-budgets.json +9 -5
  4. package/config/plugin-scripts.json +4 -0
  5. package/config/proportional-route-table.ts +155 -0
  6. package/config/rules/strict/runtime-boundaries.yaml +1 -1
  7. package/config/rules/structure/protected-files.yaml +3 -0
  8. package/config/task-pipeline-proportional-migration-plan.md +79 -0
  9. package/config/templates/AGENTS.md +5 -0
  10. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +2 -0
  11. package/config/workflows/basic.yaml +5 -2
  12. package/config/workflows/docs-pipeline.yaml +101 -54
  13. package/config/workflows/feature-dev.yaml +57 -27
  14. package/config/workflows/feature-lifecycle.yaml +1 -0
  15. package/config/workflows/history-anatomy.yaml +8 -4
  16. package/config/workflows/idea-pipeline.yaml +66 -33
  17. package/config/workflows/pr-review.yaml +31 -8
  18. package/config/workflows/task-lifecycle.yaml +28 -15
  19. package/config/workflows/task-pipeline.yaml +929 -844
  20. package/config/workflows/wayfinder-resolution.yaml +124 -90
  21. package/config/workflows/wrapup-pipeline.yaml +290 -107
  22. package/package.json +9 -9
  23. package/plugins/sp/plugin.json +1 -1
  24. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +2 -4
  25. package/plugins/sp/scripts/daily-summary/daily-summary.ts +2 -4
  26. package/plugins/sp/scripts/history-anatomy-cache.mjs +7 -1
  27. package/plugins/sp/scripts/history-anatomy-cache.ts +11 -2
  28. package/plugins/sp/scripts/inline-pipeline-parity-check.ts +185 -0
  29. package/plugins/sp/scripts/pr-reviewing.mjs +5 -1
  30. package/plugins/sp/scripts/pr-reviewing.ts +7 -1
  31. package/plugins/sp/scripts/task-evidence-precheck.ts +1 -1
  32. package/plugins/sp/scripts/transition-shim-check.ts +6 -6
  33. package/plugins/sp/scripts/verify-answer-lint.ts +4 -0
  34. package/plugins/sp/skills/pr-reviewing/SKILL.md +4 -1
  35. package/plugins/sp/skills/spur-cli/references/self.md +17 -3
  36. package/plugins/sp/skills/spur-cli/references/tasks.md +1 -1
  37. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +23 -0
  38. package/plugins/sp/skills/spur-cli/references/workflows/workflow-fit-and-tuning.md +5 -5
  39. package/plugins/sp/skills/spur-cli/references/workflows.md +4 -4
  40. package/plugins/sp/skills/spur-dev/SKILL.md +17 -0
  41. package/plugins/sp/skills/spur-dev/references/execution-batch.md +3 -3
  42. package/plugins/sp/skills/spur-dev/references/gate-checklists.md +1 -2
  43. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +20 -0
  44. package/plugins/sp/skills/wayfinder/SKILL.md +1 -1
  45. package/schemas/state-machine-workflow.schema.json +3 -1
  46. package/schemas/transition-flow-workflow.schema.json +3 -1
  47. package/spur.js +12639 -10106
  48. package/web/_astro/{BoardApp.BYCNkMOn.js → BoardApp.C60RJZRj.js} +78 -77
  49. package/web/_astro/BoardApp.ymfj1EHA.js +1 -0
  50. package/web/_astro/{TaskDetail.CgUreSP2.js → TaskDetail.Guk8VuNu.js} +1 -1
  51. package/web/_astro/{arc.BySSh34M.js → arc.CAZtlWJM.js} +1 -1
  52. package/web/_astro/{architectureDiagram-3BPJPVTR.DM46TS_h.js → architectureDiagram-3BPJPVTR.9-XbF_Tz.js} +1 -1
  53. package/web/_astro/{blockDiagram-GPEHLZMM.tZhvNUHA.js → blockDiagram-GPEHLZMM.DC4WLx3L.js} +1 -1
  54. package/web/_astro/{c4Diagram-AAUBKEIU.PT4Or4Nf.js → c4Diagram-AAUBKEIU.JgjUQMgG.js} +1 -1
  55. package/web/_astro/channel.Bxxe2Byc.js +1 -0
  56. package/web/_astro/{chunk-2J33WTMH.J9r0_Bbe.js → chunk-2J33WTMH.D_JFiXa-.js} +1 -1
  57. package/web/_astro/{chunk-4BX2VUAB.hzyeIvhR.js → chunk-4BX2VUAB.Bk_RxoeT.js} +1 -1
  58. package/web/_astro/{chunk-55IACEB6.B0rO7qVh.js → chunk-55IACEB6.a_Rj2VxL.js} +1 -1
  59. package/web/_astro/{chunk-727SXJPM.wE_Uk5D4.js → chunk-727SXJPM.C2NoS1U6.js} +1 -1
  60. package/web/_astro/{chunk-AQP2D5EJ.DqEEjQw7.js → chunk-AQP2D5EJ.D1Xn6CNa.js} +1 -1
  61. package/web/_astro/{chunk-FMBD7UC4.CDoD9sBX.js → chunk-FMBD7UC4.BhJrSBsX.js} +1 -1
  62. package/web/_astro/{chunk-ND2GUHAM.CtX5nF9P.js → chunk-ND2GUHAM.ByVGduYQ.js} +1 -1
  63. package/web/_astro/{chunk-QZHKN3VN.CK_EwfaT.js → chunk-QZHKN3VN.B47Paunq.js} +1 -1
  64. package/web/_astro/{classDiagram-4FO5ZUOK.DLt5a8Lh.js → classDiagram-4FO5ZUOK.BERMSD5C.js} +1 -1
  65. package/web/_astro/{classDiagram-v2-Q7XG4LA2.DLt5a8Lh.js → classDiagram-v2-Q7XG4LA2.BERMSD5C.js} +1 -1
  66. package/web/_astro/{cose-bilkent-S5V4N54A.CMCWP49h.js → cose-bilkent-S5V4N54A.O_rHGlhU.js} +1 -1
  67. package/web/_astro/{cynefin-OW5HDTMX.HyXw_vdS.js → cynefin-OW5HDTMX.qCW2GdNp.js} +1 -1
  68. package/web/_astro/{dagre-BM42HDAG.BTuAzh01.js → dagre-BM42HDAG.C5Y4lH_u.js} +1 -1
  69. package/web/_astro/{diagram-2AECGRRQ.D9dr9wfT.js → diagram-2AECGRRQ.DxZmRXxF.js} +1 -1
  70. package/web/_astro/{diagram-5GNKFQAL.C4Rot0hj.js → diagram-5GNKFQAL.qSxZeYSK.js} +1 -1
  71. package/web/_astro/{diagram-KO2AKTUF.B_TK5uWC.js → diagram-KO2AKTUF.-7vl3bXs.js} +1 -1
  72. package/web/_astro/{diagram-LMA3HP47.JkXKK7CO.js → diagram-LMA3HP47.BHVV-UH3.js} +1 -1
  73. package/web/_astro/{diagram-OG6HWLK6.BzMN8Bd6.js → diagram-OG6HWLK6.DHAuW9sK.js} +1 -1
  74. package/web/_astro/{erDiagram-TEJ5UH35.DVZaWGUd.js → erDiagram-TEJ5UH35.DMRLQwPN.js} +1 -1
  75. package/web/_astro/{flowDiagram-I6XJVG4X.rjEiWUfR.js → flowDiagram-I6XJVG4X.B3EpPp_8.js} +1 -1
  76. package/web/_astro/{ganttDiagram-6RSMTGT7.C_EgAarK.js → ganttDiagram-6RSMTGT7.BgUrExXM.js} +1 -1
  77. package/web/_astro/{gitGraphDiagram-PVQCEYII.B-QQSDsK.js → gitGraphDiagram-PVQCEYII.CU-9yVN4.js} +1 -1
  78. package/web/_astro/{index.B5MTfe7k.css → index.BhvM3djt.css} +1 -1
  79. package/web/_astro/{infoDiagram-5YYISTIA.DlWesz7T.js → infoDiagram-5YYISTIA.CvVTCRLe.js} +1 -1
  80. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BUMZOawi.js → ishikawaDiagram-YF4QCWOH.BUzOfKPf.js} +1 -1
  81. package/web/_astro/{journeyDiagram-JHISSGLW.CWfkxfjY.js → journeyDiagram-JHISSGLW.BBTb7ziz.js} +1 -1
  82. package/web/_astro/{kanban-definition-UN3LZRKU.B-YpMwXf.js → kanban-definition-UN3LZRKU.CTQQr70i.js} +1 -1
  83. package/web/_astro/{linear.D7uqzENp.js → linear.CLKlJPiS.js} +1 -1
  84. package/web/_astro/{mermaid.core.CxrNppBD.js → mermaid.core.BA4wFhLP.js} +4 -4
  85. package/web/_astro/{mindmap-definition-RKZ34NQL.B4Qe7cM2.js → mindmap-definition-RKZ34NQL.t1vG2l5e.js} +1 -1
  86. package/web/_astro/{pieDiagram-4H26LBE5.Ds-5j2ro.js → pieDiagram-4H26LBE5.C8zJhyTu.js} +1 -1
  87. package/web/_astro/{quadrantDiagram-W4KKPZXB.tBd38uNC.js → quadrantDiagram-W4KKPZXB.DqYYvb7T.js} +1 -1
  88. package/web/_astro/{requirementDiagram-4Y6WPE33.sFENkWl3.js → requirementDiagram-4Y6WPE33.lzfDZ9nI.js} +1 -1
  89. package/web/_astro/{sankeyDiagram-5OEKKPKP.BeB-Hk7C.js → sankeyDiagram-5OEKKPKP.lm6N5ORM.js} +1 -1
  90. package/web/_astro/{sequenceDiagram-3UESZ5HK.DnTeaSpx.js → sequenceDiagram-3UESZ5HK.E6_IV4Dj.js} +1 -1
  91. package/web/_astro/{stateDiagram-AJRCARHV.B-8Jt5EJ.js → stateDiagram-AJRCARHV.Bnv6Ok2p.js} +1 -1
  92. package/web/_astro/{stateDiagram-v2-BHNVJYJU.Br7xoqMW.js → stateDiagram-v2-BHNVJYJU.mJHH_Ng_.js} +1 -1
  93. package/web/_astro/{timeline-definition-PNZ67QCA.C-3WdOyi.js → timeline-definition-PNZ67QCA.BhRgqSLa.js} +1 -1
  94. package/web/_astro/{vennDiagram-CIIHVFJN.DCIs7Lc6.js → vennDiagram-CIIHVFJN.pjb5DMC9.js} +1 -1
  95. package/web/_astro/{wardleyDiagram-YWT4CUSO.rGAL-bbz.js → wardleyDiagram-YWT4CUSO.CW20KUng.js} +1 -1
  96. package/web/_astro/{xychartDiagram-2RQKCTM6.hkfQKiRl.js → xychartDiagram-2RQKCTM6.vX9_yuMl.js} +1 -1
  97. package/web/index.html +2 -2
  98. package/config/corpus-baseline.json +0 -1637
  99. package/config/workflow-composition-baseline.json +0 -576
  100. package/web/_astro/BoardApp.E12MFjOS.js +0 -1
  101. package/web/_astro/channel.5cYKr5cs.js +0 -1
@@ -1,48 +1,74 @@
1
- # Wrap-up pipeline — post-execution wrap-up for one task or a batch (design §wrapup-pipeline).
1
+ # Wrap-up pipeline — post-execution wrap-up for one task or a batch (design
2
+ # §wrapup-pipeline).
2
3
  #
3
- # Orchestration is configuration (ADR-022 / §3.2): this is YAML over the existing
4
- # dual-workflow engine — zero new engine code. The pipeline NEVER mutates task status —
5
- # wrap-up consumes completed tasks and produces learning/metrics/doc artifacts only.
6
- # Feature transitions go through `spur feature update` so the feature-lifecycle guards
7
- # apply identically. Branch cleanup is an irreversible HITL gate — it always pauses,
4
+ # Orchestration is configuration (ADR-022 / §3.2): this is YAML over the
5
+ # existing
6
+ # dual-workflow engine zero new engine code. The pipeline NEVER mutates task
7
+ # status
8
+ # wrap-up consumes completed tasks and produces learning/metrics/doc artifacts
9
+ # only.
10
+ # Feature transitions go through `spur feature update` so the feature-lifecycle
11
+ # guards
12
+ # apply identically. Branch cleanup is an irreversible HITL gate — it always
13
+ # pauses,
8
14
  # even under --auto (Iron Law #6: irreversible action -> surface to human).
9
15
  #
10
16
  # Shape: start -> task-resolve -> doc-sync -> metrics-record
11
17
  # -> feature-transition (conditional: if vars.feature set)
12
18
  # -> branch-cleanup (conditional: if vars.merge=true)
13
19
  # -> done
14
- # (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.
15
24
  #
16
25
  # Vars (passed as a JSON object via `--vars`):
17
- # tasks — JSON array of WBS strings, passed as a JSON-encoded STRING value (the CLI
18
- # rejects non-string --vars values), e.g. --vars '{"tasks":"[\"0167\"]"}'
26
+ # tasks — JSON array of WBS strings, passed as a JSON-encoded STRING value
27
+ # (the CLI
28
+ # rejects non-string --vars values), e.g. --vars '{"tasks":"[\"0167\"]"}'
19
29
  # feature — feature id to advance through legal lifecycle edges (optional)
20
30
  # profile — set --vars '{"profile":"auto"}' to skip objective confirmations
21
- # merge — set --vars '{"merge":"true"}' to run branch cleanup (irreversible HITL)
31
+ # merge — set --vars '{"merge":"true"}' to run branch cleanup (irreversible
32
+ # HITL)
22
33
  # spurBin — PATH-independent spur invocation (overridden by CLI at run start)
23
34
  # agent — agent for agent.run steps (default: omp)
24
35
  #
25
36
  # Reliability (aligned with task-pipeline / ADR-043):
26
- # - Prefer pure slash commands when a command exists; free-form inputs remain only
37
+ # - Prefer pure slash commands when a command exists; free-form inputs remain
38
+ # only
27
39
  # where capture artifacts (answerFile) have no dedicated slash surface yet.
28
40
  # - doc-sync / metrics-record use expectFile + soft append (empty capture
29
- # does not abort wrap-up after prior steps already landed). The doc-sync hop is a
30
- # single model query that both repairs doc drift (sp:doc-evolve) and captures
41
+ # does not abort wrap-up after prior steps already landed). The doc-sync hop is
42
+ # a
43
+ # single model query that both repairs doc drift (sp:doc-evolve) and captures
31
44
  # working learnings (task 0607 R2 — one query answers both; count 2 -> 1).
32
- # - feature-transition is a soft shell (always exit 0) so a blocked feature sync
33
- # does not abort wrap-up after learnings/metrics already landed.
34
- # - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer defense).
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.
55
+ # - branch-cleanup HITL is exhaustive (yes/no/cancel → done; missing answer
56
+ # defense) and consent-only: it records the decision, performs no git op.
35
57
  # - Wrap-up never mutates task status (consumes completed work only).
36
58
 
37
59
  "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
38
60
  kind: state-machine
39
61
  name: wrapup-pipeline
62
+ version: "1"
40
63
  description: "Post-execution wrap-up: doc-sync (doc drift + learning capture), metrics, feature-transition, branch-cleanup"
41
64
  iterationBound: 10
42
65
  initialState: start
43
66
  terminalStates:
44
67
  - done
45
68
  - skipped
69
+ - failed
70
+ failureStates:
71
+ - failed
46
72
  vars:
47
73
  tasks: "[]"
48
74
  feature: ""
@@ -50,20 +76,14 @@ vars:
50
76
  merge: "false"
51
77
  spurBin: "spur"
52
78
  agent: "auto"
79
+ __runId: ""
80
+ mode: ""
53
81
  stepTimeoutMs: "1800000"
54
- # Corpus-aware quality gate for the feature-transition hop (R1, task 0625).
55
- # The per-task gate (`spur-check`) deliberately excludes corpus-check the only
56
- # sweep that observes feature-level findings — so a transition that arms one
57
- # would go green. This gate is therefore the CORPUS SWEEP ALONE (~29 s), not
58
- # `spur-check-new`: every task in the feature already paid a full `spur-check`
59
- # in its own pipeline, and the states that run before this hop (`doc-sync`,
60
- # `metrics-record`) write only markdown and task sections, which Biome skips
61
- # (`ignoreUnknown: true`). Re-running the ~105 s per-task gate here re-verifies
62
- # unchanged code and measures nothing new. Soft by design: the gate reports
63
- # PASS/FAIL and lets the operator decide; it never hard-fails the wrap-up shell.
82
+ # Recheck the affected feature after sync (D61 / ADR-108), not the corpus
83
+ # or full code suite. Explicit caller overrides remain supported.
64
84
  # TRUSTED CONFIG ONLY — this string is executed via `sh -c` (same surface as
65
85
  # task-pipeline's qualityGateCmd). Never interpolate untrusted input into it.
66
- featureGateCmd: "bun run corpus-check"
86
+ featureGateCmd: '$spurBin feature check "$feature"'
67
87
  __hitlAnswer: ""
68
88
 
69
89
  states:
@@ -78,13 +98,92 @@ states:
78
98
 
79
99
  - id: task-resolve
80
100
  description: >
81
- Validate the task list passed by the wrapper is non-empty. The command wrapper
82
- (dev-wrap/dev-wrapall) resolves the task list and passes it as vars.tasks;
83
- this state validates it before proceeding. Empty list routes to `skipped`.
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.
84
112
  onEnter:
85
113
  - kind: note
86
114
  options:
87
- message: "Resolving task list: ${vars.tasks}"
115
+ 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.
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
88
187
 
89
188
  - id: doc-sync
90
189
  description: >
@@ -105,71 +204,92 @@ states:
105
204
  docs/00_ADR.md, docs/03_ARCHITECTURE.md, docs/04_DESIGN.md, docs/design/* per constitution edit
106
205
  rules; do not write task/feature corpus. THEN extract working learnings from tasks ${vars.tasks} —
107
206
  conventions, errors fixed, patterns, gotchas, grouped by date and task WBS — as raw markdown
108
- (no fences) and END your final message with that markdown (it is captured to .spur/run/wrapup-learnings.md).
109
- # Declared Layer-1 role (0538 R2): routing reason beside the agent: pin.
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).
209
+ # Declared Layer-1 role (0538 R2): routing reason beside the agent:
210
+ # pin.
110
211
  role: coder
111
- answerFile: .spur/run/wrapup-learnings.md
112
- expectFile: .spur/run/wrapup-learnings.md
212
+ answerFile: .spur/run/${vars.__runId}-wrapup-learnings.md
213
+ expectFile: .spur/run/${vars.__runId}-wrapup-learnings.md
113
214
  timeoutMs: ${vars.stepTimeoutMs}
114
215
  - kind: shell
115
216
  options:
116
217
  command: >-
117
- mkdir -p .spur/memory &&
118
- if test -s .spur/run/wrapup-learnings.md; then
119
- 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 &&
120
222
  printf '\n' >> .spur/memory/learnings.md;
121
223
  else
122
- 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;
123
225
  fi &&
124
226
  exit 0
125
227
 
126
228
  - id: metrics-record
127
229
  description: >
128
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.
129
234
  onEnter:
130
235
  - kind: shell
131
236
  options:
132
237
  command: >-
133
- mkdir -p .spur/memory &&
134
- printf '%s' "$tasks" | jq -r '(if type == "string" then (try fromjson catch .) else . end) | if type == "array" then .[] else empty end' |
135
- while IFS= read -r wbs; do
136
- if [ -n "$wbs" ]; then
137
- task_json=$($spurBin task show "$wbs" --json 2>/dev/null || true);
138
- if [ -n "$task_json" ]; then
139
- feat=$(printf '%s' "$task_json" | jq -r '.frontmatter.feature_id // .feature_id // empty' 2>/dev/null || true);
140
- stat=$(printf '%s' "$task_json" | jq -r '.frontmatter.status // .status // "unknown"' 2>/dev/null || true);
141
- verdict="UNKNOWN";
142
- if [ -f ".spur/run/$wbs-verdict.json" ]; then
143
- v=$(jq -r '.verdict // "UNKNOWN"' ".spur/run/$wbs-verdict.json" 2>/dev/null || true);
144
- if [ -n "$v" ]; then verdict="$v"; fi;
145
- fi;
146
- ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ");
147
- 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;
148
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;
149
257
  fi;
150
- done &&
258
+ done;
259
+ if [ "$METRICS_RC" -eq 0 ]; then printf 'PASS\n' > "$STATUS_FILE"; else printf 'FAIL\n' > "$STATUS_FILE"; fi;
151
260
  exit 0
152
261
 
153
262
  - id: feature-transition
154
263
  description: >
155
264
  If vars.feature is set, sync feature status via bounded feature-sync-bounded
156
- (0411). Soft shell — blocked/failed sync must not abort wrap-up after metrics.
157
- Empty vars.feature is NOT a blocked sync but a mis-invocation (the engine only
158
- enters this state when vars.feature is set) and fails loud (dogfood 2026-08-15,
159
- 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).
160
273
  onEnter:
161
- # Genuinely soft: the terminating `exit 0` must be reached on BOTH branches, so
162
- # the sync is chained with `;` — with `&&` a non-zero sync skipped `exit 0` and
163
- # aborted the run at this state, discarding the learnings/metrics that already
164
- # landed and never reaching branch-cleanup/done (the exact failure the state
165
- # description promises against). `feature-sync-bounded.ts` is a Spur-monorepo
166
- # path (`spur init` does not scaffold `plugins/sp/`), so seeded projects fall
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
167
286
  # back to the plain `spur feature sync` verb.
168
- # R1 (0625): a feature transition that changed state may have armed a
169
- # feature-level finding the per-task fast gate (spur-check) never sees.
170
- # Run the corpus-aware gate after an applied sync OR a non-zero exit that
171
- # may follow a partial multi-hop transition, then report PASS/FAIL. Soft
172
- # 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.
173
293
  # NOTE: do not add
174
294
  # `#` comments INSIDE the folded shell string — YAML `>-` folds them onto
175
295
  # the following statement line and comments it out.
@@ -189,24 +309,41 @@ states:
189
309
  fi);
190
310
  SYNC_RC=$?;
191
311
  printf '%s\n' "$SYNC_OUTPUT";
192
- 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";
193
318
  if [ "$APPLIED" = "true" ] || [ "$SYNC_RC" -ne 0 ]; then
194
- 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";
195
320
  if sh -c "$featureGateCmd"; then
196
- echo "feature-transition: corpus-aware gate PASS for feature $feature";
321
+ GATE="PASS";
322
+ echo "feature-transition: feature gate PASS for feature $feature";
197
323
  else
198
- 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;
199
326
  fi;
200
327
  else
201
- 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";
202
336
  fi;
203
337
  exit 0
204
338
 
205
339
  - id: branch-cleanup
206
340
  description: >
207
- Irreversible HITL gate. If vars.merge=true, dispatch branch cleanup (merge or
208
- delete). This gate ALWAYS pauses even under --auto because branch operations
209
- 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).
210
347
  pause: true
211
348
  onEnter:
212
349
  - kind: hitl.confirm
@@ -215,13 +352,15 @@ states:
215
352
 
216
353
  - id: done
217
354
  description: >
218
- Terminal — wrap-up complete. Output summary: tasks wrapped, learnings captured,
219
- 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).
220
358
  onEnter:
221
359
  - kind: note
222
360
  options:
223
- message: "Wrap-up pipeline complete for tasks: ${vars.tasks}. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl."
224
- # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
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)
225
364
  - kind: shell
226
365
  options:
227
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'
@@ -229,6 +368,13 @@ states:
229
368
  - id: skipped
230
369
  description: Terminal — wrap-up skipped (empty task list or operator abort).
231
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
+
232
378
  transitions:
233
379
  # ── start -> task-resolve ──
234
380
  - from: start
@@ -237,18 +383,17 @@ transitions:
237
383
  guard:
238
384
  kind: always
239
385
 
240
- # ── task-resolve: non-empty -> doc-sync, empty/unparseable -> skipped ──
241
- # `|| echo 0` is load-bearing: a malformed vars.tasks (or a box without `jq`) makes
242
- # `jq length` emit nothing, and bare `test "" -gt 0` exits 2 — so BOTH edges failed
243
- # and the run died with an opaque `no-passing-transition` instead of skipping. The
244
- # trailing `always` edge is the belt-and-braces defense for any other guard fault.
386
+ # ── task-resolve: validation fail + proportional route table (0758 R1-R4, 0770) ──
245
387
  - from: task-resolve
246
- to: doc-sync
247
- description: Task list resolved and non-empty — begin doc-sync.
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.
248
393
  guard:
249
394
  kind: shell
250
395
  options:
251
- command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0'
396
+ command: 'test "$(cat .spur/run/$__runId-wrapup-resolve.status 2>/dev/null)" = FAIL'
252
397
  - from: task-resolve
253
398
  to: skipped
254
399
  description: Task list is empty — skip wrap-up.
@@ -257,8 +402,24 @@ transitions:
257
402
  options:
258
403
  command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -eq 0'
259
404
  - from: task-resolve
260
- to: skipped
261
- description: Defense task list unparseable; skip rather than fail with no-passing-transition.
405
+ to: metrics-record
406
+ description: Proportional fast path complete and consistent evidence bypasses doc-sync.
407
+ guard:
408
+ kind: shell
409
+ options:
410
+ command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" = fast'
411
+ - from: task-resolve
412
+ to: doc-sync
413
+ description: Proportional safety path — missing/unknown/conflicting evidence routes to full doc-sync.
414
+ guard:
415
+ kind: shell
416
+ options:
417
+ command: 'test "$(echo "$tasks" | jq length 2>/dev/null || echo 0)" -gt 0 && test "$mode" != fast'
418
+ - from: task-resolve
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.
262
423
  guard:
263
424
  kind: always
264
425
 
@@ -269,49 +430,71 @@ transitions:
269
430
  guard:
270
431
  kind: always
271
432
 
272
- # ── metrics-record: conditional routing based on feature and merge ──
273
- # Declaration order matters: feature-transition is tried first (if feature is set),
274
- # 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'
275
447
  - from: metrics-record
276
448
  to: feature-transition
277
- 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.
278
450
  guard:
279
451
  kind: shell
280
452
  options:
281
- command: 'test -n "$feature"'
453
+ command: 'test "$(cat .spur/run/$__runId-wrapup-metrics.status 2>/dev/null)" = PASS && test -n "$feature"'
282
454
  - from: metrics-record
283
455
  to: branch-cleanup
284
- 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.
285
457
  guard:
286
458
  kind: shell
287
459
  options:
288
- 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'
289
461
  - from: metrics-record
290
462
  to: done
291
- 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.
292
464
  guard:
293
465
  kind: shell
294
466
  options:
295
- 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'
296
468
 
297
- # ── 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'
298
480
  - from: feature-transition
299
481
  to: branch-cleanup
300
- description: Feature advanced — merge is requested.
482
+ description: Sync PASS — merge is requested.
301
483
  guard:
302
484
  kind: shell
303
485
  options:
304
- command: 'test "$merge" = true'
486
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" = true'
305
487
  - from: feature-transition
306
488
  to: done
307
- description: Feature advanced — no merge requested.
489
+ description: Sync PASS — no merge requested.
308
490
  guard:
309
491
  kind: shell
310
492
  options:
311
- command: 'test "$merge" != true'
493
+ command: 'test "$(cat .spur/run/$__runId-wrapup-sync.status 2>/dev/null)" = PASS && test "$merge" != true'
312
494
 
313
495
  # ── branch-cleanup: HITL exhaustive routing (yes / no / cancel → done) ──
314
- # No irreversible git op is wired yet; confirmation is recorded, then wrap completes.
496
+ # No irreversible git op is wired yet; confirmation is recorded, then wrap
497
+ # completes.
315
498
  - from: branch-cleanup
316
499
  to: done
317
500
  description: Branch cleanup HITL answered — wrap-up complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.72",
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",
@@ -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.54",
57
- "@gobing-ai/ts-ai-runner": "^0.4.54",
58
- "@gobing-ai/ts-dual-workflow-engine": "^0.4.54",
59
- "@gobing-ai/ts-infra": "^0.4.54",
60
- "@gobing-ai/ts-llm-jsonl-importer": "^0.4.54",
61
- "@gobing-ai/ts-rule-engine": "^0.4.54",
62
- "@gobing-ai/ts-runtime": "^0.4.54",
63
- "@gobing-ai/ts-utils": "^0.4.54",
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",
64
64
  "@types/bun": "1.3.14",
65
65
  "@types/figlet": "^1.7.0",
66
66
  "@types/node-notifier": "8.0.5",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.72",
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"]
@@ -194,15 +194,13 @@ async function getSpurHistoryHealth(date, dbPath = ".spur/spur.db") {
194
194
  const cleanTool = lp.toolName.replace(/[^a-zA-Z0-9_-]/g, "_");
195
195
  const key = `repetition:${cleanTool}:${lp.argsDigest.slice(0, 16)}`;
196
196
  const title = `Break execution loop in ${lp.toolName} (${lp.repeats} repeats)`;
197
- const body = `Finding: ${key}\\nObserved ${lp.repeats} redundant invocations in session ${lp.sessionId} (steps #${lp.fromSeq ?? 1}\u2192#${lp.toSeq ?? lp.repeats}). Wasted tokens: ~${lp.wastedTokens}.`;
198
- const command = `spur task create --section "Fix ${cleanTool} repetition loop" --body "${body}"`;
197
+ const command = `spur task create "<title>" --feature <id> && spur task update <wbs> --section Plan --from-file <path>`;
199
198
  remediationProposals.push({ key, title, command });
200
199
  }
201
200
  if (toolCalls > 0 && errorRatePct > 10) {
202
201
  const key = "reliability:tooling:high-error-rate";
203
202
  const title = `Investigate high tool error rate (${errorRatePct.toFixed(1)}%)`;
204
- const body = `Finding: ${key}\\nObserved ${toolErrors} errors across ${toolCalls} tool calls (${errorRatePct.toFixed(1)}% error rate) on ${date}.`;
205
- const command = `spur task create --section "Investigate tool error rate spikes" --body "${body}"`;
203
+ const command = `spur task create "<title>" --feature <id> && spur task update <wbs> --section Plan --from-file <path>`;
206
204
  remediationProposals.push({ key, title, command });
207
205
  }
208
206
  return {