@gobing-ai/spur 0.4.34 → 0.4.35

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.
@@ -7,7 +7,7 @@
7
7
  "plugins": [
8
8
  {
9
9
  "name": "sp",
10
- "version": "0.4.34",
10
+ "version": "0.4.35",
11
11
  "source": "./plugins/sp"
12
12
  }
13
13
  ]
@@ -4,10 +4,10 @@
4
4
  "note": "Per-pipeline model-query + wall-clock cost budgets (task 0607 R3). modelQueries anchors the live workflow definitions' query lists — the SSOT, extracted by extractResolvedWorkflowFacts and guarded by composition-baseline.test.ts (0775 retired the snapshot this file previously anchored to). wallClockMs is a gross-regression ceiling anchored to measured real-run data (R5): the gate compares the median-of-sane terminal-run wall-clock against it. A null budget is unenforced until measured — never treated as 0. Raising a numeric budget requires a fresh recorded decision in the same commit (config/pipeline-budgets.json vs git HEAD); the `spur workflow cost --budget` gate fails a silent raise by name.",
5
5
  "budgets": {
6
6
  "task-pipeline": {
7
- "modelQueries": 4,
7
+ "modelQueries": 3,
8
8
  "wallClockMs": 2300000,
9
9
  "tokenCostUsd": null,
10
- "source": "Full-depth runs 2026-08-08..20: 1459s-2719s; authoritative 2053s/2023s/1985s (n=3, 2026-08-19/20, per ADR-076 retirement — 538s unusable). Budget = 2300s ≈ max+~12% headroom. Gate measures median-of-sane terminal runs; short precheck-failure/dry runs pull the median down, so this is a gross-regression ceiling. Live fixture re-measurement: bun scripts/spur-dev.ts eval-pipeline --runs 3 (operator command in 0607 Solution).",
10
+ "source": "Full-depth runs 2026-08-08..20: 1459s-2719s; authoritative 2053s/2023s/1985s (n=3, 2026-08-19/20, per ADR-076 retirement — 538s unusable). Budget = 2300s ≈ max+~12% headroom. Gate measures median-of-sane terminal runs; short precheck-failure/dry runs pull the median down, so this is a gross-regression ceiling. Live fixture re-measurement: bun scripts/spur-dev.ts eval-pipeline --runs 3 (operator command in 0607 Solution). 2026-11-14 recount: 4 -> 3 model queries (implement, remediate, review) after the slim-harness refactor; the wall-clock ceiling predates it and is reference only — no pipeline, gate or hook reads it; only a manual `spur workflow cost --budget` compares against it.",
11
11
  "decision": null
12
12
  },
13
13
  "idea-pipeline": {
@@ -694,7 +694,7 @@ states:
694
694
  args: ["task", "verdict", "${vars.wbs}", "--from-answer", ".spur/run/${vars.__runId}-review-cert.md"]
695
695
  resultFile: .spur/run/${vars.__runId}-review-verdict.status
696
696
  softFail: true
697
- # Derivation only (no `--replay`: the gate already ran the tests for this tree).
697
+ # Derivation only: the gate already ran the tests for this tree.
698
698
  timeoutMs: "120000"
699
699
  # (e) one fold, one token: PASS requires the typed verdict PASS and the artifact's own PASS.
700
700
  # Fail-closed: any missing/corrupt input leaves FAIL, so the transitions can route a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
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",
@@ -55,6 +55,8 @@ Entry points that feed the main flow above, or run independently of it — see t
55
55
  from git history.
56
56
  - The **project bootstrapper** scaffolds a brand-new Spur project (config + docs), then tailors it
57
57
  to the target stack.
58
+ - The **[downstream migration guide](skills/spur-dev/references/migration-guide.md)** upgrades an
59
+ existing project without replacing its `.spur/config.yaml`, feature tree, or task history.
58
60
 
59
61
  ### Batch and parallel paths
60
62
 
@@ -242,8 +244,8 @@ plugins/sp/
242
244
  │ │ └── references/ # ac-style-guide, cross-cutting, decision-brief, dev-operations,
243
245
  │ │ done-housekeeping, execution-batch, execution-workflow,
244
246
  │ │ feature-link-helper, flag-glossary, gate-checklists, glossary,
245
- │ │ idea-evaluation, execution-workflow, planning-workflow,
246
- │ │ product-planning, section-batching (16 files)
247
+ │ │ idea-evaluation, migration-guide, execution-workflow, planning-workflow,
248
+ │ │ product-planning, section-batching (17 files)
247
249
  │ ├── test-driven-development/ # TDD workflow companion (SKILL.md only)
248
250
  │ ├── reverse-engineering/ # Codebase reverse engineering / HLD / audit
249
251
  │ │ ├── agents/openai.yaml
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.34",
3
+ "version": "0.4.35",
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
  }
@@ -144,13 +144,8 @@ assign a per-requirement status:
144
144
  Record the evidence string (repo-relative `file:line`, command, or test name) per requirement —
145
145
  this lands in `## Testing`.
146
146
 
147
- **Line-anchor verification (anti-stale-citation rule).** Every `file:line` evidence citation
148
- written into the Testing table MUST be re-read at the cited lines this run, and the re-read content
149
- MUST name the requirement's subject (the R-item's noun - not merely exist on disk) before a MET row
150
- is written. A citation whose anchor resolves to another ticket's content, a stale line range, or a
151
- heading/comment unrelated to the requirement fails the row to UNMET and surfaces the stale anchor as
152
- a finding (severity >= P2). This closes the gap where a verify run certified a task `done` citing
153
- `evidence:134` that was actually a sibling ticket's telemetry text (0299 R1, from the 0282 re-audit).
147
+ **Cite what you actually read.** Evidence must be something you inspected this run and that
148
+ supports the requirement; nothing parses or re-checks the cell, so its accuracy is yours.
154
149
 
