@gobing-ai/spur 0.3.94 → 0.3.95

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 (140) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +42 -0
  3. package/board/index.d.ts +38 -0
  4. package/config/plugin-scripts.json +5 -0
  5. package/config/workflows/feature-verification.yaml +7 -4
  6. package/config/workflows/idea-pipeline.yaml +10 -4
  7. package/config/workflows/task-pipeline.yaml +54 -26
  8. package/config/workflows/wrapup-pipeline.yaml +92 -17
  9. package/package.json +15 -11
  10. package/plugins/sp/commands/dev-verify.md +7 -4
  11. package/plugins/sp/commands/dev-verifyall.md +4 -3
  12. package/plugins/sp/commands/dev-wrap.md +1 -1
  13. package/plugins/sp/hooks/context-post-tool.ts +2 -2
  14. package/plugins/sp/hooks/context-session-start.ts +2 -1
  15. package/plugins/sp/hooks/context-session-stop.ts +22 -14
  16. package/plugins/sp/hooks/pi/guard-extension.ts +109 -150
  17. package/plugins/sp/lib/idea-handoff.generated.mjs +2 -2
  18. package/plugins/sp/lib/inline-run.generated.d.mts +2 -2
  19. package/plugins/sp/lib/inline-run.generated.mjs +10 -10
  20. package/plugins/sp/plugin.json +1 -1
  21. package/plugins/sp/scripts/daily-summary/daily-summary.mjs +3 -3
  22. package/plugins/sp/scripts/daily-summary/daily-summary.ts +3 -3
  23. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -0
  24. package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +1 -0
  25. package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +9 -7
  26. package/plugins/sp/scripts/dogfood-testing/validate-report.ts +8 -7
  27. package/plugins/sp/scripts/feature-sync-bounded.mjs +5 -2
  28. package/plugins/sp/scripts/feature-sync-bounded.ts +3 -1
  29. package/plugins/sp/scripts/feature-verification-steps.ts +6 -6
  30. package/plugins/sp/scripts/history-anatomy-cache.mjs +3 -3
  31. package/plugins/sp/scripts/history-anatomy-cache.ts +4 -4
  32. package/plugins/sp/scripts/inline-run-setup.mjs +23 -13
  33. package/plugins/sp/scripts/inline-run-setup.ts +60 -27
  34. package/plugins/sp/scripts/pr-reviewing.mjs +2 -0
  35. package/plugins/sp/scripts/pr-reviewing.ts +1 -0
  36. package/plugins/sp/scripts/quality-gate.mjs +4 -2
  37. package/plugins/sp/scripts/quality-gate.ts +8 -2
  38. package/plugins/sp/scripts/residual-scan.mjs +3 -3
  39. package/plugins/sp/scripts/residual-scan.ts +3 -3
  40. package/plugins/sp/scripts/script-contract-check.ts +175 -46
  41. package/plugins/sp/scripts/script-root.mjs +125 -0
  42. package/plugins/sp/scripts/script-root.ts +195 -0
  43. package/plugins/sp/scripts/stage-registry-adapter.ts +1 -1
  44. package/plugins/sp/scripts/task-evidence-precheck.ts +4 -2
  45. package/plugins/sp/scripts/task-size-precheck.ts +3 -1
  46. package/plugins/sp/scripts/validate-commands.ts +12 -12
  47. package/plugins/sp/scripts/validate-flag-contracts.ts +42 -30
  48. package/plugins/sp/scripts/verify-answer-lint.ts +3 -1
  49. package/plugins/sp/scripts/workflow-step-profile.mjs +5 -2
  50. package/plugins/sp/scripts/workflow-step-profile.ts +3 -1
  51. package/plugins/sp/scripts/wrapup-drift-probe.mjs +5 -2
  52. package/plugins/sp/scripts/wrapup-drift-probe.ts +4 -2
  53. package/plugins/sp/scripts/wrapup-steps.mjs +32 -8
  54. package/plugins/sp/scripts/wrapup-steps.ts +56 -10
  55. package/plugins/sp/skills/code-verification/SKILL.md +12 -7
  56. package/plugins/sp/skills/code-verification/references/verdict-schema.md +11 -0
  57. package/plugins/sp/skills/functional-review/SKILL.md +1 -1
  58. package/plugins/sp/skills/next-router/references/routing-table.md +1 -1
  59. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +9 -1
  60. package/plugins/sp/skills/spur-dev/references/execution-batch.md +30 -10
  61. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +6 -3
  62. package/plugins/sp/tsconfig.json +8 -0
  63. package/spur.js +5194 -4457
  64. package/web/_astro/{BoardApp.FTEs3-N8.js → BoardApp.BxJuwD7I.js} +150 -153
  65. package/web/_astro/BoardApp.Cpxntzad.js +1 -0
  66. package/web/_astro/TaskDetail.C5bW4WGV.js +1 -0
  67. package/web/_astro/_commonjsHelpers.CqkleIqs.js +1 -0
  68. package/web/_astro/{arc.uuAf51IT.js → arc.CyjRvNMY.js} +1 -1
  69. package/web/_astro/architectureDiagram-3BPJPVTR.B4lWRzJA.js +36 -0
  70. package/web/_astro/{blockDiagram-GPEHLZMM.D1mGCq3p.js → blockDiagram-GPEHLZMM.Be38USQ2.js} +1 -1
  71. package/web/_astro/board-facade-react-dom-client.B2U5wMvk.js +1 -0
  72. package/web/_astro/board-facade-react-dom.Cd2MuMhF.js +1 -0
  73. package/web/_astro/board-facade-react-jsx-dev-runtime.B_L3JzfC.js +1 -0
  74. package/web/_astro/board-facade-react-jsx-runtime.BY2yfHRi.js +1 -0
  75. package/web/_astro/board-facade-react-router-dom.Dy5pjzOw.js +1 -0
  76. package/web/_astro/board-facade-react-router.DjjqU27_.js +1 -0
  77. package/web/_astro/board-facade-react.Dgf4GRyj.js +1 -0
  78. package/web/_astro/{c4Diagram-AAUBKEIU.CMsolcde.js → c4Diagram-AAUBKEIU.DT9Fj5Qx.js} +1 -1
  79. package/web/_astro/channel.CI6N_tCg.js +1 -0
  80. package/web/_astro/{chunk-2J33WTMH.CrGA3fik.js → chunk-2J33WTMH.SeSBWLg5.js} +1 -1
  81. package/web/_astro/{chunk-4BX2VUAB.DsLVVla0.js → chunk-4BX2VUAB.DoO4VE14.js} +1 -1
  82. package/web/_astro/{chunk-55IACEB6.Dxc59Tfi.js → chunk-55IACEB6.DrmTFA53.js} +1 -1
  83. package/web/_astro/{chunk-727SXJPM.CaEVE2Wy.js → chunk-727SXJPM.Bi-TUb_V.js} +1 -1
  84. package/web/_astro/{chunk-AQP2D5EJ.BiJ4HXeI.js → chunk-AQP2D5EJ.B-xp8Uhi.js} +1 -1
  85. package/web/_astro/{chunk-FMBD7UC4.Mxf1fru5.js → chunk-FMBD7UC4.DGWrDnUU.js} +1 -1
  86. package/web/_astro/chunk-JMJ3UQ3L.BQJEJHu4.js +34 -0
  87. package/web/_astro/{chunk-ND2GUHAM.pyOWQixH.js → chunk-ND2GUHAM._BagvPDy.js} +1 -1
  88. package/web/_astro/{chunk-QZHKN3VN.BmEsg4vr.js → chunk-QZHKN3VN.VOboYWQ0.js} +1 -1
  89. package/web/_astro/chunk-YNUBSHFH.CxeAygfi.js +7 -0
  90. package/web/_astro/{classDiagram-4FO5ZUOK.BHhhMFTO.js → classDiagram-4FO5ZUOK.D_apfHS7.js} +1 -1
  91. package/web/_astro/{classDiagram-v2-Q7XG4LA2.BHhhMFTO.js → classDiagram-v2-Q7XG4LA2.D_apfHS7.js} +1 -1
  92. package/web/_astro/client.BhluLQqe.js +1 -0
  93. package/web/_astro/client.DBpSRLnx.js +9 -0
  94. package/web/_astro/cose-bilkent-S5V4N54A.DNLU_L-x.js +1 -0
  95. package/web/_astro/{cynefin-OW5HDTMX.C2j1_lKL.js → cynefin-OW5HDTMX.D8borhV-.js} +1 -1
  96. package/web/_astro/{dagre-BM42HDAG.hZ2NCTdT.js → dagre-BM42HDAG.Uc0lvjcV.js} +1 -1
  97. package/web/_astro/{diagram-2AECGRRQ.Bfw5_EzK.js → diagram-2AECGRRQ.DDIxtDBo.js} +1 -1
  98. package/web/_astro/{diagram-5GNKFQAL.Bt1V_Tmk.js → diagram-5GNKFQAL.DVoDnD3H.js} +1 -1
  99. package/web/_astro/{diagram-KO2AKTUF.DEk-YFwp.js → diagram-KO2AKTUF.CRubTJ-0.js} +1 -1
  100. package/web/_astro/{diagram-LMA3HP47.CDjndtQm.js → diagram-LMA3HP47.m9SeYcXQ.js} +1 -1
  101. package/web/_astro/{diagram-OG6HWLK6.cFnUHScG.js → diagram-OG6HWLK6.C6s2ZjV5.js} +1 -1
  102. package/web/_astro/{erDiagram-TEJ5UH35.DlhYp7NV.js → erDiagram-TEJ5UH35.nSzr5KTj.js} +1 -1
  103. package/web/_astro/{flowDiagram-I6XJVG4X.DNTpsxfx.js → flowDiagram-I6XJVG4X.DrZcys16.js} +1 -1
  104. package/web/_astro/ganttDiagram-6RSMTGT7.Do4M1b-K.js +292 -0
  105. package/web/_astro/{gitGraphDiagram-PVQCEYII.DkEqNI0P.js → gitGraphDiagram-PVQCEYII.BWc9oJ4l.js} +1 -1
  106. package/web/_astro/index.5dn-Zm-2.js +1 -0
  107. package/web/_astro/index.C-IqPpW2.js +1 -0
  108. package/web/_astro/index.CENnIEqT.css +1 -0
  109. package/web/_astro/index.Cyc_GA4f.js +1 -0
  110. package/web/_astro/index.tZfqwSut.js +1 -0
  111. package/web/_astro/{infoDiagram-5YYISTIA.CrjioCTG.js → infoDiagram-5YYISTIA.BIdOctFk.js} +1 -1
  112. package/web/_astro/{ishikawaDiagram-YF4QCWOH.BcK0CN8n.js → ishikawaDiagram-YF4QCWOH.CV5SuG2t.js} +1 -1
  113. package/web/_astro/{journeyDiagram-JHISSGLW.p0CbBDX1.js → journeyDiagram-JHISSGLW.CxE-rEJ-.js} +1 -1
  114. package/web/_astro/jsx-runtime.DWOiEOII.js +1 -0
  115. package/web/_astro/{kanban-definition-UN3LZRKU.puHIFt6J.js → kanban-definition-UN3LZRKU.BPRdKWs_.js} +1 -1
  116. package/web/_astro/{linear.Bb-3a1d7.js → linear.zRsuuDTE.js} +1 -1
  117. package/web/_astro/{mermaid.core.CJDgXOJs.js → mermaid.core.jAJTcMKc.js} +4 -4
  118. package/web/_astro/{mindmap-definition-RKZ34NQL.BoAZEI9t.js → mindmap-definition-RKZ34NQL.BffJxfCr.js} +1 -1
  119. package/web/_astro/{pieDiagram-4H26LBE5.CJNSdqY7.js → pieDiagram-4H26LBE5.CacwWaE3.js} +1 -1
  120. package/web/_astro/{quadrantDiagram-W4KKPZXB.DtTy7_0Z.js → quadrantDiagram-W4KKPZXB.hXELD06-.js} +1 -1
  121. package/web/_astro/{requirementDiagram-4Y6WPE33.BNYV98Xg.js → requirementDiagram-4Y6WPE33.DdEbPcqT.js} +1 -1
  122. package/web/_astro/{sankeyDiagram-5OEKKPKP.DRq9DGHp.js → sankeyDiagram-5OEKKPKP.DgaDocKv.js} +1 -1
  123. package/web/_astro/{sequenceDiagram-3UESZ5HK.B7KdBFCm.js → sequenceDiagram-3UESZ5HK.B0KScnAu.js} +1 -1
  124. package/web/_astro/{stateDiagram-AJRCARHV.CD62ZJ2H.js → stateDiagram-AJRCARHV.CV9M_WNc.js} +1 -1
  125. package/web/_astro/{stateDiagram-v2-BHNVJYJU.CeVk1TAj.js → stateDiagram-v2-BHNVJYJU.BOAo74Et.js} +1 -1
  126. package/web/_astro/{timeline-definition-PNZ67QCA.C_ZPA5tT.js → timeline-definition-PNZ67QCA.CqqepUMe.js} +1 -1
  127. package/web/_astro/{vennDiagram-CIIHVFJN.CrThO_FQ.js → vennDiagram-CIIHVFJN.DDqe4dOG.js} +1 -1
  128. package/web/_astro/{wardleyDiagram-YWT4CUSO.DSZCA5nl.js → wardleyDiagram-YWT4CUSO.B3R9OPdO.js} +1 -1
  129. package/web/_astro/{xychartDiagram-2RQKCTM6.KI7baTuk.js → xychartDiagram-2RQKCTM6.B3SvgXpQ.js} +1 -1
  130. package/web/board-runtime.json +69 -0
  131. package/web/index.html +2 -2
  132. package/web/_astro/BoardApp.C02hAHPO.js +0 -1
  133. package/web/_astro/TaskDetail.C-GdsS-t.js +0 -1
  134. package/web/_astro/architectureDiagram-3BPJPVTR.CGe629A1.js +0 -36
  135. package/web/_astro/channel.fsgl7o5j.js +0 -1
  136. package/web/_astro/client.yhYJvxCU.js +0 -9
  137. package/web/_astro/cose-bilkent-S5V4N54A.2fH4YOlp.js +0 -1
  138. package/web/_astro/ganttDiagram-6RSMTGT7.DC_p36PI.js +0 -292
  139. package/web/_astro/index.De90oHcH.js +0 -1
  140. package/web/_astro/index.Hjbr15fG.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gobing-ai/spur",
