@gobing-ai/spur 0.4.26 → 0.4.27

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.26",
10
+ "version": "0.4.27",
11
11
  "source": "./plugins/sp"
12
12
  }
13
13
  ]
@@ -88,6 +88,8 @@ rules:
88
88
  exclude:
89
89
  # the CLI migration journal — 43 DDL statements (verified load-bearing)
90
90
  - "packages/domain/src/migrations.ts"
91
+ # 0037's embedded mirror, generated verbatim from released drizzle SQL (task 1312)
92
+ - "packages/domain/src/migration-sql.generated.ts"
91
93
 
92
94
  - id: raw-sql-only-in-domain
93
95
  description: >
@@ -44,6 +44,8 @@ rules:
44
44
  - "**/schema/**"
45
45
  - "**/schema.ts"
46
46
  - "**/migrations.ts"
47
+ # generated migration SQL; scripts/commands/emit-migration-sql.test.ts pins it (task 1312)
48
+ - "packages/domain/src/migration-sql.generated.ts"
47
49
  - "**/db.ts"
48
50
  ## Entity declarations — contracts are transport DTOs tested by the single contract.test.ts
49
51
  - "packages/contracts/src/**"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
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",
@@ -50,7 +50,7 @@
50
50
  "dev": "bun run src/index.ts",
51
51
  "build": "bun run ../../scripts/spur-dev.ts build-cli",
52
52
  "build:binaries": "bun run ../../scripts/spur-dev.ts build-binaries",
53
- "build:bundle": "bun run ../../scripts/spur-dev.ts bundle-web && bun run ../../scripts/spur-dev.ts emit-board-types && bun build src/index.ts --target=bun --outfile spur.js --external @johnhenry/backend-webgpu && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-laya-worker && bun run ../../scripts/spur-dev.ts bundle-plugins",
53
+ "build:bundle": "bun run ../../scripts/spur-dev.ts bundle-web && bun run ../../scripts/spur-dev.ts emit-board-types && bun run ../../scripts/spur-dev.ts emit-migration-sql && bun build src/index.ts --target=bun --outfile spur.js --external @johnhenry/backend-webgpu && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-laya-worker && bun run ../../scripts/spur-dev.ts bundle-plugins",
54
54
  "test": "bun test tests --coverage --coverage-dir=../../.coverage --pass-with-no-tests",
55
55
  "typecheck": "tsc --noEmit",
56
56
  "prepack": "bun run build:bundle",
@@ -17,9 +17,9 @@ Wraps the **sp:spur-dev** skill.
17
17
  | `--tasks` `<selector>` | Task selector to run. | required |
18
18
  | `--feature` `<id>` | Restrict to a feature. | omitted |
19
19
  | `--mode` `<sequential\|parallel\|auto>` | Batch execution order. `auto` computes the frozen batch's dependency waves and runs parallel only when a wave holds ≥2 independent tasks (see below). | sequential |
20
- | `--keep-going` | Continue past per-task failures. | off |
21
- | `--auto` | Skip objective HITL gates. | off |
22
- | `--agent` `<inline\|auto\|name>` | Who runs each task's pipeline stages. Interactive sequential omit/`inline` uses the host-session driver with unified inline semantics (task 0687 — see below). `auto`, a name, parallel mode, and headless invocation use subprocesses. The opt-in `fleet` selector (0942) maps each task run to `executor: 'fleet'` so `agent.run` stages dispatch through the fleet control plane (ADR-126) instead of a subprocess. | omit |
20
+ | `--keep-going` | Continue past per-task failures (implied by `--auto`). | off |
21
+ | `--auto` | Skip objective HITL gates, and treat the batch as unattended: `--auto` implies `--keep-going` (a failed task is `failed`, its in-batch dependents `skipped`, independents keep running). | off |
22
+ | `--agent` `<inline\|auto\|name>` | Who runs each task's pipeline stages. Interactive sequential omit/`inline` uses the host-session driver with unified inline semantics (task 0687 — see below). `auto`, a name, parallel mode, and headless invocation use subprocesses; `auto` is resolved **once per batch** — the first run's implement `result.executor` is pinned for every later task, and only a `failed-agent` ending re-resolves it, exactly once (1318 R2/R3). The opt-in `fleet` selector (0942) maps each task run to `executor: 'fleet'` so `agent.run` stages dispatch through the fleet control plane (ADR-126) instead of a subprocess. | omit |
23
23
  | `--json` | Emit structured JSON. | off |
24
24
  | `--wrap` | Run the wrap hop **once for the batch** over the `done` subset only (Step 6 of execution-batch.md): `vars.feature` is passed only when every frozen task is `done`/`cancelled`; an empty done subset skips the wrap with a reason. The `--agent` selector is preserved into the `/sp:dev-wrap` handoff when supplied; omission remains omission. | off |
25
25
  | `--next` | Chain-to-completion via the next-router. | off |
@@ -50,8 +50,9 @@ see `execution-parallel-isolation.md`; `auto` picks one per batch — see below)
50
50
  (parallel-mode worker bound — at most <n> task pipelines at once, default 2; a dependent task starts
51
51
  only after its in-set dependencies are **integrated** onto the base ref), `--keep-going`
52
52
  (batch failure policy — skip a failed task's in-batch dependents, continue independents; default
53
- halts on first failure), `--auto`
54
- (sets `profile=auto` on each per-task run, skipping the HITL approve gate), `--agent <inline|auto|name>`
53
+ halts on first failure, and `--auto` implies `--keep-going`), `--auto`
54
+ (sets `profile=auto` on each per-task run, skipping the HITL approve gate; makes the batch
55
+ unattended, so it also implies `--keep-going`), `--agent <inline|auto|name>`
55
56
  (names who runs the pipeline's stages — interactive sequential omit/`inline` uses the host driver;
56
57
  `auto` or a name is merged into each per-task `vars.agent` and `vars.implementAgent`; `fleet` maps each task run to `executor: 'fleet'` instead (fleet dispatch, 0942); parallel mode
57
58
  retains isolated subprocesses, ADR-047), `--json` (emit the
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.4.26",
3
+ "version": "0.4.27",
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
  }
