@gobing-ai/spur 0.3.67 → 0.3.69

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/corpus-baseline.json +2 -50
  3. package/config/templates/docs/99_PROJECT_CONSTITUTION.md +21 -16
  4. package/config/workflow-composition-baseline.json +110 -60
  5. package/config/workflows/basic.yaml +1 -1
  6. package/config/workflows/docs-pipeline.yaml +99 -23
  7. package/config/workflows/feature-dev.yaml +1 -1
  8. package/config/workflows/idea-pipeline.yaml +21 -23
  9. package/config/workflows/task-pipeline.yaml +233 -121
  10. package/config/workflows/wayfinder-resolution.yaml +1 -1
  11. package/config/workflows/wrapup-pipeline.yaml +1 -1
  12. package/package.json +9 -9
  13. package/plugins/sp/README.md +6 -7
  14. package/plugins/sp/commands/dev-idea.md +5 -3
  15. package/plugins/sp/commands/dev-plan.md +3 -1
  16. package/plugins/sp/commands/dev-review-session.md +2 -1
  17. package/plugins/sp/hooks/context-post-tool.ts +101 -2
  18. package/plugins/sp/hooks/context-session-start.ts +22 -1
  19. package/plugins/sp/plugin.json +1 -1
  20. package/plugins/sp/scripts/stage-registry-adapter.ts +144 -2
  21. package/plugins/sp/scripts/surface-drift-inventory.ts +2 -15
  22. package/plugins/sp/scripts/task-size-precheck.ts +21 -79
  23. package/plugins/sp/skills/session-review/SKILL.md +16 -0
  24. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +42 -17
  25. package/plugins/sp/skills/spur-dev/references/dev-operations.md +6 -6
  26. package/plugins/sp/skills/spur-dev/references/execution-batch.md +80 -11
  27. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +18 -13
  28. package/spur.js +2062 -609
  29. package/web/_astro/{BoardApp.BEtcJqde.js → BoardApp.BQFbkeqq.js} +15 -15
  30. package/web/_astro/BoardApp.CTkqrhWd.js +1 -0
  31. package/web/_astro/{TaskDetail.ClAbCXom.js → TaskDetail.Dl2Eaj1w.js} +1 -1
  32. package/web/_astro/{arc.CCvf51_y.js → arc.uG14rp8A.js} +1 -1
  33. package/web/_astro/{architectureDiagram-3BPJPVTR.C0cb0J5M.js → architectureDiagram-3BPJPVTR.Dye6uD_x.js} +1 -1
  34. package/web/_astro/{blockDiagram-GPEHLZMM.CIyjqoCE.js → blockDiagram-GPEHLZMM.B9Pkh7Hb.js} +1 -1
  35. package/web/_astro/{c4Diagram-AAUBKEIU.fs14IuFs.js → c4Diagram-AAUBKEIU.C2x7SC_X.js} +1 -1
  36. package/web/_astro/channel.Dsvulp7W.js +1 -0
  37. package/web/_astro/{chunk-2J33WTMH.CaBKv4ZO.js → chunk-2J33WTMH.D2p4-nWk.js} +1 -1
  38. package/web/_astro/{chunk-4BX2VUAB.BOllTPto.js → chunk-4BX2VUAB.S-6jf33o.js} +1 -1
  39. package/web/_astro/{chunk-55IACEB6.ChEof0O4.js → chunk-55IACEB6.DVXj4Fdh.js} +1 -1
  40. package/web/_astro/{chunk-727SXJPM.Co2kdjD8.js → chunk-727SXJPM.Dz689FMN.js} +1 -1
  41. package/web/_astro/{chunk-AQP2D5EJ.SWmfcnog.js → chunk-AQP2D5EJ.KxYj5TnI.js} +1 -1
  42. package/web/_astro/{chunk-FMBD7UC4.rDAFifF3.js → chunk-FMBD7UC4.itTQyHQB.js} +1 -1
  43. package/web/_astro/{chunk-ND2GUHAM.BCnoXKCw.js → chunk-ND2GUHAM.euSrbJf5.js} +1 -1
  44. package/web/_astro/{chunk-QZHKN3VN.RSmy2hDO.js → chunk-QZHKN3VN.OWASJRQy.js} +1 -1
  45. package/web/_astro/{classDiagram-4FO5ZUOK.Be7PEfrX.js → classDiagram-4FO5ZUOK.BLvrlpNO.js} +1 -1
  46. package/web/_astro/{classDiagram-v2-Q7XG4LA2.Be7PEfrX.js → classDiagram-v2-Q7XG4LA2.BLvrlpNO.js} +1 -1
  47. package/web/_astro/{cose-bilkent-S5V4N54A.BkUp2aSK.js → cose-bilkent-S5V4N54A.XBF-rmyD.js} +1 -1
  48. package/web/_astro/{cynefin-OW5HDTMX.BegGGlUV.js → cynefin-OW5HDTMX.DlCx762Z.js} +1 -1
  49. package/web/_astro/{dagre-BM42HDAG.BkUdjsaC.js → dagre-BM42HDAG.D17Rshxv.js} +1 -1
  50. package/web/_astro/{diagram-2AECGRRQ.E9vugt3-.js → diagram-2AECGRRQ.AhBIVJC8.js} +1 -1
  51. package/web/_astro/{diagram-5GNKFQAL.Dj4yeHXB.js → diagram-5GNKFQAL.C9ximjyC.js} +1 -1
  52. package/web/_astro/{diagram-KO2AKTUF.Buaquwli.js → diagram-KO2AKTUF.CZb7Ru_9.js} +1 -1
  53. package/web/_astro/{diagram-LMA3HP47.BV3dgGgm.js → diagram-LMA3HP47.BW7LwqoS.js} +1 -1
  54. package/web/_astro/{diagram-OG6HWLK6.Cnx3s-tc.js → diagram-OG6HWLK6.XC025W0V.js} +1 -1
  55. package/web/_astro/{erDiagram-TEJ5UH35.DKK_abu4.js → erDiagram-TEJ5UH35.CpMXmBDP.js} +1 -1
  56. package/web/_astro/{flowDiagram-I6XJVG4X.BNuu9fbm.js → flowDiagram-I6XJVG4X.D2ednJWg.js} +1 -1
  57. package/web/_astro/{ganttDiagram-6RSMTGT7.b16KUMjy.js → ganttDiagram-6RSMTGT7.BjL9FGKO.js} +1 -1
  58. package/web/_astro/{gitGraphDiagram-PVQCEYII.Kh41lbG5.js → gitGraphDiagram-PVQCEYII.B90g1VGk.js} +1 -1
  59. package/web/_astro/{infoDiagram-5YYISTIA.DEWBXkp-.js → infoDiagram-5YYISTIA.RqgycKtQ.js} +1 -1
  60. package/web/_astro/{ishikawaDiagram-YF4QCWOH.DiAdmcL6.js → ishikawaDiagram-YF4QCWOH.Ctn-zt6a.js} +1 -1
  61. package/web/_astro/{journeyDiagram-JHISSGLW.D1Ki7IRm.js → journeyDiagram-JHISSGLW.DJhT8Ctp.js} +1 -1
  62. package/web/_astro/{kanban-definition-UN3LZRKU.CWUhrQpc.js → kanban-definition-UN3LZRKU.BY1QdejI.js} +1 -1
  63. package/web/_astro/{linear.BaFsgcCe.js → linear.Di7YObSt.js} +1 -1
  64. package/web/_astro/{mermaid.core.CHw_AsGy.js → mermaid.core.CbxtJS3Q.js} +4 -4
  65. package/web/_astro/{mindmap-definition-RKZ34NQL.UIhghgmN.js → mindmap-definition-RKZ34NQL.CxvR4g_J.js} +1 -1
  66. package/web/_astro/{pieDiagram-4H26LBE5.D05l3JUA.js → pieDiagram-4H26LBE5.jNWqnBHH.js} +1 -1
  67. package/web/_astro/{quadrantDiagram-W4KKPZXB.BcWIhIcE.js → quadrantDiagram-W4KKPZXB.BCp12MbA.js} +1 -1
  68. package/web/_astro/{requirementDiagram-4Y6WPE33.B1rYvKGn.js → requirementDiagram-4Y6WPE33.Dxhm4TyR.js} +1 -1
  69. package/web/_astro/{sankeyDiagram-5OEKKPKP.CKylVRC4.js → sankeyDiagram-5OEKKPKP.BtQXp4J9.js} +1 -1
  70. package/web/_astro/{sequenceDiagram-3UESZ5HK.Dm3uA_s4.js → sequenceDiagram-3UESZ5HK.BWEM1R_Q.js} +1 -1
  71. package/web/_astro/{stateDiagram-AJRCARHV.Bgca_BLe.js → stateDiagram-AJRCARHV.BG3wUkWB.js} +1 -1
  72. package/web/_astro/{stateDiagram-v2-BHNVJYJU.C1T7YFrG.js → stateDiagram-v2-BHNVJYJU.BLtMeFVP.js} +1 -1
  73. package/web/_astro/{timeline-definition-PNZ67QCA.ZOHJn3Sn.js → timeline-definition-PNZ67QCA.D5fHo0az.js} +1 -1
  74. package/web/_astro/{vennDiagram-CIIHVFJN.DegZitjD.js → vennDiagram-CIIHVFJN.0DcuMluU.js} +1 -1
  75. package/web/_astro/{wardleyDiagram-YWT4CUSO.BDsC115d.js → wardleyDiagram-YWT4CUSO.BZ-dxgHm.js} +1 -1
  76. package/web/_astro/{xychartDiagram-2RQKCTM6.D0MO70ea.js → xychartDiagram-2RQKCTM6.Bg-XWF7z.js} +1 -1
  77. package/web/index.html +1 -1
  78. package/web/_astro/BoardApp.DBEin4N5.js +0 -1
  79. package/web/_astro/channel.BGn_DUCD.js +0 -1