3
- "version": "0.3.94",
3
+ "version": "0.3.95",
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",
@@ -24,12 +24,16 @@
24
24
  "spur": "spur.js"
25
25
  },
26
26
  "exports": {
27
- "./schemas/*": "./schemas/*"
27
+ "./schemas/*": "./schemas/*",
28
+ "./board": {
29
+ "types": "./board/index.d.ts"
30
+ }
28
31
  },
29
32
  "files": [
30
33
  "spur.js",
31
34
  "config",
32
35
  "schemas",
36
+ "board",
33
37
  "web",
34
38
  "README.md",
35
39
  "plugins/",
@@ -45,7 +49,7 @@
45
49
  "dev": "bun run src/index.ts",
46
50
  "build": "bun run ../../scripts/spur-dev.ts build-cli",
47
51
  "build:binaries": "bun run ../../scripts/spur-dev.ts build-binaries",
48
- "build:bundle": "bun run ../../scripts/spur-dev.ts bundle-web && bun build src/index.ts --target=bun --outfile spur.js && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-plugins",
52
+ "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 && bun run ../../scripts/spur-dev.ts bundle-config config && bun run ../../scripts/spur-dev.ts bundle-plugins",
49
53
  "test": "bun test tests --coverage --coverage-dir=../../.coverage --pass-with-no-tests",
50
54
  "typecheck": "tsc --noEmit",
51
55
  "prepack": "bun run build:bundle",
@@ -53,14 +57,14 @@
53
57
  },