@@ -481,7 +481,7 @@ gate commands, pending questions and approval requirements.>
481
481
  ### 13. runall
482
482
 
483
483
  - **Purpose:** Run a batch of tasks through their pipelines in dependency-correct order — resolve a set, topo-sort, run each via `task-pipeline.yaml`, inspect verdicts, apply the failure policy, emit a batch report.
484
- - **Inputs:** `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`, or `ready`). `--mode <sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset per `execution-batch.md` § Parallel Execution). `--keep-going` skips a failed task's in-batch dependents and continues independents (default halts on first failure). `--auto` sets `profile=auto` on each per-task run (skips the HITL approve gate). `--feature <id>` is sugar for `--tasks feature:<id>`; when the effective selector is feature-derived, the batch runs `spur feature check <id> --fix --json` then `spur feature check <id> --strict --json` **once** before task resolution — a non-zero strict check aborts with verdict `aborted`, zero attempted tasks, and the structured findings (task 0510 R2); scoped: `L4.scenario-unverified` and `L4.verifying-incomplete-tasks` (expected pre-run states of a not-yet-run or reopened feature) are reported verbatim but do not abort — the non-aborting set is `NON_ABORTING_PREFLIGHT_CODES` behind `spur task preflight --classify-feature` and `--fix` repairs are listed under `autoRepairs` — any other strict error aborts. Explicit WBS/status/`ready` selectors add no feature check. The orchestrator loop itself continues in this session; interactive sequential omit/`inline` uses the host driver — host-controlled with eligible `agent.run` stages dispatching once to a native subagent and host fallback (task 0508) — while `--agent auto`/a name, parallel mode, and headless invocation keep the isolated per-task workflow subprocess boundary. `--agent <inline|auto|name>` pins the executor for the per-task stages (see [SSOT](cross-cutting.md#inline-default-execution-surface)); the value crosses into per-task `vars.agent`, not the orchestrator. `--json` emits the report as JSON. `--wrap` triggers `wrapup-pipeline.yaml` **once for the batch** over the `done` subset only (execution-batch.md Step 6: `vars.feature` only when every frozen task is `done`/`cancelled`; empty done subset skips with a reason), `--next` chains each task to terminal status then runs the same batch-once wrap, `--continue` resumes from checkpoint. `--no-summary` skips the [execution summary](#execution-summary) printed after the batch report (one row per attempted run).
484
+ - **Inputs:** `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`, or `ready`). `--mode <sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset per `execution-batch.md` § Parallel Execution). `--keep-going` skips a failed task's in-batch dependents and continues independents (default off; `--auto` implies `--keep-going`, so an unattended batch never stops for a human — 1318 R1). `--auto` sets `profile=auto` on each per-task run (skips the HITL approve gate). `--feature <id>` is sugar for `--tasks feature:<id>`; when the effective selector is feature-derived, the batch runs `spur feature check <id> --fix --json` then `spur feature check <id> --strict --json` **once** before task resolution — a non-zero strict check aborts with verdict `aborted`, zero attempted tasks, and the structured findings (task 0510 R2); scoped: `L4.scenario-unverified` and `L4.verifying-incomplete-tasks` (expected pre-run states of a not-yet-run or reopened feature) are reported verbatim but do not abort — the non-aborting set is `NON_ABORTING_PREFLIGHT_CODES` behind `spur task preflight --classify-feature` and `--fix` repairs are listed under `autoRepairs` — any other strict error aborts. Explicit WBS/status/`ready` selectors add no feature check. The orchestrator loop itself continues in this session; interactive sequential omit/`inline` uses the host driver — host-controlled with eligible `agent.run` stages dispatching once to a native subagent and host fallback (task 0508) — while `--agent auto`/a name, parallel mode, and headless invocation keep the isolated per-task workflow subprocess boundary. `--agent <inline|auto|name>` pins the executor for the per-task stages (see [SSOT](cross-cutting.md#inline-default-execution-surface)); the value crosses into per-task `vars.agent`, not the orchestrator. `--json` emits the report as JSON. `--wrap` triggers `wrapup-pipeline.yaml` **once for the batch** over the `done` subset only (execution-batch.md Step 6: `vars.feature` only when every frozen task is `done`/`cancelled`; empty done subset skips with a reason), `--next` chains each task to terminal status then runs the same batch-once wrap, `--continue` resumes from checkpoint. `--no-summary` skips the [execution summary](#execution-summary) printed after the batch report (one row per attempted run).
485
485
  - **Three orthogonal axes (do not confuse):** `--keep-going` = batch failure policy (halt vs skip dependents); `--continue` = resume from checkpoint against the batch's original frozen identity (execution-batch.md § Batch continuation, task 0919); `--next` = per-task lifecycle chaining (advance status on a verdict — `dev-verify`/`dev-verifyall` only). `routing-table.md` offers `--continue` and `--next` as competing options for the same situation only when the batch was interrupted mid-run; otherwise they address different problems.
486
486
  - **Backing:** `sp:spur-dev` skill, `runall` operation → delegates the driver loop to the **`sp:super-planner`** agent (the batch orchestrator).
487
487
  - **Behavior:** The orchestrator reads [execution-batch.md](execution-batch.md) and drives: resolve selector → freeze set → topo-sort by `dependencies[]` (Kahn, WBS-ascending tie-break; cycle aborts) → resolve out-of-set deps by status (done → allow, else → block subtree) → run each task via `spur workflow run task-pipeline.yaml --async` + `spur workflow trace` polling → inspect terminal state + `.spur/run/<wbs>-verdict.json` → stop-the-batch default or `--keep-going` subtree skip → emit batch report. Per-task pipeline is invoked **verbatim** — no new FSM, no step edits. `--auto`/`--agent` are the only flags that cross the orchestrator→pipeline boundary (both into per-task `--vars`).