@@ -567,8 +567,9 @@ graph TB
567
567
  1. User types `/sp:dev-plan "add task body write API"`.
568
568
  2. **Command** (`dev-plan.md`) parses `$ARGUMENTS` and calls
569
569
  `Skill(skill="sp:spur-dev", args="plan $ARGUMENTS")`.
570
- 3. **Skill** (`spur-dev/SKILL.md`) drives the planning half: intake `spur feature create` → AC
571
- generation → `spur feature check` gate decomposition → `spur task batch-create`.
570
+ 3. **Skill** (`spur-dev/SKILL.md`) reads `idea-pipeline.yaml` and drives its states in the current
571
+ session by default: intake → `spur feature create` → AC generation → `spur feature check` gate →
572
+ decomposition → `spur task batch-create`. Explicit `--agent auto|<name>` uses the async worker.
572
573
  4. **CLI** validates each step before writing — feature IDs are race-safe, WBS allocation is atomic,
573
574
  `check` is the readiness matrix.
574
575
  5. Result: validated feature file + decomposed task batch in `docs/features/` and `docs/tasks/`.
@@ -577,10 +578,9 @@ graph TB
577
578
 
578
579
  1. User types `/sp:dev-run 0090`.
579
580
  2. **Command** delegates to `sp:spur-dev` skill (execution half).
