@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/config/config.global.yaml +4 -2
- package/config/decisions/gates.yaml +36 -0
- package/config/decisions/history-anatomy.yaml +35 -0
- package/config/decisions/idea-pipeline.yaml +38 -0
- package/config/plugin-scripts.json +15 -0
- package/config/rules/docs/sourced-api-claims.yaml +60 -0
- package/config/rules/recommended-pre-check.yaml +8 -3
- package/config/rules/structure/scratch-evidence-pointer.yaml +35 -0
- package/config/script-placement-baseline.json +2 -2
- package/config/templates/AGENTS.md +1 -1
- package/config/workflows/feature-lifecycle.yaml +9 -2
- package/config/workflows/history-anatomy.yaml +22 -0
- package/config/workflows/idea-pipeline.yaml +75 -0
- package/config/workflows/task-pipeline.yaml +216 -57
- package/config/workflows/wrapup-pipeline.yaml +107 -14
- package/package.json +10 -10
- package/plugins/sp/README.md +12 -3
- package/plugins/sp/agents/super-planner.md +16 -6
- package/plugins/sp/agents/super-reviewer.md +14 -0
- package/plugins/sp/bunfig.toml +8 -0
- package/plugins/sp/commands/dev-brainstorm.md +29 -16
- package/plugins/sp/commands/dev-find-way.md +80 -0
- package/plugins/sp/commands/dev-idea.md +6 -0
- package/plugins/sp/commands/dev-plan.md +6 -0
- package/plugins/sp/commands/dev-review-session.md +4 -1
- package/plugins/sp/commands/dev-run.md +12 -2
- package/plugins/sp/commands/dev-runall.md +32 -15
- package/plugins/sp/hooks/context-owner.ts +56 -0
- package/plugins/sp/hooks/context-post-tool.ts +23 -0
- package/plugins/sp/hooks/context-session-start.ts +49 -27
- package/plugins/sp/hooks/context-session-stop.ts +131 -30
- package/plugins/sp/hooks/pi/guard-extension.ts +21 -8
- package/plugins/sp/lib/idea-handoff.generated.mjs +43 -5
- package/plugins/sp/lib/inline-run-app.ts +28 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +5 -0
- package/plugins/sp/lib/inline-run.generated.mjs +351 -46
- package/plugins/sp/lib/plan-projection.generated.d.mts +47 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +368 -0
- package/plugins/sp/lib/quality-gate.generated.d.mts +35 -1
- package/plugins/sp/lib/quality-gate.generated.mjs +30296 -71
- package/plugins/sp/lib/residual-scan.generated.d.mts +19 -0
- package/plugins/sp/lib/residual-scan.generated.mjs +66 -0
- package/plugins/sp/lib/run-summary-core.ts +561 -0
- package/plugins/sp/lib/transcript.ts +150 -7
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +488 -0
- package/plugins/sp/scripts/batch-plan.ts +145 -0
- package/plugins/sp/scripts/batch-preflight.mjs +51 -3
- package/plugins/sp/scripts/batch-preflight.ts +72 -1
- package/plugins/sp/scripts/commit-guard.mjs +216 -0
- package/plugins/sp/scripts/commit-guard.ts +249 -0
- package/plugins/sp/scripts/inline-run-dispatch.mjs +130 -0
- package/plugins/sp/scripts/inline-run-dispatch.ts +135 -0
- package/plugins/sp/scripts/inline-run-setup.mjs +562 -80
- package/plugins/sp/scripts/inline-run-setup.ts +52 -78
- package/plugins/sp/scripts/persist-out-check.mjs +90 -41
- package/plugins/sp/scripts/persist-out-check.ts +112 -57
- package/plugins/sp/scripts/pr-reviewing.mjs +19 -5
- package/plugins/sp/scripts/pr-reviewing.ts +34 -9
- package/plugins/sp/scripts/quality-gate.mjs +30213 -61
- package/plugins/sp/scripts/quality-gate.ts +21 -5
- package/plugins/sp/scripts/residual-scan.mjs +86 -31
- package/plugins/sp/scripts/residual-scan.ts +28 -30
- package/plugins/sp/scripts/run-summary.mjs +432 -14
- package/plugins/sp/scripts/run-summary.ts +41 -177
- package/plugins/sp/scripts/session-timeline.mjs +109 -11
- package/plugins/sp/scripts/session-timeline.ts +57 -19
- package/plugins/sp/scripts/task-diffstat.mjs +32 -29
- package/plugins/sp/scripts/task-diffstat.ts +34 -34
- package/plugins/sp/scripts/wrapup-steps.mjs +150 -2
- package/plugins/sp/scripts/wrapup-steps.ts +203 -1
- package/plugins/sp/skills/brainstorm/SKILL.md +9 -47
- package/plugins/sp/skills/branch-workflow/SKILL.md +1 -0
- package/plugins/sp/skills/branch-workflow/references/worktree-patterns.md +1 -0
- package/plugins/sp/skills/code-implementation/SKILL.md +48 -1
- package/plugins/sp/skills/code-verification/SKILL.md +3 -4
- package/plugins/sp/skills/code-verification/references/secu-review.md +3 -0
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +68 -0
- package/plugins/sp/skills/fleet-join/SKILL.md +1 -1
- package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
- package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +1 -1
- package/plugins/sp/skills/parallel-execution/references/fan-out-patterns.md +2 -2
- package/plugins/sp/skills/pr-reviewing/SKILL.md +7 -3
- package/plugins/sp/skills/session-review/SKILL.md +40 -7
- package/plugins/sp/skills/source-driven-development/SKILL.md +5 -0
- package/plugins/sp/skills/spec-decomposition/references/decomposition.md +5 -3
- package/plugins/sp/skills/spur-cli/SKILL.md +5 -2
- package/plugins/sp/skills/spur-cli/references/agent.md +20 -15
- package/plugins/sp/skills/spur-cli/references/decision.md +16 -8
- package/plugins/sp/skills/spur-cli/references/message.md +7 -7
- package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
- package/plugins/sp/skills/spur-cli/references/tasks/verbs.md +19 -3
- package/plugins/sp/skills/spur-cli/references/tasks.md +10 -7
- package/plugins/sp/skills/spur-cli/references/workflows/authoring-workflows.md +7 -0
- package/plugins/sp/skills/spur-cli/references/workflows.md +7 -2
- package/plugins/sp/skills/spur-composer/SKILL.md +1 -1
- package/plugins/sp/skills/spur-dev/SKILL.md +20 -14
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +33 -3
- package/plugins/sp/skills/spur-dev/references/cross-cutting.md +49 -14
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +37 -17
- package/plugins/sp/skills/spur-dev/references/execution-batch-continuation.md +68 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +199 -0
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +156 -1024
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +133 -0
- package/plugins/sp/skills/spur-dev/references/execution-workflow.md +6 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +471 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +379 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +44 -12
- package/plugins/sp/skills/spur-dev/references/glossary.md +2 -2
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +340 -165
- package/plugins/sp/skills/spur-dev/references/structured-trace-emission.md +178 -0
- package/plugins/sp/skills/wayfinder/SKILL.md +10 -14
- package/schemas/state-machine-workflow.schema.json +23 -0
- package/spur.js +42402 -26889
- package/web/_astro/{BoardApp.VeoxxLBy.js → BoardApp.B3GQZvj8.js} +1 -1
- package/web/_astro/BoardApp.BT7L2kSb.js +324 -0
- package/web/_astro/TaskDetail.Cxov1_N_.js +1 -0
- package/web/_astro/{arc.f4e0OYLg.js → arc.vaT7tPOB.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.SSIvxi-Z.js → architectureDiagram-3BPJPVTR.DRMmTjkN.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DSXIHOFf.js → blockDiagram-GPEHLZMM.cla0LO_q.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.DG9Ubu85.js → c4Diagram-AAUBKEIU.D4YAIBKn.js} +1 -1
- package/web/_astro/channel.D4nG9YLf.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.C7QU88Qh.js → chunk-2J33WTMH.BrSJbMRZ.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.26Z94xBj.js → chunk-4BX2VUAB.CuLMgKF5.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DYRiBkqw.js → chunk-55IACEB6.zkvgu54F.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.D5Vfv5pe.js → chunk-727SXJPM.DxWkACh8.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.DXHV47bN.js → chunk-AQP2D5EJ.42XrmIlH.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.D1suAfOs.js → chunk-FMBD7UC4.Bao2wMK9.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.D6BmdHEY.js → chunk-ND2GUHAM.BhydojD1.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.rnPU-Ak-.js → chunk-QZHKN3VN.CmZ82g-H.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.CvEfXIo1.js → classDiagram-4FO5ZUOK.Blr83Gt_.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.CvEfXIo1.js → classDiagram-v2-Q7XG4LA2.Blr83Gt_.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.DkMisLSm.js → cose-bilkent-S5V4N54A.qmfv0SPn.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.C77Q-pF6.js → cynefin-OW5HDTMX.BtELhXPR.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.YaV8ce27.js → dagre-BM42HDAG.AWmaAvXo.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.CUyGEKge.js → diagram-2AECGRRQ.DdH34KDi.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.CRgd8dgj.js → diagram-5GNKFQAL.BmNPix1k.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.Bmzys9hq.js → diagram-KO2AKTUF.BNQd_Ppo.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.BvmJY4Lw.js → diagram-LMA3HP47.BAPgDi0e.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.0hEhD30d.js → diagram-OG6HWLK6.DZ4zyLdC.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.Bjt8lrnm.js → erDiagram-TEJ5UH35.Q1TySMmw.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.CJZTjRDV.js → flowDiagram-I6XJVG4X.BXhFBicG.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7._VqoVafc.js → ganttDiagram-6RSMTGT7.vHSgcuo2.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CEDb7tVo.js → gitGraphDiagram-PVQCEYII.Bi1f73C7.js} +1 -1
- package/web/_astro/index.BhSwN8yY.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.CgwifPpK.js → infoDiagram-5YYISTIA.DPvXZEWm.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.B99wx2J1.js → ishikawaDiagram-YF4QCWOH.Cz829fsV.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.2hRwJbEo.js → journeyDiagram-JHISSGLW.enRnB9Me.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.BahJkKNC.js → kanban-definition-UN3LZRKU.DnTNk6d2.js} +1 -1
- package/web/_astro/{linear.DguUUe6V.js → linear.BzGnBwNL.js} +1 -1
- package/web/_astro/{mermaid.core.CvgfmqVr.js → mermaid.core.CSbTpIVz.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.B3qEA3x9.js → mindmap-definition-RKZ34NQL.DGJDy8zT.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.FlWA-_ru.js → pieDiagram-4H26LBE5.HSPdi5_8.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.9bVn35X7.js → quadrantDiagram-W4KKPZXB.6Gv8mz2Y.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.ChykIhct.js → requirementDiagram-4Y6WPE33.CnBCtFwa.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.Z2Y3hkYb.js → sankeyDiagram-5OEKKPKP.5M8VWAu8.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.J7JsHWDt.js → sequenceDiagram-3UESZ5HK.DN41XN-U.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.DAPbiCSS.js → stateDiagram-AJRCARHV.ErOO3W8j.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.D2jvSi0q.js → stateDiagram-v2-BHNVJYJU.B_ZHICM-.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.psW2ci29.js → timeline-definition-PNZ67QCA.CWdosr07.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DlILYVmg.js → vennDiagram-CIIHVFJN.BHzYjNV5.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.xNWwLJnn.js → wardleyDiagram-YWT4CUSO.h8x4IwPX.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.EU_BuH7V.js → xychartDiagram-2RQKCTM6.RTapW2X8.js} +1 -1
- package/web/index.html +2 -2
- package/plugins/sp/commands/dev-parallel.md +0 -31
- package/web/_astro/BoardApp.Bzr4OXrW.js +0 -324
- package/web/_astro/TaskDetail.LAyR_Pvn.js +0 -1
- package/web/_astro/channel.CUFdtWhm.js +0 -1
- 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)
|
|
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
|
-
(
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
|
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) —
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|