155
150
  **External evidence (task 0584 / ADR-062) — legal citation form.** Evidence that lives OUTSIDE this
156
151
  repository has a frozen non-anchor form: a named origin plus a backticked path with the line number
@@ -199,9 +194,7 @@ The answer file must include a stable AC table:
199
194
  | Scenario: CLI emits JSON | MET | test | `apps/cli/tests/foo.test.ts:42` |
200
195
  ```
201
196
 
202
- **Line-anchor verification applies to AC evidence too.** Every `file:line` citation in the AC
203
- evidence column is subject to the Step 4 line-anchor rule - re-read at the cited lines this run and
204
- confirm the content names the AC's subject before marking the row MET.
197
+ **AC evidence follows the same rule as Step 4:** cite what you inspected this run.
205
198
 
206
199
  ### Step 6 — Design conformance (Phase 7a; Wave C / 0179 R1–R3)
207
200
 
@@ -256,6 +256,9 @@ the active tasks folder (or `--folder`).
256
256
  | `--folder <path>` | Custom tasks folder. |
257
257
  | `--json` | Machine-readable report envelope. |
258
258
 
259
+ For a complete downstream upgrade, use the [migration guide](../../../spur-dev/references/migration-guide.md):
260
+ `task migrate` is only the A17 normalization step, not the whole migration.
261
+
259
262
  ## `resolve <file-path>`
260
263
 
261
264
  Map a file path to its **owning task** — returns the WBS + task file. Strategies, in order: direct
@@ -333,5 +333,5 @@ truly does not apply (do not silently default new tasks to it).
333
333
  | Legacy raw bullets | `- AC1 …` (no checkbox, no `Scenario:`) | n/a | unparsed: invisible to subset, binding, and box checks — preserve in place, never author new |
334
334
 
335
335
  `task-local` altitude skips only the feature-subset check. It does not waive the unchecked-box check,
336
- required sections, the proof-bound verdict, line anchors, or any other done check — AC syntax alone,
336
+ required sections, the proof-bound verdict, or any other done check — AC syntax alone,
337
337
  at any altitude, never makes a task done.
@@ -0,0 +1,214 @@
1
+ ---
2
+ name: downstream-migration
3
+ description: "Migrate an existing Spur project to the current CLI and plugin contract while preserving local config and planning history."
4
+ see_also:
5
+ - spur-dev
6
+ - spur-cli
7
+ ---
8
+
9
+ # Downstream migration guide
10
+
11
+ Use this guide to upgrade an existing project to **Spur 0.4.34** and its matching `sp` plugin.
12
+ For a later release, use the same procedure and read that release's `CHANGELOG.md` entries for
13
+ additional required changes. Do not treat every release as requiring a corpus rewrite.
14
+
15
+ ## 1. Preservation contract
16
+
17
+ - `.spur/config.yaml`, `docs/features/`, and `docs/tasks*/` are project-owned inputs. Never replace
18
+ them with package examples or a fresh checkout.
19
+ - Task and feature Markdown is the planning SSOT. The SQLite database is derived; database
20
+ migrations do not rewrite that Markdown. `docs/features/INDEX.md` and generated task rosters are
21
+ outputs and must only be regenerated through `spur feature`.
22
+ - Do not run `spur self init --force`, bulk-copy the packaged default configuration or its
23
+ workflow definitions, or edit generated feature regions directly. Existing project overrides need
24
+ reconciliation, not replacement.
25
+ - Keep history. Retired commands and old evidence prose can remain in completed task records;
26
+ they are not instructions to re-run. `spur task migrate-anchors` and `spur task residual` are
27
+ removed and must not be called.
28
+ - Use CLI-gated writes. Feature acceptance criteria go through `spur feature update --section`;
29
+ task sections go through `spur task update --section`. No direct corpus edits.
30
+
31
+ ## 2. Preflight and backup
32
+
33
+ Work from the downstream project root, with a clean Git tree and a named backup ref:
34
+
35
+ ```bash
36
+ cd /path/to/project
37
+ git status --short
38
+ git switch -c chore/spur-0.4.34-migration
39
+ spur --version
40
+ spur self status --json
41
+ spur self migrate --check --json
42
+ spur self exec --list --json
43
+ ```
44
+
45
+ `self migrate --check` is read-only: exit 1 means importer-schema drift and requires the apply
46
+ step below; an absent database is allowed. Keep the current checkout or another backup ref so the
47
+ migration can be reverted. If `.spur/spur.db` exists, stop the project server and copy it to a
48
+ backup location before continuing. Do not back up or restore Markdown by deleting it first.
49
+
50
+ ## 3. Install matching CLI and plugin
51
+
52
+ Install the CLI, then install the plugin from that same package rather than mixing versions:
53
+
54
+ ```bash
55
+ bun add -g @gobing-ai/superskill @gobing-ai/spur@0.4.34
56
+ superskill install sp --marketplace $(dirname $(bun pm bin -g))/install/global/node_modules/@gobing-ai/spur
57
+ spur --version
58
+ spur agent doctor --json
59
+ ```
60
+
61
+ Confirm the reported CLI version is the intended release. `agent doctor` checks executor usability;
62
+ it does not prove authentication or provider quota. The Electron shell is retired: use
63
+ `spur self serve` for the Board.
64
+
65
+ ## 4. Migrate `.spur/config.yaml`
66
+
67
+ Preserve project-specific values and make only the applicable edits:
68
+
69
+ | Existing state | Required action |
70
+ | --- | --- |
71
+ | `.spur/config.json` still supplies project config | Move the project-owned values into `.spur/config.yaml`; do not keep a second config authority. |
72
+ | `version` is absent or an integer | Use the YAML string `version: "1.1"`. |
73
+ | `agent.team` or `.spur/fleet.json` declares a roster | Move it to **project-layer** `agent.fleet`. Keep executor profiles in the global layer, not in the fleet. A leftover old carrier fails loading by design. |
74
+ | `workflow.hitlDecisionMaker` | Rename the section to `workflows.hitlDecisionMaker`. |
75
+ | Bun project with no `builder` block | No change: `builder.preset: bun` supplies the existing commands. |
76
+ | Non-Bun project, or custom commands required | Set `builder.preset: none` and bind `builder.actions` (`lint`, `test`, `check`, and any required `*-changed` actions) to the project's real commands. |
77
+ | `tasks.folders` and `tasks.active` | Preserve folder names, labels, `baseCounter`, and the active folder. Archived folders remain readable history, not a bulk-migration target. |
78
+ | `features.dir` | Preserve the existing feature tree and hierarchical IDs; do not renumber features or scenario titles. |
79
+
80
+ Example non-Bun binding only (replace placeholders with real commands):
81
+
82
+ ```yaml
83
+ builder:
84
+ preset: none
85
+ actions:
86
+ lint: <project-lint-command>
87
+ test: <project-test-command>
88
+ check: [lint, test]
89
+ ```
90
+
91
+ Validate resolution without running the actions:
92
+
93
+ ```bash
94
+ spur self exec --list --json
95
+ spur self exec check --dry-run --json
96
+ spur self status --json
97
+ ```
98
+
99
+ Do not copy `config/config.example.yaml` over the project file. That example documents available
100
+ keys; it is not a migration target.
101
+
102
+ ## 5. Reconcile project workflow overrides
103
+
104
+ The bundled definitions are not drop-in replacements for project overrides. Compare with
105
+ `spur workflow list --json` and validate every project-owned definition with
106
+ `spur workflow validate <path>`.
107
+
108
+ For 0.4.34 specifically:
109
+
110
+ - Task execution is D65: `precheck → implement[⇄escalate] → gate[⇄remediate] → review → done`.
111
+ There are no separate `test`/`test-recheck`, `approve`, `verify`, or `record` states. Completion
112
+ evidence is a tree-hash gate receipt plus the reviewer verdict; the old proof-digest chain,
113
+ answer lint, answer-confidence fields, residual sweep, and verdict replay are gone.
114
+ - `deferQualityGate: "true"` makes the incremental gate the per-task default. The full project
115
+ gate runs once per feature or batch; do not remove that integrated-tree check.
116
+ - Feature verification is owned by `feature-lifecycle`: entering `verifying` runs
117
+ `spur feature verify`. The separate `feature-verification.yaml` workflow is removed, and
118
+ `wrapup-pipeline` no longer changes feature status.
119
+ - Planning uses `idea-pipeline`. The legacy planning, docs, feature-development, duplicate task,
120
+ PR-review, and wayfinder workflow definitions are removed. Route former callers to `idea-pipeline`,
121
+ `task-pipeline`, `feature-lifecycle`,
122
+ `spur builder pr-check|pr-open|pr-review|pr-status`, or the wayfinder skill as appropriate.
123
+ - Project-layer `task-lifecycle.yaml` and `feature-lifecycle.yaml` overrides must be re-synced for
124
+ current guards and the non-terminal `deprecated` state. The status addition itself needs no
125
+ corpus migration.
126
+
127
+ ## 6. Migrate and validate the planning corpus
128
+
129
+ Run only the operations that apply to the project's actual state:
130
+
131
+ ```bash
132
+ # A17 normalization only, not a general schema migrator — preview first.
133
+ spur task migrate --dry-run --json
134
+ # If the preview shows wanted A17 changes and no conflicts:
135
+ spur task migrate --json
136
+
137
+ # Structural checks and optional mechanical repairs; review their diffs afterward.
138
+ spur task check --fix --json
139
+ spur feature check --fix --json
140
+
141
+ # Required final validation (warnings are reported separately from errors).
142
+ spur task check --corpus --json
143
+ spur feature check --json
144
+ ```
145
+
146
+ `task migrate` normalizes the active task folder (or an explicit `--folder`). It is not a
147
+ feature-migration or a reason to rewrite archived folders. `task check --fix` and
148
+ `feature check --fix` repair structure, not semantics or Gherkin conversion.
149
+
150
+ Feature acceptance criteria must use valid BDD with a `Feature:` declaration and `Scenario:`
151
+ blocks. Raw legacy bullets need a deliberate conversion through:
152
+
153
+ ```bash
154
+ spur feature update <id> --section 'Acceptance Criteria' --from-file /tmp/<id>-ac.md
155
+ ```
156
+
157
+ Preserve each scenario's R-number and meaning. Use `@deprecated` and a Notes entry for a scenario
158
+ that is historical rather than a current requirement. If validation exposes an old hook or
159
+ lifecycle claim, check the current ADR/design owner instead of editing it blindly.
160
+
161
+ Preview lifecycle derivation before applying it, then regenerate derived projections only after
162
+ the corpus checks are understood:
163
+
164
+ ```bash
165
+ spur feature sync --all --dry-run --json
166
+ spur feature sync --all --json
167
+ spur feature refresh --all --json
168
+ ```
169
+
170
+ A missing, stale, running, or failed feature-verification receipt is unfinished work, not something
171
+ to hide by forcing a transition. Run `spur feature verify <id>` after the applicable full project
172
+ gate passes. Ordinary warnings can remain visible; do not manufacture a clean report by editing
173
+ history or suppressing findings.
174
+
175
+ ## 7. Final acceptance and rollback
176
+
177
+ Run the project gate and inspect the migration diff:
178
+
179
+ ```bash
180
+ spur self exec check
181
+ spur self exec check-feature # when closing a feature; full project gate
182
+ spur workflow list --json
183
+ spur task check --corpus --json
184
+ spur feature check --json
185
+ git diff --check
186
+ git status --short
187
+ ```
188
+
189
+ `check-feature` is the full project gate for feature closure, not a replacement for the default
190
+ incremental task gate. If an action is unbound, fix `builder.actions` instead of substituting a
191
+ command from another repository.
192
+
193
+ Stop on the first material error. Restore from the backup ref rather than running init or
194
+ bulk-deleting files:
195
+
196
+ ```bash
197
+ git diff <backup-ref> -- .spur/config.yaml docs/features docs/tasks*
198
+ git restore --source=<backup-ref> -- .spur/config.yaml docs/features docs/tasks*
199
+ git status --short
200
+ ```
201
+
202
+ Review new untracked files separately; `git restore` does not remove them. Reinstall the previous
203
+ CLI/plugin version and restore the database backup only after stopping the server. Keep failure
204
+ logs and durable evidence under `.spur/memory/`; `.spur/run/` is disposable scratch, not a rollback
205
+ authority.
206
+
207
+ ## Agent handoff
208
+
209
+ Give the downstream agent this boundary verbatim:
210
+
211
+ > Follow `plugins/sp/skills/spur-dev/references/migration-guide.md`. Preserve `.spur/config.yaml`,
212
+ > `docs/features/`, and `docs/tasks*/`. Use dry runs and CLI-gated writes. Do not run `init --force`,
213
+ > replace project workflows wholesale, rewrite historical corpus, or hide verification findings.
214
+ > Stop on the first material error and report it with its evidence.
package/spur.js CHANGED
@@ -111389,29 +111389,6 @@ function isPlaceholderBody(body) {
111389
111389
  const stripped = body.replace(/<!--[\s\S]*?-->/g, "").replace(/^\s*>\s*TBD\s*$/gim, "").trim();
111390
111390
  return stripped.length === 0;
111391
111391
  }
111392
- function extractBacktickLineAnchors(body) {
111393
- const out = [];
111394
- const re = /`([^`\n]+?:(\d+)(?:-(\d+))?)`/g;
111395
- let m = re.exec(body);
111396
- while (m !== null) {
111397
- const raw = m[1] ?? "";
111398
- const startStr = m[2];
111399
- const endStr = m[3];
111400
- if (startStr) {
111401
- const pathPart = raw.replace(/:(\d+)(?:-(\d+))?$/, "");
111402
- const hasExt = /\.\w{1,8}$/.test(pathPart.split("/").pop() ?? "");
111403
- if (pathPart && !pathPart.includes("://") && hasExt) {
111404
- const startLine = Number(startStr);
111405
- const endLine = endStr !== undefined ? Number(endStr) : undefined;
111406
- if (Number.isFinite(startLine) && startLine >= 1) {
111407
- out.push({ raw, path: pathPart, startLine, endLine });
111408
- }
111409
- }
111410
- }
111411
- m = re.exec(body);
111412
- }
111413
- return out;
111414
- }
111415
111392
  var TaskCheckService;
111416
111393
  var init_task_check = __esm(() => {
111417
111394
  init_src2();
@@ -128900,174 +128877,6 @@ var init_token_ledger_watcher = __esm(() => {
128900
128877
  init_token_ledger_service();
128901
128878
  });
128902
128879
 
128903
- // ../../packages/app/src/services/sdlc-actions.ts
128904
- function isReservedAction(name) {
128905
- return RESERVED_ACTIONS.includes(name);
128906
- }
128907
- function resolveAction(builder, name) {
128908
- const reserved = isReservedAction(name);
128909
- const declared = builder?.actions?.[name];
128910
- if (declared !== undefined) {
128911
- return Array.isArray(declared) ? { name, reserved, source: "config", kind: "composite", steps: [...declared] } : { name, reserved, source: "config", kind: "command", command: declared };
128912
- }
128913
- if ((builder?.preset ?? "bun") === "bun") {
128914
- if (reserved)
128915
- return { name, reserved, source: "preset", kind: "command", command: BUN_PRESET[name] };
128916
- } else {
128917
- const command = reserved ? STACK_NEUTRAL_DEFAULTS[name] : undefined;
128918
- if (command !== undefined)
128919
- return { name, reserved, source: "default", kind: "command", command };
128920
- }
128921
- return { name, reserved, source: "unbound", kind: "unbound" };
128922
- }
128923
- function listActions(builder) {
128924
- const custom = Object.keys(builder?.actions ?? {}).filter((name) => !isReservedAction(name)).sort();
128925
- return [...RESERVED_ACTIONS, ...custom].map((name) => resolveAction(builder, name));
128926
- }
128927
- function renderScoped(command, values) {
128928
- let rendered = command;
128929
- for (const [placeholder, items] of [
128930
- ["{files}", values.files],
128931
- ["{paths}", values.paths]
128932
- ]) {
128933
- if (!rendered.includes(placeholder))
128934
- continue;
128935
- if (items === undefined)
128936
- throw new Error(`renderScoped: ${placeholder} has no value`);
128937
- if (items.length === 0)
128938
- throw new Error(`renderScoped: ${placeholder} has an empty list`);
128939
- rendered = rendered.replaceAll(placeholder, () => items.map(shQuote2).join(" "));
128940
- }
128941
- if (rendered.includes("{workspace}")) {
128942
- const workspace = values.workspace;
128943
- if (workspace === undefined)
128944
- throw new Error("renderScoped: {workspace} has no value");
128945
- if (workspace.length === 0)
128946
- throw new Error("renderScoped: {workspace} is empty");
128947
- rendered = rendered.replaceAll("{workspace}", () => shQuote2(workspace));
128948
- }
128949
- return rendered;
128950
- }
128951
- var RESERVED_ACTIONS, BUN_PRESET, STACK_NEUTRAL_DEFAULTS;
128952
- var init_sdlc_actions = __esm(() => {
128953
- init_src();
128954
- RESERVED_ACTIONS = SDLC_ACTIONS;
128955
- BUN_PRESET = {
128956
- build: "bun run build",
128957
- dev: "bun run dev",
128958
- clean: "bun run clean",
128959
- format: "bun run format",
128960
- autofix: "bun run autofix",
128961
- lint: "bun run lint",
128962
- typecheck: "bun run typecheck",
128963
- test: "bun run test",
128964
- "test-full": "bun run test:full",
128965
- "test-heavy": "bun run test:heavy",
128966
- "rules-pre": "bun run test-pre-check",
128967
- "rules-post": "bun run test-post-check",
128968
- check: "bun run spur-check",
128969
- "check-full": "bun run spur-check:full",
128970
- "check-feature": "bun run spur-check-feature",
128971
- "lint-changed": "bunx biome check {files}",
128972
- "typecheck-changed": "cd {workspace} && bun run typecheck",
128973
- "test-changed": "cd {workspace} && bun test --coverage --coverage-reporter=text {paths}"
128974
- };
128975
- STACK_NEUTRAL_DEFAULTS = SDLC_STACK_NEUTRAL_ACTIONS;
128976
- });
128977
-
128978
- // ../../packages/app/src/services/verdict-replay.ts
128979
- function citedTestAnchors(rows, max) {
128980
- const seen = new Map;
128981
- for (const row of rows) {
128982
- for (const anchor of extractBacktickLineAnchors(row.evidence ?? "")) {
128983
- if (!TEST_FILE_PATTERN.test(anchor.path) || seen.has(anchor.path))
128984
- continue;
128985
- seen.set(anchor.path, anchor.raw);
128986
- }
128987
- }
128988
- return new Map([...seen].slice(0, max));
128989
- }
128990
- function replayWorkspace(file) {
128991
- const parts = file.split("/");
128992
- if (parts.length >= 3 && (parts[0] === "apps" || parts[0] === "packages"))
128993
- return `${parts[0]}/${parts[1]}`;
128994
- return ".";
128995
- }
128996
- async function replayCitedTests(rows, options = {}) {
128997
- const cwd = options.cwd ?? process.cwd();
128998
- const timeoutMs = options.timeoutMs ?? REPLAY_TIMEOUT_MS;
128999
- const anchors = citedTestAnchors(rows, options.maxFiles ?? REPLAY_MAX_FILES);
129000
- const files = [...anchors.keys()];
129001
- const executor = options.executor ?? new NodeProcessExecutor2;
129002
- const fileSystem = options.fs ?? createNodeFileSystem2();
129003
- const resolved = resolveAction(options.builder, "test-changed");
129004
- const template = resolved.kind === "command" ? resolved.command : resolved.kind === "composite" ? resolved.steps.join(" && ") : "";
129005
- const ran = [];
129006
- const skipped = [];
129007
- const findings = [];
129008
- const thresholdByWorkspace = new Map;
129009
- for (const file of files) {
129010
- const anchor = anchors.get(file) ?? file;
129011
- const exists = await fileSystem.exists(`${cwd}/${file}`);
129012
- if (!exists) {
129013
- findings.push({ anchor, file, missing: true });
129014
- continue;
129015
- }
129016
- if (template.length === 0)
129017
- continue;
129018
- const workspace = replayWorkspace(file);
129019
- let threshold = thresholdByWorkspace.get(workspace);
129020
- if (threshold === undefined) {
129021
- threshold = await declaresCoverageThreshold(fileSystem, `${cwd}/${workspace}/bunfig.toml`);
129022
- thresholdByWorkspace.set(workspace, threshold);
129023
- }
129024
- if (threshold) {
129025
- skipped.push({ file, reason: `coverageThreshold in ${workspace}/bunfig.toml` });
129026
- continue;
129027
- }
129028
- const path = workspace === "." ? file : file.slice(workspace.length + 1);
129029
- const command = renderScoped(template, { workspace, paths: [path] });
129030
- const result = await executor.run({
129031
- command: "sh",
129032
- args: ["-c", command],
129033
- cwd,
129034
- forceBuffered: true,
129035
- rejectOnError: false,
129036
- timeout: timeoutMs
129037
- });
129038
- const exitCode = result.exitCode ?? null;
129039
- ran.push({ file, exitCode });
129040
- if (result.outcome === "timeout")
129041
- findings.push({ anchor, file, timedOut: true, exitCode });
129042
- else if (exitCode !== 0)
129043
- findings.push({ anchor, file, exitCode });
129044
- }
129045
- return { files, ran, skipped, findings };
129046
- }
129047
- async function declaresCoverageThreshold(fileSystem, bunfigPath) {
129048
- try {
129049
- if (!await fileSystem.exists(bunfigPath))
129050
- return false;
129051
- return COVERAGE_THRESHOLD_PATTERN.test(await fileSystem.readFile(bunfigPath));
129052
- } catch {
129053
- return false;
129054
- }
129055
- }
129056
- function formatReplayReport(replay) {
129057
- return {
129058
- summary: `Replay: ${replay.files.length} cited test file(s), ${replay.findings.length} finding(s)${replay.skipped.length > 0 ? `, ${replay.skipped.length} skipped` : ""}`,
129059
- findings: replay.findings.map((finding) => ` ${finding.anchor}: ${finding.missing ? "missing" : finding.timedOut ? "timed out" : `exit ${finding.exitCode ?? "unknown"}`}`)
129060
- };
129061
- }
129062
- var REPLAY_MAX_FILES = 5, REPLAY_TIMEOUT_MS = 120000, TEST_FILE_PATTERN, COVERAGE_THRESHOLD_PATTERN;
129063
- var init_verdict_replay = __esm(() => {
129064
- init_dist5();
129065
- init_sdlc_actions();
129066
- init_task_check();
129067
- TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/;
129068
- COVERAGE_THRESHOLD_PATTERN = /^\s*coverageThreshold\s*=/m;
129069
- });
129070
-
129071
128880
  // ../../packages/app/src/workflow/checkpoint-contract.ts
129072
128881
  function parseCheckpointMetadata(raw) {
129073
128882
  if (!raw.startsWith("---"))
@@ -136582,7 +136391,6 @@ var init_src4 = __esm(() => {
136582
136391
  init_task_verdict();
136583
136392
  init_token_ledger_service();
136584
136393
  init_token_ledger_watcher();
136585
- init_verdict_replay();
136586
136394
  init_warm_turn_dispatch();
136587
136395
  init_workflow_clean_ops();
136588
136396
  init_workflow_service();
@@ -143810,6 +143618,81 @@ var init_task_diffstat = __esm(() => {
143810
143618
  init_task_triage();
143811
143619
  });
143812
143620
 
143621
+ // ../../packages/app/src/services/sdlc-actions.ts
143622
+ function isReservedAction(name) {
143623
+ return RESERVED_ACTIONS.includes(name);
143624
+ }
143625
+ function resolveAction(builder, name) {
143626
+ const reserved = isReservedAction(name);
143627
+ const declared = builder?.actions?.[name];
143628
+ if (declared !== undefined) {
143629
+ return Array.isArray(declared) ? { name, reserved, source: "config", kind: "composite", steps: [...declared] } : { name, reserved, source: "config", kind: "command", command: declared };
143630
+ }
143631
+ if ((builder?.preset ?? "bun") === "bun") {
143632
+ if (reserved)
143633
+ return { name, reserved, source: "preset", kind: "command", command: BUN_PRESET[name] };
143634
+ } else {
143635
+ const command = reserved ? STACK_NEUTRAL_DEFAULTS[name] : undefined;
143636
+ if (command !== undefined)
143637
+ return { name, reserved, source: "default", kind: "command", command };
143638
+ }
143639
+ return { name, reserved, source: "unbound", kind: "unbound" };
143640
+ }
143641
+ function listActions(builder) {
143642
+ const custom = Object.keys(builder?.actions ?? {}).filter((name) => !isReservedAction(name)).sort();
143643
+ return [...RESERVED_ACTIONS, ...custom].map((name) => resolveAction(builder, name));
143644
+ }
143645
+ function renderScoped(command, values) {
143646
+ let rendered = command;
143647
+ for (const [placeholder, items] of [
143648
+ ["{files}", values.files],
143649
+ ["{paths}", values.paths]
143650
+ ]) {
143651
+ if (!rendered.includes(placeholder))
143652
+ continue;
143653
+ if (items === undefined)
143654
+ throw new Error(`renderScoped: ${placeholder} has no value`);
143655
+ if (items.length === 0)
143656
+ throw new Error(`renderScoped: ${placeholder} has an empty list`);
143657
+ rendered = rendered.replaceAll(placeholder, () => items.map(shQuote2).join(" "));
143658
+ }
143659
+ if (rendered.includes("{workspace}")) {
143660
+ const workspace = values.workspace;
143661
+ if (workspace === undefined)
143662
+ throw new Error("renderScoped: {workspace} has no value");
143663
+ if (workspace.length === 0)
143664
+ throw new Error("renderScoped: {workspace} is empty");
143665
+ rendered = rendered.replaceAll("{workspace}", () => shQuote2(workspace));
143666
+ }
143667
+ return rendered;
143668
+ }
143669
+ var RESERVED_ACTIONS, BUN_PRESET, STACK_NEUTRAL_DEFAULTS;
143670
+ var init_sdlc_actions = __esm(() => {
143671
+ init_src();
143672
+ RESERVED_ACTIONS = SDLC_ACTIONS;
143673
+ BUN_PRESET = {
143674
+ build: "bun run build",
143675
+ dev: "bun run dev",
143676
+ clean: "bun run clean",
143677
+ format: "bun run format",
143678
+ autofix: "bun run autofix",
143679
+ lint: "bun run lint",
143680
+ typecheck: "bun run typecheck",
143681
+ test: "bun run test",
143682
+ "test-full": "bun run test:full",
143683
+ "test-heavy": "bun run test:heavy",
143684
+ "rules-pre": "bun run test-pre-check",
143685
+ "rules-post": "bun run test-post-check",
143686
+ check: "bun run spur-check",
143687
+ "check-full": "bun run spur-check:full",
143688
+ "check-feature": "bun run spur-check-feature",
143689
+ "lint-changed": "bunx biome check {files}",
143690
+ "typecheck-changed": "cd {workspace} && bun run typecheck",
143691
+ "test-changed": "cd {workspace} && bun test --coverage --coverage-reporter=text {paths}"
143692
+ };
143693
+ STACK_NEUTRAL_DEFAULTS = SDLC_STACK_NEUTRAL_ACTIONS;
143694
+ });
143695
+
143813
143696
  // ../../packages/app/src/services/quality-gate.ts
143814
143697
  import { spawnSync as spawnSync6 } from "child_process";
143815
143698
  import { randomUUID as randomUUID12 } from "crypto";
@@ -144131,7 +144014,7 @@ function lightScope(changedFiles, exists = existsSync23, graph) {
144131
144014
  tests.add(candidate);
144132
144015
  matched = true;
144133
144016
  }
144134
- } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN2.test(rest)) {
144017
+ } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN.test(rest)) {
144135
144018
  tests.add(file);
144136
144019
  matched = true;
144137
144020
  }
@@ -144148,7 +144031,7 @@ function lightScope(changedFiles, exists = existsSync23, graph) {
144148
144031
  const candidate = `${workspace}/tests/${rest.slice("src/".length).replace(/\.tsx?$/, (ext) => `.test${ext}`)}`;
144149
144032
  if (exists(candidate))
144150
144033
  tests.add(candidate);
144151
- } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN2.test(rest)) {
144034
+ } else if (rest.startsWith("tests/") && TEST_FILE_PATTERN.test(rest)) {
144152
144035
  tests.add(file);
144153
144036
  }
144154
144037
  }
@@ -144959,7 +144842,7 @@ function qualityGateMain(argv, rawEnv = getEnvVars(), options = {}) {
144959
144842
  return 2;
144960
144843
  }
144961
144844
  }
