@gobing-ai/spur 0.4.0 → 0.4.2

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 (172) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/config/config.global.yaml +4 -2
  3. package/config/decisions/gates.yaml +36 -0
  4. package/config/decisions/history-anatomy.yaml +35 -0
  5. package/config/decisions/idea-pipeline.yaml +38 -0
  6. package/config/plugin-scripts.json +15 -0
  7. package/config/rules/docs/sourced-api-claims.yaml +60 -0
  8. package/config/rules/recommended-pre-check.yaml +8 -3
  9. package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
  10. package/config/script-placement-baseline.json +2 -2
  11. package/config/templates/AGENTS.md +1 -1
  12. package/config/workflows/feature-lifecycle.yaml +9 -2
  13. package/config/workflows/history-anatomy.yaml +22 -0
  14. package/config/workflows/idea-pipeline.yaml +75 -0
  15. package/config/workflows/task-pipeline.yaml +216 -57
  16. package/config/workflows/wrapup-pipeline.yaml +107 -14
  17. package/package.json +10 -10
  18. package/plugins/sp/README.md +12 -3
  19. package/plugins/sp/agents/super-planner.md +16 -6
  20. package/plugins/sp/agents/super-reviewer.md +14 -0
  21. package/plugins/sp/bunfig.toml +8 -0
  22. package/plugins/sp/commands/dev-brainstorm.md +29 -16
  23. package/plugins/sp/commands/dev-find-way.md +80 -0
  24. package/plugins/sp/commands/dev-idea.md +6 -0
  25. package/plugins/sp/commands/dev-plan.md +6 -0
  26. package/plugins/sp/commands/dev-review-session.md +4 -1
  27. package/plugins/sp/commands/dev-run.md +12 -2
  28. package/plugins/sp/commands/dev-runall.md +32 -15
  29. package/plugins/sp/hooks/context-owner.ts +56 -0
  30. package/plugins/sp/hooks/context-post-tool.ts +23 -0
  31. package/plugins/sp/hooks/context-session-start.ts +49 -27
  32. package/plugins/sp/hooks/context-session-stop.ts +131 -30
  33. package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
  34. package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
  35. package/plugins/sp/lib/inline-run-app.ts +28 -0
  36. package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
  37. package/plugins/sp/lib/inline-run.generated.mjs +351 -46
  38. package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
  39. package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
  40. package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
  41. package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
  42. package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
  43. package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
  44. package/plugins/sp/lib/run-summary-core.ts +561 -0
  45. package/plugins/sp/lib/transcript.ts +150 -7
  46. package/plugins/sp/plugin.json +1 -1
  47. package/plugins/sp/references/roles.md +1 -1
  48. package/plugins/sp/scripts/batch-plan.mjs +488 -0
  49. package/plugins/sp/scripts/batch-plan.ts +145 -0
  50. package/plugins/sp/scripts/batch-preflight.mjs +51 -3
  51. package/plugins/sp/scripts/batch-preflight.ts +72 -1
  52. package/plugins/sp/scripts/commit-guard.mjs +216 -0
  53. package/plugins/sp/scripts/commit-guard.ts +249 -0
  54. package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
  55. package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
  56. package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
  57. package/plugins/sp/scripts/inline-run-setup.ts +52 -78
  58. package/plugins/sp/scripts/persist-out-check.mjs +90 -41
  59. package/plugins/sp/scripts/persist-out-check.ts +112 -57
  60. package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
  61. package/plugins/sp/scripts/pr-reviewing.ts +34 -9
  62. package/plugins/sp/scripts/quality-gate.mjs +30213 -61
  63. package/plugins/sp/scripts/quality-gate.ts +21 -5
  64. package/plugins/sp/scripts/residual-scan.mjs +86 -31
  65. package/plugins/sp/scripts/residual-scan.ts +28 -30
  66. package/plugins/sp/scripts/run-summary.mjs +432 -14
  67. package/plugins/sp/scripts/run-summary.ts +41 -177
  68. package/plugins/sp/scripts/session-timeline.mjs +109 -11
  69. package/plugins/sp/scripts/session-timeline.ts +57 -19
  70. package/plugins/sp/scripts/task-diffstat.mjs +32 -29
  71. package/plugins/sp/scripts/task-diffstat.ts +34 -34
  72. package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
  73. package/plugins/sp/scripts/wrapup-steps.ts +203 -1
  74. package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
  75. package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
  76. package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
  77. package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
  78. package/plugins/sp/skills/code-verification/SKILL.md +3 -4
  79. package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
  80. package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
  81. package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
  82. package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
  83. package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
  84. package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
  85. package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
  86. package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
  87. package/plugins/sp/skills/session-review/SKILL.md +40 -7
  88. package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
  89. package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
  90. package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
  91. package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
  92. package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
  93. package/plugins/sp/skills/spur-cli/references/message.md +7 -7
  94. package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
  95. package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
  96. package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
  97. package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
  98. package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
  99. package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
  100. package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
  101. package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
  102. package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
  103. package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
  104. package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
  105. package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
  106. package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
  107. package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
  108. package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
  109. package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
  110. package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
  111. package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
  112. package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
  113. package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
  114. package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
  115. package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
  116. package/schemas/state-machine-workflow.schema.json +23 -0
  117. package/spur.js +42402 -26889
  118. package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
  119. package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
  120. package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
  121. package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
  122. package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
  123. package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
  124. package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
  125. package/web/_astro/channel.D4nG9YLf.js +1 -0
  126. package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
  127. package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
  128. package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
  129. package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
  130. package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
  131. package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
  132. package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
  133. package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
  134. package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
  135. package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
  136. package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
  137. package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
  138. package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
  139. package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
  140. package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
  141. package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
  142. package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
  143. package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
  144. package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
  145. package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
  146. package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
  147. package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
  148. package/web/_astro/index.BhSwN8yY.css +1 -0
  149. package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
  150. package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
  151. package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
  152. package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
  153. package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
  154. package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
  155. package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
  156. package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
  157. package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
  158. package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
  159. package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
  160. package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
  161. package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
  162. package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
  163. package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
  164. package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
  165. package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
  166. package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
  167. package/web/index.html +2 -2
  168. package/plugins/sp/commands/dev-parallel.md +0 -31
  169. package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
  170. package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
  171. package/web/_astro/channel.CUFdtWhm.js +0 -1
  172. package/web/_astro/index.Dg_3bOaJ.css +0 -1
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Run a batch of tasks through their pipelines in dependency-correct order — resolve a set, topo-sort, run each via task-pipeline.yaml, emit a batch report
3
3
  role: planner