@@ -20,21 +20,29 @@ results). A `paused` task is non-terminal — the report marks it `paused` (neve
20
20
  the resume command. Unless `--no-summary`, follow it with the measured
21
21
  [execution summary](dev-operations.md#execution-summary), one `--progress <wbs>=<file>` per attempted run.
22
22
 
23
+ Under `--auto` the report's mode field reads `Mode: --auto (keep-going)`, and `--auto` implies `--keep-going`:
24
+ an unattended batch never stops for a human (1318 R1). For an `--agent auto` or
25
+ `--agent <name>` batch, print the header `Executor pin: <name> (from <wbs>|--agent)` and fill the
26
+ per-task `Executor` column with the pinned executor, or `A → B (failed-agent)` for a run that was
27
+ re-dispatched once on a new executor (1318 R2–R4). Leave that line and column out for host-inline
28
+ runs, which have no executor to pin.
29
+
23
30
  ```
24
31
  ## Batch Report — <selector>
25
32
 
26
33
  **Selector:** <value>
27
34
  **Plan:** <n> tasks (ordered: <wbs-list>) · <m> blocked · <p> not-attempted
28
- **Mode:** stop-the-batch | --keep-going | --auto
35
+ **Mode:** stop-the-batch | --keep-going | --auto (keep-going)
36
+ **Executor pin:** <name> (from <wbs>|--agent) ← `--agent auto`/`--agent <name>` runs only
29
37
  **Verdict:** clean | halted | aborted
30
38
 
31
- | WBS | Status | Reason |
32
- |-----|--------|--------|
33
- | 0040 | done | — |
34
- | 0042 | failed | verify verdict PARTIAL (see .spur/run/0042-verdict.json) |
35
- | 0050 | not-attempted | batch halted after 0042 (stop-the-batch) |
36
- | 0051 | skipped | dependency 0040 failed (--keep-going) |
37
- | 0060 | blocked | unmet out-of-set dep: 0099 is wip |
39
+ | WBS | Status | Executor | Reason |
40
+ |-----|--------|----------|--------|
41
+ | 0040 | done | claude | — |
42
+ | 0042 | failed | codex-a → codex-b (failed-agent) | verify verdict PARTIAL (see .spur/run/0042-verdict.json) |
43
+ | 0050 | not-attempted | — | batch halted after 0042 (stop-the-batch) |
44
+ | 0051 | skipped | — | dependency 0040 failed (--keep-going) |
45
+ | 0060 | blocked | — | unmet out-of-set dep: 0099 is wip |
38
46
 
39
47
  **Next:** <one-line action — pick up halted run / resolve 0099 / all green, feature H1 complete>
40
48
 
@@ -23,6 +23,9 @@ see_also:
23
23
  [execution-batch-continuation.md](execution-batch-continuation.md) for `--continue`.
24
24
  - **Never re-derive the plan** — the frozen plan is the persisted report; a new selector match is
25
25
  `not-admitted`, not a plan change.
26
+ - **Fresh session per segment** — when host context nears its limit, stop after a task terminal and
27
+ resume in a new session with `--continue`; the persisted report and markers carry the state. A
28
+ near-full context slows every later turn and degrades judgment.
26
29
 
27
30
  ## What this file owns
28
31
 
@@ -146,6 +149,13 @@ recorded in the batch report, and independent tasks keep running. The batch does
146
149
  - **without `--auto`:** unchanged halt behavior — the batch stops on the failing task (`--keep-going`
147
150
  still skips only its in-batch dependents).
148
151
 
152
+ **`--auto` implies `--keep-going` (1318 R1).** An unattended batch never stops for a human: under
153
+ `--auto` every per-task failure follows §4.2 whether or not the flag is passed — a failed,
154
+ cancelled, `stale-evidence` or worker-lost (1317) task is recorded `failed`, its in-batch
155
+ dependents are `skipped` with `dependency <wbs> failed`, and independent tasks keep running. The
156
+ refine pass above is only the extra step before that, not an exception. Without `--auto` the §4.1
157
+ stop-the-batch default is unchanged. No new flag.
158
+
149
159
  `--tasks <value>` (or the effective value after normalization) resolves to a frozen set of task WBS numbers. Resolution happens **once, at
150
160
  kickoff** — the driver never re-queries `spur task list` to recompute membership mid-batch (R2.1).
151
161
 
@@ -408,6 +418,18 @@ The host session remains the orchestrator for interactive sequential omit/inline
408
418
  owns explicit-executor and parallel paths; there the flag pins the per-task step executor, not the
409
419
  orchestrator.
410
420
 
421
+ **Executor pin (1318 R2/R3).** `--agent auto` is resolved **once per batch**, not once per task:
422
+ after the first task run, read the resolved implement executor from that run's implement `agent.run`
423
+ trace event — `spur workflow trace <run> --json` → `.events[] | select(.node=="implement" and
424
+ .actionKind=="agent.run") | .result.executor` — and dispatch every later task with
425
+ `--agent <that executor>`. The report's header records `Executor pin: <name> (from <wbs>)`; an
426
+ explicit `--agent <name>` records `(from --agent)` and needs no resolution. A task whose run ends
427
+ with `terminalReason == "failed-agent"` is re-dispatched **exactly once** with `--agent auto`; a
428
+ different resolved executor then becomes the pin for the remaining tasks, and a second
429
+ `failed-agent` records the task `failed` under the failure policy with the pin kept. No other terminal reason
430
+ is ever retried on a new executor. `--agent inline` (host driver) has no executor
431
+ to pin, so R2/R3 do not apply.
432
+
411
433
  ### 3.3 Terminal-state inspection
412
434
 
413
435
  Each pipeline run ends in one of two terminal states:
@@ -525,10 +547,11 @@ belongs to a subagent or the operator, not to the batch controller's report loop
525
547
 
526
548
  ## Step 4 — Failure policy (R3)
527
549
 
528
- ### 4.1 Stop-the-batch (default) (R3.1)
550
+ ### 4.1 Stop-the-batch (default unless `--auto`) (R3.1)
529
551
 
530
- By default, the **first** pipeline failure halts the batch. Remaining tasks in the plan are reported
531
- as `not-attempted`. The report lists succeeded, failed, and not-attempted tasks.
552
+ By default — i.e. without `--auto` (1318 R1) — the **first** pipeline failure halts the batch.
553
+ Remaining tasks in the plan are reported as `not-attempted`. The report lists succeeded, failed,
554
+ and not-attempted tasks.
532
555
 
533
556
  **Run-failed / task-done (observed 2026-10-19, feature P1).** A run can fail *after* the task is
534
557
  already finished: the `done` state's close commands run last, and a lint or anchor rule can refuse
@@ -540,9 +563,10 @@ contract, not to a run whose last recorded state was superseded. Surface it in t
540
563
  way (never silently continue), and never re-run the pipeline just to flip a run row from `failed` to
541
564
  `done` — a second run re-executes model stages for a task that is already closed.
542
565
 
543
- ### 4.2 `--keep-going` (R3.2)
566
+ ### 4.2 `--keep-going` (R3.2), or implied by `--auto`
544
567
 
545
- With `--keep-going`, a failed task does **not** halt the batch. Instead:
568
+ With `--keep-going` — passed explicitly, or implied by `--auto` (1318 R1) — a failed task does
569
+ **not** halt the batch. Instead:
546
570
 
547
571
  - The failed task's **in-batch dependents** (tasks in the plan that transitively depend on it) are
548
572
  marked `skipped` with the failed dependency as the reason — they cannot run because their dep did
@@ -138,6 +138,11 @@ Only declared where the command already has at least one HITL gate the flag can
138
138
  taste gates (idea-eval, design-approval) — every gate there is reversible corpus writes. A gate
139
139
  without an actionable recommendation (missing eval recommendation, FAIL design check) still pauses.
140
140
 
141
+ **Batch policy** (`dev-runall`): `--auto` implies `--keep-going` — an unattended batch never stops
142
+ for a human. With `--agent auto` the driver also pins ONE executor for the batch — `Executor pin:
143
+ <name>`, read from the first run's implement `result.executor` — and only a `failed-agent` ending
144
+ re-resolves it, exactly once (1318 R1–R3).
145
+
141
146
  ### `--keep-going` — batch failure policy: skip dependents, continue independents
142
147
 
143
148
  **Anchor:** `#flag-keep-going`.
@@ -146,6 +151,10 @@ Batch operation only (`dev-refineall`, `dev-runall`). When a task in the batch
146
151
  fails, skip its in-batch dependents and continue the independent ones, instead of the default
147
152
  halt-on-first-failure. Never silently retried; the failure is still reported.
148
153
 
154
+ **Default:** off, except under `--auto` — `--auto` implies `--keep-going` (1318 R1), because an
155
+ unattended batch that stops to wait for a human is the defect it was supposed to avoid. To stop at
156
+ the first failure, run without `--auto`.
157
+
149
158
  ### `--defer-gate` — opt-in parallel-batch gate policy (task 1111 / H15)
150
159
 
151
160
  **Applies to:** `/sp-dev-runall --mode parallel`, and `--mode auto` when it resolves to parallel.
package/spur.js CHANGED
@@ -45392,6 +45392,32 @@ var init_forensic_query = __esm(() => {
45392
45392
  END`;
45393
45393
  });
45394
45394
 
45395
+ // ../../packages/domain/src/analytics/selector-time-range.ts
45396
+ function buildTimeRangeClauses(range, target) {
45397
+ if (!COLUMN.test(target.column))
45398
+ throw new Error(`invalid time-range column: ${target.column}`);
45399
+ const bound = BOUND_SQL[target.grain];
45400
+ const clauses = [];
45401
+ const params = [];
45402
+ if (range.since !== null) {
45403
+ clauses.push(`${target.column} >= ${bound}`);
45404
+ params.push(range.since);
45405
+ }
45406
+ if (range.until !== null) {
45407
+ clauses.push(`${target.column} <= ${bound}`);
45408
+ params.push(range.until);
45409
+ }
45410
+ return { clauses, params };
45411
+ }
45412
+ var BOUND_SQL, COLUMN;
45413
+ var init_selector_time_range = __esm(() => {
45414
+ BOUND_SQL = {
45415
+ day: "DATE(?)",
45416
+ timestamp: "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"
45417
+ };
45418
+ COLUMN = /^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/;
45419
+ });
45420
+
45395
45421
  // ../../packages/domain/src/analytics/history-board-marts.ts
45396
45422
  function resolveSummaryReadPath(input) {
45397
45423
  if (!input.fresh)
@@ -45544,17 +45570,7 @@ async function deriveDimensionMarts(db, days) {
45544
45570
  await db.batch(deriveDimensionMartsOps(days));
45545
45571
  }
45546
45572
  function dayClauses(sel) {
45547
- const clauses = [];
45548
- const params = [];
45549
- if (sel.since !== null) {
45550
- clauses.push("d.day >= DATE(?)");
45551
- params.push(sel.since);
45552
- }
45553
- if (sel.until !== null) {
45554
- clauses.push("d.day <= DATE(?)");
45555
- params.push(sel.until);
45556
- }
45557
- return { clauses, params };
45573
+ return buildTimeRangeClauses(sel, { column: "d.day", grain: "day" });
45558
45574
  }
45559
45575
  function dimensionKeyClauses(sel, dimension) {
45560
45576
  let values = null;
@@ -45771,6 +45787,7 @@ async function historyBoardSummaryFromMart(db, sel, dimension) {
45771
45787
  }
45772
45788
  var MART_DIMENSIONS, MART_MIN_RANGE_DAYS = 7, MEASURE_COLS = "messages, tool_calls, skill_calls, fresh_input_tokens, cache_read_tokens, cache_write_tokens, output_tokens, duration_ms, duration_samples", MART_INSERT_COLS, MSG_DAY_PRED = "(m.bucket_start >= ? AND m.bucket_start < DATE(?, '+1 day'))", TOOL_DAY_PRED = "(t.bucket_start >= ? AND t.bucket_start < DATE(?, '+1 day'))", SKILL_DAY_PRED = "(s.bucket_start >= ? AND s.bucket_start < DATE(?, '+1 day'))", KPI_RANGE_KEY = "trend";
45773
45789
  var init_history_board_marts = __esm(() => {
45790
+ init_selector_time_range();
45774
45791
  MART_DIMENSIONS = ["model", "source", "tool", "skill"];
45775
45792
  MART_INSERT_COLS = `${MEASURE_COLS}, errors`;
45776
45793
  });
@@ -46201,16 +46218,10 @@ function appendRankedSteps(operations, kind, rows) {
46201
46218
  });
46202
46219
  }
46203
46220
  function buildRollupWhere(sel, alias, options) {
46204
- const clauses = [];
46205
- const params = [];
46206
- if (sel.since !== null) {
46207
- clauses.push(`${alias}.${options.timestamp} >= ${options.dateOnly ? "DATE(?)" : "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"}`);
46208
- params.push(sel.since);
46209
- }
46210
- if (sel.until !== null) {
46211
- clauses.push(`${alias}.${options.timestamp} <= ${options.dateOnly ? "DATE(?)" : "strftime('%Y-%m-%dT%H:%M:%SZ', ?)"}`);
46212
- params.push(sel.until);
46213
- }
46221
+ const { clauses, params } = buildTimeRangeClauses(sel, {
46222
+ column: `${alias}.${options.timestamp}`,
46223
+ grain: options.dateOnly ? "day" : "timestamp"
46224
+ });
46214
46225
  if (sel.sources !== null && sel.sources.length > 0) {
46215
46226
  clauses.push(`${alias}.source IN (${sel.sources.map(() => "?").join(", ")})`);
46216
46227
  params.push(...sel.sources);
@@ -47363,6 +47374,7 @@ var init_history_board_rollup = __esm(() => {
47363
47374
  init_forensic_query();
47364
47375
  init_history_board_marts();
47365
47376
  init_rollup_watermark();
47377
+ init_selector_time_range();
47366
47378
  init_tool_alias();
47367
47379
  init_tool_name_sql();
47368
47380
  init_tool_name_sql();
@@ -58119,7 +58131,7 @@ var init_run_dao = __esm(() => {
58119
58131
  LIMIT ?6`, filter.workflow ?? null, filter.status ?? null, filter.since ?? null, filter.before?.started_at ?? null, filter.before?.id ?? null, filter.limit);
58120
58132
  }
58121
58133
  traceRowById(runId) {
58122
- return this.adapter.queryFirst("SELECT id, workflow_name, mode, status, agent, started_at, completed_at, terminal_reason, metadata_json FROM runs WHERE id = ?", runId);
58134
+ return this.adapter.queryFirst("SELECT id, workflow_name, mode, status, agent, started_at, completed_at, terminal_reason, metadata_json, pid FROM runs WHERE id = ?", runId);
58123
58135
  }
58124
58136
  mergeMetadata(runId, patch) {
58125
58137
  return this.adapter.run(`UPDATE runs
@@ -58132,6 +58144,17 @@ var init_run_dao = __esm(() => {
58132
58144
  stampMetadata(runId, metadata) {
58133
58145
  return this.mergeMetadata(runId, metadata);
58134
58146
  }
58147
+ mergeMetadataIfStatus(runId, patch, statuses) {
58148
+ if (statuses.length === 0)
58149
+ return Promise.resolve();
58150
+ const placeholders = statuses.map((_, i) => `?${i + 3}`).join(", ");
58151
+ return this.adapter.run(`UPDATE runs
58152
+ SET metadata_json = json_patch(
58153
+ COALESCE(NULLIF(metadata_json, ''), '{}'),
58154
+ ?1
58155
+ )
58156
+ WHERE id = ?2 AND status IN (${placeholders})`, JSON.stringify(patch), runId, ...statuses);
58157
+ }
58135
58158
  stampRunIdentity(runId, definitionDigest, workflowVersion, source) {
58136
58159
  const sourceSet = source === undefined ? "" : `,
58137
58160
  '$.definitionSource.path', ?4,
@@ -72279,6 +72302,64 @@ var init_dist11 = __esm(() => {
72279
72302
  init_types3();
72280
72303
  });
72281
72304
 
72305
+ // ../../packages/domain/src/migration-sql.generated.ts
72306
+ var HISTORY_DIMENSION_MARTS_SCHEMA_SQL = `-- 0743: Dimension marts (task 0743).
72307
+ -- Two day-grain mart tables carrying the ADR-106 nine-measure additive vector for the
72308
+ -- four materialized Summary dimensions. Every measure column is nullable for exactly one
72309
+ -- reason: ADR-106 records a measure that is not well defined at a dimension as NULL, never
72310
+ -- as a zero that would be indistinguishable from a measured absence of activity.
72311
+ --
72312
+ -- - tool dimension: skill_calls is not applicable -> NULL
72313
+ -- - skill dimension: tool_calls is not applicable -> NULL
72314
+ -- - source dimension: duration_ms / duration_samples are not applicable -> NULL
72315
+ --
72316
+ -- history_board_kpi_window stores the current and previous aggregate window KPIs for a
72317
+ -- named range window. All statements are idempotent (CREATE TABLE IF NOT EXISTS).
72318
+
72319
+ CREATE TABLE IF NOT EXISTS history_board_dimension_daily (
72320
+ dimension TEXT NOT NULL,
72321
+ dimension_key TEXT NOT NULL,
72322
+ day TEXT NOT NULL,
72323
+ -- ADR-106 nine-member additive measure vector. NULLABLE, never a NOT NULL column with a
72324
+ -- zero default: a not-applicable measure is stored as NULL.
72325
+ messages INTEGER,
72326
+ tool_calls INTEGER,
72327
+ skill_calls INTEGER,
72328
+ fresh_input_tokens REAL,
72329
+ cache_read_tokens REAL,
72330
+ cache_write_tokens REAL,
72331
+ output_tokens REAL,
72332
+ duration_ms INTEGER,
72333
+ duration_samples INTEGER,
72334
+ -- Per-(dimension, key, day) tool-error count (not part of the ADR-106 additive vector but
72335
+ -- needed by the top-tools error-rate projection. NULLABLE like every other scalar.
72336
+ errors INTEGER,
72337
+ PRIMARY KEY (dimension, dimension_key, day)
72338
+ );
72339
+
72340
+ CREATE TABLE IF NOT EXISTS history_board_kpi_window (
72341
+ range_key TEXT NOT NULL,
72342
+ window_kind TEXT NOT NULL CHECK (window_kind IN ('current', 'previous')),
72343
+ -- ADR-106 nine-member additive measure vector, plus the non-additive totals the KPI
72344
+ -- projection needs (sessions and tool_errors are counts, never summed as means).
72345
+ messages INTEGER,
72346
+ tool_calls INTEGER,
72347
+ skill_calls INTEGER,
72348
+ fresh_input_tokens REAL,
72349
+ cache_read_tokens REAL,
72350
+ cache_write_tokens REAL,
72351
+ output_tokens REAL,
72352
+ duration_ms INTEGER,
72353
+ duration_samples INTEGER,
72354
+ sessions INTEGER,
72355
+ tool_errors INTEGER,
72356
+ PRIMARY KEY (range_key, window_kind)
72357
+ );
72358
+
72359
+ CREATE INDEX IF NOT EXISTS idx_history_board_dimension_daily_day
72360
+ ON history_board_dimension_daily (dimension, day);
72361
+ `;
72362
+
72282
72363
  // ../../packages/domain/src/schema/inbox-messages.ts
72283
72364
  var inboxMessagesTable, inboxMessages4, INBOX_MESSAGES_TABLE_SQL;
72284
72365
  var init_inbox_messages3 = __esm(() => {
@@ -73224,49 +73305,6 @@ CREATE TABLE IF NOT EXISTS history_board_rollup_bucket (
73224
73305
 
73225
73306
  CREATE INDEX IF NOT EXISTS idx_history_message_imported_at
73226
73307
  ON history_message (imported_at);
73227
- `, HISTORY_DIMENSION_MARTS_SCHEMA_SQL = `
73228
- CREATE TABLE IF NOT EXISTS history_board_dimension_daily (
73229
- dimension TEXT NOT NULL,
73230
- dimension_key TEXT NOT NULL,
73231
- day TEXT NOT NULL,
73232
- -- ADR-106 nine-member additive measure vector. NULLABLE, never a NOT NULL column with a
73233
- -- zero default: a not-applicable measure is stored as NULL.
73234
- messages INTEGER,
73235
- tool_calls INTEGER,
73236
- skill_calls INTEGER,
73237
- fresh_input_tokens REAL,
73238
- cache_read_tokens REAL,
73239
- cache_write_tokens REAL,
73240
- output_tokens REAL,
73241
- duration_ms INTEGER,
73242
- duration_samples INTEGER,
73243
- -- Per-(dimension, key, day) tool-error count (not part of the ADR-106 additive vector but
73244
- -- needed by the top-tools error-rate projection. NULLABLE like every other scalar.
73245
- errors INTEGER,
73246
- PRIMARY KEY (dimension, dimension_key, day)
73247
- );
73248
-
73249
- CREATE TABLE IF NOT EXISTS history_board_kpi_window (
73250
- range_key TEXT NOT NULL,
73251
- window_kind TEXT NOT NULL CHECK (window_kind IN ('current', 'previous')),
73252
- -- ADR-106 nine-member additive measure vector, plus the non-additive totals the KPI
73253
- -- projection needs (sessions and tool_errors are counts, never summed as means).
73254
- messages INTEGER,
73255
- tool_calls INTEGER,
73256
- skill_calls INTEGER,
73257
- fresh_input_tokens REAL,
73258
- cache_read_tokens REAL,
73259
- cache_write_tokens REAL,
73260
- output_tokens REAL,
73261
- duration_ms INTEGER,
73262
- duration_samples INTEGER,
73263
- sessions INTEGER,
73264
- tool_errors INTEGER,
73265
- PRIMARY KEY (range_key, window_kind)
73266
- );
73267
-
73268
- CREATE INDEX IF NOT EXISTS idx_history_board_dimension_daily_day
73269
- ON history_board_dimension_daily (dimension, day);
73270
73308
  `, RETENTION_COMPACTION_META_SCHEMA_SQL = `
73271
73309
  CREATE TABLE IF NOT EXISTS spur_retention_meta (
73272
73310
  kind TEXT NOT NULL,
@@ -100265,6 +100303,31 @@ import {
100265
100303
  writeSync as writeSync2
100266
100304
  } from "fs";
100267
100305
  import { dirname as dirname8, join as join18 } from "path";
100306
+ function writeStateFile(statePath, state) {
100307
+ const temp = `${statePath}.tmp`;
100308
+ try {
100309
+ writeFileSync3(temp, `${JSON.stringify(state, null, 2)}
100310
+ `);
100311
+ renameSync3(temp, statePath);
100312
+ } catch {
100313
+ try {
100314
+ unlinkSync2(temp);
100315
+ } catch {}
100316
+ }
100317
+ }
100318
+ function projectRunStateStatus(dir, runId, status, at) {
100319
+ const statePath = join18(dir, `${runId}.state.json`);
100320
+ let prior;
100321
+ try {
100322
+ const parsed = JSON.parse(readFileSync5(statePath, "utf8"));
100323
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
100324
+ return;
100325
+ prior = parsed;
100326
+ } catch {
100327
+ return;
100328
+ }
100329
+ writeStateFile(statePath, { ...prior, status, updatedAt: at, finalizedAt: at });
100330
+ }
100268
100331
 
100269
100332
  class WorkflowRunLogSink {
100270
100333
  filePath;
@@ -100404,16 +100467,7 @@ class WorkflowRunLogSink {
100404
100467
  ...this.lineage?.startState !== undefined ? { startState: this.lineage.startState } : {},
100405
100468
  ...this.lineage?.continuedFrom !== undefined ? { continuedFrom: this.lineage.continuedFrom } : {}
100406
100469
  };
100407
- const temp = `${this.statePath}.tmp`;
100408
- try {
100409
- writeFileSync3(temp, `${JSON.stringify(state, null, 2)}
100410
- `);
100411
- renameSync3(temp, this.statePath);
100412
- } catch {
100413
- try {
100414
- unlinkSync2(temp);
100415
- } catch {}
100416
- }
100470
+ writeStateFile(this.statePath, state);
100417
100471
  }
100418
100472
  onSteering(ack) {
100419
100473
  const note = ack.note === undefined ? "" : ` \xB7 ${bounded3(ack.note, MAX_STEERING_NOTE_CHARS)}`;
@@ -131099,6 +131153,32 @@ var init_workflow_catalog_scan = __esm(() => {
131099
131153
  import { realpathSync as realpathSync8, statSync as statSync12 } from "fs";
131100
131154
  import { readdir as readdir3, rm as rm2 } from "fs/promises";
131101
131155
  import { dirname as dirname32, join as join58, relative as relative14, resolve as resolve21, sep as sep11 } from "path";
131156
+ async function reapRun(db, engine, run, reasons, cwd) {
131157
+ const dao = new RunDao(db);
131158
+ const interrupted = run.status === "running" ? await engine.interruptRun(run.id, "interrupted") : undefined;
131159
+ if (interrupted === undefined) {
131160
+ await dao.finalizeStale(run.id, reasons.finalized);
131161
+ } else {
131162
+ await dao.mergeMetadataIfStatus(run.id, { staleReason: reasons.interrupted }, ["interrupted"]);
131163
+ }
131164
+ projectRunStateStatus(runStoragePaths(cwd).recordsDir, run.id, interrupted === undefined ? "failed" : "interrupted", new Date().toISOString());
131165
+ return interrupted === undefined ? "failed" : "interrupted";
131166
+ }
131167
+ async function reapIfWorkerLost(db, cwd, runId) {
131168
+ const dao = new RunDao(db);
131169
+ const row = await dao.traceRowById(runId);
131170
+ if (row === undefined)
131171
+ return { row, reaped: false };
131172
+ if (row.status !== "running" && row.status !== "pending")
131173
+ return { row, reaped: false };
131174
+ const pid = row.pid;
131175
+ if (pid === null || isPidAlive(pid))
131176
+ return { row, reaped: false };
131177
+ const reason = `worker-lost: pid ${pid} not alive`;
131178
+ const engine = new WorkflowService(createDefaultWorkflowEngineHost({}), new DbWorkflowPersistenceAdapter(db));
131179
+ await reapRun(db, engine, row, { interrupted: reason, finalized: reason }, cwd);
131180
+ return { row, reaped: true };
131181
+ }
131102
131182
 
131103
131183
  class WorkflowCleanOps {
131104
131184
  ctx;
@@ -131113,14 +131193,10 @@ class WorkflowCleanOps {
131113
131193
  if (!dryRun) {
131114
131194
  const engine = new WorkflowService(createDefaultWorkflowEngineHost({}), new DbWorkflowPersistenceAdapter(db));
131115
131195
  for (const run of stale) {
131116
- const interrupted = run.status === "running" ? await engine.interruptRun(run.id, "interrupted") : undefined;
131117
- if (interrupted === undefined) {
131118
- await dao.finalizeStale(run.id, `stale: non-terminal > ${olderThanMinutes}m (spur workflow clean)`);
131119
- } else {
131120
- await dao.mergeMetadata(run.id, {
131121
- staleReason: `stale: owner lost > ${olderThanMinutes}m (spur workflow clean)`
131122
- });
131123
- }
131196
+ await reapRun(db, engine, run, {
131197
+ interrupted: `stale: owner lost > ${olderThanMinutes}m (spur workflow clean)`,
131198
+ finalized: `stale: non-terminal > ${olderThanMinutes}m (spur workflow clean)`
131199
+ }, this.ctx.cwd);
131124
131200
  }
131125
131201
  }
131126
131202
  return {
@@ -131337,7 +131413,9 @@ var init_workflow_clean_ops = __esm(() => {
131337
131413
  init_src2();
131338
131414
  init_dist9();
131339
131415
  init_dist5();
131416
+ init_workflow_run_log_sink();
131340
131417
  init_checkpoint_contract();
131418
+ init_project_process();
131341
131419
  init_run_storage();
131342
131420
  init_workflow_catalog_scan();
131343
131421
  });
@@ -135113,6 +135191,8 @@ function rowToTraceEntry(row, cwd) {
135113
135191
  isDryRun = meta.dryRun === true;
135114
135192
  if (typeof meta.failureReason === "string" && meta.failureReason !== "") {
135115
135193
  failureReason = meta.failureReason;
135194
+ } else if (typeof meta.staleReason === "string" && meta.staleReason !== "") {
135195
+ failureReason = meta.staleReason;
135116
135196
  }
135117
135197
  if ("workflowVersion" in meta && (typeof meta.workflowVersion === "string" || meta.workflowVersion === null)) {
135118
135198
  version = meta.workflowVersion;
@@ -135483,9 +135563,9 @@ class WorkflowInspectOps {
135483
135563
  total: rows.length
135484
135564
  };
135485
135565
  }
135486
- async traceRun(db, runId) {
135566
+ async traceRun(db, runId, preloadedRow) {
135487
135567
  const runDao = new RunDao(db);
135488
- const row = await runDao.traceRowById(runId);
135568
+ const row = preloadedRow ?? await runDao.traceRowById(runId);
135489
135569
  if (!row)
135490
135570
  throw new Error(`Run not found: ${runId}`);
135491
135571
  const run = rowToTraceEntry(row, this.ctx.cwd);
@@ -136636,8 +136716,10 @@ class WorkflowRunOps {
136636
136716
  await dao.cancelRun(runId);
136637
136717
  const after = await dao.traceRowById(runId);
136638
136718
  const finalized = after?.status === "failed" && isNonTerminal;
136639
- if (finalized)
136719
+ if (finalized) {
136720
+ projectRunStateStatus(runStoragePaths(this.ctx.cwd).recordsDir, runId, "failed", new Date().toISOString());
136640
136721
  await releaseWorktreeRunLease(db, this.ctx.cwd, runId);
136722
+ }
136641
136723
  return { runId, finalized, status: after?.status ?? "not_found", killed };
136642
136724
  }
136643
136725
  async stampFailureReason(runId, result) {
@@ -136657,11 +136739,13 @@ var init_workflow_run_ops = __esm(() => {
136657
136739
  init_src2();
136658
136740
  init_dist9();
136659
136741
  init_dist5();
136742
+ init_workflow_run_log_sink();
136660
136743
  init_composition_baseline();
136661
136744
  init_host_executor();
136662
136745
  init_start_state();
136663
136746
  init_workflow_resolver();
136664
136747
  init_event_bridge();
136748
+ init_run_storage();
136665
136749
  init_workflow_run_recording();
136666
136750
  init_workflow_run_vars();
136667
136751
  init_workflow_worktree_lease();
@@ -136722,7 +136806,8 @@ class WorkflowAppService {
136722
136806
  async trace(runIdOrFilter) {
136723
136807
  const db = await this.ctx.getDb();
136724
136808
  if (typeof runIdOrFilter === "string") {
136725
- return this.inspectOps.traceRun(db, runIdOrFilter);
136809
+ const probe = await reapIfWorkerLost(db, this.ctx.cwd, runIdOrFilter);
136810
+ return this.inspectOps.traceRun(db, runIdOrFilter, probe.reaped ? undefined : probe.row);
136726
136811
  }
136727
136812
  return this.inspectOps.traceList(db, runIdOrFilter ?? {});
136728
136813
  }
@@ -138263,6 +138348,7 @@ var init_src4 = __esm(() => {
138263
138348
  init_token_ledger_watcher();
138264
138349
  init_verify_answer_lint();
138265
138350
  init_warm_turn_dispatch();
138351
+ init_workflow_clean_ops();
138266
138352
  init_workflow_service();
138267
138353
  init_worktree_persist();
138268
138354
  init_write_slot_service();
@@ -164625,7 +164711,7 @@ init_src2();
164625
164711
  var CLI_CONFIG = {
164626
164712
  binaryName: "spur",
164627
164713
  binaryLabel: "spur",
164628
- binaryVersion: "0.4.26",
164714
+ binaryVersion: "0.4.27",
164629
164715
  configDir: ".spur",
164630
164716
  configFile: ".spur/config.yaml",
164631
164717
  databaseFile: ".spur/spur.db"
@@ -172364,7 +172450,7 @@ init_dist5();
172364
172450
  import { homedir as homedir17 } from "os";
172365
172451
  import { basename as basename23, join as join95, relative as relative19, resolve as resolve37 } from "path";
172366
172452
  // ../server/package.json
172367
- var version4 = "0.4.26";
172453
+ var version4 = "0.4.27";
172368
172454
 
172369
172455
  // ../server/src/modules/health/index.ts
172370
172456
  var startedAt = Date.now();
@@ -178537,8 +178623,10 @@ function formatWorkflowProgress(projection) {
178537
178623
  }
178538
178624
  function registerWorkflowProgressCommand(workflow, context) {
178539
178625
  workflow.command("progress").description("Project the structured progress of a workflow run: current state, per-action attempts, and candidate next transitions.").argument("<run-id>", "Run id to project").option(...SHARED_OPTIONS.jsonSupported).option(...SHARED_OPTIONS.jsonEnvelope).action(async (runId, options) => {
178626
+ const db = await context.getDb();
178627
+ await reapIfWorkerLost(db, context.cwd, runId);
178540
178628
  const projection = await projectWorkflowProgress(runId, {
178541
- db: await context.getDb(),
178629
+ db,
178542
178630
  projectRoot: context.cwd
178543
178631
  });
178544
178632
  if (projection.diagnostics.some((diagnostic) => diagnostic.code === "orphan-row")) {
@@ -179238,6 +179326,14 @@ function formatTimelineEvent(event) {
179238
179326
  function followDeadlineLine(runId, elapsedMs, status) {
179239
179327
  return `Follow timed out after ${elapsedMs}ms: run ${runId} status=${status} \u2014 run continues; resume with spur workflow trace ${runId} --follow`;
179240
179328
  }
179329
+ function followFinalizedLine(timeline) {
179330
+ const run = timeline.run;
179331
+ const reason = run.failureReason ? ` \u2014 ${run.failureReason}` : "";
179332
+ const duration = run.durationMs === null ? "unavailable" : `${run.durationMs}ms`;
179333
+ const next = run.nextAction === undefined ? "" : `
179334
+ Next: ${run.nextAction.label} \u2014 ${run.nextAction.value}`;
179335
+ return `Run finalized: ${run.status}${reason} \u2014 outcome=${run.outcome} duration=${duration}${next}`;
179336
+ }
179241
179337
  async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(ms), now = Date.now, deadlineMs) {
179242
179338
  const start = now();
179243
179339
  const deadline = deadlineMs === undefined ? Number.POSITIVE_INFINITY : start + deadlineMs;
@@ -179260,8 +179356,10 @@ async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(
179260
179356
  }
179261
179357
  write(formatTraceTimeline(timeline));
179262
179358
  const seen = new Set(timeline.events.map((event) => JSON.stringify(event)));
179263
- if (isTerminalTraceStatus(timeline.run.status))
179359
+ if (isTerminalTraceStatus(timeline.run.status)) {
179360
+ write(followFinalizedLine(timeline));
179264
179361
  return false;
179362
+ }
179265
179363
  while (true) {
179266
179364
  await wait(pollMs);
179267
179365
  const next = await service.trace(runId);
@@ -179273,12 +179371,7 @@ async function followTrace(service, runId, pollMs, write, wait = (ms) => sleep7(
179273
179371
  write(formatTimelineEvent(event));
179274
179372
  }
179275
179373
  if (isTerminalTraceStatus(next.run.status)) {
179276
- const reason = next.run.failureReason ? ` \u2014 ${next.run.failureReason}` : "";
179277
- const duration = next.run.durationMs === null ? "unavailable" : `${next.run.durationMs}ms`;
179278
- write(`Run finalized: ${next.run.status}${reason} \u2014 outcome=${next.run.outcome} duration=${duration}`);
179279
- if (next.run.nextAction !== undefined) {
179280
- write(`Next: ${next.run.nextAction.label} \u2014 ${next.run.nextAction.value}`);
179281
- }
179374
+ write(followFinalizedLine(next));
179282
179375
  return false;
179283
179376
  }
179284
179377
  const at = now();