144962
- var MAX_GATE_ATTEMPTS = 5, MAX_FINDINGS = 20, RETRY_DELAY_MS_DEFAULT = 1e4, RETRY_DELAY_MS_ENV = "SPUR_QUALITY_GATE_RETRY_DELAY_MS", GATE_TIMEOUT_MS_ENV = "gateTimeoutMs", GATE_TIMEOUT_MS_DEFAULT = 900000, LOCKED_PATTERN, FINDINGS_PATTERN, COVERAGE_ROW_PATTERN, RECEIPT_SCHEMA_VERSION = "check-receipt/v1", TEST_FILE_PATTERN2, GATE_LOCK_DIR_ENV = "SPUR_GATE_LOCK_DIR", GATE_LOCK_POLL_MS_ENV = "SPUR_GATE_LOCK_POLL_MS", GATE_LOCK_OFF_ENV = "SPUR_GATE_LOCK", GATE_LOCK_TOKEN_ENV = "SPUR_GATE_LOCK_TOKEN", GATE_LOCK_POLL_MS_DEFAULT = 5000, GATE_LOCK_TIMEOUT_MS_ENV = "SPUR_GATE_LOCK_TIMEOUT_MS", GATE_LOCK_TIMEOUT_MS_DEFAULT, GATE_LOCK_MKDIR_HOLD_MS_ENV = "SPUR_GATE_LOCK_TEST_MKDIR_HOLD_MS", GATE_LOCK_LOST_RACE_SLEEP_MS = 20, GATE_LOCK_LOST_RACE_LOG_AT = 250, GateLockTimeoutError, QUALITY_GATE_MODES, QUALITY_GATE_USAGE = "usage: spur task gate <wbs> <run|recheck|light|deferred|status> (env: qualityGateCmd, gateProbeCmd, runId)";
144845
+ var MAX_GATE_ATTEMPTS = 5, MAX_FINDINGS = 20, RETRY_DELAY_MS_DEFAULT = 1e4, RETRY_DELAY_MS_ENV = "SPUR_QUALITY_GATE_RETRY_DELAY_MS", GATE_TIMEOUT_MS_ENV = "gateTimeoutMs", GATE_TIMEOUT_MS_DEFAULT = 900000, LOCKED_PATTERN, FINDINGS_PATTERN, COVERAGE_ROW_PATTERN, RECEIPT_SCHEMA_VERSION = "check-receipt/v1", TEST_FILE_PATTERN, GATE_LOCK_DIR_ENV = "SPUR_GATE_LOCK_DIR", GATE_LOCK_POLL_MS_ENV = "SPUR_GATE_LOCK_POLL_MS", GATE_LOCK_OFF_ENV = "SPUR_GATE_LOCK", GATE_LOCK_TOKEN_ENV = "SPUR_GATE_LOCK_TOKEN", GATE_LOCK_POLL_MS_DEFAULT = 5000, GATE_LOCK_TIMEOUT_MS_ENV = "SPUR_GATE_LOCK_TIMEOUT_MS", GATE_LOCK_TIMEOUT_MS_DEFAULT, GATE_LOCK_MKDIR_HOLD_MS_ENV = "SPUR_GATE_LOCK_TEST_MKDIR_HOLD_MS", GATE_LOCK_LOST_RACE_SLEEP_MS = 20, GATE_LOCK_LOST_RACE_LOG_AT = 250, GateLockTimeoutError, QUALITY_GATE_MODES, QUALITY_GATE_USAGE = "usage: spur task gate <wbs> <run|recheck|light|deferred|status> (env: qualityGateCmd, gateProbeCmd, runId)";
144963
144846
  var init_quality_gate = __esm(() => {
144964
144847
  init_dist();
144965
144848
  init_proof_input_fingerprint();
@@ -144967,7 +144850,7 @@ var init_quality_gate = __esm(() => {
144967
144850
  LOCKED_PATTERN = /SQLiteError: database is locked|SQLite database .*is busy|SQLITE_BUSY/;
144968
144851
  FINDINGS_PATTERN = /[A-Za-z0-9_./-]+\.[A-Za-z]+:[0-9]+/g;
144969
144852
  COVERAGE_ROW_PATTERN = /^\s*(\S+\.[A-Za-z]+)\s*\|\s*([\d.]+)\s*\|\s*([\d.]+)\s*\|\s*(\S*)/;
144970
- TEST_FILE_PATTERN2 = /\.test\.tsx?$/;
144853
+ TEST_FILE_PATTERN = /\.test\.tsx?$/;
144971
144854
  GATE_LOCK_TIMEOUT_MS_DEFAULT = 2 * 60 * 60 * 1000;
144972
144855
  GateLockTimeoutError = class GateLockTimeoutError extends Error {
144973
144856
  constructor(message) {
@@ -158122,7 +158005,7 @@ function registerTaskUpdateCommand(task, context) {
158122
158005
  // src/commands/task/verdict.ts
158123
158006
  init_src4();
158124
158007
  function registerTaskVerdictCommand(task, context) {
158125
- task.command("verdict").summary("Derive PASS/PARTIAL/FAIL/UNKNOWN verdict from verify answer text (replaces pipeline grep/shell).").argument("<wbs>", "Task WBS number").option("--from-answer <path>", "Path to verify answer text file").option("--replay", "Re-execute the test files the verdict rows cite; a failure downgrades PASS to PARTIAL").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, options) => {
158008
+ task.command("verdict").summary("Derive PASS/PARTIAL/FAIL/UNKNOWN verdict from verify answer text (replaces pipeline grep/shell).").argument("<wbs>", "Task WBS number").option("--from-answer <path>", "Path to verify answer text file").option(...SHARED_OPTIONS.folderTasks).option(...SHARED_OPTIONS.json).option(...SHARED_OPTIONS.jsonEnvelope).action(async (wbs, options) => {
158126
158009
  await Promise.resolve().then(() => init_src4());
158127
158010
  const answerPath = options.fromAnswer ?? `.spur/run/${wbs}-verify-answer.txt`;
158128
158011
  let answerText;
@@ -158143,17 +158026,9 @@ function registerTaskVerdictCommand(task, context) {
158143
158026
  const taskCheckPassed = true;
158144
158027
  const taskAc = await readTaskAcceptanceCriteria(wbs, corpusDeps);
158145
158028
  const result = deriveVerdict(answerText, taskCheckPassed, taskAc);
158146
- const replay = options.replay === true ? await replayCitedTests([...result.requirements, ...result.acceptanceCriteria ?? []], {
158147
- cwd: context.cwd,
158148
- builder: context.spurConfig?.builder
158149
- }) : undefined;
158150
- const downgraded = replay !== undefined && replay.findings.length > 0 && result.verdict === "PASS";
158151
- if (downgraded)
158152
- result.verdict = "PARTIAL";
158153
158029
  const jsonOut = JSON.stringify({
158154
158030
  wbs,
158155
158031
  ...result,
158156
- ...replay !== undefined ? { replay } : {},
158157
158032
  source: "spur-task-verdict"
158158
158033
  }, null, 2);
158159
158034
  await context.fs.ensureDir(".spur/run");
@@ -158163,12 +158038,6 @@ function registerTaskVerdictCommand(task, context) {
158163
158038
  context.output.write(jsonOut);
158164
158039
  } else {
158165
158040
  context.output.write(`Verdict: ${result.verdict} (${result.requirements.length} requirements, ${result.checks.length} checks)`);
158166
- if (replay !== undefined) {
158167
- const report = formatReplayReport(replay);
158168
- context.output.write(report.summary);
158169
- for (const line of report.findings)
158170
- context.output.error(line);
158171
- }
158172
158041
  }
158173
158042
  if (result.verdict !== "PASS") {
158174
158043
  context.setExitCode(1);
@@ -163067,7 +162936,7 @@ init_src2();
163067
162936
  var CLI_CONFIG = {
163068
162937
  binaryName: "spur",
163069
162938
  binaryLabel: "spur",
163070
- binaryVersion: "0.4.34",
162939
+ binaryVersion: "0.4.35",
163071
162940
  configDir: ".spur",
163072
162941
  configFile: ".spur/config.yaml",
163073
162942
  databaseFile: ".spur/spur.db"
@@ -170806,7 +170675,7 @@ init_dist5();
170806
170675
  import { homedir as homedir17 } from "os";
170807
170676
  import { basename as basename21, join as join91, relative as relative18, resolve as resolve37 } from "path";
170808
170677
  // ../server/package.json
170809
- var version4 = "0.4.34";
170678
+ var version4 = "0.4.35";
170810
170679
 
170811
170680
  // ../server/src/modules/health/index.ts
170812
170681
  var startedAt = Date.now();