54
58
  "devDependencies": {
55
59
  "@commander-js/extra-typings": "^14.0.0",
56
- "@gobing-ai/ts-db": "^0.5.9",
57
- "@gobing-ai/ts-ai-runner": "^0.5.9",
58
- "@gobing-ai/ts-dual-workflow-engine": "^0.5.9",
59
- "@gobing-ai/ts-infra": "^0.5.9",
60
- "@gobing-ai/ts-llm-jsonl-importer": "^0.5.9",
61
- "@gobing-ai/ts-rule-engine": "^0.5.9",
62
- "@gobing-ai/ts-runtime": "^0.5.9",
63
- "@gobing-ai/ts-utils": "^0.5.9",
60
+ "@gobing-ai/ts-db": "^0.5.11",
61
+ "@gobing-ai/ts-ai-runner": "^0.5.11",
62
+ "@gobing-ai/ts-dual-workflow-engine": "^0.5.11",
63
+ "@gobing-ai/ts-infra": "^0.5.11",
64
+ "@gobing-ai/ts-llm-jsonl-importer": "^0.5.11",
65
+ "@gobing-ai/ts-rule-engine": "^0.5.11",
66
+ "@gobing-ai/ts-runtime": "^0.5.11",
67
+ "@gobing-ai/ts-utils": "^0.5.11",
64
68
  "@types/bun": "1.3.14",
65
69
  "@types/figlet": "^1.7.0",
66
70
  "@types/node-notifier": "8.0.5",
@@ -40,10 +40,13 @@ See `sp:code-verification` § Shippable readiness gate.
40
40
 
41
41
  - Apply the [inline-default execution-surface contract](../skills/spur-dev/references/cross-cutting.md#inline-default-execution-surface).
42
42
  - `Skill(skill="sp:code-verification", args="verify $ARGUMENTS")`
43
- - **Residual sweep (F96 R1):** after the verify answer is written and `task verdict` derives
44
- `.spur/run/<wbs>-verdict.json`, run `residual-scan scan` + `fold` (observe-only; folds the
45
- verdict's `residual-sweep` check, PASS → PARTIAL when a blocking residual exists) — under every
46
- `--fix` mode — before `spur task record`. Resolve the script via `superskill script path sp
43
+ - **Residual sweep (F96 R1, post-record since 0987):** after `task verdict` derives
44
+ `.spur/run/<wbs>-verdict.json` and `spur task record` has flipped the verdict-proven R/AC boxes,
45
+ run `residual-scan scan` + `fold` (observe-only; folds the verdict's `residual-sweep` check,
46
+ PASS → PARTIAL when a blocking residual exists) — under every `--fix` mode — then, only on a
47
+ downgrade, re-run `spur task record <wbs> --verdict-file .spur/run/<wbs>-verdict.json` so Testing
48
+ carries the final verdict. Same order as the pipeline `record` state (0983): scanning before
49
+ record reads unticked-but-proven boxes and folds a clean PASS to PARTIAL. Resolve the script via `superskill script path sp
47
50
  residual-scan.mjs`; never reference `plugins/sp/scripts/` in shipped surfaces
48
51
  (script-contract-check rule 4). When the task reaches `done` through `--next`, run
49
52
  `residual-scan settle` (best-effort). On a folded PARTIAL: the bounded remediation loop has
@@ -72,8 +72,9 @@ Without `--fix all`, shippable is not evaluated (optional note: use `--fix all`
72
72
  - Batch orchestration: `Skill(skill="sp:spur-dev", args="verifyall $ARGUMENTS")`
73
73
  - Per-task verification (inner): `Skill(skill="sp:code-verification", args="verify <wbs> $SHARED_FLAGS")`
74
74
  - Shippable gate: once after the batch (same skill § Shippable readiness gate)
75
- - **Residual sweep (F96 R1, per task):** same contract as `/sp:dev-verify` — after each task's
76
- verdict artifact is derived, run `residual-scan scan` + `fold` (under every `--fix` mode) before
77
- `spur task record`; on reaching `done` via `--next`, run `settle`. Script resolution:
75
+ - **Residual sweep (F96 R1, per task, post-record since 0987):** same contract as
76
+ `/sp:dev-verify` — after each task's verdict artifact is derived and `spur task record` has
77
+ flipped its verdict-proven boxes, run `residual-scan scan` + `fold` (under every `--fix` mode),
78
+ and re-record only on a downgrade; on reaching `done` via `--next`, run `settle`. Script resolution:
78
79
  `superskill script path sp residual-scan.mjs` (never `plugins/sp/scripts/` —
79
80
  script-contract-check rule 4).
@@ -17,7 +17,7 @@ Wraps the **wrapup-pipeline.yaml** workflow.
17
17
  | `--agent` `<inline\|auto\|name>` | Who runs the wrap's model-bearing steps. Wrap is workflow-backed (headless): `omit` and `--agent inline` resolve identically per task 0687 — tier substitution under objective trigger 3 (durable auditable run record required) with a warning naming the substituted executor; `auto` tier-resolves an executor; a name pins that executor into `vars.agent`. | inline |
18
18
  | `--auto` | Skip objective HITL gates. | off |
19
19
  | `--merge` | Merge the wrap branch. | off |
20
- | `--dry-run` | Render the wrap without writing. | off |
20
+ | `--dry-run` | Validate and walk transitions without executing actions. Conditional states therefore resolve to their declared failure edges (their guard files are never written), so a render ending in `failed` is **not** a run failure — it reports "this state's route cannot be decided without running it". Use a real run to preview a route. | off |
21
21
 
22
22
  For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag-glossary.md).
23
23
 
@@ -34,7 +34,7 @@ export const REDACTION_CAP_BYTES = 4096;
34
34
  /** Max command/pattern summary length before ellipsis (chars). */
35
35
  export const SUMMARY_MAX_CHARS = 200;
36
36
 
37
- interface ToolPayload {
37
+ export interface ToolPayload {
38
38
  session_id?: string;
39
39
  tool_name?: string;
40
40
  tool_input?: {
@@ -115,7 +115,7 @@ export function redactText(text: string, capBytes: number = REDACTION_CAP_BYTES)
115
115
  if (bytes.length <= capBytes) return s;
116
116
  // Truncate on byte boundary then append marker
117
117
  let end = capBytes;
118
- while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
118
+ while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
119
119
  return `${new TextDecoder().decode(bytes.subarray(0, end))}…[truncated]`;
120
120
  }
121
121
 
@@ -126,6 +126,7 @@ export function recordSessionStart(
126
126
  dir: string,
127
127
  env: NodeJS.ProcessEnv = getEnvVars(),
128
128
  now: () => Date = () => new Date(),
129
+ agentFallback?: string,
129
130
  ): string | null {
130
131
  try {
131
132
  mkdirSync(dir, { recursive: true });
@@ -140,7 +141,7 @@ export function recordSessionStart(
140
141
  const pad = (n: number) => String(n).padStart(2, '0');
141
142
  const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}`;
142
143
  const ts = at.toISOString();
143
- const agent = resolveAgentHint(env);
144
+ const agent = resolveAgentHint(env, agentFallback);
144
145
  const model = resolveModelHint(env);
145
146
 
146
147
  const sessionBody: Record<string, unknown> = {
@@ -49,30 +49,30 @@ function computeSessionTotals(
49
49
  return { reads, writes, tokens };
50
50
  }
51
51
 
52
- function exitOk(): never {
53
- process.exit(0);
54
- }
55
-
56
- async function main(): Promise<void> {
57
- const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
58
-
52
+ /**
53
+ * Finalize one session (task 0969 R3): read the `.session.json` pointer, compute the session's
54
+ * rollup totals, append the `session_end` row, remove the pointer (best-effort) and return the
55
+ * event. Shared by this hook's entrypoint and Pi's `session_shutdown` so both hosts roll up
56
+ * identically. Returns null on every fail-open path.
57
+ */
58
+ export function recordSessionEnd(dir: string, now: () => Date = () => new Date()): Record<string, unknown> | null {
59
59
  const sessionFile = join(dir, '.session.json');
60
- if (!existsSync(sessionFile)) exitOk();
60
+ if (!existsSync(sessionFile)) return null;
61
61
 
62
62
  let sessionId = '';
63
63
  try {
64
64
  const session = JSON.parse(readFileSync(sessionFile, 'utf-8')) as { session?: string };
65
65
  sessionId = session.session ?? '';
66
66
  } catch {
67
- exitOk();
67
+ return null;
68
68
  }
69
69
 
70
- if (!sessionId) exitOk();
70
+ if (!sessionId) return null;
71
71
 
72
72
  const totals = computeSessionTotals(join(dir, 'token-ledger.jsonl'), sessionId);
73
73
 
74
74
  const event = {
75
- ts: new Date().toISOString(),
75
+ ts: now().toISOString(),
76
76
  session: sessionId,
77
77
  type: 'session_end' as const,
78
78
  totals,
@@ -81,7 +81,7 @@ async function main(): Promise<void> {
81
81
  try {
82
82
  appendFileSync(join(dir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
83
83
  } catch {
84
- exitOk();
84
+ return null;
85
85
  }
86
86
 
87
87
  try {
@@ -90,7 +90,15 @@ async function main(): Promise<void> {
90
90
  // cleanup is best-effort
91
91
  }
92
92
 
93
- exitOk();
93
+ return event;
94
94
  }
95
95
 
96
- void main().catch(exitOk);
96
+ // Entrypoint: run the shared core, then exit 0 (fail-open) as this hook always has.
97
+ if (import.meta.main) {
98
+ try {
99
+ recordSessionEnd(join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context'));
100
+ } catch {
101
+ /* fail-open: every error path exits 0 */
102
+ }
103
+ process.exit(0);
104
+ }
@@ -7,9 +7,12 @@
7
7
  * Implements:
8
8
  * - task-write-guard (tool_call → block Write/Edit to Spur task files)
9
9
  * - careful-guard (tool_call → warn on destructive Bash commands)
10
- * - context-post-tool (tool_result → append to token-ledger.jsonl)
11
- * - context-session-start (session_start → init .session.json)
12
- * - context-session-stop (session_shutdown → rollup + cleanup)
10
+ * - context-post-tool (tool_result → shared recordToolUseEvent)
11
+ * - context-session-start (session_start → shared recordSessionStart)
12
+ * - context-session-stop (session_shutdown → shared recordSessionEnd)
13
+ *
14
+ * The ledger and session cores are shared with the Claude hooks (task 0969): this
15
+ * extension only normalizes Pi event shapes and adapts them to those cores.
13
16
  *
14
17
  * Installation:
15
18
  * Add to ~/.pi/agent/settings.json packages:
@@ -19,13 +22,14 @@
19
22
  */
20
23
 
21
24
  import { spawnSync } from 'node:child_process';
22
- import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
25
+ import { existsSync, realpathSync } from 'node:fs';
23
26
  import { homedir } from 'node:os';
24
27
  import { isAbsolute, join, resolve } from 'node:path';
25
28
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
26
29
  import { getEnvVar, getEnvVars } from '../../lib/env';
27
- import { resolveAgentHint as resolveAgentHintShared, resolveModelHint as resolveModelHintShared } from '../agent-hint';
28
- import { cappedByteLength, truncateSummary } from '../context-post-tool';
30
+ import { recordToolUseEvent, type ToolPayload } from '../context-post-tool';
31
+ import { recordSessionStart } from '../context-session-start';
32
+ import { recordSessionEnd } from '../context-session-stop';
29
33
  import { classifyCommand } from '../destructive-policy';
30
34
  import { couldBeTaskFile } from '../task-file-policy';
31
35
 
@@ -35,8 +39,6 @@ import { couldBeTaskFile } from '../task-file-policy';
35
39
  // process lifetime, and lazy resolution keeps the extension testable (tests
36
40
  // chdir into a temp project before driving handlers).
37
41
  const spurContextDir = (): string => join(process.cwd(), '.spur', 'context');
38
- const sessionFilePath = (): string => join(spurContextDir(), '.session.json');
39
- const ledgerFilePath = (): string => join(spurContextDir(), 'token-ledger.jsonl');
40
42
 
41
43
  // ─── Helpers ─────────────────────────────────────────────────────────────
42
44
 
@@ -113,137 +115,86 @@ function resolveSpurTaskOwnership(filePath: string): TaskOwnership {
113
115
  // `git push origin +main`, and warned on `git push --force-with-lease`). Do not
114
116
  // re-introduce a local copy — add cases to `destructive-policy.test.ts` instead.
115
117
 
116
- // ─── Token ledger helpers (mirrors context-post-tool.ts) ──────────────────
117
-
118
- interface ToolEvent {
119
- session_id?: string;
120
- tool_name?: string;
121
- tool_input?: Record<string, unknown>;
122
- }
123
-
124
- // Summaries persist to disk, so they go through the same scrubber as the Claude hook —
125
- // the local copy this replaced stored the raw command (secrets included) as `summary`.
126
- function summarizeToolEvent(event: ToolEvent): string {
127
- const input = event.tool_input ?? {};
128
- const candidates = [input.file_path, input.command, input.pattern, input.glob_pattern, input.glob, input.path];
129
- for (const c of candidates) {
130
- if (typeof c === 'string' && c.trim()) return truncateSummary(c);
131
- }
132
- return `(${event.tool_name ?? 'unknown'})`;
133
- }
134
-
135
- function appendToLedger(event: ToolEvent, command: string | undefined): void {
136
- try {
137
- if (!existsSync(spurContextDir())) return;
138
- const sessionId = readSessionId();
139
- if (!sessionId) return;
140
-
141
- const summary = summarizeToolEvent(event);
142
- const tokens = command ? Math.ceil(cappedByteLength(command) / 4) : 0;
143
- const input = event.tool_input ?? {};
144
- // Pi tool names are lowercase (`read`) and its write/edit tools take `path`, not `file_path`.
145
- const path = typeof input.file_path === 'string' ? input.file_path : input.path;
146
-
147
- const ledgerEntry = JSON.stringify({
148
- session: sessionId,
149
- type: event.tool_name?.toLowerCase() === 'read' ? 'read' : 'write',
150
- tool: event.tool_name,
151
- file: typeof path === 'string' ? path : undefined,
152
- summary,
153
- tokens,
154
- ts: new Date().toISOString(),
155
- });
156
-
157
- appendFileSync(ledgerFilePath(), `${ledgerEntry}\n`);
158
- } catch {
159
- // fail-open: skip ledger writes on error
160
- }
161
- }
162
-
163
- function readSessionId(): string | undefined {
164
- try {
165
- if (!existsSync(sessionFilePath())) return undefined;
166
- const data = JSON.parse(readFileSync(sessionFilePath(), 'utf-8')) as { session_id?: string };
167
- return data.session_id;
168
- } catch {
169
- return undefined;
170
- }
171
- }
172
-
173
- // ─── Session helpers (agent/model hints from agent-hint.ts; mirrors context-session-stop.ts) ─
174
-
175
- function generateSessionId(): string {
176
- const timestamp = Date.now().toString(36);
177
- const random = Math.random().toString(36).slice(2, 10);
178
- return `${timestamp}-${random}`;
179
- }
180
-
181
- function initSession(): void {
182
- try {
183
- mkdirSync(spurContextDir(), { recursive: true });
184
- const sessionId = generateSessionId();
185
- const session = {
186
- session_id: sessionId,
187
- agent: resolveAgentHintShared(getEnvVars(), 'pi'),
188
- model: resolveModelHintShared(getEnvVars()),
189
- started_at: new Date().toISOString(),
190
- };
191
- writeFileSync(sessionFilePath(), `${JSON.stringify(session, null, 2)}\n`);
192
-
193
- // Append session_start event to ledger
194
- const startEntry = JSON.stringify({
195
- session: sessionId,
196
- type: 'session_start',
197
- agent: session.agent,
198
- model: session.model,
199
- ts: session.started_at,
200
- });
201
- appendFileSync(ledgerFilePath(), `${startEntry}\n`);
202
- } catch {
203
- // fail-open
118
+ // ─── Pi → Claude payload normalization (task 0969 R1) ─────────────────────
119
+
120
+ /** Pi tool name → the Claude tool name the shared core records. */
121
+ const PI_TO_CLAUDE: Record<string, string> = {
122
+ read: 'Read',
123
+ write: 'Write',
124
+ edit: 'Edit',
125
+ bash: 'Bash',
126
+ grep: 'Grep',
127
+ find: 'Glob',
128
+ };
129
+
130
+ /** Join the text parts of a Pi tool_result `content` payload. */
131
+ function piContentText(content: unknown): string {
132
+ if (typeof content === 'string') return content;
133
+ if (!Array.isArray(content)) return '';
134
+ const parts: string[] = [];
135
+ for (const part of content) {
136
+ if (part && typeof part === 'object' && typeof (part as { text?: unknown }).text === 'string') {
137
+ parts.push((part as { text: string }).text);
138
+ }
204
139
  }
140
+ return parts.join('\n');
205
141
  }
206
142
 
207
- function cleanupSession(): void {
208
- try {
209
- if (!existsSync(sessionFilePath())) return;
210
- const session = JSON.parse(readFileSync(sessionFilePath(), 'utf-8')) as { session_id?: string };
211
- const sessionId = session.session_id;
212
-
213
- // Compute rollup totals from ledger
214
- let reads = 0;
215
- let writes = 0;
216
- let tokens = 0;
217
- if (sessionId && existsSync(ledgerFilePath())) {
218
- for (const line of readFileSync(ledgerFilePath(), 'utf-8').split('\n')) {
219
- if (!line.trim()) continue;
220
- try {
221
- const evt = JSON.parse(line) as { session?: string; type?: string; tokens?: number };
222
- if (evt.session !== sessionId) continue;
223
- if (evt.type === 'read') reads++;
224
- else if (evt.type === 'write') writes++;
225
- if (evt.tokens) tokens += evt.tokens;
226
- } catch {
227
- // skip unparseable lines
228
- }
229
- }
143
+ /**
144
+ * Pure Pi-event → Claude-{@link ToolPayload} adapter. An unmapped tool returns null so the
145
+ * shared core writes no row (R1's row filter). Search tools keep `path` (never `file_path`)
146
+ * because the core treats `file_path` as the written file, not a search root.
147
+ */
148
+ export function normalizePiToolEvent(
149
+ toolName: string,
150
+ input: Record<string, unknown> | undefined,
151
+ content?: unknown,
152
+ ): ToolPayload | null {
153
+ const mapped = PI_TO_CLAUDE[toolName];
154
+ if (mapped === undefined) return null;
155
+ const i = input ?? {};
156
+ const str = (value: unknown): string => (typeof value === 'string' ? value : '');
157
+ const path = str(i.path) || str(i.file_path);
158
+ const text = piContentText(content);
159
+ const tool_response = text.length > 0 ? { content: text } : undefined;
160
+
161
+ switch (mapped) {
162
+ case 'Read':
163
+ return { tool_name: mapped, tool_input: { file_path: path }, tool_response };
164
+ case 'Write':
165
+ return { tool_name: mapped, tool_input: { file_path: path, content: str(i.content) }, tool_response };
166
+ case 'Edit': {
167
+ const edits = Array.isArray(i.edits) ? (i.edits as Array<Record<string, unknown>>) : [];
168
+ return {
169
+ tool_name: mapped,
170
+ tool_input: {
171
+ file_path: path,
172
+ old_string: edits.map((e) => str(e.oldText)).join(''),
173
+ new_string: edits.map((e) => str(e.newText)).join(''),
174
+ },
175
+ tool_response,
176
+ };
230
177
  }
231
-
232
- // Append session_end event
233
- const endEntry = JSON.stringify({
234
- session: sessionId,
235
- type: 'session_end',
236
- reads,
237
- writes,
238
- tokens,
239
- ts: new Date().toISOString(),
240
- });
241
- appendFileSync(ledgerFilePath(), `${endEntry}\n`);
242
-
243
- // Cleanup session file
244
- rmSync(sessionFilePath(), { force: true });
245
- } catch {
246
- // fail-open
178
+ case 'Grep':
179
+ return {
180
+ tool_name: mapped,
181
+ tool_input: {
182
+ pattern: str(i.pattern),
183
+ ...(path ? { path } : {}),
184
+ ...(str(i.glob) ? { glob: str(i.glob) } : {}),
185
+ },
186
+ tool_response,
187
+ };
188
+ case 'Glob':
189
+ return {
190
+ tool_name: mapped,
191
+ tool_input: { pattern: str(i.pattern), ...(path ? { path } : {}) },
192
+ tool_response,
193
+ };
194
+ case 'Bash':
195
+ return { tool_name: mapped, tool_input: { command: str(i.command) }, tool_response };
196
+ default:
197
+ return null;
247
198
  }
248
199
  }
249
200
 
@@ -284,27 +235,35 @@ export default function (pi: ExtensionAPI): void {
284
235
  return {};
285
236
  });
286
237
 
287
- // ── context-post-tool: append to token-ledger.jsonl ───────────────
238
+ // ── context-post-tool: shared recordToolUseEvent (task 0969 R1) ──
288
239
  pi.on('tool_result', async (event) => {
289
- const input = event.input as Record<string, unknown> | undefined;
290
- const command = typeof input?.command === 'string' ? input.command : undefined;
291
- appendToLedger(
292
- {
293
- session_id: readSessionId(),
294
- tool_name: event.toolName,
295
- tool_input: input as Record<string, unknown>,
296
- },
297
- command,
298
- );
240
+ try {
241
+ const payload = normalizePiToolEvent(
242
+ event.toolName,
243
+ event.input as Record<string, unknown> | undefined,
244
+ (event as { content?: unknown }).content,
245
+ );
246
+ if (payload) recordToolUseEvent(spurContextDir(), payload);
247
+ } catch {
248
+ // fail-open
249
+ }
299
250
  });
300
251
 
301
- // ── context-session-start: init session tracking ─────────────────
252
+ // ── context-session-start: shared recordSessionStart (R2) ────────
302
253
  pi.on('session_start', async () => {
303
- initSession();
254
+ try {
255
+ recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi');
256
+ } catch {
257
+ // fail-open
258
+ }
304
259
  });
305
260
 
306
- // ── context-session-stop: rollup + cleanup ───────────────────────
261
+ // ── context-session-stop: shared recordSessionEnd (R3) ──────────
307
262
  pi.on('session_shutdown', async () => {
308
- cleanupSession();
263
+ try {
264
+ recordSessionEnd(spurContextDir());
265
+ } catch {
266
+ // fail-open
267
+ }
309
268
  });
310
269
  }