@gobing-ai/spur 0.2.10 → 0.2.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
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,8 +1,8 @@
1
- $schema: "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
2
1
  # Canonical basic workflow: implement -> check -> fix until the check passes or the
3
2
  # iteration bound is exhausted. Authored for the @gobing-ai/ts-dual-workflow-engine
4
3
  # state-machine schema (initialState / states[].id / onEnter / top-level transitions).
5
4
  # See Architecture Section 5 and Design Section 3.3.
5
+ "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
6
6
  name: basic
7
7
  kind: state-machine
8
8
  description: The canonical implement-check-fix-until-pass loop
@@ -86,6 +86,11 @@ states:
86
86
 
87
87
  - id: done
88
88
  description: Terminal — feature planned, all tasks executed, and the feature verified.
89
+ # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
90
+ onEnter:
91
+ - kind: shell
92
+ options:
93
+ command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: feature-dev done featureId=${vars.featureId} ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/${vars.featureId}-checkpoint.md'
89
94
 
90
95
  - id: failed
91
96
  description: Terminal — plan rejected, a task failed verification, or the feature check did not pass.
@@ -0,0 +1,285 @@
1
+ # Idea-to-feature pipeline — unified entry from a vague idea to a feature, AC, and task batch
2
+ # (design §idea-pipeline).
3
+ #
4
+ # Orchestration is configuration (ADR-022 / §3.2): this is YAML over the existing
5
+ # dual-workflow engine — zero new engine code. The pipeline STOPS at handoff — tasks are
6
+ # created but NOT executed. Use task-pipeline.yaml or feature-dev.yaml for execution.
7
+ # No state in this pipeline inlines another pipeline's state graph (no-nesting principle,
8
+ # design §System Principles 4).
9
+ #
10
+ # Shape: start -> discovery -> feature-create -> ac-generate -> feature-check
11
+ # -> system-design (conditional: needs_design signal or --design)
12
+ # -> design-approval (taste HITL gate; not auto-clicked by --auto)
13
+ # -> decompose -> batch-create -> handoff
14
+ # (feature-check failure routes back to ac-generate; batch-create failure to decompose).
15
+ #
16
+ # Vars (passed as a JSON object via `--vars`):
17
+ # idea — the idea text (required), e.g. --vars '{"idea":"add a --dry-run flag to dev-wrap"}'
18
+ # profile — set --vars '{"profile":"auto"}' to skip objective HITL gates (feature-check, batch-create)
19
+ # design — "auto" (default, signal-driven), "force" (--design), "skip" (--skip-design)
20
+ # spurBin — PATH-independent spur invocation (overridden by CLI at run start)
21
+ # agent — agent for agent.run steps (default: omp)
22
+ #
23
+ # Seeded by `spur init`; adapt the agent.run inputs to your project's command set.
24
+
25
+ "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
26
+ kind: state-machine
27
+ name: idea-pipeline
28
+ description: "Idea to feature + AC + task batch: discovery, feature-create, ac-generate, feature-check, system-design, decompose, batch-create, handoff"
29
+ iterationBound: 15
30
+ initialState: start
31
+ terminalStates:
32
+ - handoff
33
+ - cancelled
34
+ - failed
35
+ vars:
36
+ idea: ""
37
+ profile: "standard"
38
+ design: "auto"
39
+ spurBin: "spur"
40
+ agent: "omp"
41
+ stepTimeoutMs: "600000"
42
+
43
+ states:
44
+ - id: start
45
+ description: >
46
+ Pipeline start. The idea text arrives as vars.idea. The pipeline stops at
47
+ handoff — no task execution.
48
+ onEnter:
49
+ - kind: note
50
+ options:
51
+ message: "Idea pipeline start for idea: ${vars.idea}"
52
+ - kind: shell
53
+ options:
54
+ command: 'rm -f .spur/run/idea-ac-retry-count .spur/run/idea-decompose-retry-count'
55
+
56
+ - id: discovery
57
+ description: >
58
+ Dispatch sp:brainstorm to explore the idea, generate approaches with trade-offs,
59
+ and record a design summary. The brainstorm ALWAYS records a design summary
60
+ ("nothing is too simple" pattern). The brainstorm also emits a needs_design
61
+ boolean signal written to .spur/run/idea-needs-design.json — this determines
62
+ whether the system-design state runs.
63
+ onEnter:
64
+ - kind: agent.run
65
+ options:
66
+ agent: ${vars.agent}
67
+ input: "Run sp:brainstorm for the idea: ${vars.idea}. Generate 2-3 approaches with trade-offs and a design summary. Perform the scope decomposition check and write the needs_design boolean to .spur/run/idea-needs-design.json as {\"needs_design\": true|false}. Criteria: true if multiple subsystems/schema/transport/dependency changes; false if single-module/bug-fix/pattern-following. Ties lean true. ALWAYS record a design summary — no idea is too simple."
68
+ timeoutMs: ${vars.stepTimeoutMs}
69
+
70
+ - id: feature-create
71
+ description: >
72
+ Create a feature via spur feature create, or select an existing feature id.
73
+ The agent writes the feature id to .spur/run/idea-feature-id.txt for subsequent
74
+ states to read.
75
+ onEnter:
76
+ - kind: agent.run
77
+ options:
78
+ agent: ${vars.agent}
79
+ input: "Create a feature for the idea: ${vars.idea}. Use 'spur feature create \"<name>\" --json' to create it. Write the feature id to .spur/run/idea-feature-id.txt. If an existing feature is appropriate, use its id instead."
80
+ timeoutMs: ${vars.stepTimeoutMs}
81
+
82
+ - id: ac-generate
83
+ description: >
84
+ Generate acceptance criteria per ac-style-guide.md. Write AC scenarios to the
85
+ feature file via spur feature update (direct body edit, not --section). The
86
+ agent authors R-numbered Gherkin scenarios tied to the design summary.
87
+ onEnter:
88
+ - kind: agent.run
89
+ options:
90
+ agent: ${vars.agent}
91
+ input: "Generate acceptance criteria for the feature whose id is in .spur/run/idea-feature-id.txt. Read the feature file, author R-numbered BDD Gherkin scenarios per ac-style-guide.md, and write them to the feature body via direct file edit (spur feature update does not support --section for features). Run 'spur feature check <id>' to validate."
92
+ timeoutMs: ${vars.stepTimeoutMs}
93
+
94
+ - id: feature-check
95
+ description: >
96
+ Objective gate: run spur feature check <id> --strict. Under --auto, routes
97
+ around the HITL gate when the check passes. On failure, routes back to
98
+ ac-generate for AC revision. The feature id is read from
99
+ .spur/run/idea-feature-id.txt.
100
+ onEnter:
101
+ - kind: note
102
+ options:
103
+ message: "Running feature check for feature $(cat .spur/run/idea-feature-id.txt)"
104
+
105
+ - id: system-design
106
+ description: >
107
+ Dispatch sp:sys-architecture to produce the system design. This state runs
108
+ only when needs_design=true (or --design forces it). The agent creates ADR
109
+ entries, architecture updates, and design satellites through constitution rules.
110
+ The design-approval state follows (taste gate).
111
+ onEnter:
112
+ - kind: agent.run
113
+ options:
114
+ agent: ${vars.agent}
115
+ input: "Run sp:sys-architecture for the feature whose id is in .spur/run/idea-feature-id.txt. Read the brainstorm artifact and feature AC. Produce ADR entries, architecture updates, and design satellites (docs/design/<slug>.md) following the constitution edit rules. Do not write task or feature corpus files directly."
116
+ timeoutMs: ${vars.stepTimeoutMs}
117
+
118
+ - id: design-approval
119
+ description: >
120
+ HITL taste gate. The operator reviews the system design and approves or
121
+ rejects it. This gate is NOT auto-clicked by --auto (Auto-Decision Principle
122
+ #5: taste-decision -> surface to human). Only routes around when
123
+ vars.design_approved=true (explicit prior approval).
124
+ pause: true
125
+ onEnter:
126
+ - kind: hitl.confirm
127
+ options:
128
+ prompt: "Review the system design for feature $(cat .spur/run/idea-feature-id.txt). Approve to proceed to decomposition?"
129
+
130
+ - id: decompose
131
+ description: >
132
+ Dispatch sp:spec-decomposition with the brainstorm artifact, feature AC, and
133
+ design doc as input. The agent produces a task-batch JSON file at
134
+ .spur/run/idea-task-batch.json, validated against task-batch.schema.json.
135
+ onEnter:
136
+ - kind: agent.run
137
+ options:
138
+ agent: ${vars.agent}
139
+ input: "Run sp:spec-decomposition for the feature whose id is in .spur/run/idea-feature-id.txt. Read the brainstorm artifact, feature AC, and design doc. Produce a task-batch JSON array at .spur/run/idea-task-batch.json, validated against apps/cli/schemas/task-batch.schema.json. Each entry must have filled Requirements, AC, Design, and Plan sections."
140
+ timeoutMs: ${vars.stepTimeoutMs}
141
+
142
+ - id: batch-create
143
+ description: >
144
+ Objective gate: run spur task batch-create with the batch JSON from the
145
+ decompose state. Under --auto, routes around the HITL gate when the batch
146
+ validates. On failure, routes back to decompose.
147
+ onEnter:
148
+ - kind: note
149
+ options:
150
+ message: "Running batch-create for feature $(cat .spur/run/idea-feature-id.txt)"
151
+
152
+ - id: handoff
153
+ description: >
154
+ Terminal — idea pipeline complete. Output: feature id, task WBS list, and
155
+ the next command to run (/sp:dev-run <first-wbs> or /sp:dev-runall --tasks feature:<id>).
156
+ No task execution — the pipeline stops here.
157
+ onEnter:
158
+ - kind: note
159
+ options:
160
+ message: "Idea pipeline handoff. Feature: $(cat .spur/run/idea-feature-id.txt). Tasks created. Next: /sp:dev-runall --tasks feature:$(cat .spur/run/idea-feature-id.txt)"
161
+ # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
162
+ - kind: shell
163
+ options:
164
+ command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: idea-pipeline handoff feature=$(cat .spur/run/idea-feature-id.txt) ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/idea-checkpoint.md'
165
+
166
+ - id: cancelled
167
+ description: Terminal — pipeline cancelled by operator or error.
168
+
169
+ - id: failed
170
+ description: >
171
+ Terminal — pipeline failed after exhausting retry caps on cyclic edges
172
+ (feature-check → ac-generate or batch-create → decompose). The error
173
+ message in the run trace identifies which edge exceeded the cap.
174
+
175
+ transitions:
176
+ # ── start -> discovery ──
177
+ - from: start
178
+ to: discovery
179
+ description: Begin discovery — brainstorm the idea.
180
+ guard:
181
+ kind: always
182
+
183
+ # ── discovery -> feature-create ──
184
+ - from: discovery
185
+ to: feature-create
186
+ description: Discovery complete — create or select a feature.
187
+ guard:
188
+ kind: always
189
+
190
+ # ── feature-create -> ac-generate ──
191
+ - from: feature-create
192
+ to: ac-generate
193
+ description: Feature created — generate acceptance criteria.
194
+ guard:
195
+ kind: always
196
+
197
+ # ── ac-generate -> feature-check ──
198
+ - from: ac-generate
199
+ to: feature-check
200
+ description: AC generated — run feature check.
201
+ guard:
202
+ kind: always
203
+
204
+ # ── feature-check: pass + design route -> system-design, pass + skip route -> decompose, fail -> ac-generate ──
205
+ # Declaration order matters: system-design route tried first, then skip route, then fail.
206
+ - from: feature-check
207
+ to: system-design
208
+ description: Feature check passed, design route — run system design.
209
+ guard:
210
+ kind: shell
211
+ options:
212
+ command: '${vars.spurBin} feature check $(cat .spur/run/idea-feature-id.txt) --strict && (test "${vars.design}" = force || (test "${vars.design}" = auto && test "$(jq -r .needs_design .spur/run/idea-needs-design.json 2>/dev/null)" != false))'
213
+ - from: feature-check
214
+ to: decompose
215
+ description: Feature check passed, skip-design route — go directly to decompose.
216
+ guard:
217
+ kind: shell
218
+ options:
219
+ command: '${vars.spurBin} feature check $(cat .spur/run/idea-feature-id.txt) --strict && (test "${vars.design}" = skip || (test "${vars.design}" = auto && test "$(jq -r .needs_design .spur/run/idea-needs-design.json 2>/dev/null)" = false))'
220
+ - from: feature-check
221
+ to: ac-generate
222
+ description: "Feature check failed — revise AC (retry cap: 3)."
223
+ guard:
224
+ kind: shell
225
+ options:
226
+ command: '! ${vars.spurBin} feature check $(cat .spur/run/idea-feature-id.txt) --strict && test "$(cat .spur/run/idea-ac-retry-count 2>/dev/null || echo 0)" -lt 3 && echo $(( $(cat .spur/run/idea-ac-retry-count 2>/dev/null || echo 0) + 1 )) > .spur/run/idea-ac-retry-count'
227
+ - from: feature-check
228
+ to: failed
229
+ description: Feature check failed after 3 retries — escalate to failed.
230
+ guard:
231
+ kind: shell
232
+ options:
233
+ command: '! ${vars.spurBin} feature check $(cat .spur/run/idea-feature-id.txt) --strict && test "$(cat .spur/run/idea-ac-retry-count 2>/dev/null || echo 0)" -ge 3'
234
+
235
+ # ── system-design: -> design-approval (normal) OR -> decompose (auto + prior approval) ──
236
+ # Declaration order: auto-skip guard tried FIRST (like task-pipeline's review->verify pattern).
237
+ - from: system-design
238
+ to: decompose
239
+ description: profile=auto AND design_approved=true — skip design approval gate.
240
+ guard:
241
+ kind: shell
242
+ options:
243
+ command: 'test "${vars.profile}" = auto && test "${vars.design_approved}" = true'
244
+ - from: system-design
245
+ to: design-approval
246
+ description: System design done — gate on design approval.
247
+ guard:
248
+ kind: always
249
+
250
+ # ── design-approval -> decompose (after operator approval) ──
251
+ - from: design-approval
252
+ to: decompose
253
+ description: Design approved — proceed to decomposition.
254
+ guard:
255
+ kind: always
256
+
257
+ # ── decompose -> batch-create ──
258
+ - from: decompose
259
+ to: batch-create
260
+ description: Decomposition complete — create task batch.
261
+ guard:
262
+ kind: always
263
+
264
+ # ── batch-create: success -> handoff, failure -> decompose (retry cap: 3), failure+cap -> failed ──
265
+ - from: batch-create
266
+ to: handoff
267
+ description: Batch created — handoff.
268
+ guard:
269
+ kind: shell
270
+ options:
271
+ command: '${vars.spurBin} task batch-create --file .spur/run/idea-task-batch.json'
272
+ - from: batch-create
273
+ to: decompose
274
+ description: "Batch-create failed — revise decomposition (retry cap: 3)."
275
+ guard:
276
+ kind: shell
277
+ options:
278
+ command: '! ${vars.spurBin} task batch-create --file .spur/run/idea-task-batch.json && test "$(cat .spur/run/idea-decompose-retry-count 2>/dev/null || echo 0)" -lt 3 && echo $(( $(cat .spur/run/idea-decompose-retry-count 2>/dev/null || echo 0) + 1 )) > .spur/run/idea-decompose-retry-count'
279
+ - from: batch-create
280
+ to: failed
281
+ description: Batch-create failed after 3 retries — escalate to failed.
282
+ guard:
283
+ kind: shell
284
+ options:
285
+ command: '! ${vars.spurBin} task batch-create --file .spur/run/idea-task-batch.json && test "$(cat .spur/run/idea-decompose-retry-count 2>/dev/null || echo 0)" -ge 3'
@@ -12,7 +12,9 @@
12
12
  # commit (Q3 — hybrid by risk).
13
13
  #
14
14
  # Shape: phasing(HITL) → feature-id → design-gen → design-approval(HITL) → handoff
15
- # (phasing/design-approval skipped when profile=auto).
15
+ # (phasing skipped when profile=auto; design-approval skipped when profile=auto
16
+ # AND design_approved=true — taste gates pause even in auto unless prior approval
17
+ # is encoded, per design doc HITL taxonomy).
16
18
  #
17
19
  # Vars (passed as a JSON object via `--vars`):
18
20
  # slug — the design-doc slug, e.g. `--vars '{"slug":"auth-migration"}'`
@@ -78,8 +80,11 @@ states:
78
80
  - id: design-approval
79
81
  description: >
80
82
  HITL gate — the highest-leverage gate in the pipeline. Loop until the operator
81
- approves the design doc. Under profile=auto this state is never entered (design-gen
82
- routes around it straight to handoff).
83
+ approves the design doc. Under profile=auto this state is skipped ONLY when
84
+ vars.design_approved=true (explicit prior approval); otherwise the taste gate
85
+ pauses even in auto mode (design doc HITL taxonomy). Loop back to design-gen
86
+ for revisions.
87
+ pause: true
83
88
  onEnter:
84
89
  - kind: hitl.confirm
85
90
  options:
@@ -90,6 +95,11 @@ states:
90
95
  Terminal — design doc approved; the drafted feature list is ready. Hand off to
91
96
  sp:spur-dev (steps 7–12), which starts at `spur feature create`. The seam is the
92
97
  drafted-feature-list file at docs/plans/${vars.slug}-drafted.md.
98
+ # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
99
+ onEnter:
100
+ - kind: shell
101
+ options:
102
+ command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: planning-pipeline handoff slug=${vars.slug} ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/${vars.slug}-checkpoint.md'
93
103
 
94
104
  - id: cancelled
95
105
  description: Terminal — planning abandoned by the operator.
@@ -131,16 +141,17 @@ transitions:
131
141
  guard:
132
142
  kind: always
133
143
 
134
- # ── design-gen → design-approval, OR skip the HITL gate when profile=auto ──
135
- # Auto-skip guard tried FIRST: under profile=auto, route straight to handoff
136
- # without entering design-approval (whose onEnter hitl.confirm would block).
144
+ # ── design-gen → design-approval, OR skip the HITL gate when profile=auto AND design_approved=true ──
145
+ # Auto-skip guard tried FIRST: under profile=auto with explicit prior approval,
146
+ # route straight to handoff without entering design-approval. Without prior
147
+ # approval, the taste gate pauses even in auto (design doc HITL taxonomy).
137
148
  - from: design-gen
138
149
  to: handoff
139
- description: profile=auto — skip the design-approval HITL gate, hand off directly.
150
+ description: profile=auto AND design_approved=true — skip the design-approval HITL gate.
140
151
  guard:
141
152
  kind: shell
142
153
  options:
143
- command: 'test "${vars.profile}" = auto'
154
+ command: 'test "${vars.profile}" = auto && test "${vars.design_approved}" = true'
144
155
  - from: design-gen
145
156
  to: design-approval
146
157
  description: Design doc drafted — gate on human approval.
@@ -75,6 +75,15 @@ states:
75
75
  - kind: shell
76
76
  options:
77
77
  command: "${vars.spurBin} task update ${vars.wbs} wip --no-lifecycle"
78
+ # Post-implement cleanup: auto-format any unformatted output the agent produced.
79
+ # The agent may leave files that don't pass `biome check`, which then fails
80
+ # `bun run lint` downstream. Running format here is cheap (< 1 s) and prevents
81
+ # the test stage from tripping on a purely mechanical formatting defect (dogfood
82
+ # bug-733 — omp left agent-run.test.ts unformatted, causing a spurious lint gate
83
+ # failure that the ## Testing section mis-attributed to "pre-existing gaps").
84
+ - kind: shell
85
+ options:
86
+ command: "bun run format"
78
87
 
79
88
  - id: test
80
89
  description: Test execution + coverage via /sp:dev-unit.
@@ -84,6 +93,13 @@ states:
84
93
  agent: ${vars.agent}
85
94
  input: /sp:dev-unit ${vars.wbs} --auto
86
95
  timeoutMs: ${vars.stepTimeoutMs}
96
+ # Post-test gate: run the project's full lint gate (biome check + tsc). If the
97
+ # implement or test step left unformatted files or type errors, the run routes to
98
+ # `failed` rather than letting an agent-authored `## Testing` claim "all pass"
99
+ # against a red gate (dogfood bug-733).
100
+ - kind: shell
101
+ options:
102
+ command: "bun run lint"
87
103
 
88
104
  - id: review
89
105
  description: SECUA-framework code review via /sp:dev-review (Security, Efficiency, Correctness, Usability, Architecture).
@@ -97,10 +113,9 @@ states:
97
113
  - id: approve
98
114
  description: >
99
115
  Human-in-the-loop approval gate. Under `profile=auto` this state is never entered
100
- (review routes around it straight to verify). NOTE: to make this state PAUSE for `spur workflow continue`
101
- (E3), add `pause: true` here deferred until the globally-installed `@gobing-ai/spur`
102
- schema (currently a stale 0.2.5 that lacks the `pause` field) is refreshed, so
103
- full `spur workflow validate` stays green. The workspace schema already supports it.
116
+ (review routes around it straight to verify). In interactive mode this state pauses
117
+ the run for `spur workflow continue` (E3), making approval an explicit operator action.
118
+ pause: true
104
119
  onEnter:
105
120
  - kind: hitl.confirm
106
121
  options:
@@ -148,6 +163,10 @@ states:
148
163
  - kind: note
149
164
  options:
150
165
  message: "Pipeline complete for task ${vars.wbs} (done gate cleared)."
166
+ # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
167
+ - kind: shell
168
+ options:
169
+ command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: task-pipeline done wbs=${vars.wbs} ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/${vars.wbs}-checkpoint.md'
151
170
 
152
171
  - id: failed
153
172
  description: Terminal — precheck (or a gated step) failed; reported, not advanced.
@@ -0,0 +1,225 @@
1
+ # Wrap-up pipeline — post-execution wrap-up for one task or a batch (design §wrapup-pipeline).
2
+ #
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,
8
+ # even under --auto (Iron Law #6: irreversible action -> surface to human).
9
+ #
10
+ # Shape: start -> task-resolve -> doc-sync -> learning-capture -> metrics-record
11
+ # -> feature-transition (conditional: if vars.feature set)
12
+ # -> branch-cleanup (conditional: if vars.merge=true)
13
+ # -> done
14
+ # (task-resolve with empty list short-circuits to `skipped`).
15
+ #
16
+ # Vars (passed as a JSON object via `--vars`):
17
+ # tasks — JSON array of WBS strings (required), e.g. --vars '{"tasks":["0167"]}'
18
+ # feature — feature id to advance through legal lifecycle edges (optional)
19
+ # profile — set --vars '{"profile":"auto"}' to skip objective confirmations
20
+ # merge — set --vars '{"merge":"true"}' to run branch cleanup (irreversible HITL)
21
+ # spurBin — PATH-independent spur invocation (overridden by CLI at run start)
22
+ # agent — agent for agent.run steps (default: omp)
23
+ #
24
+ # Seeded by `spur init`; adapt the agent.run inputs to your project's command set.
25
+
26
+ "$schema": "@gobing-ai/spur/schemas/state-machine-workflow.schema.json"
27
+ kind: state-machine
28
+ name: wrapup-pipeline
29
+ description: "Post-execution wrap-up: doc-sync, learning-capture, metrics, feature-transition, branch-cleanup"
30
+ iterationBound: 10
31
+ initialState: start
32
+ terminalStates:
33
+ - done
34
+ - skipped
35
+ vars:
36
+ tasks: "[]"
37
+ feature: ""
38
+ profile: "standard"
39
+ merge: "false"
40
+ spurBin: "spur"
41
+ agent: "omp"
42
+ stepTimeoutMs: "600000"
43
+
44
+ states:
45
+ - id: start
46
+ description: >
47
+ Pipeline start. Task statuses are NOT mutated by wrap-up — wrap-up consumes
48
+ completed tasks and produces learning/metrics/doc artifacts only.
49
+ onEnter:
50
+ - kind: note
51
+ options:
52
+ message: "Wrap-up pipeline start for tasks: ${vars.tasks}. Task statuses are NOT mutated."
53
+
54
+ - id: task-resolve
55
+ description: >
56
+ Validate the task list passed by the wrapper is non-empty. The command wrapper
57
+ (dev-wrap/dev-wrapall) resolves the task list and passes it as vars.tasks;
58
+ this state validates it before proceeding. Empty list routes to `skipped`.
59
+ onEnter:
60
+ - kind: note
61
+ options:
62
+ message: "Resolving task list: ${vars.tasks}"
63
+
64
+ - id: doc-sync
65
+ description: >
66
+ Dispatch sp:doc-evolve once for the entire batch. Project-level doc drift repair
67
+ runs once, not per-task. The agent reads the batch's completed tasks, identifies
68
+ doc drift (04_DESIGN, 03_ARCHITECTURE, 00_ADR, docs/design/*), and repairs it
69
+ following the constitution's edit rules. Does NOT write task or feature corpus.
70
+ onEnter:
71
+ - kind: agent.run
72
+ options:
73
+ agent: ${vars.agent}
74
+ input: "Run sp:doc-evolve for completed tasks ${vars.tasks}. Repair doc drift in 04_DESIGN, 03_ARCHITECTURE, 00_ADR, docs/design/* following the constitution edit rules. Do not write task or feature corpus files."
75
+ timeoutMs: ${vars.stepTimeoutMs}
76
+
77
+ - id: learning-capture
78
+ description: >
79
+ Append working learnings to .spur/memory/learnings.md. The agent extracts insights
80
+ from the batch — conventions discovered, errors hit and resolved, patterns that
81
+ worked, gotchas for future tasks. Grouped by date and task WBS. Working scratchpad,
82
+ not a CLI-gated corpus artifact.
83
+ onEnter:
84
+ - kind: agent.run
85
+ options:
86
+ agent: ${vars.agent}
87
+ input: "Extract working learnings from tasks ${vars.tasks}. Append to .spur/memory/learnings.md grouped by date and task WBS. Include: conventions discovered, errors hit and resolved, patterns that worked, gotchas. This is a working scratchpad, not a validated corpus."
88
+ timeoutMs: ${vars.stepTimeoutMs}
89
+
90
+ - id: metrics-record
91
+ description: >
92
+ Append one JSONL row per task to .spur/memory/wrapup-metrics.jsonl. Each row
93
+ records: task WBS, feature id, status, verdict, timestamp. Append-only and
94
+ machine-readable.
95
+ onEnter:
96
+ - kind: agent.run
97
+ options:
98
+ agent: ${vars.agent}
99
+ input: "For each task in ${vars.tasks}, read the task file and append one JSONL row to .spur/memory/wrapup-metrics.jsonl with fields: wbs, feature_id, status, verdict (from .spur/run/<wbs>-verdict.json if available), timestamp. Append-only; do not overwrite existing rows."
100
+ timeoutMs: ${vars.stepTimeoutMs}
101
+
102
+ - id: feature-transition
103
+ description: >
104
+ If vars.feature is set, advance the feature through legal lifecycle edges only
105
+ via spur feature update. Never attempt backlog|active -> done directly. The
106
+ legal path: backlog -> active (always), active -> verifying (feature check
107
+ guard), verifying -> done (strict feature check guard). Task statuses are NOT
108
+ mutated. The agent checks current status and advances idempotently.
109
+ onEnter:
110
+ - kind: agent.run
111
+ options:
112
+ agent: ${vars.agent}
113
+ input: "Advance feature ${vars.feature} through legal lifecycle edges using spur feature update. Check current status with 'spur feature show ${vars.feature} --json'. If backlog, transition to active. If active, run 'spur feature check ${vars.feature}' then transition to verifying. If verifying, run 'spur feature check ${vars.feature} --strict' then transition to done. Never skip edges (no backlog->done directly). Report transitions made."
114
+ timeoutMs: ${vars.stepTimeoutMs}
115
+
116
+ - id: branch-cleanup
117
+ description: >
118
+ Irreversible HITL gate. If vars.merge=true, dispatch branch cleanup (merge or
119
+ delete). This gate ALWAYS pauses — even under --auto — because branch operations
120
+ are irreversible (Auto-Decision Principle #6). The operator must explicitly confirm.
121
+ pause: true
122
+ onEnter:
123
+ - kind: hitl.confirm
124
+ options:
125
+ prompt: "Branch cleanup for tasks ${vars.tasks}. This is IRREVERSIBLE (merge or delete). Confirm to proceed?"
126
+
127
+ - id: done
128
+ description: >
129
+ Terminal — wrap-up complete. Output summary: tasks wrapped, learnings captured,
130
+ metrics recorded, feature advanced (if applicable), branch cleaned (if applicable).
131
+ onEnter:
132
+ - kind: note
133
+ options:
134
+ message: "Wrap-up pipeline complete for tasks: ${vars.tasks}. Learnings at .spur/memory/learnings.md, metrics at .spur/memory/wrapup-metrics.jsonl."
135
+ # Checkpoint write: record session state for resume (Phase 4, task 0171 R3)
136
+ - kind: shell
137
+ options:
138
+ command: 'mkdir -p .spur/memory/sessions && echo "checkpoint: wrapup-pipeline done tasks=${vars.tasks} ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .spur/memory/sessions/wrapup-checkpoint.md'
139
+
140
+ - id: skipped
141
+ description: Terminal — wrap-up skipped (empty task list or operator abort).
142
+
143
+ transitions:
144
+ # ── start -> task-resolve ──
145
+ - from: start
146
+ to: task-resolve
147
+ description: Begin wrap-up.
148
+ guard:
149
+ kind: always
150
+
151
+ # ── task-resolve: non-empty -> doc-sync, empty -> skipped ──
152
+ - from: task-resolve
153
+ to: doc-sync
154
+ description: Task list resolved and non-empty — begin doc-sync.
155
+ guard:
156
+ kind: shell
157
+ options:
158
+ command: 'test "$(echo "${vars.tasks}" | jq length)" -gt 0'
159
+ - from: task-resolve
160
+ to: skipped
161
+ description: Task list is empty — skip wrap-up.
162
+ guard:
163
+ kind: shell
164
+ options:
165
+ command: 'test "$(echo "${vars.tasks}" | jq length)" -eq 0'
166
+
167
+ # ── linear body: doc-sync -> learning-capture -> metrics-record ──
168
+ - from: doc-sync
169
+ to: learning-capture
170
+ description: Doc sync complete — capture learnings.
171
+ guard:
172
+ kind: always
173
+ - from: learning-capture
174
+ to: metrics-record
175
+ description: Learnings captured — record metrics.
176
+ guard:
177
+ kind: always
178
+
179
+ # ── metrics-record: conditional routing based on feature and merge ──
180
+ # Declaration order matters: feature-transition is tried first (if feature is set),
181
+ # then branch-cleanup (if merge=true but no feature), then done (neither).
182
+ - from: metrics-record
183
+ to: feature-transition
184
+ description: Feature id is set — advance feature through legal lifecycle edges.
185
+ guard:
186
+ kind: shell
187
+ options:
188
+ command: 'test -n "${vars.feature}"'
189
+ - from: metrics-record
190
+ to: branch-cleanup
191
+ description: No feature id, but merge is requested — go to branch cleanup.
192
+ guard:
193
+ kind: shell
194
+ options:
195
+ command: 'test -z "${vars.feature}" && test "${vars.merge}" = true'
196
+ - from: metrics-record
197
+ to: done
198
+ description: No feature id and no merge — wrap-up is done.
199
+ guard:
200
+ kind: shell
201
+ options:
202
+ command: 'test -z "${vars.feature}" && test "${vars.merge}" != true'
203
+
204
+ # ── feature-transition: -> branch-cleanup (if merge) or done ──
205
+ - from: feature-transition
206
+ to: branch-cleanup
207
+ description: Feature advanced — merge is requested.
208
+ guard:
209
+ kind: shell
210
+ options:
211
+ command: 'test "${vars.merge}" = true'
212
+ - from: feature-transition
213
+ to: done
214
+ description: Feature advanced — no merge requested.
215
+ guard:
216
+ kind: shell
217
+ options:
218
+ command: 'test "${vars.merge}" != true'
219
+
220
+ # ── branch-cleanup: HITL confirmed -> done ──
221
+ - from: branch-cleanup
222
+ to: done
223
+ description: Branch cleanup confirmed — wrap-up complete.
224
+ guard:
225
+ kind: always
package/spur.js CHANGED
@@ -32493,7 +32493,40 @@ var init_template_renderer = () => {};
32493
32493
  // ../../packages/config/src/loader.ts
32494
32494
  import { existsSync as existsSync3 } from "fs";
32495
32495
  import { homedir } from "os";
32496
- import { join as join2 } from "path";
32496
+ import { dirname as dirname3, join as join2 } from "path";
32497
+ import { fileURLToPath as fileURLToPath3 } from "url";
32498
+ function embeddedSchemasId(embeddedSchemas) {
32499
+ if (embeddedSchemas === undefined)
32500
+ return "disk";
32501
+ let id = embeddedSchemasIds.get(embeddedSchemas);
32502
+ if (id === undefined) {
32503
+ id = nextEmbeddedSchemasId;
32504
+ nextEmbeddedSchemasId += 1;
32505
+ embeddedSchemasIds.set(embeddedSchemas, id);
32506
+ }
32507
+ return `embedded:${id}`;
32508
+ }
32509
+ function cacheKey(configPath, opts, validateJsonSchema2) {
32510
+ return [
32511
+ configPath,
32512
+ validateJsonSchema2 ? "schema" : "zod",
32513
+ opts?.schemaManifestSpecifier ?? "@gobing-ai/spur/package.json",
32514
+ embeddedSchemasId(opts?.embeddedSchemas)
32515
+ ].join("\x00");
32516
+ }
32517
+ function resolveSchemaSpecifier(specifier, manifestSpecifier) {
32518
+ if (specifier === manifestSpecifier) {
32519
+ const workspaceManifest = join2(LOADER_DIR, "..", "..", "..", "apps", "cli", "package.json");
32520
+ if (existsSync3(workspaceManifest))
32521
+ return workspaceManifest;
32522
+ }
32523
+ try {
32524
+ const resolved = import.meta.resolve(specifier);
32525
+ return resolved.startsWith("file:") ? fileURLToPath3(resolved) : resolved;
32526
+ } catch {
32527
+ return specifier;
32528
+ }
32529
+ }
32497
32530
  function resolveConfigFile(cwd) {
32498
32531
  const projectConfig = join2(cwd ?? process.cwd(), SPUR_CONFIG_DIR, SPUR_CONFIG_FILE);
32499
32532
  if (existsSync3(projectConfig))
@@ -32518,11 +32551,21 @@ function makeEmbeddedReader(embeddedSchemas) {
32518
32551
  };
32519
32552
  }
32520
32553
  async function loadSpurConfig(cwd = process.cwd(), opts) {
32521
- const configPath = join2(cwd, SPUR_CONFIG_DIR, SPUR_CONFIG_FILE);
32522
- if (!existsSync3(configPath)) {
32554
+ const configPath = resolveConfigFile(cwd);
32555
+ if (configPath === undefined) {
32523
32556
  return spurConfigSchema.parse({});
32524
32557
  }
32525
32558
  const validateJsonSchema2 = opts?.validateJsonSchema ?? true;
32559
+ const key = cacheKey(configPath, opts, validateJsonSchema2);
32560
+ const cached2 = spurConfigCache.get(key);
32561
+ if (cached2 !== undefined)
32562
+ return cached2;
32563
+ const promise2 = loadSpurConfigFile(configPath, opts, validateJsonSchema2);
32564
+ spurConfigCache.set(key, promise2);
32565
+ promise2.catch(() => spurConfigCache.delete(key));
32566
+ return promise2;
32567
+ }
32568
+ async function loadSpurConfigFile(configPath, opts, validateJsonSchema2) {
32526
32569
  let raw;
32527
32570
  if (validateJsonSchema2) {
32528
32571
  const embeddedSchemas = opts?.embeddedSchemas;
@@ -32531,7 +32574,7 @@ async function loadSpurConfig(cwd = process.cwd(), opts) {
32531
32574
  if (embeddedSchemas !== undefined && specifier === manifestSpecifier) {
32532
32575
  return `${EMBEDDED_PREFIX}/package.json`;
32533
32576
  }
32534
- return specifier;
32577
+ return resolveSchemaSpecifier(specifier, manifestSpecifier);
32535
32578
  };
32536
32579
  const fileSystem = embeddedSchemas ? { readFile: makeEmbeddedReader(embeddedSchemas) } : undefined;
32537
32580
  raw = await loadStructuredConfig(configPath, {
@@ -32561,7 +32604,7 @@ async function loadStructuredSpurConfig(configPath, opts) {
32561
32604
  if (embeddedSchemas !== undefined && specifier === manifestSpecifier) {
32562
32605
  return `${EMBEDDED_PREFIX}/package.json`;
32563
32606
  }
32564
- return specifier;
32607
+ return resolveSchemaSpecifier(specifier, manifestSpecifier);
32565
32608
  };
32566
32609
  const fileSystem = embeddedSchemas ? { readFile: makeEmbeddedReader(embeddedSchemas) } : undefined;
32567
32610
  return await loadStructuredConfig(configPath, {
@@ -32584,6 +32627,14 @@ function defaultPlanningFolders() {
32584
32627
  };
32585
32628
  }
32586
32629
  async function resolvePlanningFolders(fs) {
32630
+ const cached2 = planningFoldersCache.get(fs);
32631
+ if (cached2 !== undefined)
32632
+ return cached2;
32633
+ const promise2 = resolvePlanningFoldersUncached(fs);
32634
+ planningFoldersCache.set(fs, promise2);
32635
+ return promise2;
32636
+ }
32637
+ async function resolvePlanningFoldersUncached(fs) {
32587
32638
  const configPath = fs.resolve(join2(SPUR_CONFIG_DIR, SPUR_CONFIG_FILE));
32588
32639
  if (!await fs.exists(configPath))
32589
32640
  return defaultPlanningFolders();
@@ -32614,7 +32665,7 @@ async function resolvePlanningFolders(fs) {
32614
32665
  return defaultPlanningFolders();
32615
32666
  }
32616
32667
  }
32617
- var SPUR_CONFIG_DIR = ".spur", SPUR_CONFIG_FILE = "config.yaml", GLOBAL_CONFIG_FILE, EMBEDDED_PREFIX = "\x00embedded-spur";
32668
+ var SPUR_CONFIG_DIR = ".spur", SPUR_CONFIG_FILE = "config.yaml", GLOBAL_CONFIG_FILE, LOADER_DIR, nextEmbeddedSchemasId = 1, embeddedSchemasIds, spurConfigCache, planningFoldersCache, EMBEDDED_PREFIX = "\x00embedded-spur";
32618
32669
  var init_loader = __esm(() => {
32619
32670
  init_dist3();
32620
32671
  init_dist2();
@@ -32622,6 +32673,10 @@ var init_loader = __esm(() => {
32622
32673
  init_bundled_config();
32623
32674
  init_template_renderer();
32624
32675
  GLOBAL_CONFIG_FILE = join2(homedir(), ".config", "spur", "config.yaml");
32676
+ LOADER_DIR = dirname3(fileURLToPath3(import.meta.url));
32677
+ embeddedSchemasIds = new WeakMap;
32678
+ spurConfigCache = new Map;
32679
+ planningFoldersCache = new WeakMap;
32625
32680
  });
32626
32681
 
32627
32682
  // ../../node_modules/.bun/@gobing-ai+ts-runtime@0.4.2/node_modules/@gobing-ai/ts-runtime/dist/bun-sqlite.js
@@ -55614,7 +55669,7 @@ var init_db2 = __esm(() => {
55614
55669
 
55615
55670
  // ../../packages/domain/src/planning/locks.ts
55616
55671
  import { closeSync, fsyncSync, openSync, renameSync as renameSync2 } from "fs";
55617
- import { dirname as dirname5, join as join5 } from "path";
55672
+ import { dirname as dirname6, join as join5 } from "path";
55618
55673
  function lockPathForEntity(folder, entityId) {
55619
55674
  return join5(folder, `.${entityId}.lock`);
55620
55675
  }
@@ -55701,7 +55756,7 @@ function fsyncPath(path8, swallowErrors = false) {
55701
55756
  }
55702
55757
  }
55703
55758
  function atomicWrite(dest, content, entityId, fs3, projectName = "spur") {
55704
- const dir = dirname5(dest);
55759
+ const dir = dirname6(dest);
55705
55760
  const rand = Math.random().toString(36).slice(2, 10);
55706
55761
  const tempPath = join5(dir, `.${projectName}.${entityId}.${rand}.tmp`);
55707
55762
  fs3.writeFile(tempPath, content);
@@ -55710,7 +55765,7 @@ function atomicWrite(dest, content, entityId, fs3, projectName = "spur") {
55710
55765
  fsyncPath(dir, true);
55711
55766
  }
55712
55767
  async function atomicWriteAsync(dest, content, entityId, fs3, projectName = "spur") {
55713
- const dir = dirname5(dest);
55768
+ const dir = dirname6(dest);
55714
55769
  const rand = Math.random().toString(36).slice(2, 10);
55715
55770
  const tempPath = join5(dir, `.${projectName}.${entityId}.${rand}.tmp`);
55716
55771
  await fs3.writeFile(tempPath, content);
@@ -55774,6 +55829,19 @@ function findHeadings(body, hashes) {
55774
55829
  }
55775
55830
  return hits;
55776
55831
  }
55832
+ function normalizeYamlScalars(data) {
55833
+ const out = {};
55834
+ for (const [key, value] of Object.entries(data)) {
55835
+ if (value instanceof Date) {
55836
+ out[key] = value.toISOString();
55837
+ } else if (value !== null && typeof value === "object" && !Array.isArray(value)) {
55838
+ out[key] = normalizeYamlScalars(value);
55839
+ } else {
55840
+ out[key] = value;
55841
+ }
55842
+ }
55843
+ return out;
55844
+ }
55777
55845
 
55778
55846
  class MarkdownDocument {
55779
55847
  _domain;
@@ -55804,7 +55872,7 @@ class MarkdownDocument {
55804
55872
  try {
55805
55873
  const parsed = $parse(raw);
55806
55874
  if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
55807
- data = parsed;
55875
+ data = normalizeYamlScalars(parsed);
55808
55876
  }
55809
55877
  } catch {}
55810
55878
  frontmatter = { raw, data };
@@ -55971,8 +56039,19 @@ ${body.trimEnd()}
55971
56039
  ${content}
55972
56040
  ${END_MARKER}${after}`;
55973
56041
  }
56042
+ yamlSafeValue(value) {
56043
+ if (value.startsWith('"') && value.endsWith('"'))
56044
+ return value;
56045
+ if (/[:{}[\],&*?|>!%@`#"'\\\n\r]/.test(value))
56046
+ return `"${value}"`;
56047
+ if (/^(null|true|false|yes|no|on|off)$/i.test(value))
56048
+ return `"${value}"`;
56049
+ if (/^\d/.test(value) && !/^\d{4}-\d{2}-\d{2}/.test(value))
56050
+ return `"${value}"`;
56051
+ return value;
56052
+ }
55974
56053
  setFrontmatterField(key, value) {
55975
- const fieldLine = `${key}: ${value}`;
56054
+ const fieldLine = `${key}: ${this.yamlSafeValue(value)}`;
55976
56055
  if (this._frontmatter === null) {
55977
56056
  const raw2 = fieldLine;
55978
56057
  this._frontmatter = { raw: raw2, data: { [key]: value } };
@@ -58476,7 +58555,7 @@ var init_rule_service = __esm(() => {
58476
58555
  });
58477
58556
 
58478
58557
  // ../../packages/app/src/services/task-check.ts
58479
- import { dirname as dirname6, join as join8 } from "path";
58558
+ import { dirname as dirname7, join as join8 } from "path";
58480
58559
  function isPlaceholderBody(body) {
58481
58560
  const stripped = body.replace(/<!--[\s\S]*?-->/g, "").replace(/^\s*>\s*TBD\s*$/gim, "").trim();
58482
58561
  return stripped.length === 0;
@@ -58526,6 +58605,29 @@ function isProseOnlyReview(body) {
58526
58605
  return !stripped.split(`
58527
58606
  `).some((l) => l.includes("|"));
58528
58607
  }
58608
+ function hasAdjacentFileLineColumns(body) {
58609
+ const lines = body.split(`
58610
+ `);
58611
+ for (const line of lines) {
58612
+ if (!line.includes("|"))
58613
+ continue;
58614
+ const cells = line.split("|").map((c3) => c3.trim());
58615
+ if (cells.length < 3)
58616
+ continue;
58617
+ for (let i2 = 0;i2 < cells.length - 1; i2++) {
58618
+ const a2 = cells[i2];
58619
+ const b = cells[i2 + 1];
58620
+ if (a2 === undefined || b === undefined)
58621
+ continue;
58622
+ const fileText = a2.replace(/`/g, "");
58623
+ const fileCell = /\.(tsx?|jsx?|mjs|cjs|md|ya?ml|json|css|html|sql|sh|toml)$/i.test(fileText);
58624
+ const lineCell = /^\d+(-\d+)?$/.test(b);
58625
+ if (fileCell && lineCell)
58626
+ return true;
58627
+ }
58628
+ }
58629
+ return false;
58630
+ }
58529
58631
  var TaskCheckService;
58530
58632
  var init_task_check = __esm(() => {
58531
58633
  init_src2();
@@ -58554,8 +58656,8 @@ var init_task_check = __esm(() => {
58554
58656
  const entry = this.resolveMatrixEntry(variant, status);
58555
58657
  this.runL2(doc2, entry, findings);
58556
58658
  this.runL3(doc2, entry, findings);
58557
- const tasksDir = dirname6(filePath);
58558
- const featuresDir = join8(dirname6(tasksDir), "features");
58659
+ const tasksDir = dirname7(filePath);
58660
+ const featuresDir = join8(dirname7(tasksDir), "features");
58559
58661
  await this.runL4(doc2, fm, findings, featuresDir, tasksDir);
58560
58662
  await this.runL4Rollup(doc2, wbs, status, findings, tasksDir);
58561
58663
  return { wbs, ...this.summarizeWithStatus(status, findings, strict) };
@@ -58563,18 +58665,16 @@ var init_task_check = __esm(() => {
58563
58665
  runL3(doc2, entry, findings) {
58564
58666
  const reqBody = doc2.getSection("Requirements");
58565
58667
  if (reqBody !== null && !isPlaceholderBody(reqBody)) {
58566
- const rLines = reqBody.trim().split(`
58567
- `);
58668
+ const blocks = reqBody.trim().split(/\n\s*\n/).filter((b) => b.trim().length > 0);
58568
58669
  let numbered = 0;
58569
- let allLines = 0;
58570
- for (const l of rLines) {
58571
- if (l.trim().length > 0) {
58572
- allLines++;
58573
- if (/^\s*[-*]?\s*(?:\[[ xX]\]\s*)?R\d+\.?\s/.test(l))
58574
- numbered++;
58670
+ for (const block of blocks) {
58671
+ const firstLine = block.trimStart().split(`
58672
+ `)[0] ?? "";
58673
+ if (/^\s*[-*]?\s*(?:\[[ xX]\]\s*)?R\d+\.?\s/.test(firstLine)) {
58674
+ numbered++;
58575
58675
  }
58576
58676
  }
58577
- if (numbered === 0 || numbered < allLines * 0.5) {
58677
+ if (numbered === 0 || numbered < blocks.length * 0.5) {
58578
58678
  findings.push({
58579
58679
  layer: "L3",
58580
58680
  severity: "warning",
@@ -58586,7 +58686,8 @@ var init_task_check = __esm(() => {
58586
58686
  const solBody = doc2.getSection("Solution");
58587
58687
  if (solBody !== null && !isPlaceholderBody(solBody)) {
58588
58688
  const hasFileLine = /`[^`]+?:\d+(-\d+)?`/.test(solBody) || /[^\s`]\.\w+:\d+/.test(solBody);
58589
- if (!hasFileLine) {
58689
+ const hasTableFileLine = hasFileLine || hasAdjacentFileLineColumns(solBody);
58690
+ if (!hasTableFileLine) {
58590
58691
  findings.push({
58591
58692
  layer: "L3",
58592
58693
  severity: "error",
@@ -58997,7 +59098,7 @@ function gitDiffU0(cwd) {
58997
59098
  var init_task_record = () => {};
58998
59099
 
58999
59100
  // ../../packages/app/src/services/task-service.ts
59000
- import { dirname as dirname7, isAbsolute, relative as relative2, resolve as resolve2 } from "path";
59101
+ import { dirname as dirname8, isAbsolute, relative as relative2, resolve as resolve2 } from "path";
59001
59102
  function patchFrontmatterField(rendered, key, value) {
59002
59103
  const existingRe = new RegExp(`^(?=\\s*${escapeRegex2(key)}:)`, "m");
59003
59104
  if (existingRe.test(rendered)) {
@@ -59590,7 +59691,7 @@ ${block}` : block);
59590
59691
  resolveListDir(folder) {
59591
59692
  if (folder === undefined)
59592
59693
  return this.ctx.tasksDir;
59593
- const root = resolve2(dirname7(this.ctx.tasksDir));
59694
+ const root = resolve2(dirname8(this.ctx.tasksDir));
59594
59695
  const candidate = resolve2(root, folder);
59595
59696
  const rel = relative2(root, candidate);
59596
59697
  if (rel.startsWith("..") || isAbsolute(rel)) {
@@ -59863,8 +59964,9 @@ var init_team_service = __esm(() => {
59863
59964
  });
59864
59965
 
59865
59966
  // ../../packages/app/src/workflow/actions/agent-run.ts
59967
+ import { existsSync as existsSync4 } from "fs";
59866
59968
  import { mkdir, writeFile } from "fs/promises";
59867
- import { dirname as dirname8, isAbsolute as isAbsolute2, join as join10 } from "path";
59969
+ import { dirname as dirname9, isAbsolute as isAbsolute2, join as join10 } from "path";
59868
59970
 
59869
59971
  class AgentRunActionRunner {
59870
59972
  kind = KIND;
@@ -59909,6 +60011,7 @@ class AgentRunActionRunner {
59909
60011
  if (continueFlag !== undefined)
59910
60012
  flags.continue = continueFlag;
59911
60013
  const answerFile = asOptionalString(options.answerFile);
60014
+ const expectFile = asOptionalString(options.expectFile);
59912
60015
  const capture = asOptionalBoolean(options.capture) || answerFile !== undefined;
59913
60016
  const agentLabel = agent ?? "<default>";
59914
60017
  if (capture) {
@@ -59916,9 +60019,19 @@ class AgentRunActionRunner {
59916
60019
  const ok2 = exitCode2 === 0;
59917
60020
  if (answerFile !== undefined) {
59918
60021
  const target = isAbsolute2(answerFile) ? answerFile : join10(cwd, answerFile);
59919
- await mkdir(dirname8(target), { recursive: true });
60022
+ await mkdir(dirname9(target), { recursive: true });
59920
60023
  await writeFile(target, answer, "utf8");
59921
60024
  }
60025
+ if (ok2 && expectFile !== undefined) {
60026
+ const target = isAbsolute2(expectFile) ? expectFile : join10(cwd, expectFile);
60027
+ if (!existsSync4(target)) {
60028
+ return {
60029
+ ok: false,
60030
+ data: { exitCode: exitCode2, agent: agentLabel, answer },
60031
+ error: `agent.run (${agentLabel}) exited 0 but expected file is absent: ${expectFile}`
60032
+ };
60033
+ }
60034
+ }
59922
60035
  return {
59923
60036
  ok: ok2,
59924
60037
  data: { exitCode: exitCode2, agent: agentLabel, answer },
@@ -59928,6 +60041,16 @@ class AgentRunActionRunner {
59928
60041
  }
59929
60042
  const exitCode = await this.agentService.run(input, flags);
59930
60043
  const ok = exitCode === 0;
60044
+ if (ok && expectFile !== undefined) {
60045
+ const target = isAbsolute2(expectFile) ? expectFile : join10(cwd, expectFile);
60046
+ if (!existsSync4(target)) {
60047
+ return {
60048
+ ok: false,
60049
+ data: { exitCode, agent: agentLabel },
60050
+ error: `agent.run (${agentLabel}) exited 0 but expected file is absent: ${expectFile}`
60051
+ };
60052
+ }
60053
+ }
59931
60054
  return {
59932
60055
  ok,
59933
60056
  data: { exitCode, agent: agentLabel },
@@ -61232,7 +61355,7 @@ init_loader();
61232
61355
  init_dist4();
61233
61356
  init_dist3();
61234
61357
  import { appendFileSync as appendFileSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync4 } from "fs";
61235
- import { dirname as dirname3 } from "path";
61358
+ import { dirname as dirname4 } from "path";
61236
61359
 
61237
61360
  // ../../node_modules/.bun/@gobing-ai+ts-infra@0.4.2+be3e9d72efacb189/node_modules/@gobing-ai/ts-infra/dist/application/index.js
61238
61361
  init_default_observers();
@@ -64634,7 +64757,7 @@ function validateAppConfig(validator, raw, section, filePath) {
64634
64757
  function createFileSink(filePath) {
64635
64758
  return (line) => {
64636
64759
  try {
64637
- mkdirSync2(dirname3(filePath), { recursive: true });
64760
+ mkdirSync2(dirname4(filePath), { recursive: true });
64638
64761
  appendFileSync3(filePath, line);
64639
64762
  } catch {}
64640
64763
  };
@@ -66052,8 +66175,8 @@ var figlet = (() => {
66052
66175
  })();
66053
66176
 
66054
66177
  // ../../node_modules/.bun/figlet@1.11.0/node_modules/figlet/dist/node-figlet.mjs
66055
- import { fileURLToPath as fileURLToPath3 } from "url";
66056
- var __filename2 = fileURLToPath3(import.meta.url);
66178
+ import { fileURLToPath as fileURLToPath4 } from "url";
66179
+ var __filename2 = fileURLToPath4(import.meta.url);
66057
66180
  var __dirname2 = path7.dirname(__filename2);
66058
66181
  var fontPath = path7.join(__dirname2, "/../fonts/");
66059
66182
  var nodeFiglet = figlet;
@@ -68582,7 +68705,7 @@ init_src3();
68582
68705
  init_src3();
68583
68706
  init_loader();
68584
68707
  init_src2();
68585
- import { existsSync as existsSync4 } from "fs";
68708
+ import { existsSync as existsSync5 } from "fs";
68586
68709
  import { join as join12 } from "path";
68587
68710
 
68588
68711
  // src/workflow/resolve-spur-bin.ts
@@ -68605,11 +68728,11 @@ function resolveWorkflowPath(context3, profile) {
68605
68728
  const bundledRoot = bundledConfigRoot();
68606
68729
  if (bundledRoot !== null) {
68607
68730
  const bundledPath = join12(bundledRoot, "workflows", `${profile.workflowName}.yaml`);
68608
- if (existsSync4(bundledPath))
68731
+ if (existsSync5(bundledPath))
68609
68732
  return bundledPath;
68610
68733
  }
68611
68734
  const projectPath = join12(context3.cwd, ".spur", "workflows", `${profile.workflowName}.yaml`);
68612
- if (existsSync4(projectPath))
68735
+ if (existsSync5(projectPath))
68613
68736
  return projectPath;
68614
68737
  return null;
68615
68738
  }
@@ -68864,7 +68987,7 @@ import { join as join13, resolve as resolve4 } from "path";
68864
68987
  var CLI_CONFIG = {
68865
68988
  binaryName: "spur",
68866
68989
  binaryLabel: "spur",
68867
- binaryVersion: "0.2.10",
68990
+ binaryVersion: "0.2.12",
68868
68991
  configDir: ".spur",
68869
68992
  configFile: ".spur/config.yaml",
68870
68993
  databaseFile: ".spur/spur.db"
@@ -69394,7 +69517,7 @@ init_src();
69394
69517
 
69395
69518
  // ../server/src/serve.ts
69396
69519
  init_src3();
69397
- import { dirname as dirname9, isAbsolute as isAbsolute3, join as join16 } from "path";
69520
+ import { dirname as dirname10, isAbsolute as isAbsolute3, join as join16 } from "path";
69398
69521
  init_dist3();
69399
69522
 
69400
69523
  // ../server/src/bootstrap.ts
@@ -72635,15 +72758,15 @@ var getPattern = (label, next) => {
72635
72758
  }
72636
72759
  const match = label.match(/^\:([^\{\}]+)(?:\{(.+)\})?$/);
72637
72760
  if (match) {
72638
- const cacheKey = `${label}#${next}`;
72639
- if (!patternCache[cacheKey]) {
72761
+ const cacheKey2 = `${label}#${next}`;
72762
+ if (!patternCache[cacheKey2]) {
72640
72763
  if (match[2]) {
72641
- patternCache[cacheKey] = next && next[0] !== ":" && next[0] !== "*" ? [cacheKey, match[1], new RegExp(`^${match[2]}(?=/${next})`)] : [label, match[1], new RegExp(`^${match[2]}$`)];
72764
+ patternCache[cacheKey2] = next && next[0] !== ":" && next[0] !== "*" ? [cacheKey2, match[1], new RegExp(`^${match[2]}(?=/${next})`)] : [label, match[1], new RegExp(`^${match[2]}$`)];
72642
72765
  } else {
72643
- patternCache[cacheKey] = [label, match[1], true];
72766
+ patternCache[cacheKey2] = [label, match[1], true];
72644
72767
  }
72645
72768
  }
72646
- return patternCache[cacheKey];
72769
+ return patternCache[cacheKey2];
72647
72770
  }
72648
72771
  return null;
72649
72772
  };
@@ -77367,7 +77490,7 @@ var defaultDeps = {
77367
77490
  async function resolveWebDistPath(configuredPath) {
77368
77491
  const candidates = configuredPath && configuredPath.trim() !== "" ? [isAbsolute3(configuredPath) ? configuredPath : join16(process.cwd(), configuredPath)] : [
77369
77492
  join16(process.cwd(), "dist/web"),
77370
- join16(dirname9(process.execPath), "../web"),
77493
+ join16(dirname10(process.execPath), "../web"),
77371
77494
  join16(import.meta.dir, "../../../dist/web")
77372
77495
  ];
77373
77496
  for (const candidate of candidates) {
@@ -77569,7 +77692,7 @@ async function readTargetStatus(context3, targetPath) {
77569
77692
  init_src3();
77570
77693
  init_loader();
77571
77694
  init_src2();
77572
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
77695
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
77573
77696
  import { join as join18 } from "path";
77574
77697
  // schemas/section-matrix.schema.json
77575
77698
  var section_matrix_schema_default = {
@@ -78135,6 +78258,15 @@ ${result.content}`);
78135
78258
  } else {
78136
78259
  context3.output.write(`${result.ref.id}: ${result.fromStatus} \u2192 ${result.toStatus}`);
78137
78260
  }
78261
+ if (status === "done" && !options.json) {
78262
+ const task2 = await svc.show(wbs);
78263
+ const featureId2 = task2.frontmatter.feature_id;
78264
+ if (!featureId2 || featureId2.length === 0) {
78265
+ context3.output.write(`\u24D8 Task ${wbs} is now done but has no feature_id.
78266
+ ` + ` Link it with: spur task update ${wbs} --feature <id>
78267
+ ` + " (Advisory only \u2014 the task is already done.)");
78268
+ }
78269
+ }
78138
78270
  } else {
78139
78271
  context3.output.error("Either <status>, --section/--from-file, or --feature/--priority is required");
78140
78272
  context3.setExitCode(2);
@@ -78383,7 +78515,7 @@ function loadTemplateContent(projectRoot, variant) {
78383
78515
  if (templateMissSet.has(variant))
78384
78516
  return;
78385
78517
  const localPath = join18(projectRoot, ".spur", "tasks", "templates", `${variant}.md`);
78386
- if (existsSync5(localPath)) {
78518
+ if (existsSync6(localPath)) {
78387
78519
  const content = readFileSync6(localPath, "utf8");
78388
78520
  templateContentCache.set(variant, content);
78389
78521
  return content;
@@ -78391,7 +78523,7 @@ function loadTemplateContent(projectRoot, variant) {
78391
78523
  const root = bundledConfigRoot();
78392
78524
  if (root !== null) {
78393
78525
  const templatePath = join18(root, "templates", "task", `${variant}.md`);
78394
- if (existsSync5(templatePath)) {
78526
+ if (existsSync6(templatePath)) {
78395
78527
  const content = readFileSync6(templatePath, "utf8");
78396
78528
  templateContentCache.set(variant, content);
78397
78529
  return content;
@@ -78407,13 +78539,13 @@ function loadTemplateBodies(projectRoot, variant) {
78407
78539
  return cached2;
78408
78540
  let bodies = {};
78409
78541
  const localPath = join18(projectRoot, ".spur", "tasks", "templates", `${variant}.md`);
78410
- if (existsSync5(localPath)) {
78542
+ if (existsSync6(localPath)) {
78411
78543
  bodies = extractTemplateBodies(readFileSync6(localPath, "utf8"));
78412
78544
  } else {
78413
78545
  const root = bundledConfigRoot();
78414
78546
  if (root !== null) {
78415
78547
  const templatePath = join18(root, "templates", "task", `${variant}.md`);
78416
- if (existsSync5(templatePath)) {
78548
+ if (existsSync6(templatePath)) {
78417
78549
  bodies = extractTemplateBodies(readFileSync6(templatePath, "utf8"));
78418
78550
  }
78419
78551
  }
@@ -78436,9 +78568,19 @@ async function runDoneGateCheck(context3, wbs, folderOverride) {
78436
78568
  const result = await svc.check(`${tasksDir}/${fileName}`, wbs, { strict: false });
78437
78569
  return result.pass;
78438
78570
  }
78571
+ var sectionMatrixCache = new Map;
78439
78572
  async function loadSectionMatrix(projectRoot) {
78573
+ const cached2 = sectionMatrixCache.get(projectRoot);
78574
+ if (cached2 !== undefined)
78575
+ return cached2;
78576
+ const promise2 = loadSectionMatrixUncached(projectRoot);
78577
+ sectionMatrixCache.set(projectRoot, promise2);
78578
+ promise2.catch(() => sectionMatrixCache.delete(projectRoot));
78579
+ return promise2;
78580
+ }
78581
+ async function loadSectionMatrixUncached(projectRoot) {
78440
78582
  const localPath = join18(projectRoot, ".spur", "tasks", "section-matrix.yaml");
78441
- if (existsSync5(localPath)) {
78583
+ if (existsSync6(localPath)) {
78442
78584
  const data = await loadStructuredSpurConfig(localPath, {
78443
78585
  validateJsonSchema: true,
78444
78586
  embeddedSchemas: EMBEDDED_SPUR_SCHEMAS
@@ -78448,7 +78590,7 @@ async function loadSectionMatrix(projectRoot) {
78448
78590
  const root = bundledConfigRoot();
78449
78591
  if (root !== null) {
78450
78592
  const matrixPath = join18(root, "tasks", "section-matrix.yaml");
78451
- if (existsSync5(matrixPath)) {
78593
+ if (existsSync6(matrixPath)) {
78452
78594
  const data = await loadStructuredSpurConfig(matrixPath, {
78453
78595
  validateJsonSchema: true,
78454
78596
  embeddedSchemas: EMBEDDED_SPUR_SCHEMAS
@@ -78826,7 +78968,7 @@ init_src3();
78826
78968
  init_src();
78827
78969
  init_src2();
78828
78970
  init_dist3();
78829
- import { dirname as dirname10, join as join19, resolve as resolve6 } from "path";
78971
+ import { dirname as dirname11, join as join19, resolve as resolve6 } from "path";
78830
78972
  import { isatty as isatty3 } from "tty";
78831
78973
 
78832
78974
  // ../../node_modules/.bun/@clack+core@1.4.1/node_modules/@clack/core/dist/index.mjs
@@ -80238,7 +80380,7 @@ async function createMigratedDbAdapter(cwd = process.cwd(), env = process.env, d
80238
80380
  const configuredUrl = env.DATABASE_URL === undefined ? join19(cwd, CLI_CONFIG.databaseFile) : config3.database.url;
80239
80381
  const url2 = dbUrl ?? configuredUrl;
80240
80382
  if (url2 !== ":memory:") {
80241
- await createNodeFileSystem().ensureDir(dirname10(url2));
80383
+ await createNodeFileSystem().ensureDir(dirname11(url2));
80242
80384
  }
80243
80385
  return createMigratedDb({ url: url2 });
80244
80386
  }