4
- argument-hint: "--tasks <selector> [--feature <id>] [--mode <sequential|parallel>] [--keep-going] [--auto] [--agent <inline|auto|name>] [--json] [--wrap] [--next] [--continue] [--worktree [<name>]] [--no-summary]"
4
+ argument-hint: "--tasks <selector> [--feature <id>] [--mode <sequential|parallel|auto>] [--concurrency <n>] [--keep-going] [--auto] [--agent <inline|auto|name>] [--json] [--wrap] [--next] [--continue] [--worktree [<name>]] [--no-summary] [--defer-gate]"
5
5
  allowed-tools: ["Bash", "Read", "Skill"]
6
6
  ---
7
7
 
@@ -15,10 +15,10 @@ Wraps the **sp:spur-dev** skill.
15
15
  | --- | --- | --- |
16
16
  | `--tasks` `<selector>` | Task selector to run. | required |
17
17
  | `--feature` `<id>` | Restrict to a feature. | omitted |
18
- | `--mode` `<sequential\|parallel>` | Batch execution order. | sequential |
18
+ | `--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 |
19
19
  | `--keep-going` | Continue past per-task failures. | off |
20
20
  | `--auto` | Skip objective HITL gates. | off |
21
- | `--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 |
21
+ | `--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 |
22
22
  | `--json` | Emit structured JSON. | off |
23
23
  | `--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 |
24
24
  | `--next` | Chain-to-completion via the next-router. | off |
@@ -26,24 +26,26 @@ Wraps the **sp:spur-dev** skill.
26
26
  | `--worktree` `[<name>]` | Run the batch in an isolated git worktree; FF-merge on success, retain on failure. Bare `--worktree` creates a fresh tree; `--worktree <name>` adopts an existing worktree by name/path/branch. | off |
27
27
  | `--no-summary` | Skip the measured execution summary (one row per task run: time, wait, tool calls, total / non-cached tokens) printed after the batch report. | off (summary printed) |
28
28
  | `--concurrency` `<n>` | Parallel-mode worker bound: at most `<n>` task pipelines run at once (task 0931); a dependent starts only after its in-set dependencies are integrated onto the base ref. No-op in sequential mode (ignored). | 2 |
29
+ | `--defer-gate` | Opt-in parallel-batch gate policy (task 1111): each task's `test` hop runs the light tier and writes `DEFERRED`; the batch runs one integrated full gate on the base ref before the feature sync. Requires `--mode parallel`, or `--mode auto` resolved to parallel (dropped with a note otherwise); default off. | off |
29
30
 
30
31
  For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag-glossary.md).
31
32
 
32
33
  ## Usage
33
34
 
34
35
  ```
35
- /sp:dev-runall --tasks <selector> [--feature <id>] [--mode <sequential|parallel>] [--keep-going] [--auto] [--agent <inline|auto|name>] [--json] [--wrap] [--next] [--continue] [--worktree [<name>]] [--no-summary]
36
+ /sp:dev-runall --tasks <selector> [--feature <id>] [--mode <sequential|parallel|auto>] [--concurrency <n>] [--keep-going] [--auto] [--agent <inline|auto|name>] [--json] [--wrap] [--next] [--continue] [--worktree [<name>]] [--no-summary] [--defer-gate]
36
37
  ```
37
38
 
38
39
  Flags: `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`,