580
- 3. **Skill** reads the task, loads `task-pipeline.yaml`, and runs
581
- `spur workflow run` with HITL surfacing.
582
- 4. **CLI** executes the workflow engine (`@gobing-ai/ts-dual-workflow-engine`), pauses at HITL gates,
583
- persists run state.
581
+ 3. **Skill** reads the task and `task-pipeline.yaml`, then drives its actions and guards in-session.
582
+ 4. **CLI** supplies deterministic actions and lifecycle gates; explicit executors use the async
583
+ workflow worker and persisted run state.
584
584
  5. Result: task driven through implement → check → fix → verify lifecycle.
585
585
 
586
586
  **Task-corpus write protection.**
@@ -606,7 +606,6 @@ pipeline owns one lifecycle phase:
606
606
  | `task-pipeline.yaml` | Single-task execution | `/sp:dev-run` |
607
607
  | `idea-pipeline.yaml` | Idea/planning → feature + tasks | `/sp:dev-idea`, `/sp:dev-plan` |
608
608
  | `feature-dev.yaml` | Feature umbrella execution | `/sp:dev-runall --feature` |
609
- | `idea-pipeline.yaml` | Idea to feature + AC + task batch | `/sp:dev-idea` |
610
609
  | `wrapup-pipeline.yaml` | Post-execution wrap-up | `/sp:dev-wrap`, `/sp:dev-wrapall` |
611
610
  | `docs-pipeline.yaml` | Docs-only task execution | `/sp:dev-run --mode implement` |
612
611
  | `wayfinder-resolution.yaml` | Wayfinder ticket resolution loop | `spur workflow run` (free-form) |
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Turn a vague idea into a feature with AC and a decomposed task batch — discovery, idea-eval, feature-create, AC, feature-check, system-design, decompose, batch-create (Design by default), handoff
3
3
  role: planner
4
- argument-hint: "\"<idea>\" [--auto] [--skip-design] [--approve-taste] [--agent <auto|name>]"
4
+ argument-hint: "\"<idea>\" [--auto] [--skip-design] [--approve-taste] [--agent <inline|auto|name>]"
5
5
  allowed-tools: ["Bash", "Read", "Skill", "AskUserQuestion"]
6
6
  ---
7
7
 
@@ -20,7 +20,7 @@ contract below maps to that workflow's transitions.
20
20
  | `--approve-taste` | With `--auto`: set idea_approved + design_approved so idea-eval / design-approval do not pause. | off |
21
21
  | `--idea-approved` | Compatibility alias for idea_approved=true (subset of --approve-taste). | off |
22
22
  | `--design-approved` | Compatibility alias for design_approved=true (subset of --approve-taste). | off |
23
- | `--agent` `<auto\|name>` | Who runs the model-bearing ideation. The pipeline's `agent.run` stages are headless — they always dispatch a subprocess. Omission and explicit `--agent inline` resolve identically per task 0687: tier substitution plus one warning naming the substituted executor; `auto` (tier-resolves an executor); a name (pins that executor). | inline |
23
+ | `--agent` `<inline\|auto\|name>` | Who runs the model-bearing ideation. Omission and `inline` drive `idea-pipeline.yaml` in this session with zero external agent/workflow processes; `auto` tier-resolves an executor and a name pins one, both through the async workflow worker. | inline |
24
24
 
25
25
  For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag-glossary.md).
26
26
 
@@ -31,7 +31,7 @@ For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag
31
31
  [--auto] # skip objective HITL only (feature-check, batch-create)
