@gobing-ai/spur 0.4.1 → 0.4.3
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/maker-liveness.yaml +39 -0
- package/config/plugin-scripts.json +0 -4
- package/config/rules/README.md +1 -0
- package/config/rules/docs/sourced-api-claims.yaml +56 -12
- package/config/rules/strict/runtime-boundaries.yaml +7 -0
- package/config/script-placement-baseline.json +1 -19
- package/config/templates/AGENTS.md +1 -1
- package/config/workflows/feature-lifecycle.yaml +46 -1
- package/config/workflows/feature-verification.yaml +11 -0
- package/config/workflows/wrapup-pipeline.yaml +1 -1
- package/package.json +10 -10
- package/plugins/sp/README.md +23 -18
- package/plugins/sp/agents/super-planner.md +5 -6
- package/plugins/sp/agents/super-reviewer.md +43 -0
- package/plugins/sp/bunfig.toml +8 -5
- package/plugins/sp/commands/dev-review-session.md +2 -1
- package/plugins/sp/commands/dev-runall.md +18 -9
- package/plugins/sp/hooks/careful-guard.ts +1 -1
- package/plugins/sp/hooks/context-post-tool.ts +7 -404
- package/plugins/sp/hooks/context-session-start.ts +6 -192
- package/plugins/sp/hooks/context-session-stop.ts +6 -91
- package/plugins/sp/hooks/pi/guard-extension.ts +26 -12
- package/plugins/sp/hooks/task-write-guard.ts +1 -1
- package/plugins/sp/{hooks → lib}/agent-hint.ts +1 -1
- package/plugins/sp/lib/context-owner.ts +56 -0
- package/plugins/sp/lib/context-post-tool.ts +432 -0
- package/plugins/sp/lib/context-session-start.ts +214 -0
- package/plugins/sp/lib/context-session-stop.ts +196 -0
- package/plugins/sp/lib/dogfood-pipeline.ts +265 -0
- package/plugins/sp/lib/dogfood-report.ts +278 -0
- package/plugins/sp/lib/idea-handoff.generated.mjs +6 -4
- package/plugins/sp/lib/inline-run-app.ts +40 -0
- package/plugins/sp/lib/inline-run.generated.d.mts +3 -3
- package/plugins/sp/lib/inline-run.generated.mjs +1361 -87
- package/plugins/sp/lib/plan-projection.generated.d.mts +169 -0
- package/plugins/sp/lib/plan-projection.generated.mjs +704 -1
- package/plugins/sp/lib/quality-gate.generated.d.mts +0 -12
- package/plugins/sp/lib/quality-gate.generated.mjs +62 -231
- package/plugins/sp/lib/residual-scan.generated.d.mts +7 -1
- package/plugins/sp/lib/residual-scan.generated.mjs +71 -6
- package/plugins/sp/lib/run-summary-core.ts +34 -370
- package/plugins/sp/lib/transcript.ts +37 -200
- package/plugins/sp/plugin.json +1 -1
- package/plugins/sp/references/roles.md +1 -1
- package/plugins/sp/scripts/batch-plan.mjs +740 -1
- package/plugins/sp/scripts/batch-plan.ts +48 -3
- package/plugins/sp/scripts/batch-preflight.mjs +23 -1
- package/plugins/sp/scripts/batch-preflight.ts +35 -295
- package/plugins/sp/scripts/daily-summary/daily-summary.mjs +108 -87
- package/plugins/sp/scripts/daily-summary/daily-summary.ts +111 -104
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.mjs +2 -1
- package/plugins/sp/scripts/dogfood-testing/detect-pipeline-driving.ts +7 -265
- package/plugins/sp/scripts/dogfood-testing/validate-report.mjs +3 -0
- package/plugins/sp/scripts/dogfood-testing/validate-report.ts +6 -272
- package/plugins/sp/scripts/feature-verification-steps.mjs +7 -123
- package/plugins/sp/scripts/feature-verification-steps.ts +29 -181
- package/plugins/sp/scripts/inline-run-dispatch.mjs +27 -26
- package/plugins/sp/scripts/inline-run-dispatch.ts +1 -21
- package/plugins/sp/scripts/inline-run-setup.mjs +158 -120
- package/plugins/sp/scripts/inline-run-setup.ts +1 -21
- 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 +38 -208
- package/plugins/sp/scripts/residual-scan.mjs +73 -6
- package/plugins/sp/scripts/residual-scan.ts +8 -5
- package/plugins/sp/scripts/run-summary.mjs +160 -95
- package/plugins/sp/scripts/run-summary.ts +7 -5
- package/plugins/sp/scripts/session-timeline.mjs +57 -29
- package/plugins/sp/scripts/session-timeline.ts +4 -18
- package/plugins/sp/scripts/task-diffstat.mjs +43 -30
- package/plugins/sp/scripts/task-diffstat.ts +40 -71
- package/plugins/sp/skills/code-implementation/SKILL.md +7 -1
- package/plugins/sp/skills/code-verification/references/secu-review.md +2 -1
- package/plugins/sp/skills/code-verification/references/verdict-schema.md +4 -0
- package/plugins/sp/skills/dogfood-testing/SKILL.md +2 -2
- package/plugins/sp/skills/indexed-context/SKILL.md +14 -7
- package/plugins/sp/skills/next-feature/SKILL.md +1 -1
- package/plugins/sp/skills/parallel-execution/SKILL.md +2 -2
- package/plugins/sp/skills/parallel-execution/references/dispatch-surface.md +2 -2
- 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 +5 -3
- package/plugins/sp/skills/source-driven-development/SKILL.md +7 -4
- package/plugins/sp/skills/spur-cli/references/builder.md +3 -2
- package/plugins/sp/skills/spur-cli/references/decision.md +40 -8
- package/plugins/sp/skills/spur-cli/references/features.md +46 -5
- package/plugins/sp/skills/spur-cli/references/projects.md +7 -5
- package/plugins/sp/skills/spur-dev/SKILL.md +2 -0
- package/plugins/sp/skills/spur-dev/references/ac-style-guide.md +27 -0
- package/plugins/sp/skills/spur-dev/references/dev-operations.md +2 -3
- package/plugins/sp/skills/spur-dev/references/execution-batch-report.md +6 -1
- package/plugins/sp/skills/spur-dev/references/execution-batch.md +56 -7
- package/plugins/sp/skills/spur-dev/references/execution-parallel-isolation.md +4 -1
- package/plugins/sp/skills/spur-dev/references/execution-worktree-landing.md +22 -0
- package/plugins/sp/skills/spur-dev/references/execution-worktree-setup.md +14 -0
- package/plugins/sp/skills/spur-dev/references/flag-glossary.md +13 -11
- package/plugins/sp/skills/spur-dev/references/inline-pipeline-driver.md +37 -25
- package/spur.js +89792 -76992
- package/web/_astro/{BoardApp.D66k_s_T.js → BoardApp.5IIl2O-z.js} +1 -1
- package/web/_astro/BoardApp.Drnx-KeM.js +324 -0
- package/web/_astro/TaskDetail.Ci4LNn9S.js +1 -0
- package/web/_astro/{arc._BUadrdH.js → arc.j2EVkYVd.js} +1 -1
- package/web/_astro/{architectureDiagram-3BPJPVTR.Dh7xS1Pp.js → architectureDiagram-3BPJPVTR.DZWSFqEG.js} +1 -1
- package/web/_astro/{blockDiagram-GPEHLZMM.DFDVpfXm.js → blockDiagram-GPEHLZMM.DE6GpXCN.js} +1 -1
- package/web/_astro/{c4Diagram-AAUBKEIU.V7_3_glh.js → c4Diagram-AAUBKEIU.DoVR1G6B.js} +1 -1
- package/web/_astro/channel.DmdDxGsq.js +1 -0
- package/web/_astro/{chunk-2J33WTMH.De1yx7Jz.js → chunk-2J33WTMH.Do6-Gbyf.js} +1 -1
- package/web/_astro/{chunk-4BX2VUAB.BDhHFuxx.js → chunk-4BX2VUAB.BYV1HGHx.js} +1 -1
- package/web/_astro/{chunk-55IACEB6.DDLkWnmC.js → chunk-55IACEB6.CvI1zkrc.js} +1 -1
- package/web/_astro/{chunk-727SXJPM.CwfiLnA7.js → chunk-727SXJPM.DNGSCRlX.js} +1 -1
- package/web/_astro/{chunk-AQP2D5EJ.CtGDmUnM.js → chunk-AQP2D5EJ.DXhhgT5z.js} +1 -1
- package/web/_astro/{chunk-FMBD7UC4.BOLjpw73.js → chunk-FMBD7UC4.dzkRKP2u.js} +1 -1
- package/web/_astro/{chunk-ND2GUHAM.BDhQls6O.js → chunk-ND2GUHAM.DujGLeEf.js} +1 -1
- package/web/_astro/{chunk-QZHKN3VN.76qBynDF.js → chunk-QZHKN3VN.BWtIT3ZU.js} +1 -1
- package/web/_astro/{classDiagram-4FO5ZUOK.N0mpPHT0.js → classDiagram-4FO5ZUOK.DyApg0Nn.js} +1 -1
- package/web/_astro/{classDiagram-v2-Q7XG4LA2.N0mpPHT0.js → classDiagram-v2-Q7XG4LA2.DyApg0Nn.js} +1 -1
- package/web/_astro/{cose-bilkent-S5V4N54A.CKknFfjB.js → cose-bilkent-S5V4N54A.DJQB-u_l.js} +1 -1
- package/web/_astro/{cynefin-OW5HDTMX.Cw3ZWBAh.js → cynefin-OW5HDTMX.bi3D_siV.js} +1 -1
- package/web/_astro/{dagre-BM42HDAG.D3yIKCRD.js → dagre-BM42HDAG.C-f7TrRg.js} +1 -1
- package/web/_astro/{diagram-2AECGRRQ.D51dO83Z.js → diagram-2AECGRRQ.DrPfjHQB.js} +1 -1
- package/web/_astro/{diagram-5GNKFQAL.Bb-FViDj.js → diagram-5GNKFQAL.C9S4WUHO.js} +1 -1
- package/web/_astro/{diagram-KO2AKTUF.D3mN8S6x.js → diagram-KO2AKTUF.Dzzye8QH.js} +1 -1
- package/web/_astro/{diagram-LMA3HP47.dMMyPcbJ.js → diagram-LMA3HP47.DX-LRiof.js} +1 -1
- package/web/_astro/{diagram-OG6HWLK6.AzbvzY4C.js → diagram-OG6HWLK6.Z-kAKwIz.js} +1 -1
- package/web/_astro/{erDiagram-TEJ5UH35.BR5I4OZb.js → erDiagram-TEJ5UH35.D6dyClSv.js} +1 -1
- package/web/_astro/{flowDiagram-I6XJVG4X.DFqvhGkn.js → flowDiagram-I6XJVG4X.Cx3q2cxD.js} +1 -1
- package/web/_astro/{ganttDiagram-6RSMTGT7.ByJbhH6J.js → ganttDiagram-6RSMTGT7.DU3gH6CT.js} +1 -1
- package/web/_astro/{gitGraphDiagram-PVQCEYII.CInHjQR3.js → gitGraphDiagram-PVQCEYII.ISmCnJ87.js} +1 -1
- package/web/_astro/index.Do2lLdgo.css +1 -0
- package/web/_astro/{infoDiagram-5YYISTIA.Cm0dGVjJ.js → infoDiagram-5YYISTIA.DR6_iNOK.js} +1 -1
- package/web/_astro/{ishikawaDiagram-YF4QCWOH.C3nOP6Sj.js → ishikawaDiagram-YF4QCWOH.D7wY-ZGA.js} +1 -1
- package/web/_astro/{journeyDiagram-JHISSGLW.4IvLa2MW.js → journeyDiagram-JHISSGLW.DkP9-WK8.js} +1 -1
- package/web/_astro/{kanban-definition-UN3LZRKU.DS87Cl-k.js → kanban-definition-UN3LZRKU.wtgY98M2.js} +1 -1
- package/web/_astro/{linear.MzmRrNER.js → linear.CI6djNi7.js} +1 -1
- package/web/_astro/{mermaid.core.fK-WNswS.js → mermaid.core.CuQPxS8b.js} +4 -4
- package/web/_astro/{mindmap-definition-RKZ34NQL.BqipOHhY.js → mindmap-definition-RKZ34NQL.5HNIEG7R.js} +1 -1
- package/web/_astro/{pieDiagram-4H26LBE5.0nBiPqFG.js → pieDiagram-4H26LBE5.CchhDFjE.js} +1 -1
- package/web/_astro/{quadrantDiagram-W4KKPZXB.Szj7Zpx5.js → quadrantDiagram-W4KKPZXB.PYXXJPSJ.js} +1 -1
- package/web/_astro/{requirementDiagram-4Y6WPE33.D4UR6TWo.js → requirementDiagram-4Y6WPE33.cUQuNAKU.js} +1 -1
- package/web/_astro/{sankeyDiagram-5OEKKPKP.DAE7BdOs.js → sankeyDiagram-5OEKKPKP.BO7MnRIA.js} +1 -1
- package/web/_astro/{sequenceDiagram-3UESZ5HK.BP9ZVq25.js → sequenceDiagram-3UESZ5HK.D9bElyD6.js} +1 -1
- package/web/_astro/{stateDiagram-AJRCARHV.BQW7mIxk.js → stateDiagram-AJRCARHV.DhGdRz7I.js} +1 -1
- package/web/_astro/{stateDiagram-v2-BHNVJYJU.PbK4nDih.js → stateDiagram-v2-BHNVJYJU.5GvLeAoL.js} +1 -1
- package/web/_astro/{timeline-definition-PNZ67QCA.Ci_GvUpo.js → timeline-definition-PNZ67QCA.LtAT9haM.js} +1 -1
- package/web/_astro/{vennDiagram-CIIHVFJN.DNw1Y5qx.js → vennDiagram-CIIHVFJN.DRth1yLA.js} +1 -1
- package/web/_astro/{wardleyDiagram-YWT4CUSO.CwlkaaNe.js → wardleyDiagram-YWT4CUSO.h9MlZfcr.js} +1 -1
- package/web/_astro/{xychartDiagram-2RQKCTM6.MI6ZuiH_.js → xychartDiagram-2RQKCTM6.DHqy9DYA.js} +1 -1
- package/web/index.html +2 -2
- package/config/proportional-route-table.ts +0 -155
- package/plugins/sp/commands/dev-parallel.md +0 -37
- package/plugins/sp/scripts/daily-summary/logger.ts +0 -28
- package/web/_astro/BoardApp.D6sjAJr_.js +0 -324
- package/web/_astro/TaskDetail.BynLnMNc.js +0 -1
- package/web/_astro/channel.DZVfto-r.js +0 -1
- package/web/_astro/index.COh_kBxN.css +0 -1
- /package/plugins/sp/{hooks → lib}/destructive-policy.ts +0 -0
- /package/plugins/sp/{hooks → lib}/task-file-policy.ts +0 -0
|
@@ -18,7 +18,8 @@ work time, operator wait, tool calls and tokens (total / non-cached) per stage,
|
|
|
18
18
|
`session-timeline.mjs` — with durations in `M:SS` or `H:MM:SS` form and `n/a` only when that
|
|
19
19
|
measurement is unavailable. The transcript resolves from `--transcript`, else
|
|
20
20
|
`CLAUDE_CODE_SESSION_ID`, else `PI_SESSION_FILE` (the pi host sets it and no Claude session id); a
|
|
21
|
-
|
|
21
|
+
pi `<skill name="…">` wrapper row is the operator's `/skill` invocation and opens a segment
|
|
22
|
+
rendered `/<name> <args>` (or `/<name>` argument-less), never an injected body.
|
|
22
23
|
|
|
23
24
|
## Argument Flags
|
|
24
25
|
|
|
@@ -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] [--defer-gate]"
|
|
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,14 +26,14 @@ 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
|
|
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 |
|
|
30
30
|
|
|
31
31
|
For shared semantics, see the [flag glossary](../skills/spur-dev/references/flag-glossary.md).
|
|
32
32
|
|
|
33
33
|
## Usage
|
|
34
34
|
|
|
35
35
|
```
|
|
36
|
-
/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] [--defer-gate]
|
|
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]
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
Flags: `--tasks <selector>` (required — explicit WBS list, status pseudo-list, `feature:<id>`,
|
|
@@ -44,8 +44,8 @@ structured findings, before any task pipeline action, task 0510 R2; scoped: `L4.
|
|
|
44
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
45
|
`NON_ABORTING_PREFLIGHT_CODES` in `plugins/sp/scripts/batch-preflight.ts`; `--fix` repairs land in the report's `autoRepairs`) —
|
|
46
46
|
any other strict error aborts), `--mode`
|
|
47
|
-
`<sequential|parallel>` (default `sequential`; `parallel` fans out a proven-independent subset —
|
|
48
|
-
see `execution-parallel-isolation.md`), `--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>`
|
|
49
49
|
(parallel-mode worker bound — at most <n> task pipelines at once, default 2; a dependent task starts
|
|
50
50
|
only after its in-set dependencies are **integrated** onto the base ref), `--keep-going`
|
|
51
51
|
(batch failure policy — skip a failed task's in-batch dependents, continue independents; default
|
|
@@ -62,6 +62,15 @@ FF-merge onto the base ref on full success, retain intact on any failure/halt/no
|
|
|
62
62
|
creates a fresh tree, `<name>` form adopts an existing worktree by name/path/branch; see
|
|
63
63
|
`execution-worktree-setup.md` + `execution-worktree-landing.md`).
|
|
64
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.
|
|
73
|
+
|
|
65
74
|
**`--worktree` and parallel mode.** `--worktree --mode parallel` is **rejected** — parallel mode
|
|
66
75
|
already isolates each task in its own worktree (see `execution-parallel-isolation.md`), and
|
|
67
76
|
reuse mode (`--worktree <name>`) has no per-task meaning. Run parallel batches without
|
|
@@ -98,7 +107,7 @@ full distinction.
|
|
|
98
107
|
§ 2.7 Visible batch plan.
|
|
99
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.
|
|
100
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).
|
|
101
|
-
- Interactive sequential omit/inline: `Skill(skill="sp:spur-dev", args="runall-inline $ARGUMENTS")
|
|
102
|
-
- Explicit executor or parallel mode: `Skill(skill="sp:spur-dev", args="runall $ARGUMENTS")` → `sp:super-planner` agent.
|
|
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.
|
|
103
112
|
- After the batch report, print the [execution summary](../skills/spur-dev/references/dev-operations.md#execution-summary) over every attempted run unless
|
|
104
113
|
`--no-summary`.
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
* adapter. This file owns the Claude Code payload/decision shape only.
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import { classifyCommand } from '../lib/destructive-policy';
|
|
25
26
|
import { getEnvVar } from '../lib/env';
|
|
26
|
-
import { classifyCommand } from './destructive-policy';
|
|
27
27
|
|
|
28
28
|
interface ToolPayload {
|
|
29
29
|
tool_name?: string;
|
|
@@ -1,415 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
3
2
|
/**
|
|
4
|
-
* context-post-tool — PostToolUse
|
|
3
|
+
* context-post-tool — installed PostToolUse entry for indexed-context
|
|
5
4
|
* (matcher: Bash|Grep|Glob|Read|Write|Edit — task 0248).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* Token cascade (task 0246): tool_response.content → Write tool_input.content →
|
|
12
|
-
* Edit old/new strings → Read file stat → omit tokens (never store 0 for unknown).
|
|
13
|
-
*
|
|
14
|
-
* Redaction (task 0248): store summary only (never full stdout); cap text ~4 KiB;
|
|
15
|
-
* strip obvious secret patterns. Bash tokens estimate from capped stdout/stderr length.
|
|
16
|
-
*
|
|
17
|
-
* **Fail-open contract:** every error path exits 0 with no output.
|
|
18
|
-
*
|
|
19
|
-
* Self-contained by design (task 0232/0246/0248). Installed hook configs use
|
|
20
|
-
* `superskill hook run sp context-post-tool`.
|
|
6
|
+
* Task 1174: the capture logic lives in `plugins/sp/lib/context-post-tool.ts`; this file is the
|
|
7
|
+
* argv/stdin adapter the installed `superskill hook run sp context-post-tool` entry resolves to.
|
|
8
|
+
* The redaction, byte-cap and fail-open contract (every error path exits 0 with no output) is
|
|
9
|
+
* unchanged — the adapter adds no policy of its own.
|
|
21
10
|
*/
|
|
22
11
|
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
import { join, resolve, sep } from 'node:path';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { recordToolUseEvent, type ToolPayload } from '../lib/context-post-tool';
|
|
26
14
|
import { getEnvVar } from '../lib/env';
|
|
27
15
|
|
|
28
|
-
/** Tools recorded by this hook (must match hooks.json PostToolUse matcher). */
|
|
29
|
-
export const ALLOWED_TOOLS = new Set(['Bash', 'Grep', 'Glob', 'Read', 'Write', 'Edit']);
|
|
30
|
-
|
|
31
|
-
/** Max stored summary / token-estimate input size (bytes). Task 0248 ~2–4 KiB. */
|
|
32
|
-
export const REDACTION_CAP_BYTES = 4096;
|
|
33
|
-
|
|
34
|
-
/** Max command/pattern summary length before ellipsis (chars). */
|
|
35
|
-
export const SUMMARY_MAX_CHARS = 200;
|
|
36
|
-
|
|
37
|
-
export interface ToolPayload {
|
|
38
|
-
session_id?: string;
|
|
39
|
-
tool_name?: string;
|
|
40
|
-
tool_input?: {
|
|
41
|
-
file_path?: string;
|
|
42
|
-
content?: string;
|
|
43
|
-
old_string?: string;
|
|
44
|
-
new_string?: string;
|
|
45
|
-
/** Bash */
|
|
46
|
-
command?: string;
|
|
47
|
-
/** Grep / Glob (Claude Code shapes vary) */
|
|
48
|
-
pattern?: string;
|
|
49
|
-
glob_pattern?: string;
|
|
50
|
-
glob?: string;
|
|
51
|
-
path?: string;
|
|
52
|
-
};
|
|
53
|
-
tool_response?: {
|
|
54
|
-
content?: string | unknown;
|
|
55
|
-
filePath?: string;
|
|
56
|
-
stdout?: string;
|
|
57
|
-
stderr?: string;
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface SessionFile {
|
|
62
|
-
session?: string;
|
|
63
|
-
agent?: string;
|
|
64
|
-
model?: string;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Estimate tokens from raw byte count: Math.ceil(bytes / 4). */
|
|
68
|
-
export function estimateTokens(text: string): number {
|
|
69
|
-
return Math.ceil(new TextEncoder().encode(text).length / 4);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Scrub secret-like patterns without size capping. */
|
|
73
|
-
export function scrubSecrets(text: string): string {
|
|
74
|
-
let s = text;
|
|
75
|
-
// Bearer / API key prefixes first (before generic key=value)
|
|
76
|
-
s = s.replace(/\bBearer\s+[A-Za-z0-9._\-+=/]+/gi, 'Bearer ***');
|
|
77
|
-
// `sk-` keys. Segments may be hyphen-separated (`sk-ant-api03-…`), so match the
|
|
78
|
-
// whole run rather than a single alphanumeric block — anchoring on `[A-Za-z0-9]{8,}`
|
|
79
|
-
// alone stopped at the first hyphen and let every Anthropic key through, which is
|
|
80
|
-
// the most likely secret to appear in a Claude-agent harness.
|
|
81
|
-
s = s.replace(/\bsk-[A-Za-z0-9_-]{8,}/g, 'sk-***');
|
|
82
|
-
s = s.replace(/\bAKIA[0-9A-Z]{16}\b/g, 'AKIA***');
|
|
83
|
-
// Provider token prefixes with a fixed, unambiguous shape.
|
|
84
|
-
s = s.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}/g, 'github_pat_***');
|
|
85
|
-
s = s.replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, (m) => `${m.slice(0, 4)}***`);
|
|
86
|
-
// Key=value style secrets (value = rest of non-space token). The key may carry a
|
|
87
|
-
// prefix (`ANTHROPIC_API_KEY`, `GITHUB_TOKEN`): `_` is a word character, so a
|
|
88
|
-
// leading `\b` would not match there and every namespaced env var leaked. Allow an
|
|
89
|
-
// optional `WORD_`-style prefix instead of requiring a boundary.
|
|
90
|
-
// The whole key (prefix included) is captured so the ledger still says *which*
|
|
91
|
-
// variable was scrubbed — `ANTHROPIC_API_KEY=***`, not a bare `API_KEY=***`.
|
|
92
|
-
s = s.replace(
|
|
93
|
-
/\b((?:[A-Za-z0-9]+[_-])*(?:api[_-]?key|password|passwd|secret|token|access[_-]?key|private[_-]?key))\s*[:=]\s*\S+/gi,
|
|
94
|
-
'$1=***',
|
|
95
|
-
);
|
|
96
|
-
// Authorization: <scheme> <credentials...>
|
|
97
|
-
s = s.replace(/\bAuthorization\s*:\s*\S+(?:\s+\S+)*/gi, 'Authorization: ***');
|
|
98
|
-
// PEM private key blocks — redact the payload, keep the header as a marker.
|
|
99
|
-
s = s.replace(
|
|
100
|
-
/-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----[\s\S]*?(?:-----END (?:[A-Z ]+ )?PRIVATE KEY-----|$)/g,
|
|
101
|
-
'[private-key-redacted]',
|
|
102
|
-
);
|
|
103
|
-
// Collapse long base64 only when + / or = present (avoid pure a-z body blobs)
|
|
104
|
-
s = s.replace(/\b[A-Za-z0-9+/]{80,}={0,2}\b/g, (m) => (/[+/=]/.test(m) ? '[base64-redacted]' : m));
|
|
105
|
-
return s;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Cap text to {@link REDACTION_CAP_BYTES} and scrub obvious secret patterns.
|
|
110
|
-
* Used for any stored summary fragments — never full env dumps.
|
|
111
|
-
*/
|
|
112
|
-
export function redactText(text: string, capBytes: number = REDACTION_CAP_BYTES): string {
|
|
113
|
-
const s = scrubSecrets(text);
|
|
114
|
-
const bytes = new TextEncoder().encode(s);
|
|
115
|
-
if (bytes.length <= capBytes) return s;
|
|
116
|
-
// Truncate on byte boundary then append marker
|
|
117
|
-
let end = capBytes;
|
|
118
|
-
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) end--;
|
|
119
|
-
return `${new TextDecoder().decode(bytes.subarray(0, end))}…[truncated]`;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/** Byte length after scrub + hard cap (no truncation marker) — for token estimates. */
|
|
123
|
-
export function cappedByteLength(text: string, capBytes: number = REDACTION_CAP_BYTES): number {
|
|
124
|
-
const scrubbed = scrubSecrets(text);
|
|
125
|
-
return Math.min(new TextEncoder().encode(scrubbed).length, capBytes);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Truncate a one-line summary for the ledger (after light redaction). */
|
|
129
|
-
export function truncateSummary(text: string, maxChars: number = SUMMARY_MAX_CHARS): string {
|
|
130
|
-
const cleaned = redactText(text.replace(/\s+/g, ' ').trim(), REDACTION_CAP_BYTES);
|
|
131
|
-
if (cleaned.length <= maxChars) return cleaned;
|
|
132
|
-
return `${cleaned.slice(0, Math.max(0, maxChars - 1))}…`;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Map platform tool name → ledger `type` (task 0248 R2).
|
|
137
|
-
* Edit stays `write` with action=edit (0245/0246 convention).
|
|
138
|
-
*/
|
|
139
|
-
export function mapToolType(toolName: string): string {
|
|
140
|
-
switch (toolName) {
|
|
141
|
-
case 'Read':
|
|
142
|
-
return 'read';
|
|
143
|
-
case 'Write':
|
|
144
|
-
case 'Edit':
|
|
145
|
-
return 'write';
|
|
146
|
-
case 'Bash':
|
|
147
|
-
return 'bash';
|
|
148
|
-
case 'Grep':
|
|
149
|
-
return 'grep';
|
|
150
|
-
case 'Glob':
|
|
151
|
-
return 'glob';
|
|
152
|
-
default:
|
|
153
|
-
return toolName.toLowerCase();
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Short summary for Bash/Grep/Glob (command truncated, pattern, path glob).
|
|
159
|
-
* Not full stdout. Returns undefined when nothing useful to store.
|
|
160
|
-
*/
|
|
161
|
-
export function buildToolSummary(toolName: string, toolInput: ToolPayload['tool_input']): string | undefined {
|
|
162
|
-
if (!toolInput) return undefined;
|
|
163
|
-
if (toolName === 'Bash') {
|
|
164
|
-
const cmd = typeof toolInput.command === 'string' ? toolInput.command : '';
|
|
165
|
-
if (!cmd.trim()) return undefined;
|
|
166
|
-
return truncateSummary(cmd);
|
|
167
|
-
}
|
|
168
|
-
if (toolName === 'Grep') {
|
|
169
|
-
const pattern = typeof toolInput.pattern === 'string' ? toolInput.pattern : '';
|
|
170
|
-
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
171
|
-
const glob =
|
|
172
|
-
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
173
|
-
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
174
|
-
'';
|
|
175
|
-
const parts = [pattern && `/${pattern}/`, path, glob].filter(Boolean);
|
|
176
|
-
if (parts.length === 0) return undefined;
|
|
177
|
-
return truncateSummary(parts.join(' '));
|
|
178
|
-
}
|
|
179
|
-
if (toolName === 'Glob') {
|
|
180
|
-
const pattern =
|
|
181
|
-
(typeof toolInput.pattern === 'string' && toolInput.pattern) ||
|
|
182
|
-
(typeof toolInput.glob_pattern === 'string' && toolInput.glob_pattern) ||
|
|
183
|
-
(typeof toolInput.glob === 'string' && toolInput.glob) ||
|
|
184
|
-
'';
|
|
185
|
-
const path = typeof toolInput.path === 'string' ? toolInput.path : '';
|
|
186
|
-
const parts = [pattern, path].filter(Boolean);
|
|
187
|
-
if (parts.length === 0) return undefined;
|
|
188
|
-
return truncateSummary(parts.join(' in '));
|
|
189
|
-
}
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Response text used only for size/token estimate — never written to the ledger. */
|
|
194
|
-
function responseTextForEstimate(toolResponse: ToolPayload['tool_response']): string {
|
|
195
|
-
if (!toolResponse) return '';
|
|
196
|
-
const parts: string[] = [];
|
|
197
|
-
if (typeof toolResponse.content === 'string') parts.push(toolResponse.content);
|
|
198
|
-
if (typeof toolResponse.stdout === 'string') parts.push(toolResponse.stdout);
|
|
199
|
-
if (typeof toolResponse.stderr === 'string') parts.push(toolResponse.stderr);
|
|
200
|
-
return parts.join('\n');
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/**
|
|
204
|
-
* Token cascade (tasks 0246 / 0248). Returns undefined when unknown — callers omit
|
|
205
|
-
* the field rather than writing tokens: 0.
|
|
206
|
-
*
|
|
207
|
-
* Bash: capped stdout/stderr length. Grep/Glob: capped result size or undefined.
|
|
208
|
-
*/
|
|
209
|
-
export function resolveTokenEstimate(
|
|
210
|
-
toolName: string,
|
|
211
|
-
toolInput: ToolPayload['tool_input'],
|
|
212
|
-
toolResponse: ToolPayload['tool_response'],
|
|
213
|
-
): number | undefined {
|
|
214
|
-
// Bash: estimate from capped response only (never uncapped multi-MB stdout).
|
|
215
|
-
if (toolName === 'Bash') {
|
|
216
|
-
const raw = responseTextForEstimate(toolResponse);
|
|
217
|
-
if (raw.length === 0) return undefined;
|
|
218
|
-
return Math.ceil(cappedByteLength(raw) / 4);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Grep / Glob: result size after cap, or omit when empty.
|
|
222
|
-
if (toolName === 'Grep' || toolName === 'Glob') {
|
|
223
|
-
const raw = responseTextForEstimate(toolResponse);
|
|
224
|
-
if (raw.length === 0) return undefined;
|
|
225
|
-
return Math.ceil(cappedByteLength(raw) / 4);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const responseContent = toolResponse?.content;
|
|
229
|
-
if (typeof responseContent === 'string' && responseContent.length > 0) {
|
|
230
|
-
return estimateTokens(responseContent);
|
|
231
|
-
}
|
|
232
|
-
if (toolName === 'Write' && typeof toolInput?.content === 'string' && toolInput.content.length > 0) {
|
|
233
|
-
return estimateTokens(toolInput.content);
|
|
234
|
-
}
|
|
235
|
-
if (toolName === 'Edit') {
|
|
236
|
-
const parts = [toolInput?.old_string ?? '', toolInput?.new_string ?? ''].join('');
|
|
237
|
-
if (parts.length > 0) return estimateTokens(parts);
|
|
238
|
-
}
|
|
239
|
-
if (toolName === 'Read' && typeof toolInput?.file_path === 'string' && toolInput.file_path) {
|
|
240
|
-
try {
|
|
241
|
-
if (existsSync(toolInput.file_path)) {
|
|
242
|
-
const size = statSync(toolInput.file_path).size;
|
|
243
|
-
if (size > 0) return Math.ceil(size / 4);
|
|
244
|
-
}
|
|
245
|
-
} catch {
|
|
246
|
-
/* fail-open: skip stat */
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
return undefined;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function readSessionFile(dir: string): SessionFile {
|
|
253
|
-
const sessionFile = join(dir, '.session.json');
|
|
254
|
-
if (!existsSync(sessionFile)) return {};
|
|
255
|
-
try {
|
|
256
|
-
return JSON.parse(readFileSync(sessionFile, 'utf-8')) as SessionFile;
|
|
257
|
-
} catch {
|
|
258
|
-
return {};
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
/**
|
|
263
|
-
* Core record path (testable). Returns the written event, or null when nothing was logged
|
|
264
|
-
* (unknown tool, missing session, missing path/summary, I/O failure).
|
|
265
|
-
*/
|
|
266
|
-
export function recordToolUseEvent(
|
|
267
|
-
contextDir: string,
|
|
268
|
-
payload: ToolPayload,
|
|
269
|
-
now: () => Date = () => new Date(),
|
|
270
|
-
): Record<string, unknown> | null {
|
|
271
|
-
const toolName = payload.tool_name ?? '';
|
|
272
|
-
if (!ALLOWED_TOOLS.has(toolName)) return null;
|
|
273
|
-
|
|
274
|
-
const filePath = payload.tool_input?.file_path ?? '';
|
|
275
|
-
const summary = buildToolSummary(toolName, payload.tool_input);
|
|
276
|
-
|
|
277
|
-
// Read/Write/Edit still require a path; Bash/Grep/Glob require a short summary.
|
|
278
|
-
if (toolName === 'Read' || toolName === 'Write' || toolName === 'Edit') {
|
|
279
|
-
if (!filePath) return null;
|
|
280
|
-
} else if (!summary) {
|
|
281
|
-
return null;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
const sessionMeta = readSessionFile(contextDir);
|
|
285
|
-
const session = sessionMeta.session ?? '';
|
|
286
|
-
if (!session) return null;
|
|
287
|
-
|
|
288
|
-
// Freshness stamp (task 0711 R4): the indexed-context producer is the agent
|
|
289
|
-
// following the skill's update guidance; the observable producer moment in
|
|
290
|
-
// code is a Write/Edit landing on a `.spur/context/*.md` file. Each such
|
|
291
|
-
// write refreshes `.freshness.json` so staleness checks can compare the
|
|
292
|
-
// sidecar's source commit against HEAD until the next regeneration.
|
|
293
|
-
if ((toolName === 'Write' || toolName === 'Edit') && isContextIndexFile(contextDir, filePath)) {
|
|
294
|
-
stampContextFreshness(contextDir, currentHeadCommit(), now());
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const tokens = resolveTokenEstimate(toolName, payload.tool_input, payload.tool_response);
|
|
298
|
-
const ts = now().toISOString();
|
|
299
|
-
const type = mapToolType(toolName);
|
|
300
|
-
const action = toolName === 'Write' ? 'create' : toolName === 'Edit' ? 'edit' : undefined;
|
|
301
|
-
|
|
302
|
-
const event: Record<string, unknown> = { ts, session, type };
|
|
303
|
-
if (filePath) event.file = filePath;
|
|
304
|
-
if (summary) event.summary = summary;
|
|
305
|
-
if (tokens !== undefined) event.tokens = tokens;
|
|
306
|
-
if (action) event.action = action;
|
|
307
|
-
|
|
308
|
-
// Best-effort identity fields — never block logging.
|
|
309
|
-
if (typeof payload.session_id === 'string' && payload.session_id) {
|
|
310
|
-
event.sessionId = payload.session_id;
|
|
311
|
-
}
|
|
312
|
-
if (typeof sessionMeta.agent === 'string' && sessionMeta.agent) event.agent = sessionMeta.agent;
|
|
313
|
-
if (typeof sessionMeta.model === 'string' && sessionMeta.model) event.model = sessionMeta.model;
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
appendFileSync(join(contextDir, 'token-ledger.jsonl'), `${JSON.stringify(event)}\n`);
|
|
317
|
-
} catch {
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
return event;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// ─── Context freshness sidecar (task 0711 R4) ───────────────────────────
|
|
325
|
-
|
|
326
|
-
export interface ContextFreshness {
|
|
327
|
-
schema_version: number;
|
|
328
|
-
source_commit: string;
|
|
329
|
-
generated_at: string;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
export const CONTEXT_FRESHNESS_SCHEMA_VERSION = 1;
|
|
333
|
-
|
|
334
|
-
/** True when `filePath` is a regenerated context index inside `contextDir` (not the sidecars). */
|
|
335
|
-
export function isContextIndexFile(contextDir: string, filePath: string): boolean {
|
|
336
|
-
const resolvedDir = resolve(contextDir);
|
|
337
|
-
const resolvedFile = resolve(filePath);
|
|
338
|
-
if (!resolvedFile.startsWith(resolvedDir + sep)) return false;
|
|
339
|
-
const name = resolvedFile.slice(resolvedDir.length + 1);
|
|
340
|
-
return name.endsWith('.md');
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
/** Current HEAD, best-effort — null when git is unavailable or the dir is not a work tree. */
|
|
344
|
-
export function currentHeadCommit(): string | null {
|
|
345
|
-
try {
|
|
346
|
-
const out = execSync('git rev-parse HEAD', { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
347
|
-
const commit = out.trim();
|
|
348
|
-
return commit.length > 0 ? commit : null;
|
|
349
|
-
} catch {
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* Write/refresh `.spur/context/.freshness.json`. Best-effort: any I/O failure
|
|
356
|
-
* leaves the previous sidecar (or none) in place — the hook contract is
|
|
357
|
-
* fail-open, never blocking.
|
|
358
|
-
*/
|
|
359
|
-
export function stampContextFreshness(contextDir: string, sourceCommit: string | null, at: Date): void {
|
|
360
|
-
if (!sourceCommit) return;
|
|
361
|
-
const body: ContextFreshness = {
|
|
362
|
-
schema_version: CONTEXT_FRESHNESS_SCHEMA_VERSION,
|
|
363
|
-
source_commit: sourceCommit,
|
|
364
|
-
generated_at: at.toISOString(),
|
|
365
|
-
};
|
|
366
|
-
try {
|
|
367
|
-
writeFileSync(join(contextDir, '.freshness.json'), JSON.stringify(body));
|
|
368
|
-
} catch {
|
|
369
|
-
/* fail-open */
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export interface ContextFreshnessCheck {
|
|
374
|
-
stale: boolean;
|
|
375
|
-
reason?: string;
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
/**
|
|
379
|
-
* Compare a freshness sidecar against the current HEAD (task 0711 R4). A
|
|
380
|
-
* missing/unparsable sidecar is stale (`never stamped`); a commit mismatch is
|
|
381
|
-
* stale until the producer regenerates the index.
|
|
382
|
-
*/
|
|
383
|
-
export function checkContextFreshness(raw: string | null, headCommit: string | null): ContextFreshnessCheck {
|
|
384
|
-
if (raw === null) return { stale: true, reason: 'never stamped' };
|
|
385
|
-
let body: ContextFreshness;
|
|
386
|
-
try {
|
|
387
|
-
body = JSON.parse(raw) as ContextFreshness;
|
|
388
|
-
} catch {
|
|
389
|
-
return { stale: true, reason: 'malformed sidecar' };
|
|
390
|
-
}
|
|
391
|
-
if (body.schema_version !== CONTEXT_FRESHNESS_SCHEMA_VERSION) {
|
|
392
|
-
return { stale: true, reason: `schema_version ${String(body.schema_version)}` };
|
|
393
|
-
}
|
|
394
|
-
if (typeof body.source_commit !== 'string' || body.source_commit === '') {
|
|
395
|
-
return { stale: true, reason: 'missing source_commit' };
|
|
396
|
-
}
|
|
397
|
-
if (headCommit !== null && body.source_commit !== headCommit) {
|
|
398
|
-
return { stale: true, reason: 'source commit changed since generation' };
|
|
399
|
-
}
|
|
400
|
-
return { stale: false };
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
/** Read the freshness sidecar, or null when absent/unreadable. */
|
|
404
|
-
export function readContextFreshness(contextDir: string): string | null {
|
|
405
|
-
try {
|
|
406
|
-
return readFileSync(join(contextDir, '.freshness.json'), 'utf-8');
|
|
407
|
-
} catch {
|
|
408
|
-
return null;
|
|
409
|
-
}
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
// Entrypoint — thin wrapper; logic lives in {@link recordToolUseEvent} for unit coverage.
|
|
413
16
|
if (import.meta.main) {
|
|
414
17
|
void (async () => {
|
|
415
18
|
const dir = join(getEnvVar('CLAUDE_PROJECT_DIR') ?? process.cwd(), '.spur', 'context');
|