39
40
  or `ready`), `--feature <id>` (sugar for `feature:<id>`; when the effective selector is
40
- feature-derived, `dev-runall` runs `spur feature check <id> --strict --json` **once** before
41
+ feature-derived, `dev-runall` runs `spur feature check <id> --fix --json` then `spur feature check <id> --strict --json` **once** before
41
42
  resolving tasks — a non-zero strict check aborts the batch with verdict `aborted` and the
42
- structured findings, before any task pipeline action, task 0510 R2; scoped: `L4.scenario-unverified`
43
- (the expected pre-run state of any not-yet-run feature) is reported verbatim but does not abort —
43
+ structured findings, before any task pipeline action, task 0510 R2; scoped: `L4.scenario-unverified` and `L4.verifying-incomplete-tasks`
44
+ (expected pre-run states of a not-yet-run or reopened feature) are reported verbatim but do not abort (the non-aborting set is
45
+ `NON_ABORTING_PREFLIGHT_CODES` in `plugins/sp/scripts/batch-preflight.ts`; `--fix` repairs land in the report's `autoRepairs`) —
44
46
  any other strict error aborts), `--mode`
45
- `<sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset —
46
- see `execution-batch.md` § Parallel isolation), `--concurrency <n>`
47
+ `<sequential|parallel|auto>` (default `sequential`; `parallel` fans out a proven-independent subset —
48
+ see `execution-parallel-isolation.md`; `auto` picks one per batch — see below), `--concurrency <n>`
47
49
  (parallel-mode worker bound — at most <n> task pipelines at once, default 2; a dependent task starts
48
50
  only after its in-set dependencies are **integrated** onto the base ref), `--keep-going`
49
51
  (batch failure policy — skip a failed task's in-batch dependents, continue independents; default
@@ -58,10 +60,19 @@ report as JSON), `--wrap` (trigger
58
60
  `--worktree` `[<name>]` (run the batch in an isolated git worktree —
59
61
  FF-merge onto the base ref on full success, retain intact on any failure/halt/non-FF; bare form
60
62
  creates a fresh tree, `<name>` form adopts an existing worktree by name/path/branch; see
61
- `execution-batch.md` § Worktree isolation).
63
+ `execution-worktree-setup.md` + `execution-worktree-landing.md`).
64
+
65
+ **`--mode auto` (smart scheduling).** Ordering is always dependency-correct — every mode
66
+ topo-sorts the frozen set with `batch-plan.mjs order` (deterministic, never hand-sorted). `auto`
67
+ additionally reads that schedule's dependency waves (tasks declaring the same file are serialized)
68
+ and decides once, before the first task: `parallel` when some wave holds ≥2 independent tasks and
69
+ `--concurrency` ≥2; otherwise `sequential` in the host session. `--worktree` or explicit
70
+ `--agent inline` pins `sequential`. The batch report records `mode: auto → <resolved>` and why.
71
+ Default stays `sequential`: parallel costs ~N× tokens, worktree setup and subprocess executors, which
72
+ a dependency chain never pays back. Rules: execution-batch.md § 2.4a.
62
73
 
63
74
  **`--worktree` and parallel mode.** `--worktree --mode parallel` is **rejected** — parallel mode
64
- already isolates each task in its own worktree (see `execution-batch.md` § Parallel isolation), and
75
+ already isolates each task in its own worktree (see `execution-parallel-isolation.md`), and
65
76
  reuse mode (`--worktree <name>`) has no per-task meaning. Run parallel batches without
66
77
  `--worktree`, or run them sequentially with it.
67
78
 
@@ -81,16 +92,22 @@ advancing the feature lifecycle). **was: `--next` deliberately omitted; the old
81
92
  = batch failure policy (does a failure halt the batch or skip dependents?);
82
93
  `--continue` = resume from
83
94
  checkpoint against the batch's original frozen identity — persisted plan membership, worktree
84
- marker, identity-filtered checkpoints as hints (execution-batch.md § Batch continuation, task
95
+ marker, identity-filtered checkpoints as hints (execution-batch-continuation.md, task
85
96
  0919); `--next`
86
97
  = chain each task to terminal status + batch-once wrap. See `dev-operations.md` § runall for the
87
98
  full distinction.
88
99
 
89
100
  ## Implementation
90
101
 
102
+ - **Bootstrap reads (1128):** before the first dispatch, read [SKILL.md § Bootstrap reads](../skills/spur-dev/SKILL.md#bootstrap-reads) and load only that mode's files — the section whose trigger has not fired is on demand.
103
+ - **Publish the visible batch plan first (1105):** publish `A Prepare batch` (A1–A4) and `Z Batch
104
+ report` before the first task, then one letter per task from `batch-plan.mjs waves` after
105
+ freeze/order and digit children from `batch-plan.mjs task-children` at task start —
106
+ [execution-batch.md](../skills/spur-dev/references/execution-batch.md#27-visible-batch-plan-1105-r3)
107
+ § 2.7 Visible batch plan.
91
108
  - Apply the [inline-default execution-surface contract](../skills/spur-dev/references/cross-cutting.md#inline-default-execution-surface). Interactive sequential omit/`inline` keeps the orchestrator in the host session through the [inline pipeline driver](../skills/spur-dev/references/inline-pipeline-driver.md); the resolved selector applies uniformly — eligible `agent.run` stages dispatch once to a native subagent with host fallback (0508). `--agent auto`, a name, or parallel mode retains the isolated per-task workflow boundary.
92
- - Apply the [shared startup contract](../skills/spur-dev/references/cross-cutting.md#shared-startup-contract-task-0814-r1r3r4r6r7r8) — bootstrap checklist, quick readiness before isolation, workflow inventory before YAML, comprehensive checks at owning boundaries (task 0814 R1/R3/R4/R7/R8).
93
- - Interactive sequential omit/inline: `Skill(skill="sp:spur-dev", args="runall-inline $ARGUMENTS")`.
94
- - Explicit executor or parallel mode: `Skill(skill="sp:spur-dev", args="runall $ARGUMENTS")` → `sp:super-planner` agent.
109
+ - Apply the [shared startup contract](../skills/spur-dev/references/cross-cutting.md#shared-startup-contract-task-0814-r1r3r4r6r7r8) — publish the generated plan first, quick readiness before isolation, digest binding, comprehensive checks at owning boundaries (task 0814 R1/R3/R4/R7/R8; 1105 R1).
110
+ - Interactive sequential omit/inline, and `--mode auto` without an explicit executor: `Skill(skill="sp:spur-dev", args="runall-inline $ARGUMENTS")` — auto resolves in the host after freeze/order (§ 2.4a) and hands a parallel resolution to the `runall` path with the frozen explicit WBS list.
111
+ - Explicit executor (any mode, including `auto`) or parallel mode: `Skill(skill="sp:spur-dev", args="runall $ARGUMENTS")` → `sp:super-planner` agent.
95
112
  - After the batch report, print the [execution summary](../skills/spur-dev/references/dev-operations.md#execution-summary) over every attempted run unless
96
113
  `--no-summary`.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * context-owner — host-native session ownership identity shared by the indexed-context hooks
3
+ * (task 1159).
4
+ *
5
+ * Extracted from `context-session-start`/`context-session-stop` so the Claude stdin-payload →
6
+ * owner derivation is a pure, importable function. A hook's `if (import.meta.main)` entrypoint runs
7
+ * in a subprocess whose body Bun's coverage never attributes, so any real logic left there is
8
+ * untestable; this module keeps that logic where a unit test can reach it.
9
+ *
10
+ * Standalone-safe (sp-plugin-standalone): no imports, so a hook bundles it as a relative path and
11
+ * `context-session-stop` stays free of the session-start module's heavier dependency surface.
12
+ */
13
+
14
+ /**
15
+ * Who owns a session ledger pointer (task 1159): the host that opened the session plus that host's
16
+ * own native session id — Claude's hook payload `session_id`, Pi's `ctx.sessionManager.getSessionId()`.
17
+ * Deliberately **not** the ledger id (shared by every nested run), `process.pid` (a fresh hook process
18
+ * per event) or `SPUR_RUN_ID` (present on nested children too): none of those separate owner from
19
+ * child, so none may gate teardown; the host-native id can.
20
+ */
21
+ export interface SessionOwner {
22
+ host: 'claude' | 'pi';
23
+ sessionId: string;
24
+ }
25
+
26
+ /**
27
+ * Claude hook identity (task 1159): the payload's `session_id`, falling back to
28
+ * `CLAUDE_CODE_SESSION_ID` only when the payload id is absent or empty (some Claude hook payloads
29
+ * omit `session_id`). Returns null when neither yields a nonempty id — the pointer is then written
30
+ * ownerless, so a later shutdown cannot prove ownership of it (bounded legacy policy in
31
+ * context-session-stop).
32
+ */
33
+ export function claudeSessionOwner(payload: unknown, env: NodeJS.ProcessEnv): SessionOwner | null {
34
+ const raw = payload && typeof payload === 'object' ? (payload as { session_id?: unknown }).session_id : undefined;
35
+ const fromPayload = typeof raw === 'string' ? raw.trim() : '';
36
+ const fromEnv = (env.CLAUDE_CODE_SESSION_ID ?? '').trim();
37
+ const sessionId = fromPayload.length > 0 ? fromPayload : fromEnv;
38
+ return sessionId.length > 0 ? { host: 'claude', sessionId } : null;
39
+ }
40
+
41
+ /**
42
+ * Derive the Claude owner from a raw hook stdin payload (task 1159).
43
+ *
44
+ * Malformed or empty stdin carries no payload identity, so it degrades to the env-only fallback
45
+ * rather than throwing — both hooks keep their fail-open contract. Behaviour is identical to the
46
+ * former inline `JSON.parse(await Bun.stdin.text())` try/catch in each entrypoint.
47
+ */
48
+ export function claudeHookOwnerFromPayloadText(text: string, env: NodeJS.ProcessEnv): SessionOwner | null {
49
+ let payload: unknown;
50
+ try {
51
+ payload = JSON.parse(text) as unknown;
52
+ } catch {
53
+ payload = undefined;
54
+ }
55
+ return claudeSessionOwner(payload, env);
56
+ }
@@ -72,6 +72,29 @@ export function estimateTokens(text: string): number {
72
72
  /** Scrub secret-like patterns without size capping. */
73
73
  export function scrubSecrets(text: string): string {
74
74
  let s = text;
75
+ // Quoted JSON fields first (task 1158): in `{"password": "…"}` the key is itself
76
+ // quoted, so the bare `key=value` pass below never sees a separator directly after
77
+ // the key and would persist the value verbatim. Match the quoted sensitive key, keep
78
+ // it, and replace the entire value. The key may be spelled three ways in real Bash:
79
+ // `"key"`, `'key'`, and the shell-escaped `\"key\"` inside a double-quoted argument
80
+ // (`curl -d "{\"password\":\"…\"}"`); the value may be quoted the same way or left
81
+ // unquoted (`{"password": 12345}`). Escape-aware: a `\"` or `\\` inside a value is
82
+ // content, never a terminator, and an unterminated value (including a trailing lone
83
+ // backslash) is redacted through end-of-input so a truncated command cannot leak a
84
+ // suffix. In the shell-escaped opener form the JSON quote shows up in three raw
85
+ // spellings: a terminator `\"`, a JSON-level escaped quote `\\\"` (escaped
86
+ // backslash + escaped quote) that is value content, and a bare `"` that is neither.
87
+ // That form gets a dedicated alternative whose body consumes `\\\"` as content and
88
+ // stops only at a clean `\"` terminator; when no clean terminator follows it fails
89
+ // closed and redacts through end-of-input, so a bare `"` cannot end the value early
90
+ // and leak its suffix. Runs before
91
+ // Bearer/`key=` passes so the whole value is replaced, not prefixed. The key must end
92
+ // on a sensitive word, so `token_count`, `tokens`, `tokenizer` and
93
+ // `authorization_header` stay readable.
94
+ s = s.replace(
95
+ /((\\?(["']))(?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key|authorization)\2\s*:\s*)(?:\\\3(?:[^"\\]|\\\\(?:\\\3)|\\[^"])*(?:\\\3|[\s\S]*$)|"(?:[^"\\]|\\.)*(?:"|\\?$)|'(?:[^'\\]|\\.)*(?:'|\\?$)|[^\s,}\][{"']+)/gi,
96
+ '$1"***"',
97
+ );
75
98
  // Bearer / API key prefixes first (before generic key=value)
76
99
  s = s.replace(/\bBearer\s+[A-Za-z0-9._\-+=/]+/gi, 'Bearer ***');
77
100
  // `sk-` keys. Segments may be hyphen-separated (`sk-ant-api03-…`), so match the
@@ -4,8 +4,10 @@
4
4
  * context-session-start — SessionStart hook for indexed-context.
5
5
  *
6
6
  * Generates a session ID, writes `.spur/context/.session.json` (tracking the current session
7
- * for PostToolUse and Stop hooks), and appends a `session_start` event to
8
- * `token-ledger.jsonl`. Task 0246: best-effort agent/model hints on session file + event.
7
+ * for PostToolUse and Stop hooks), and appends a `session_start` event to `token-ledger.jsonl`.
8
+ * Task 0246: best-effort agent/model hints on session file + event. Task 1159: the pointer records
9
+ * the host-native {@link SessionOwner} that created it, so a nested child can reuse the ledger for
10
+ * tool recording without being able to tear it down.
9
11
  *
10
12
  * **Fail-open contract:** every error path exits 0 with no output.
11
13
  *
@@ -13,13 +15,19 @@
13
15
  */
14
16
 
15
17
  import { execSync } from 'node:child_process';
18
+ import { randomUUID } from 'node:crypto';
16
19
  import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
20
  import { join } from 'node:path';
18
21
  import { getEnvVar, getEnvVars } from '../lib/env';
19
22
  import { resolveAgentHint, resolveModelHint } from './agent-hint';
23
+ import { claudeHookOwnerFromPayloadText, type SessionOwner } from './context-owner';
20
24
  import { checkContextFreshness } from './context-post-tool';
21
25
 
22
26
  export { resolveAgentHint, resolveModelHint } from './agent-hint';
27
+ export type { SessionOwner } from './context-owner';
28
+ // Owner identity + Claude payload derivation live in ./context-owner (pure, unit-testable);
29
+ // re-exported here because this module stays the hook surface consumers reach for.
30
+ export { claudeHookOwnerFromPayloadText, claudeSessionOwner } from './context-owner';
23
31
 
24
32
  /**
25
33
  * Ancestor-run marker exported by `@gobing-ai/ts-ai-runner` ≥ 0.4.15 (`AGENT_RUN_ID_ENV`).
@@ -64,38 +72,29 @@ interface SessionFileBody {
64
72
  * Return the id of the session already in flight, or null when a new one should be minted.
65
73
  *
66
74
  * **Why this exists (0398 R3).** `SessionStart` fires in every nested `agent.run` subprocess, not
67
- * once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id, append a
75
+ * once per host session. Each firing used to mint a fresh `session-<date>-<HHMM>` id (now
76
+ * seconds-plus-random, 1166), append a
68
77
  * `session_start` row, and overwrite the `.session.json` pointer that `context-post-tool` reads —
69
78
  * so a single pipeline run registered as dozens of sessions and orphaned the parent's event
70
79
  * stream. The H6 corpus shows the signature: 332 `session_start` against 157 `session_end` over
71
80
  * 18 days, 298 distinct ids, 39 starts in a 2-day window that held a handful of real sessions.
72
81
  *
73
- * **Two signals, in precedence order.**
74
- *
75
- * 1. {@link AGENT_RUN_ID_ENV} present → this process is a descendant of an agent run, definitively.
76
- * Reuse the recorded session with no time bound: a pipeline step legitimately runs for the full
77
- * `implementTimeoutMs` (30 min) and longer batches run for hours, so any wall-clock window would
78
- * eventually split a run that is demonstrably still in flight.
79
- * 2. No marker → fall back to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for
80
- * hosts that nest without propagating a correlation.
81
- *
82
- * A crashed session leaves a stale `.session.json`; on path 2 the idle window retires it. On path 1
83
- * a stale file is only reachable from inside a live agent run, where reusing it is the correct
84
- * answer anyway.
82
+ * **Two signals, in precedence order.** (1) {@link AGENT_RUN_ID_ENV} present → definitively a
83
+ * descendant of an agent run: reuse the recorded session with no time bound, because a pipeline step
84
+ * legitimately runs for the full `implementTimeoutMs` (45 min) and longer batches for hours, so any
85
+ * wall-clock window would eventually split a run that is still in flight. (2) No marker → fall back
86
+ * to the {@link SESSION_REUSE_IDLE_MS} window on the `started` stamp, for hosts that nest without
87
+ * propagating a correlation. A crashed session leaves a stale `.session.json`: on (2) the idle window
88
+ * retires it, on (1) a stale file is only reachable from inside a live agent run, where reusing it is
89
+ * the correct answer anyway.
85
90
  */
86
91
  export function resolveActiveSession(dir: string, now: Date, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
87
- let raw: string;
88
- try {
89
- raw = readFileSync(join(dir, '.session.json'), 'utf-8');
90
- } catch {
91
- return null; // no in-flight session (or unreadable) → mint a new one
92
- }
93
-
94
92
  let body: SessionFileBody;
95
93
  try {
96
- body = JSON.parse(raw) as SessionFileBody;
94
+ // No in-flight session, unreadable file or corrupt pointer → mint a new one.
95
+ body = JSON.parse(readFileSync(join(dir, '.session.json'), 'utf-8')) as SessionFileBody;
97
96
  } catch {
98
- return null; // corrupt pointer → mint a new one
97
+ return null;
99
98
  }
100
99
 
101
100
  if (typeof body.session !== 'string' || body.session.length === 0) return null;
@@ -121,12 +120,17 @@ export function resolveActiveSession(dir: string, now: Date, env: NodeJS.Process
121
120
  *
122
121
  * Idempotent per in-flight session (0398 R3): when `resolveActiveSession` finds one, this returns
123
122
  * that id and writes nothing — no ledger row, no pointer rewrite.
123
+ *
124
+ * Ownership (task 1159): a **new** pointer records a valid `owner` only; a reused start never
125
+ * rewrites the pointer, so a nested child cannot take ownership from the parent that opened it,
126
+ * and only the recorded owner's shutdown can finalize the ledger.
124
127
  */
125
128
  export function recordSessionStart(
126
129
  dir: string,
127
130
  env: NodeJS.ProcessEnv = getEnvVars(),
128
131
  now: () => Date = () => new Date(),
129
132
  agentFallback?: string,
133
+ owner?: SessionOwner,
130
134
  ): string | null {
131
135
  try {
132
136
  mkdirSync(dir, { recursive: true });
@@ -139,7 +143,11 @@ export function recordSessionStart(
139
143
  const active = resolveActiveSession(dir, at, env);
140
144
  if (active !== null) return active;
141
145
  const pad = (n: number) => String(n).padStart(2, '0');
142
- const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}`;
146
+ // Uniqueness suffix (1166): minute-granular ids collided when a session started in the same
147
+ // minute an earlier one ended, and the frozen end algorithm then suppressed the new session's
148
+ // `session_end` row as a duplicate. Seconds + a random suffix make collisions practically
149
+ // impossible; the id stays an opaque ledger key everywhere else.
150
+ const sessionId = `session-${at.toISOString().slice(0, 10)}-${pad(at.getHours())}${pad(at.getMinutes())}${pad(at.getSeconds())}-${randomUUID().slice(0, 8)}`;
143
151
  const ts = at.toISOString();
144
152
  const agent = resolveAgentHint(env, agentFallback);
145
153
  const model = resolveModelHint(env);
@@ -153,6 +161,9 @@ export function recordSessionStart(
153
161
  };
154
162
  if (agent) sessionBody.agent = agent;
155
163
  if (model) sessionBody.model = model;
164
+ const ownerId = typeof owner?.sessionId === 'string' ? owner.sessionId.trim() : '';
165
+ const host = owner?.host;
166
+ if (ownerId !== '' && (host === 'claude' || host === 'pi')) sessionBody.owner = { host, sessionId: ownerId };
156
167
 
157
168
  const sessionFile = join(dir, '.session.json');
158
169
  try {
@@ -194,10 +205,21 @@ export function recordSessionStart(
194
205
  return sessionId;
195
206
  }
196
207
 
197
- // Entrypoint — kept minimal so unit coverage focuses on pure helpers above.
208
+ /**
209
+ * Open (or reuse) the session for a raw Claude stdin payload (task 1159, testable half).
210
+ *
211
+ * Malformed/absent text still opens the session ownerless (legacy policy); the dir resolution lives
212
+ * here rather than in the entrypoint so the untestable subprocess body stays a few lines.
213
+ */
214
+ export function recordSessionStartFromPayloadText(text: string, env: NodeJS.ProcessEnv = getEnvVars()): string | null {
215
+ const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
216
+ return recordSessionStart(dir, env, undefined, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
217
+ }
218
+
219
+ // Entrypoint — thin stdin adapter; logic lives in the exported helpers above (task 1159 coverage).
198
220
  if (import.meta.main) {
199
221
  try {
200
- recordSessionStart(join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context'));
222
+ recordSessionStartFromPayloadText(await Bun.stdin.text());
201
223
  } catch {
202
224
  /* fail-open */
203
225
  }
@@ -8,6 +8,11 @@
8
8
  * per-turn and tore down after turn 1), and on Pi/omp it maps to `session_shutdown` — off the
9
9
  * `agent_end`/Stop event where blocking gates (e.g. cc/anti-hallucination) live and would collide.
10
10
  *
11
+ * **Owner-gated finalization (task 1159).** A nested `agent.run` reuses its parent's pointer but
12
+ * used to finalize and delete it on its own shutdown, so every later parent tool event landed in a
13
+ * dead ledger. Shutdown now proceeds only when the caller's host-native identity equals the owner
14
+ * recorded on the pointer, so a child's shutdown is a no-op.
15
+ *
11
16
  * **Fail-open contract:** every error path — missing `.spur/context/`, missing/unparseable
12
17
  * `.session.json`, write failure — exits 0 with no output.
13
18
  *
@@ -17,7 +22,11 @@
17
22
 
18
23
  import { appendFileSync, existsSync, readFileSync, rmSync } from 'node:fs';
19
24
  import { join } from 'node:path';
20
- import { getEnvVar } from '../lib/env';
25
+ import { getEnvVar, getEnvVars } from '../lib/env';
26
+ import { claudeHookOwnerFromPayloadText } from './context-owner';
27
+ // Type-only import: erased at bundle time, so this hook keeps its minimal
28
+ // node:fs/node:path/env dependency surface (sp-plugin-standalone contract).
29
+ import type { SessionOwner } from './context-session-start';
21
30
 
22
31
  interface LedgerEvent {
23
32
  session: string;
@@ -25,16 +34,51 @@ interface LedgerEvent {
25
34
  tokens?: number;
26
35
  }
27
36
 
37
+ interface SessionPointer {
38
+ session?: unknown;
39
+ owner?: unknown;
40
+ }
41
+
42
+ /** Parse and validate an owner read from the pointer or supplied by a caller. */
43
+ function validSessionOwner(raw: unknown): SessionOwner | null {
44
+ if (!raw || typeof raw !== 'object') return null;
45
+ const host = (raw as { host?: unknown }).host;
46
+ const sessionId = (raw as { sessionId?: unknown }).sessionId;
47
+ if (host !== 'claude' && host !== 'pi') return null;
48
+ if (typeof sessionId !== 'string') return null;
49
+ const trimmed = sessionId.trim();
50
+ return trimmed.length > 0 ? { host, sessionId: trimmed } : null;
51
+ }
52
+
53
+ function sameOwner(a: SessionOwner, b: SessionOwner): boolean {
54
+ return a.host === b.host && a.sessionId === b.sessionId;
55
+ }
56
+
57
+ /** Read and validate the private session pointer, or null when absent/corrupt. */
58
+ function readPointer(sessionFile: string): SessionPointer | null {
59
+ if (!existsSync(sessionFile)) return null;
60
+ try {
61
+ return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionPointer;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
28
67
  /** Scan the token-ledger JSONL for this session's events and compute rollup totals. */
29
68
  function computeSessionTotals(
30
69
  ledgerPath: string,
31
70
  sessionId: string,
32
71
  ): { reads: number; writes: number; tokens: number } {
33
- if (!existsSync(ledgerPath)) return { reads: 0, writes: 0, tokens: 0 };
34
72
  let reads = 0;
35
73
  let writes = 0;
36
74
  let tokens = 0;
37
- for (const line of readFileSync(ledgerPath, 'utf-8').split('\n')) {
75
+ let lines: string[];
76
+ try {
77
+ lines = readFileSync(ledgerPath, 'utf-8').split('\n');
78
+ } catch {
79
+ return { reads, writes, tokens }; // missing/unreadable ledger → zero totals
80
+ }
81
+ for (const line of lines) {
38
82
  if (!line.trim()) continue;
39
83
  try {
40
84
  const evt = JSON.parse(line) as LedgerEvent;
@@ -49,43 +93,86 @@ function computeSessionTotals(
49
93
  return { reads, writes, tokens };
50
94
  }
51
95
 
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
- const sessionFile = join(dir, '.session.json');
60
- if (!existsSync(sessionFile)) return null;
61
-
62
- let sessionId = '';
96
+ /** Return the existing `session_end` row for `sessionId`, or null when the ledger has none. */
97
+ function findSessionEnd(ledgerPath: string, sessionId: string): Record<string, unknown> | null {
98
+ let lines: string[];
63
99
  try {
64
- const session = JSON.parse(readFileSync(sessionFile, 'utf-8')) as { session?: string };
65
- sessionId = session.session ?? '';
100
+ lines = readFileSync(ledgerPath, 'utf-8').split('\n');
66
101
  } catch {
67
102
  return null;
68
103
  }
104
+ for (const line of lines) {
105
+ if (!line.trim()) continue;
106
+ try {
107
+ const evt = JSON.parse(line) as Record<string, unknown>;
108
+ if (evt.type === 'session_end' && evt.session === sessionId) return evt;
109
+ } catch {
110
+ // skip unparseable lines
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ /**
117
+ * Finalize one session (task 0969 R3; owner-gated task 1159): read the `.session.json` pointer,
118
+ * compute the session's rollup totals, append the `session_end` row, remove the pointer
119
+ * (best-effort) and return the event. Shared by this hook's entrypoint and Pi's `session_shutdown`
120
+ * so both hosts roll up identically. Returns null on every fail-open path.
121
+ *
122
+ * `caller` is the identity of the process firing shutdown. Finalization requires it to be a valid
123
+ * identity that **equals** the pointer's recorded owner; a missing/mismatched/malformed caller, a
124
+ * legacy ownerless pointer or a corrupt pointer all return null with no writes and no removal.
125
+ * Ownerless pointers stay readable and reusable for tool recording — the bounded legacy policy is
126
+ * that an unattributable session is retired by the existing idle-start expiry, never claimed here.
127
+ *
128
+ * Repeat safety: before appending, an existing `session_end` row for the ledger suppresses a
129
+ * second one, so a replay after a failed pointer cleanup neither duplicates the row nor strands the
130
+ * pointer. Removal re-reads the pointer and requires both the ledger id and the owner to be
131
+ * unchanged, so a pointer replaced since the first read is left untouched.
132
+ */
133
+ export function recordSessionEnd(
134
+ dir: string,
135
+ now: () => Date = () => new Date(),
136
+ caller?: SessionOwner,
137
+ ): Record<string, unknown> | null {
138
+ const sessionFile = join(dir, '.session.json');
139
+ const pointer = readPointer(sessionFile);
140
+ if (!pointer) return null;
69
141
 
142
+ const sessionId = typeof pointer.session === 'string' ? pointer.session : '';
70
143
  if (!sessionId) return null;
71
144
 
72
- const totals = computeSessionTotals(join(dir, 'token-ledger.jsonl'), sessionId);
145
+ // Ownership gate: only the identity recorded at creation may finalize or remove.
146
+ const pointerOwner = validSessionOwner(pointer.owner);
147
+ const callerOwner = validSessionOwner(caller);
148
+ if (!pointerOwner || !callerOwner || !sameOwner(pointerOwner, callerOwner)) return null;
73
149
 
74
- const event = {
75
- ts: now().toISOString(),
76
- session: sessionId,
77
- type: 'session_end' as const,
78
- totals,
79
- };
150
+ const ledgerPath = join(dir, 'token-ledger.jsonl');
80
151
 
81
- try {
82
- appendFileSync(join(dir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
83
- } catch {
84
- return null;
152
+ let event: Record<string, unknown>;
153
+ const existing = findSessionEnd(ledgerPath, sessionId);
154
+ if (existing) {
155
+ // Replay: the end row survived an earlier failed pointer cleanup. Retry the removal below
156
+ // without appending a duplicate.
157
+ event = existing;
158
+ } else {
159
+ const totals = computeSessionTotals(ledgerPath, sessionId);
160
+ event = { ts: now().toISOString(), session: sessionId, type: 'session_end', totals };
161
+ try {
162
+ appendFileSync(ledgerPath, `${JSON.stringify(event)}\n`);
163
+ } catch {
164
+ return null; // retain the pointer so a later owning shutdown can retry
165
+ }
85
166
  }
86
167
 
168
+ // Remove only the exact pointer finalized above: re-read and require the ledger id and owner
169
+ // to be unchanged, so a pointer replaced during recording survives.
87
170
  try {
88
- rmSync(sessionFile, { force: true });
171
+ const current = readPointer(sessionFile);
172
+ const currentOwner = current ? validSessionOwner(current.owner) : null;
173
+ if (current?.session === sessionId && currentOwner && sameOwner(currentOwner, pointerOwner)) {
174
+ rmSync(sessionFile, { force: true });
175
+ }
89
176
  } catch {
90
177
  // cleanup is best-effort
91
178
  }
@@ -93,10 +180,24 @@ export function recordSessionEnd(dir: string, now: () => Date = () => new Date()
93
180
  return event;
94
181
  }
95
182
 
96
- // Entrypoint: run the shared core, then exit 0 (fail-open) as this hook always has.
183
+ /**
184
+ * Finalize the session for a raw Claude stdin payload (task 1159, testable half).
185
+ *
186
+ * Malformed/absent text carries no identity, so shutdown fails closed; the dir resolution lives here
187
+ * rather than in the entrypoint so the untestable subprocess body stays a few lines.
188
+ */
189
+ export function recordSessionEndFromPayloadText(
190
+ text: string,
191
+ env: NodeJS.ProcessEnv = getEnvVars(),
192
+ ): Record<string, unknown> | null {
193
+ const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
194
+ return recordSessionEnd(dir, undefined, claudeHookOwnerFromPayloadText(text, env) ?? undefined);
195
+ }
196
+
197
+ // Entrypoint — thin stdin adapter (fail-open); logic lives in the exported core above.
97
198
  if (import.meta.main) {
98
199
  try {
99
- recordSessionEnd(join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context'));
200
+ recordSessionEndFromPayloadText(await Bun.stdin.text());
100
201
  } catch {
101
202
  /* fail-open: every error path exits 0 */
102
203
  }
@@ -26,11 +26,11 @@ import { spawnSync } from 'node:child_process';
26
26
  import { existsSync, realpathSync } from 'node:fs';
27
27
  import { homedir } from 'node:os';
28
28
  import { isAbsolute, join, resolve } from 'node:path';
29
- import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
29
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
30
30
  import { getEnvVar, getEnvVars } from '../../lib/env';
31
31
  import { reportLifecycleHook } from '../agent-lifecycle';
32
32
  import { recordToolUseEvent, type ToolPayload } from '../context-post-tool';
33
- import { recordSessionStart } from '../context-session-start';
33
+ import { recordSessionStart, type SessionOwner } from '../context-session-start';
34
34
  import { recordSessionEnd } from '../context-session-stop';
35
35
  import { classifyCommand } from '../destructive-policy';
36
36
  import { couldBeTaskFile } from '../task-file-policy';
@@ -44,6 +44,16 @@ const spurContextDir = (): string => join(process.cwd(), '.spur', 'context');
44
44
 
45
45
  // ─── Helpers ─────────────────────────────────────────────────────────────
46
46
 
47
+ /**
48
+ * Pi session owner identity (task 1159): the extension's own session-manager id, stable for the
49
+ * life of the Pi session and distinct from any nested child session. Read here — not from a PID or
50
+ * `SPUR_RUN_ID` — so a nested child's `session_shutdown` cannot claim the parent's pointer.
51
+ */
52
+ function piSessionOwner(ctx: ExtensionContext): SessionOwner | null {
53
+ const sessionId = ctx.sessionManager.getSessionId().trim();
54
+ return sessionId.length > 0 ? { host: 'pi', sessionId } : null;
55
+ }
56
+
47
57
  type TaskOwnership = 'owned' | 'unowned' | 'unknown';
48
58
 
49
59
  /**
@@ -238,32 +248,35 @@ export default function (pi: ExtensionAPI): void {
238
248
  });
239
249
 
240
250
  // ── context-post-tool: shared recordToolUseEvent (task 0969 R1) ──
241
- pi.on('tool_result', async (event) => {
251
+ pi.on('tool_result', async (event, ctx) => {
242
252
  try {
243
253
  const payload = normalizePiToolEvent(
244
254
  event.toolName,
245
255
  event.input as Record<string, unknown> | undefined,
246
256
  (event as { content?: unknown }).content,
247
257
  );
248
- if (payload) recordToolUseEvent(spurContextDir(), payload);
258
+ // Host-native session id, as Claude's payload carries (H21 parity).
259
+ const sessionId = piSessionOwner(ctx)?.sessionId;
260
+ if (payload)
261
+ recordToolUseEvent(spurContextDir(), sessionId ? { ...payload, session_id: sessionId } : payload);
249
262
  } catch {
250
263
  // fail-open
251
264
  }
252
265
  });
253
266
 
254
267
  // ── context-session-start: shared recordSessionStart (R2) ────────
255
- pi.on('session_start', async () => {
268
+ pi.on('session_start', async (_event, ctx) => {
256
269
  try {
257
- recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi');
270
+ recordSessionStart(spurContextDir(), getEnvVars(), undefined, 'pi', piSessionOwner(ctx) ?? undefined);
258
271
  } catch {
259
272
  // fail-open
260
273
  }
261
274
  });
262
275
 
263
276
  // ── context-session-stop: shared recordSessionEnd (R3) ──────────
264
- pi.on('session_shutdown', async () => {
277
+ pi.on('session_shutdown', async (_event, ctx) => {
265
278
  try {
266
- recordSessionEnd(spurContextDir());
279
+ recordSessionEnd(spurContextDir(), undefined, piSessionOwner(ctx) ?? undefined);
267
280
  } catch {
268
281
  // fail-open
269
282
  }