32
32
  [--skip-design] # design package off (system-design + task Design)
33
33
  [--approve-taste] # with --auto: skip idea-eval + design-approval pauses
34
- [--agent <auto|name>] # who runs the model-bearing ideation (default: agent.default)
34
+ [--agent <inline|auto|name>] # inline is the current session; auto/name are async workers
35
35
  ```
36
36
 
37
37
  There is **no** `--design` force flag. Design is default-on; only `--skip-design` opts out.
@@ -42,6 +42,8 @@ vars as subsets of `--approve-taste` (`idea_approved` / `design_approved`). Pref
42
42
  ## Implementation
43
43
 
44
44
  - Apply the [inline-default execution-surface contract](../skills/spur-dev/references/cross-cutting.md#inline-default-execution-surface).
45
+ - Omitted/`inline`: drive `idea-pipeline.yaml` through the [inline pipeline driver](../skills/spur-dev/references/inline-pipeline-driver.md). Do not launch `spur workflow run`, `spur agent run`, or a native subagent unless the operator explicitly requests delegation.
46
+ - `auto`/name: launch `spur workflow run idea-pipeline.yaml --async`, observe with one `workflow trace --follow`, and only report cancellation as stopped when `workflow cancel --json` returns `killed: true`.
45
47
  - `Skill(skill="sp:spur-dev", args="idea $ARGUMENTS")`
46
48
  - Stage contract (discovery → idea-eval → feature-create → AC → feature-check → system-design →
47
49
  decompose → batch-create → handoff): `plugins/sp/skills/spur-dev/references/dev-operations.md` § idea.
@@ -16,7 +16,7 @@ Wraps the **sp:spur-dev** skill.
16
16
  | `"<description>"` | Feature description to plan. | required |
17
17
  | `--feature` `<id>` | Attach to an existing feature. | omitted |
18
18
  | `--parent` `<feature-id>` | Create under a parent feature. | omitted |
19
- | `--agent` `<inline\|auto\|name>` | Who runs the model-bearing planning. The planning pipeline's `agent.run` stages are headless — they always dispatch a subprocess. `inline` also what omission resolves to since task 0687 — substitutes tier resolution there with one warning naming the resolved executor; `auto` (tier-resolves an executor); a name (pins that executor). | inline |
19
+ | `--agent` `<inline\|auto\|name>` | Who runs the model-bearing planning. Omission and `inline` drive `idea-pipeline.yaml` in this session with zero external agent/workflow processes; `auto` tier-resolves an executor and a name pins one, both through the async workflow worker. | inline |
20
20
  | `--skip-design` | Omit the system-design hop. | off |
21
21
  | `--auto` | Skip objective HITL gates. | off |
22
22
  | `--approve-taste` | With --auto: skip design-approval pause. | off |
@@ -43,5 +43,7 @@ for taste gates). Alias: `--design-approved` (prefer `--approve-taste`).
43
43
  ## Implementation
44
44
 
45
45
  - Apply the [inline-default execution-surface contract](../skills/spur-dev/references/cross-cutting.md#inline-default-execution-surface).
46
+ - Omitted/`inline`: drive `idea-pipeline.yaml` through the [inline pipeline driver](../skills/spur-dev/references/inline-pipeline-driver.md). Do not launch `spur workflow run`, `spur agent run`, or a native subagent unless the operator explicitly requests delegation.
47
+ - `auto`/name: launch `spur workflow run idea-pipeline.yaml --async`, observe with one `workflow trace --follow`, and only report cancellation as stopped when `workflow cancel --json` returns `killed: true`.
46
48
  - `Skill(skill="sp:spur-dev", args="plan $ARGUMENTS")`
47
49
  - Full Design package + batch `design` field contract: `plugins/sp/skills/spur-dev/references/dev-operations.md` § plan and `planning-workflow.md` Step 5.5.
@@ -12,7 +12,8 @@ default it is report-only: current conversation plus read-only repository eviden
12
12
  the session context is preserved — no workflow launch, history import, task creation, or
13
13
  remediation. With `--triage`, it first triages the findings, then applies direct fixes (pure
14
14
  documentation work and one-to-two-line fixes) inline and files everything remaining as exactly one
15
- new task for further fixing.
15
+ new task for further fixing. The result includes a non-overlapping time breakdown with durations in
16
+ `M:SS` or `H:MM:SS` form and `n/a` for unavailable measurements.
16
17
 
17
18
  ## Argument Flags
18
19
 
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+
2
3
  /**
3
4
  * context-post-tool — PostToolUse hook for indexed-context
4
5
  * (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
@@ -19,8 +20,9 @@
19
20
  * `superskill hook run sp context-post-tool`.
20
21
  */
21
22
 
22
- import { appendFileSync, existsSync, readFileSync, statSync } from 'node:fs';
23
- import { join } from 'node:path';
23
+ import { execSync } from 'node:child_process';
24
+ import { appendFileSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs';
25
+ import { join, resolve, sep } from 'node:path';
24
26
 
25
27
  /** Tools recorded by this hook (must match hooks.json PostToolUse matcher). */
26
28
  export const ALLOWED_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'Read', 'Write', 'Edit']);
@@ -282,6 +284,15 @@ export function recordToolUseEvent(
282
284
  const session = sessionMeta.session ?? '';
283
285
  if (!session) return null;
284
286
 
287
+ // Freshness stamp (task 0711 R4): the indexed-context producer is the agent
288
+ // following the skill's update guidance; the observable producer moment in
289
+ // code is a Write/Edit landing on a `.spur/context/*.md` file. Each such
290
+ // write refreshes `.freshness.json` so staleness checks can compare the
291
+ // sidecar's source commit against HEAD until the next regeneration.
292
+ if ((toolName === 'Write' || toolName === 'Edit') && isContextIndexFile(contextDir, filePath)) {
293
+ stampContextFreshness(contextDir, currentHeadCommit(), now());
294
+ }
295
+
285
296
  const tokens = resolveTokenEstimate(toolName, payload.tool_input, payload.tool_response);
286
297
  const ts = now().toISOString();
287
298
  const type = mapToolType(toolName);
@@ -309,6 +320,94 @@ export function recordToolUseEvent(
309
320
  return event;
310
321
  }
311
322
 
323
+ // ─── Context freshness sidecar (task 0711 R4) ───────────────────────────
324
+
325
+ export interface ContextFreshness {
326
+ schema_version: number;
327
+ source_commit: string;
328
+ generated_at: string;
329
+ }
330
+
331
+ export const CONTEXT_FRESHNESS_SCHEMA_VERSION = 1;
332
+
333
+ /** True when `filePath` is a regenerated context index inside `contextDir` (not the sidecars). */
334
+ export function isContextIndexFile(contextDir: string, filePath: string): boolean {
335
+ const resolvedDir = resolve(contextDir);
336
+ const resolvedFile = resolve(filePath);
337
+ if (!resolvedFile.startsWith(resolvedDir + sep)) return false;
338
+ const name = resolvedFile.slice(resolvedDir.length + 1);
339
+ return name.endsWith('.md');
340
+ }
341
+
342
+ /** Current HEAD, best-effort — null when git is unavailable or the dir is not a work tree. */
343
+ export function currentHeadCommit(): string | null {
344
+ try {
345
+ const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
346
+ const commit = out.trim();
347
+ return commit.length > 0 ? commit : null;
348
+ } catch {
349
+ return null;
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Write/refresh `.spur/context/.freshness.json`. Best-effort: any I/O failure
355
+ * leaves the previous sidecar (or none) in place — the hook contract is
356
+ * fail-open, never blocking.
357
+ */
358
+ export function stampContextFreshness(contextDir: string, sourceCommit: string | null, at: Date): void {
359
+ if (!sourceCommit) return;
360
+ const body: ContextFreshness = {
361
+ schema_version: CONTEXT_FRESHNESS_SCHEMA_VERSION,
362
+ source_commit: sourceCommit,
363
+ generated_at: at.toISOString(),
364
+ };
365
+ try {
366
+ writeFileSync(join(contextDir, '.freshness.json'), JSON.stringify(body));
367
+ } catch {
368
+ /* fail-open */
369
+ }
370
+ }
371
+
372
+ export interface ContextFreshnessCheck {
373
+ stale: boolean;
374
+ reason?: string;
375
+ }
376
+
377
+ /**
378
+ * Compare a freshness sidecar against the current HEAD (task 0711 R4). A
379
+ * missing/unparsable sidecar is stale (`never stamped`); a commit mismatch is
380
+ * stale until the producer regenerates the index.
381
+ */
382
+ export function checkContextFreshness(raw: string | null, headCommit: string | null): ContextFreshnessCheck {
383
+ if (raw === null) return { stale: true, reason: 'never stamped' };
384
+ let body: ContextFreshness;
385
+ try {
386
+ body = JSON.parse(raw) as ContextFreshness;
387
+ } catch {
388
+ return { stale: true, reason: 'malformed sidecar' };
389
+ }
390
+ if (body.schema_version !== CONTEXT_FRESHNESS_SCHEMA_VERSION) {
391
+ return { stale: true, reason: `schema_version ${String(body.schema_version)}` };
392
+ }
393
+ if (typeof body.source_commit !== 'string' || body.source_commit === '') {
394
+ return { stale: true, reason: 'missing source_commit' };
395
+ }
396
+ if (headCommit !== null && body.source_commit !== headCommit) {
397
+ return { stale: true, reason: 'source commit changed since generation' };
398
+ }
399
+ return { stale: false };
400
+ }
401
+
402
+ /** Read the freshness sidecar, or null when absent/unreadable. */
403
+ export function readContextFreshness(contextDir: string): string | null {
404
+ try {
405
+ return readFileSync(join(contextDir, '.freshness.json'), 'utf-8');
406
+ } catch {
407
+ return null;
408
+ }
409
+ }
410
+
312
411
  // Entrypoint — thin wrapper; logic lives in {@link recordToolUseEvent} for unit coverage.
313
412
  if (import.meta.main) {
314
413
  void (async () => {
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env bun
2
+
2
3
  /**
3
4
  * context-session-start — SessionStart hook for indexed-context.
4
5
  *
@@ -11,10 +12,11 @@
11
12
  * Self-contained by design (task 0232/0246).
12
13
  */
13
14
 
15
+ import { execSync } from 'node:child_process';
14
16
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
15
17
  import { join } from 'node:path';
16
-
17
18
  import { resolveAgentHint, resolveModelHint } from './agent-hint';
19
+ import { checkContextFreshness } from './context-post-tool';
18
20
 
19
21
  export { resolveAgentHint, resolveModelHint } from './agent-hint';
20
22
 
@@ -161,6 +163,25 @@ export function recordSessionStart(
161
163
  if (agent) startEvent.agent = agent;
162
164
  if (model) startEvent.model = model;
163
165
 
166
+ // Freshness check (task 0711 R4): report whether the context indexes were
167
+ // regenerated at the current HEAD. Best-effort, fail-open — git failures
168
+ // yield head=null, which marks stale only via the sidecar's own defects.
169
+ let headCommit: string | null = null;
170
+ try {
171
+ const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
172
+ const trimmed = out.trim();
173
+ headCommit = trimmed.length > 0 ? trimmed : null;
174
+ } catch {
175
+ headCommit = null;
176
+ }
177
+ let freshnessRaw: string | null = null;
178
+ try {
179
+ freshnessRaw = readFileSync(join(dir, '.freshness.json'), 'utf-8');
180
+ } catch {
181
+ freshnessRaw = null;
182
+ }
183
+ startEvent.contextFreshness = checkContextFreshness(freshnessRaw, headCommit);
184
+
164
185
  const ledgerPath = join(dir, 'token-ledger.jsonl');
165
186
  try {
166
187
  appendFileSync(ledgerPath, `${JSON.stringify(startEvent)}\n`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sp",
3
- "version": "0.3.67",
3
+ "version": "0.3.69",
4
4
  "description": "Spur — a local-first harness engineering toolkit that wraps mainstream coding agents with constraint checking, workflow orchestration, and history analytics.",
5
5
  "extensions": {
6
6
  "pi": ["./hooks/pi/guard-extension.ts"]
@@ -24,7 +24,8 @@
24
24
  * bun plugins/sp/scripts/stage-registry-adapter.ts --help
25
25
  */
26
26
 
27
- import { readFileSync } from 'node:fs';
27
+ import { execSync } from 'node:child_process';
28
+ import { existsSync, readFileSync } from 'node:fs';
28
29
  import { join } from 'node:path';
29
30
 
30
31
  // ─── Inline type definitions (mirrors packages/domain/src/stage-registry/) ─
@@ -203,6 +204,107 @@ export interface TaskSignal {
203
204
  feature_id?: string | null;
204
205
  hasCheckpoint?: boolean;
205
206
  }
207
+
208
+ /**
209
+ * Result of routing-checkpoint inspection (task 0711 R3).
210
+ *
211
+ * `usable` feeds `TaskSignal.hasCheckpoint`: only a checkpoint that parses as
212
+ * the canonical frontmatter contract, matches the task (owner identity), is
213
+ * non-terminal, and is not commit-drifted may route A4 (wip + checkpoint →
214
+ * continue). Anything else is reported with its reason and IGNORED — routing
215
+ * falls through to the non-checkpoint row (A5); it is never silently trusted.
216
+ *
217
+ * Lean inline mirror of `packages/app/src/workflow/checkpoint-contract.ts`
218
+ * (the plugin installs into foreign repos and cannot import workspace
219
+ * packages — same discipline as the domain-type mirrors above); the parity
220
+ * test pins the two together.
221
+ */
222
+ export interface RoutingCheckpointInspection {
223
+ usable: boolean;
224
+ reason?: string;
225
+ }
226
+
227
+ export interface RoutingCheckpointOptions {
228
+ /** Current repository HEAD; when provided, a mismatched/absent `source_commit` is drift. */
229
+ headCommit?: string;
230
+ /** Existence probe for referenced artifacts; default `existsSync`. */
231
+ artifactExists?: (path: string) => boolean;
232
+ }
233
+
234
+ /**
235
+ * Inspect `.spur/memory/sessions/<wbs>-checkpoint.md` for resume routing
236
+ * (task 0711 R3). A missing file yields `{ usable: false, reason: 'absent' }`
237
+ * — the normal case, indistinguishable for routing from a rejected one.
238
+ */
239
+ export function inspectRoutingCheckpoint(
240
+ sessionsDir: string,
241
+ wbs: string,
242
+ opts: RoutingCheckpointOptions = {},
243
+ ): RoutingCheckpointInspection {
244
+ let raw: string;
245
+ try {
246
+ raw = readFileSync(join(sessionsDir, `${wbs}-checkpoint.md`), 'utf-8');
247
+ } catch {
248
+ return { usable: false, reason: 'absent' };
249
+ }
250
+ if (!raw.startsWith('---')) return { usable: false, reason: 'malformed: missing frontmatter' };
251
+ const end = raw.indexOf('\n---', 3);
252
+ if (end < 0) return { usable: false, reason: 'malformed: unterminated frontmatter' };
253
+
254
+ const scalars = new Map<string, string>();
255
+ const artifacts: string[] = [];
256
+ let inArtifacts = false;
257
+ for (const rawLine of raw.slice(4, end).split('\n')) {
258
+ const line = rawLine.trim();
259
+ if (line.startsWith('- ')) {
260
+ if (inArtifacts) artifacts.push(line.slice(2).trim().replace(/^"|"$/g, ''));
261
+ continue;
262
+ }
263
+ inArtifacts = false;
264
+ const sep = line.indexOf(':');
265
+ if (sep <= 0) continue;
266
+ const key = line.slice(0, sep).trim();
267
+ const value = line
268
+ .slice(sep + 1)
269
+ .trim()
270
+ .replace(/^"|"$/g, '');
271
+ if (key === 'artifacts') {
272
+ inArtifacts = true;
273
+ if (value !== '') {
274
+ artifacts.push(
275
+ ...value
276
+ .split(',')
277
+ .map((p) => p.trim().replace(/^"|"$/g, ''))
278
+ .filter((p) => p !== ''),
279
+ );
280
+ }
281
+ continue;
282
+ }
283
+ scalars.set(key, value);
284
+ }
285
+
286
+ if (scalars.get('schema_version') !== '1') return { usable: false, reason: 'malformed: schema_version' };
287
+ if (scalars.get('task_wbs') !== wbs) {
288
+ return { usable: false, reason: `owner-mismatch: task_wbs=${scalars.get('task_wbs')} != ${wbs}` };
289
+ }
290
+ const status = scalars.get('status') ?? '';
291
+ if (['done', 'failed', 'cancelled', 'skipped'].includes(status)) {
292
+ return { usable: false, reason: `terminal: status=${status}` };
293
+ }
294
+ const head = opts.headCommit;
295
+ if (head !== undefined) {
296
+ const commit = scalars.get('source_commit') ?? '';
297
+ if (commit === '' || commit !== head) {
298
+ return { usable: false, reason: `commit-drift: checkpoint@${commit.slice(0, 12) || 'none'} != HEAD` };
299
+ }
300
+ }
301
+ const probe = opts.artifactExists ?? existsSync;
302
+ for (const artifact of artifacts) {
303
+ const p = artifact.trim().replace(/^"|"$/g, '');
304
+ if (p !== '' && !probe(p)) return { usable: false, reason: `missing-artifact: ${p}` };
305
+ }
306
+ return { usable: true };
307
+ }
206
308
  export interface FeatureSignal {
207
309
  id: string;
208
310
  status: FeatureStatus;
@@ -894,6 +996,8 @@ const TABLE_C: TableCRow[] = [
894
996
  },
895
997
  {
896
998
  // C4 — rule findings: requires spur rule run
999
+ // SAFETY: sentinel, not a string — this row never dispatches; null is the
1000
+ // intentional HITL-stop placeholder that formatStageResult renders as no-command.
897
1001
  redirectDispatch: null as unknown as string, // HITL stop
898
1002
  rowId: 'C4',
899
1003
  probeRows: ['A3', 'A5', 'A6'],
@@ -1315,6 +1419,17 @@ export function formatStageResult(result: StageResolution): string[] {
1315
1419
  return lines;
1316
1420
  }
1317
1421
 
1422
+ /** Current repository HEAD, best-effort — null outside a work tree or without git. */
1423
+ function currentHeadCommit(): string | null {
1424
+ try {
1425
+ const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
1426
+ const commit = out.trim();
1427
+ return commit.length > 0 ? commit : null;
1428
+ } catch {
1429
+ return null;
1430
+ }
1431
+ }
1432
+
1318
1433
  export function runCli(argv: string[], opts?: { resolve?: (input: ResolutionInput) => StageResolution }): CliResult {
1319
1434
  const parsed = parseCliArgs(argv);
1320
1435
  const resolve = opts?.resolve ?? resolveStage;
@@ -1337,6 +1452,30 @@ export function runCli(argv: string[], opts?: { resolve?: (input: ResolutionInpu
1337
1452
  };
1338
1453
  }
1339
1454
 
1455
+ // Routing checkpoint (0711 R3): a canonical checkpoint at
1456
+ // .spur/memory/sessions/<wbs>-checkpoint.md that survives inspection (owner,
1457
+ // non-terminal, at HEAD, artifacts present) enables the A4 resume row for wip
1458
+ // tasks; anything else is REPORTED and ignored — routing falls through to the
1459
+ // non-checkpoint row. Best-effort and fail-open: git/fs failures leave the
1460
+ // checkpoint unconsidered.
1461
+ let checkpointNote: string | undefined;
1462
+ let hasCheckpoint: boolean | undefined;
1463
+ if (parsed.wbs) {
1464
+ try {
1465
+ const inspection = inspectRoutingCheckpoint(
1466
+ join(process.cwd(), '.spur', 'memory', 'sessions'),
1467
+ parsed.wbs,
1468
+ { headCommit: currentHeadCommit() ?? undefined },
1469
+ );
1470
+ hasCheckpoint = inspection.usable;
1471
+ if (!inspection.usable && inspection.reason !== 'absent') {
1472
+ checkpointNote = `note: routing checkpoint ignored — ${inspection.reason}`;
1473
+ }
1474
+ } catch {
1475
+ hasCheckpoint = undefined;
1476
+ }
1477
+ }
1478
+
1340
1479
  // Build resolution input from CLI args (note: no live corpus access in CLI mode)
1341
1480
  const input: ResolutionInput = {
1342
1481
  target: parsed.wbs ?? parsed.feature ?? '',
@@ -1345,13 +1484,16 @@ export function runCli(argv: string[], opts?: { resolve?: (input: ResolutionInpu
1345
1484
  once: parsed.once,
1346
1485
  auto: parsed.auto,
1347
1486
  fullMode: parsed.full,
1348
- task: parsed.wbs ? { wbs: parsed.wbs, status: parsed.taskStatus ?? 'unknown', dependencies: [] } : undefined,
1487
+ task: parsed.wbs
1488
+ ? { wbs: parsed.wbs, status: parsed.taskStatus ?? 'unknown', dependencies: [], hasCheckpoint }
1489
+ : undefined,
1349
1490
  feature: parsed.feature ? { id: parsed.feature, status: 'active', tasks: [] } : undefined,
1350
1491
  };
1351
1492
 
1352
1493
  try {
1353
1494
  const result = resolve(input);
1354
1495
  const lines = formatStageResult(result);
1496
+ if (checkpointNote) lines.push(checkpointNote);
1355
1497
  return { exitCode: result.reasonKind === 'dispatch' ? 0 : 2, stdout: `${lines.join('\n')}\n`, stderr: '' };
1356
1498
  } catch (e) {
1357
1499
  return { exitCode: 1, stdout: '', stderr: `error: ${(e as Error).message}` };
@@ -473,11 +473,7 @@ export function executeScripts(root: string = PLUGIN_ROOT): void {
473
473
  writeFileSync(
474
474
  fake,
475
475
  `#!/bin/sh
476
- if [ "$1" = task ] && [ "$2" = show ]; then
477
- printf '%s' '{"content":"### Requirements\\n- [ ] R1. x\\n### Plan\\n- [ ] p1"}'
478
- else
479
- printf '%s' '{"agents":[{"capabilityTier":"standard"}]}'
480
- fi
476
+ printf '%s' '{"content":"### Requirements\\n- [ ] R1. x\\n### Plan\\n- [ ] p1"}'
481
477
  `,
482
478
  );
483
479
  chmodSync(fake, 0o755);
@@ -485,7 +481,7 @@ fi
485
481
  try {
486
482
  execFileSync(
487
483
  process.execPath,
488
- [join(root, 'scripts', 'task-size-precheck.ts'), '0487', '--spur-bin', fake, '--executor', 'standard'],
484
+ [join(root, 'scripts', 'task-size-precheck.ts'), '0487', '--spur-bin', fake],
489
485
  { cwd: dir, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' },
490
486
  );
491
487
  const content = readFileSync(statusPath, 'utf8');
@@ -687,15 +683,6 @@ export function probeJsonShapes(run: CliRunner = runCli): void {
687
683
  { file: 'plugins/sp/scripts/surface-drift-inventory.ts', line: 1 },
688
684
  );
689
685
  }
690
- const doctor = jsonEnvelopeShapes['spur agent doctor omp'];
691
- const capOk = (doctor?.keys ?? []).some((k) => k.endsWith('.capabilityTier'));
692
- record(
693
- 'agent doctor <name> --json -> agents[0].capabilityTier (asserted by task-size-precheck.ts:130)',
694
- 'json-exec(field-presence)',
695
- capOk ? 'ok' : 'mismatch',
696
- capOk ? 'field present in live envelope' : 'field ABSENT from live envelope',
697
- { file: 'plugins/sp/scripts/task-size-precheck.ts', line: 130 },
698
- );
699
686
  // Curated prose flag-claims: assertions phrased as prose ("no explicit `--flag`") that the
700
687
  // generic backtick-span extractor cannot scope to a command. Extend this list when a prose
701
688
  // claim is found; each entry is verified against the live help capture.