session-orchestrator 3.21.0 → 3.23.0
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +11 -2
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +10 -10
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +269 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/memory-proposal-collector.md +6 -4
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/eli5.md +33 -0
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/commands/release.md +5 -3
- package/commands/test.md +2 -2
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +7 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +52 -5
- package/docs/session-config-reference.md +57 -12
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +26 -16
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +12 -2
- package/hooks/on-session-end.mjs +237 -104
- package/hooks/on-session-start.mjs +404 -110
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +133 -21
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +12 -8
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/auq/clarity.mjs +1314 -0
- package/scripts/lib/auq/parse.mjs +1006 -0
- package/scripts/lib/auq/schema.mjs +1457 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +221 -68
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +84 -11
- package/scripts/lib/gates/gate-helpers.mjs +93 -21
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/io.mjs +239 -0
- package/scripts/lib/issue-budget.mjs +284 -31
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +104 -4
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +165 -7
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/resource-probe/evaluate.mjs +330 -149
- package/scripts/lib/resource-probe/probe-platform.mjs +35 -0
- package/scripts/lib/resource-probe.mjs +18 -2
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +111 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +467 -72
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +59 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/vcs-repo-spec.mjs +182 -13
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +478 -0
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/release.mjs +443 -122
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +6 -0
- package/scripts/validate-wave-scope.mjs +73 -1
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +31 -25
- package/skills/_shared/parallel-aware-preamble.md +35 -4
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +61 -8
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +5 -4
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/discovery/SKILL.md +28 -24
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/gitlab-ops/SKILL.md +30 -26
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/memory-cleanup/SKILL.md +2 -2
- package/skills/npm-publish/SKILL.md +4 -4
- package/skills/peekaboo-driver/SKILL.md +18 -6
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +35 -23
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +44 -4
- package/skills/session-end/SKILL.md +70 -24
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +48 -5
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +96 -48
- package/skills/session-start/phase-2-5-docs-planning.md +8 -8
- package/skills/session-start/phase-4-5-resource-health.md +82 -19
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/session-start/soul.md +110 -0
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +129 -24
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/templates/_shared/journey-manifest.md +110 -0
- package/scripts/tests/fixtures/fetch-baseline/sample-rule.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/90-archive/bad-archived.md +0 -8
- package/skills/vault-sync/tests/fixtures/archive-test-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/archive-test-vault/live-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/bad-type.md +0 -8
- package/skills/vault-sync/tests/fixtures/broken-frontmatter-vault/good-note.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/.obsidian/config.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/01-projects/foo/projects-baseline.md +0 -10
- package/skills/vault-sync/tests/fixtures/clean-vault/03-daily/daily-2026-04-13.md +0 -8
- package/skills/vault-sync/tests/fixtures/clean-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/clean-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/has-dangling.md +0 -9
- package/skills/vault-sync/tests/fixtures/dangling-link-vault/real-target.md +0 -8
- package/skills/vault-sync/tests/fixtures/empty-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/missing-field-vault/missing-id.md +0 -7
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/03-daily/daily-2026-04-13.md +0 -9
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/nested-tag-vault/nested-tags-note.md +0 -11
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/README.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_MOC.md +0 -3
- package/skills/vault-sync/tests/fixtures/no-frontmatter-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_MOC.md +0 -11
- package/skills/vault-sync/tests/fixtures/with-moc-vault/_meta/.gitkeep +0 -0
- package/skills/vault-sync/tests/fixtures/with-moc-vault/hello-world.md +0 -11
- package/skills/vault-sync/tests/schema-drift.test.mjs +0 -133
- package/templates/_shared/rules/parallel-sessions.md +0 -77
package/scripts/lib/io.mjs
CHANGED
|
@@ -98,6 +98,45 @@ const DENY_REASON_MAX = 16_000;
|
|
|
98
98
|
*/
|
|
99
99
|
const WARN_MESSAGE_MAX = 16_000;
|
|
100
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Hard ceiling (in BYTES, including the trailing newline) for the whole
|
|
103
|
+
* {@link emitRewrite} envelope.
|
|
104
|
+
*
|
|
105
|
+
* Denominated in bytes, not characters, unlike {@link DENY_REASON_MAX} — the
|
|
106
|
+
* quantity that actually matters here is what the kernel pipe buffer holds, and
|
|
107
|
+
* a character count only approximates that for ASCII. `Buffer.byteLength` makes
|
|
108
|
+
* the bound exact for any input.
|
|
109
|
+
*
|
|
110
|
+
* Derivation, measured 2026-08-22 against the repo's own AUQ corpus
|
|
111
|
+
* (`node scripts/auq-audit.mjs --json`, 70 real question blocks, HEAD clean):
|
|
112
|
+
*
|
|
113
|
+
* | quantity | measured |
|
|
114
|
+
* |-----------------------------------------------------------|----------|
|
|
115
|
+
* | largest real tool-input in the corpus (plan/SKILL.md:136) | 1 369 B |
|
|
116
|
+
* | median / mean over all 70 blocks | 549 / 547 B |
|
|
117
|
+
* | max question text · header · label · description · preview | 269 · 12 · 48 · 180 · 252 |
|
|
118
|
+
* | protocol-cap worst case: 4 questions × 4 options, each field at its measured max | 9 785 B |
|
|
119
|
+
*
|
|
120
|
+
* The protocol caps come from `.claude/rules/ask-via-tool.md` § AUQ-003 (1–4
|
|
121
|
+
* questions, 2–4 options); the corpus tops out at 3 questions and 4 options, so
|
|
122
|
+
* the 9 785 B row is already an over-estimate of anything observed. 32 768 sits
|
|
123
|
+
* **3.35× above that worst case** and **23.9× above the largest real payload**,
|
|
124
|
+
* while being exactly **half** the 65 536-byte kernel pipe buffer — so a
|
|
125
|
+
* cap-sized envelope fits one buffer with the whole second half to spare, and a
|
|
126
|
+
* rewrite that expands its source text several-fold is still nowhere near the
|
|
127
|
+
* ceiling.
|
|
128
|
+
*
|
|
129
|
+
* ## Why this REJECTS where {@link DENY_REASON_MAX} CLAMPS
|
|
130
|
+
*
|
|
131
|
+
* A deny reason is prose: clipping it leaves a valid deny with a shorter
|
|
132
|
+
* explanation. `updatedInput` is structure: clipping it mid-object yields
|
|
133
|
+
* unparseable JSON, which the harness reads as no-decision — the truncated
|
|
134
|
+
* envelope would silently discard the rewrite while looking like a successful
|
|
135
|
+
* emit. So an over-ceiling payload is never sliced; it is refused whole, and the
|
|
136
|
+
* tool call proceeds with its original input (see {@link emitRewrite}).
|
|
137
|
+
*/
|
|
138
|
+
const REWRITE_ENVELOPE_MAX_BYTES = 32_768;
|
|
139
|
+
|
|
101
140
|
/**
|
|
102
141
|
* Reason substituted when a caller denies without supplying one.
|
|
103
142
|
*
|
|
@@ -489,6 +528,206 @@ export function emitWarn(message) {
|
|
|
489
528
|
process.exit(0);
|
|
490
529
|
}
|
|
491
530
|
|
|
531
|
+
/**
|
|
532
|
+
* Rewrite the **tool input** of the current PreToolUse invocation and let the
|
|
533
|
+
* normal flow continue: emit exactly one JSON object on stdout, then exit **0**.
|
|
534
|
+
*
|
|
535
|
+
* This is the third PreToolUse verb, alongside {@link emitDeny} ("block") and
|
|
536
|
+
* {@link emitWarn} ("allow, with a notice"). It decides nothing — it hands the
|
|
537
|
+
* harness a replacement input and steps out of the way.
|
|
538
|
+
*
|
|
539
|
+
* ## Emitted payload (single stdout line, nothing else on stdout)
|
|
540
|
+
*
|
|
541
|
+
* ```json
|
|
542
|
+
* {"hookSpecificOutput":{"hookEventName":"PreToolUse","updatedInput":{…}}}
|
|
543
|
+
* ```
|
|
544
|
+
*
|
|
545
|
+
* ## THE TRAP: there is deliberately no `permissionDecision`, and no way to add one
|
|
546
|
+
*
|
|
547
|
+
* The shipped Claude Code binary (2.1.239) routes the rewrite through a branch
|
|
548
|
+
* that is guarded on the ABSENCE of a permission decision:
|
|
549
|
+
*
|
|
550
|
+
* ```js
|
|
551
|
+
* if (p.updatedInput && p.permissionBehavior === void 0)
|
|
552
|
+
* yield { type: "hookUpdatedInput", updatedInput: p.updatedInput };
|
|
553
|
+
* ```
|
|
554
|
+
*
|
|
555
|
+
* Pair `updatedInput` with `permissionDecision: "allow"` and a different branch
|
|
556
|
+
* takes it: the input is still replaced, but the **permission stage is skipped**.
|
|
557
|
+
* For most tools that is merely a lost prompt. For `AskUserQuestion` it is fatal,
|
|
558
|
+
* because the permission stage IS the question card — `checkPermissions` returns
|
|
559
|
+
* `behavior: "ask"`, and that "ask" is what renders the options to the operator.
|
|
560
|
+
* An `allow` there does not approve the question; it routes the question PAST the
|
|
561
|
+
* human. The operator is never asked, nothing errors, and the omission is
|
|
562
|
+
* invisible from both ends.
|
|
563
|
+
*
|
|
564
|
+
* The prevention is structural, not advisory. This function takes **one**
|
|
565
|
+
* parameter — the tool input — and builds `hookSpecificOutput` here as a
|
|
566
|
+
* two-key object literal with no spread, no `opts` bag, and no caller-reachable
|
|
567
|
+
* key. There is no argument a caller can pass that lands as a sibling of
|
|
568
|
+
* `updatedInput`; a `permissionDecision` key inside the caller's own object
|
|
569
|
+
* nests one level deeper (`updatedInput.permissionDecision`), where the harness
|
|
570
|
+
* ignores it. Adding a spread or an options parameter here would re-open the
|
|
571
|
+
* trap — that is what `tests/lib/io.test.mjs` pins, since the structure cannot
|
|
572
|
+
* defend itself against a future edit.
|
|
573
|
+
*
|
|
574
|
+
* A hook that needs BOTH a permission decision and a rewrite cannot have both:
|
|
575
|
+
* choose the decision, because a rewrite that skips the operator's question is
|
|
576
|
+
* the exact failure this function is shaped to avoid.
|
|
577
|
+
*
|
|
578
|
+
* ## `updatedInput` is the COMPLETE tool input, never a patch
|
|
579
|
+
*
|
|
580
|
+
* The bundle types it as a map, not a diff (`updatedInput: oo(H(), Pn())`), and
|
|
581
|
+
* the harness substitutes it wholesale. A caller that passes only the fields it
|
|
582
|
+
* changed **deletes every field it omitted** — for `AskUserQuestion`, passing
|
|
583
|
+
* `{questions: [{question: '…'}]}` drops the options and the header along with
|
|
584
|
+
* them. Read the original input from the hook payload, modify it, and pass the
|
|
585
|
+
* whole object back.
|
|
586
|
+
*
|
|
587
|
+
* ## Fail-closed on an invalid rewrite is the HARNESS's behaviour, not ours
|
|
588
|
+
*
|
|
589
|
+
* A syntactically fine but schema-invalid `updatedInput` (5 questions where 4 is
|
|
590
|
+
* the cap, 1 option where 2 is the minimum) does not slip through as a silent
|
|
591
|
+
* pass — the bundle turns it into a deny:
|
|
592
|
+
*
|
|
593
|
+
* ```js
|
|
594
|
+
* if (!f.success && m.length > 0) { … u = { behavior: "deny", message: g, … }; continue }
|
|
595
|
+
* ```
|
|
596
|
+
*
|
|
597
|
+
* So a malformed rewrite costs the tool call, not the operator's trust. Callers
|
|
598
|
+
* are still responsible for emitting a schema-valid input; this note only
|
|
599
|
+
* records that the failure direction is safe.
|
|
600
|
+
*
|
|
601
|
+
* ## EVIDENCE STATUS — code-evidence, not runtime-evidence
|
|
602
|
+
*
|
|
603
|
+
* Everything above is read out of the shipped 2.1.239 bundle. **No hook with a
|
|
604
|
+
* matcher of `AskUserQuestion` has ever run in this repo**, so the rewrite
|
|
605
|
+
* branch has not been observed executing. The runtime proof needs a session
|
|
606
|
+
* restart and is outstanding. Treat the branch conditions as verified source and
|
|
607
|
+
* the end-to-end behaviour as expected-but-unconfirmed.
|
|
608
|
+
*
|
|
609
|
+
* ## When emitRewrite is the WRONG verb
|
|
610
|
+
*
|
|
611
|
+
* 1. **You need to block.** A rewrite carries no denial; the tool call proceeds
|
|
612
|
+
* whatever the new input says. Use {@link emitDeny}.
|
|
613
|
+
* 2. **You need to pre-approve.** Skipping the permission prompt is
|
|
614
|
+
* `permissionDecision: "allow"`, and combining it with a rewrite is the trap
|
|
615
|
+
* above. Emit one or the other, never a hand-rolled envelope carrying both.
|
|
616
|
+
* 3. **The event is not PreToolUse.** `hookEventName` is hardcoded; PostToolUse
|
|
617
|
+
* / Stop / SubagentStop have no `updatedInput` at all and signal through a
|
|
618
|
+
* top-level `decision` (same precondition as {@link emitDeny}).
|
|
619
|
+
* 4. **You cannot reconstruct the whole input.** A partial object deletes the
|
|
620
|
+
* rest — if the original input is not in hand, do nothing ({@link emitAllow})
|
|
621
|
+
* rather than emit a lossy replacement.
|
|
622
|
+
*
|
|
623
|
+
* ## Delivery, and why a failed write still exits 0
|
|
624
|
+
*
|
|
625
|
+
* The envelope goes out through {@link writeStdoutLineSync}, never
|
|
626
|
+
* `console.log`: on macOS a piped stdout is asynchronous, so `console.log` +
|
|
627
|
+
* `process.exit(0)` drops everything past the 65 536-byte kernel pipe buffer and
|
|
628
|
+
* the rewrite vanishes without a trace. The payload is additionally bounded by
|
|
629
|
+
* {@link REWRITE_ENVELOPE_MAX_BYTES} — refused whole rather than clipped, since
|
|
630
|
+
* a clipped JSON object is not a smaller rewrite but an unparseable one.
|
|
631
|
+
*
|
|
632
|
+
* ── BV-004: at TODAY'S ceiling the two bounds are not equally load-bearing ────
|
|
633
|
+
* Measured 2026-08-22 (`console.log` of an N-byte line + `process.exit(0)`,
|
|
634
|
+
* piped into a reader that sleeps 300 ms before draining, so the buffer really
|
|
635
|
+
* fills): N=200 000 → 65 536 delivered, N=70 000 → 65 536, **N=32 768 → 32 768
|
|
636
|
+
* delivered intact**. Since the ceiling refuses anything larger, no payload this
|
|
637
|
+
* function can emit is big enough for `console.log` to lose — the clamp alone
|
|
638
|
+
* carries the guarantee today, and the synchronous write is the redundant half.
|
|
639
|
+
* It stays anyway, and this is the trigger to re-read before touching either:
|
|
640
|
+
* **raise {@link REWRITE_ENVELOPE_MAX_BYTES} above 65 536 and the sync write
|
|
641
|
+
* becomes the ONLY thing standing between a large rewrite and silent
|
|
642
|
+
* truncation.** Swapping in `console.log` "because the cap already protects us"
|
|
643
|
+
* is safe only for as long as nobody moves the cap — which is precisely the kind
|
|
644
|
+
* of coupling that rots unremarked. Note also that no behavioural test can
|
|
645
|
+
* currently distinguish the two writers here (see `tests/lib/io.test.mjs`).
|
|
646
|
+
*
|
|
647
|
+
* {@link emitDeny} exits **2** when stdout is unwritable, because its decision is
|
|
648
|
+
* "block" and with the structured channel gone the exit code is the only
|
|
649
|
+
* blocking signal left. **This function must not copy that.** `emitRewrite`
|
|
650
|
+
* holds no decision to preserve: its degraded state is "the tool call runs with
|
|
651
|
+
* its original input", which is exactly what happens when no envelope is
|
|
652
|
+
* emitted. Exit 2 would convert that harmless loss into a block —
|
|
653
|
+
* `scripts/lib/pi-hook-bridge.mjs:389` treats the status unconditionally
|
|
654
|
+
* (`const blocked = result.status === 2 || …`, evaluated before stdout is
|
|
655
|
+
* consulted), and the documented contract says exit 2 discards stdout and feeds
|
|
656
|
+
* stderr back to Claude as an error. On the `AskUserQuestion` path that means
|
|
657
|
+
* the operator's question is destroyed to protect a wording improvement. Every
|
|
658
|
+
* failure here therefore degrades to "no stdout, exit 0, loud stderr" — the same
|
|
659
|
+
* asymmetry {@link emitWarn} documents, for the same reason.
|
|
660
|
+
*
|
|
661
|
+
* Never throws, for the reason {@link emitDeny} spells out: a throw unwinds into
|
|
662
|
+
* the `main().catch(() => emitAllow())` that four hooks install. Here that
|
|
663
|
+
* catch would be harmless by luck rather than by design, and a hook whose catch
|
|
664
|
+
* routes to `emitDeny` instead would turn a failed rewrite into a block.
|
|
665
|
+
*
|
|
666
|
+
* @param {object} updatedInput The COMPLETE replacement tool input. A non-object,
|
|
667
|
+
* `null`, an array, or a value `JSON.stringify` rejects degrades to a
|
|
668
|
+
* no-op plus a stderr diagnostic — never a throw, never a partial emit.
|
|
669
|
+
* @returns {never}
|
|
670
|
+
*/
|
|
671
|
+
export function emitRewrite(updatedInput) {
|
|
672
|
+
/**
|
|
673
|
+
* Abandon the rewrite: say why on stderr, emit nothing, exit 0. The tool call
|
|
674
|
+
* then runs with its original input — the intended degraded state, not a
|
|
675
|
+
* fail-open, because this helper never held a decision to lose.
|
|
676
|
+
*
|
|
677
|
+
* @param {string} diagnostic
|
|
678
|
+
* @returns {never}
|
|
679
|
+
*/
|
|
680
|
+
const bail = (diagnostic) => {
|
|
681
|
+
try {
|
|
682
|
+
process.stderr.write(
|
|
683
|
+
`⚠ io.mjs: emitRewrite ${diagnostic} — leaving the tool input unchanged\n`,
|
|
684
|
+
);
|
|
685
|
+
} catch { /* stderr may be closed; the silent no-op below is what matters */ }
|
|
686
|
+
process.exit(0);
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
if (updatedInput === null || typeof updatedInput !== 'object' || Array.isArray(updatedInput)) {
|
|
690
|
+
// `updatedInput` is a MAP in the bundle schema, so an array is as wrong as a
|
|
691
|
+
// string — and an array would serialize into a shape the harness cannot use.
|
|
692
|
+
bail(
|
|
693
|
+
`was called with ${Array.isArray(updatedInput) ? 'an array' : String(updatedInput === null ? 'null' : typeof updatedInput)}, not a tool-input object`,
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
let line;
|
|
698
|
+
try {
|
|
699
|
+
// The whole trap-closure: a two-key object literal, built here. No spread,
|
|
700
|
+
// no caller-supplied opts — nothing a caller passes can become a sibling of
|
|
701
|
+
// `updatedInput`, and `permissionDecision` therefore cannot appear.
|
|
702
|
+
line = JSON.stringify({
|
|
703
|
+
hookSpecificOutput: {
|
|
704
|
+
hookEventName: 'PreToolUse',
|
|
705
|
+
updatedInput,
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
} catch (err) {
|
|
709
|
+
// A cycle or a BigInt in the caller's object. JSON.stringify can also return
|
|
710
|
+
// undefined (a toJSON that yields undefined) — caught by the same guard.
|
|
711
|
+
bail(`could not serialize the tool input (${err?.message ?? String(err)})`);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
if (typeof line !== 'string') {
|
|
715
|
+
bail('serialized the tool input to undefined (a toJSON returning undefined?)');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const bytes = Buffer.byteLength(`${line}\n`, 'utf8');
|
|
719
|
+
if (bytes > REWRITE_ENVELOPE_MAX_BYTES) {
|
|
720
|
+
// Refused whole, never sliced — see REWRITE_ENVELOPE_MAX_BYTES for why a
|
|
721
|
+
// clipped structure is worse than no structure.
|
|
722
|
+
bail(`envelope is ${bytes} bytes, over the ${REWRITE_ENVELOPE_MAX_BYTES}-byte ceiling`);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// Return value deliberately ignored: a rewrite that cannot be delivered costs
|
|
726
|
+
// the improvement, never the tool call. See the asymmetry note above.
|
|
727
|
+
writeStdoutLineSync(line);
|
|
728
|
+
process.exit(0);
|
|
729
|
+
}
|
|
730
|
+
|
|
492
731
|
/**
|
|
493
732
|
* Inject a system message into the hook response without exiting.
|
|
494
733
|
* @param {string} msg Message text wrapped in a systemMessage JSON envelope on stdout.
|
|
@@ -27,16 +27,89 @@
|
|
|
27
27
|
* Stdlib only — the hook path must stay cheap enough to run on every Bash call.
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
|
-
import {
|
|
30
|
+
import { digestSha256Short } from './crypto-digest-utils.mjs';
|
|
31
|
+
import { existsSync, readFileSync, readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
31
32
|
import path from 'node:path';
|
|
32
33
|
|
|
33
34
|
import { writeJsonAtomicSync } from './io.mjs';
|
|
34
35
|
import { resolveInstructionFile } from './common.mjs';
|
|
35
36
|
import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
36
37
|
|
|
37
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Legacy single-slot counter file, relative to the repo root.
|
|
40
|
+
*
|
|
41
|
+
* Kept as the path for IDENTITY-LESS callers only, and as the one-time
|
|
42
|
+
* migration source for a session whose spend was recorded before #1141.
|
|
43
|
+
*/
|
|
38
44
|
export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
|
|
39
45
|
|
|
46
|
+
/** Directory holding the per-session counter files, relative to the repo root. */
|
|
47
|
+
export const BUDGET_STATE_DIR_REL = '.orchestrator/runtime/issue-budget';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Relative path of the counter file for one accounting session (#1141).
|
|
51
|
+
*
|
|
52
|
+
* WHY per session and not one file: the counter used to be ONE slot per
|
|
53
|
+
* WORKING COPY, keyed by whichever `sessionId` happened to write last, and
|
|
54
|
+
* `readBudgetState` zeroes the state whenever the file's `sessionId` differs
|
|
55
|
+
* from the reader's. Two concurrent sessions in one working copy therefore
|
|
56
|
+
* alternately reset each other's counter and BOTH ran with the cap silently
|
|
57
|
+
* off — measured 2026-08-23, where the live file was owned by a session that
|
|
58
|
+
* had started 11 h before the one reading it. Session identity belongs in the
|
|
59
|
+
* FILE NAME, not in a field the next writer overwrites.
|
|
60
|
+
*
|
|
61
|
+
* The name is a truncated SHA-256 rather than the id itself because session
|
|
62
|
+
* ids are operator/host-supplied strings: a semantic id contains `/`-free but
|
|
63
|
+
* unbounded text, and a raw id is a UUID. Hashing gives a fixed-length,
|
|
64
|
+
* filesystem-safe, path-traversal-free name for both shapes. 16 hex chars
|
|
65
|
+
* (64 bits) is far beyond the handful of sessions that ever share one working
|
|
66
|
+
* copy; revisit only if a repo ever needs the id to be readable from the name
|
|
67
|
+
* (it never has — every reader already knows which session it is).
|
|
68
|
+
*
|
|
69
|
+
* An identity-less caller (`null`/empty) keeps the legacy flat path: it never
|
|
70
|
+
* reads and never persists (see `readBudgetState` / `chargeIssueBudget`), so
|
|
71
|
+
* it needs a stable path only to NAME the store in messages.
|
|
72
|
+
*
|
|
73
|
+
* @param {string|null|undefined} sessionId accounting session key
|
|
74
|
+
* @returns {string} repo-relative path
|
|
75
|
+
*/
|
|
76
|
+
export function budgetStateRel(sessionId) {
|
|
77
|
+
const key = typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
78
|
+
if (key === null) return BUDGET_STATE_REL;
|
|
79
|
+
const digest = digestSha256Short(key, { length: 16 });
|
|
80
|
+
return `${BUDGET_STATE_DIR_REL}/${digest}.json`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the accounting key for a native session id.
|
|
85
|
+
*
|
|
86
|
+
* A semantic id is an accounting continuity bridge only after the native id
|
|
87
|
+
* proves that `current-session.json` belongs to this invocation. This neither
|
|
88
|
+
* establishes lock ownership nor bridges a host rotation that changes both ids.
|
|
89
|
+
*
|
|
90
|
+
* @param {string|null|undefined} candidateRawSessionId native hook/env session id
|
|
91
|
+
* @param {unknown} currentSession parsed `.orchestrator/current-session.json`
|
|
92
|
+
* @returns {string|null} semantic key for a verified pair, otherwise raw key
|
|
93
|
+
*/
|
|
94
|
+
export function resolveIssueBudgetSessionId(candidateRawSessionId, currentSession) {
|
|
95
|
+
const rawSessionId =
|
|
96
|
+
typeof candidateRawSessionId === 'string' && candidateRawSessionId.length > 0
|
|
97
|
+
? candidateRawSessionId
|
|
98
|
+
: null;
|
|
99
|
+
if (
|
|
100
|
+
rawSessionId === null ||
|
|
101
|
+
!currentSession ||
|
|
102
|
+
typeof currentSession !== 'object' ||
|
|
103
|
+
Array.isArray(currentSession) ||
|
|
104
|
+
currentSession.session_id !== rawSessionId ||
|
|
105
|
+
typeof currentSession.semantic_session_id !== 'string' ||
|
|
106
|
+
currentSession.semantic_session_id.length === 0
|
|
107
|
+
) {
|
|
108
|
+
return rawSessionId;
|
|
109
|
+
}
|
|
110
|
+
return currentSession.semantic_session_id;
|
|
111
|
+
}
|
|
112
|
+
|
|
40
113
|
/**
|
|
41
114
|
* Commands whose issue creation MUST NOT be blocked, with the reason recorded
|
|
42
115
|
* for the stderr trace and for the overflow bookkeeping.
|
|
@@ -96,12 +169,55 @@ export function loadIssueBudgetConfig(repoRoot) {
|
|
|
96
169
|
}
|
|
97
170
|
|
|
98
171
|
/**
|
|
99
|
-
* Absolute path of the runtime counter file for a repo.
|
|
172
|
+
* Absolute path of the runtime counter file for one session in a repo.
|
|
173
|
+
*
|
|
100
174
|
* @param {string} repoRoot
|
|
175
|
+
* @param {string|null} [sessionId] accounting session key; omitted/empty
|
|
176
|
+
* yields the legacy identity-less flat path.
|
|
101
177
|
* @returns {string}
|
|
102
178
|
*/
|
|
103
|
-
export function budgetStatePath(repoRoot) {
|
|
104
|
-
return path.join(repoRoot,
|
|
179
|
+
export function budgetStatePath(repoRoot, sessionId = null) {
|
|
180
|
+
return path.join(repoRoot, budgetStateRel(sessionId));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Coerce a parsed counter file into a state object, or `null` when it does not
|
|
185
|
+
* belong to `accountingSessionId`.
|
|
186
|
+
*
|
|
187
|
+
* The owner check survives the move to per-session files: the file NAME now
|
|
188
|
+
* carries identity, but a hand-edited, hash-colliding or hand-copied file must
|
|
189
|
+
* still not hand its spend to a different session.
|
|
190
|
+
*
|
|
191
|
+
* @param {unknown} data
|
|
192
|
+
* @param {string} accountingSessionId
|
|
193
|
+
* @returns {{ sessionId: string, count: number, exempt: number, overflow: object[] }|null}
|
|
194
|
+
*/
|
|
195
|
+
function _coerceState(data, accountingSessionId) {
|
|
196
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
197
|
+
if (data.sessionId !== accountingSessionId) return null;
|
|
198
|
+
return {
|
|
199
|
+
sessionId: accountingSessionId,
|
|
200
|
+
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
201
|
+
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
202
|
+
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Read a counter file and coerce it, swallowing every I/O and parse error
|
|
208
|
+
* (fail-open: an unreadable ledger must never block a creation).
|
|
209
|
+
*
|
|
210
|
+
* @param {string} file
|
|
211
|
+
* @param {string} accountingSessionId
|
|
212
|
+
* @returns {object|null}
|
|
213
|
+
*/
|
|
214
|
+
function _readStateFile(file, accountingSessionId) {
|
|
215
|
+
if (!existsSync(file)) return null;
|
|
216
|
+
try {
|
|
217
|
+
return _coerceState(JSON.parse(readFileSync(file, 'utf8')), accountingSessionId);
|
|
218
|
+
} catch {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
105
221
|
}
|
|
106
222
|
|
|
107
223
|
/**
|
|
@@ -109,39 +225,61 @@ export function budgetStatePath(repoRoot) {
|
|
|
109
225
|
* a fresh zeroed state for `sessionId` — the counter is per session by
|
|
110
226
|
* construction, so a new session never inherits the previous session's spend.
|
|
111
227
|
*
|
|
228
|
+
* An identity-less invocation always gets a fresh state and never reads a
|
|
229
|
+
* persisted budget. It therefore cannot provide durable per-session continuity,
|
|
230
|
+
* but avoiding cross-session budget and overflow attribution wins over a
|
|
231
|
+
* continuity guess without a verified native identity.
|
|
232
|
+
*
|
|
233
|
+
* The counter file is per session since #1141, but an identity-less charge
|
|
234
|
+
* still must never PERSIST its fresh state — it would land on the shared
|
|
235
|
+
* legacy path and, before the split, silently zeroed a live session's count
|
|
236
|
+
* and deleted its parked overflow records. `chargeIssueBudget` enforces that
|
|
237
|
+
* write-side half.
|
|
238
|
+
*
|
|
239
|
+
* MIGRATION (one-time, read-only): a session that started before the per-session
|
|
240
|
+
* split has its spend in the legacy flat file. When no per-session file exists
|
|
241
|
+
* yet and the legacy file still names THIS session, seed from it — otherwise the
|
|
242
|
+
* split itself would hand every in-flight session a fresh cap, which is the very
|
|
243
|
+
* failure it exists to remove. The legacy file is never written back; the first
|
|
244
|
+
* charge after the seed persists to the per-session path.
|
|
245
|
+
*
|
|
112
246
|
* @param {string} repoRoot
|
|
113
247
|
* @param {string|null} sessionId
|
|
114
248
|
* @returns {{ sessionId: string|null, count: number, exempt: number, overflow: object[] }}
|
|
115
249
|
*/
|
|
116
250
|
export function readBudgetState(repoRoot, sessionId) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
return fresh;
|
|
132
|
-
}
|
|
251
|
+
const accountingSessionId =
|
|
252
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
253
|
+
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
|
|
254
|
+
if (accountingSessionId === null) return fresh;
|
|
255
|
+
|
|
256
|
+
const ownFile = budgetStatePath(repoRoot, accountingSessionId);
|
|
257
|
+
const own = _readStateFile(ownFile, accountingSessionId);
|
|
258
|
+
if (own) return own;
|
|
259
|
+
// A present-but-unusable own file is a fail-open fresh state, NOT a reason to
|
|
260
|
+
// fall back to the legacy slot — the migration seed applies only before the
|
|
261
|
+
// session has a file of its own.
|
|
262
|
+
if (existsSync(ownFile)) return fresh;
|
|
263
|
+
|
|
264
|
+
return _readStateFile(path.join(repoRoot, BUDGET_STATE_REL), accountingSessionId) ?? fresh;
|
|
133
265
|
}
|
|
134
266
|
|
|
135
267
|
/**
|
|
136
|
-
* Persist the counter file. Best-effort: a write failure
|
|
137
|
-
* creation (fail-open), it only means the count is
|
|
268
|
+
* Persist the counter file for `state.sessionId`. Best-effort: a write failure
|
|
269
|
+
* never blocks a creation (fail-open), it only means the count is
|
|
270
|
+
* under-reported.
|
|
271
|
+
*
|
|
272
|
+
* The target path is derived from `state.sessionId`, so a state object can only
|
|
273
|
+
* ever be written into its OWN session's slot. `writeJsonAtomicSync` mkdir -p's
|
|
274
|
+
* the containing directory (`io.mjs#atomicWriteWithBackup`), which is what
|
|
275
|
+
* creates `.orchestrator/runtime/issue-budget/` on first use.
|
|
138
276
|
*
|
|
139
277
|
* @param {string} repoRoot
|
|
140
278
|
* @param {object} state
|
|
141
279
|
* @returns {boolean} true on success
|
|
142
280
|
*/
|
|
143
281
|
export function writeBudgetState(repoRoot, state) {
|
|
144
|
-
const res = writeJsonAtomicSync(budgetStatePath(repoRoot), state, {
|
|
282
|
+
const res = writeJsonAtomicSync(budgetStatePath(repoRoot, state?.sessionId ?? null), state, {
|
|
145
283
|
tmpPrefix: '.issue-budget',
|
|
146
284
|
});
|
|
147
285
|
return res.ok === true;
|
|
@@ -192,7 +330,12 @@ export function chargeIssueBudget({
|
|
|
192
330
|
const max = cfg['max-per-session'];
|
|
193
331
|
const mode = cfg.mode;
|
|
194
332
|
const overflowSink = cfg.overflow;
|
|
195
|
-
const
|
|
333
|
+
const accountingSessionId =
|
|
334
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
335
|
+
// Per-session store (#1141) — the path an operator or session-end must open
|
|
336
|
+
// to find THIS session's parked overflow, so the verdict has to name the
|
|
337
|
+
// session's own slot, not the directory or the legacy flat file.
|
|
338
|
+
const overflowPath = budgetStatePath(repoRoot, accountingSessionId);
|
|
196
339
|
|
|
197
340
|
const base = { max, mode, overflowSink, overflowPath };
|
|
198
341
|
|
|
@@ -200,13 +343,21 @@ export function chargeIssueBudget({
|
|
|
200
343
|
return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
|
|
201
344
|
}
|
|
202
345
|
|
|
203
|
-
const state = readBudgetState(repoRoot,
|
|
204
|
-
state.sessionId =
|
|
346
|
+
const state = readBudgetState(repoRoot, accountingSessionId);
|
|
347
|
+
state.sessionId = accountingSessionId;
|
|
348
|
+
|
|
349
|
+
// An identity-less charge has no key to account under, so it must not touch
|
|
350
|
+
// the SHARED counter file at all. Writing its fresh state would reset a live
|
|
351
|
+
// session's count to 0 AND drop its parked overflow[] entries — breaking the
|
|
352
|
+
// strict cap (a single identity-less call clears it) and session-end's
|
|
353
|
+
// "nothing is lost" promise. Read-side isolation alone does not cover this.
|
|
354
|
+
const persist = (next) =>
|
|
355
|
+
accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
|
|
205
356
|
|
|
206
357
|
const { exempt, reason } = classifyExemption(command);
|
|
207
358
|
if (exempt) {
|
|
208
359
|
state.exempt += 1;
|
|
209
|
-
|
|
360
|
+
persist(state);
|
|
210
361
|
return {
|
|
211
362
|
...base,
|
|
212
363
|
decision: 'exempt',
|
|
@@ -218,13 +369,13 @@ export function chargeIssueBudget({
|
|
|
218
369
|
|
|
219
370
|
if (state.count < max) {
|
|
220
371
|
state.count += 1;
|
|
221
|
-
|
|
372
|
+
persist(state);
|
|
222
373
|
return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
223
374
|
}
|
|
224
375
|
|
|
225
376
|
if (mode === 'warn') {
|
|
226
377
|
state.count += 1;
|
|
227
|
-
|
|
378
|
+
persist(state);
|
|
228
379
|
return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
229
380
|
}
|
|
230
381
|
|
|
@@ -234,7 +385,7 @@ export function chargeIssueBudget({
|
|
|
234
385
|
command: String(command).slice(0, 500),
|
|
235
386
|
at: now,
|
|
236
387
|
});
|
|
237
|
-
|
|
388
|
+
persist(state);
|
|
238
389
|
return {
|
|
239
390
|
...base,
|
|
240
391
|
decision: 'block',
|
|
@@ -267,3 +418,105 @@ export function formatBlockReason(v) {
|
|
|
267
418
|
`\`mode: warn\` reports without blocking, \`mode: off\` disables the gate.`,
|
|
268
419
|
].join('\n');
|
|
269
420
|
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Default age past which a per-session counter file is reaped (#1151).
|
|
424
|
+
*
|
|
425
|
+
* BV-004 ceiling: 14 days is a "nobody will ever drain this now" horizon, not a
|
|
426
|
+
* measured retention requirement. A session's overflow is drained at ITS OWN
|
|
427
|
+
* close (session-end Phase 5 Step 3b), so a file still carrying undrained
|
|
428
|
+
* overflow two weeks later belongs to a session that ended without closing —
|
|
429
|
+
* its parked items are already unreachable by the drain, which only ever reads
|
|
430
|
+
* the CURRENT session's file. Revisit if a triage workflow ever reads a foreign
|
|
431
|
+
* session's overflow after the fact.
|
|
432
|
+
*/
|
|
433
|
+
export const BUDGET_REAP_MAX_AGE_DAYS = 14;
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Recognised per-session counter file name — the 16-hex digest `budgetStateRel`
|
|
437
|
+
* emits, plus `.json`. Anything else in the directory is left alone: the reaper
|
|
438
|
+
* must only ever remove files it can prove it wrote itself.
|
|
439
|
+
*/
|
|
440
|
+
const BUDGET_FILE_NAME_RE = /^[0-9a-f]{16}\.json$/;
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Remove stale per-session counter files (#1151).
|
|
444
|
+
*
|
|
445
|
+
* WHY: the per-session split (#1141) writes one file per accounting session
|
|
446
|
+
* into `.orchestrator/runtime/issue-budget/` and NOTHING ever removed them —
|
|
447
|
+
* there was not a single unlink site for that directory in the repo. Every
|
|
448
|
+
* session in a working copy therefore left a permanent 100-200 byte artefact
|
|
449
|
+
* behind. This is the missing other half of the file's lifecycle, owned by
|
|
450
|
+
* session-end Phase 5 Step 3b (the drain runs first, this sweeps after).
|
|
451
|
+
*
|
|
452
|
+
* Age is judged by MTIME, and the CURRENT session's file is exempt regardless
|
|
453
|
+
* of age. Both halves are safety, not convenience: a parallel session in the
|
|
454
|
+
* same working copy (PSA-001) owns a file this process did not write, and its
|
|
455
|
+
* freshly-touched mtime is the only evidence available here that the owner is
|
|
456
|
+
* still alive — the same age-gate discipline `session-registry.mjs`
|
|
457
|
+
* `sweepZombies` uses for in-flight claim files.
|
|
458
|
+
*
|
|
459
|
+
* Best-effort by contract: every failure path is swallowed and the file counted
|
|
460
|
+
* as kept. A reaper that throws would abort the session close it runs inside,
|
|
461
|
+
* which is a strictly worse outcome than an un-reaped counter file.
|
|
462
|
+
*
|
|
463
|
+
* @param {{
|
|
464
|
+
* repoRoot: string,
|
|
465
|
+
* sessionId?: string|null,
|
|
466
|
+
* maxAgeDays?: number,
|
|
467
|
+
* now?: number,
|
|
468
|
+
* }} opts `sessionId` is the accounting session id whose file must survive;
|
|
469
|
+
* `now` is an epoch-ms injection point for tests.
|
|
470
|
+
* @returns {{ removed: string[], kept: string[] }} absolute paths, so a caller
|
|
471
|
+
* gets both a count (`.length`) and an audit trail.
|
|
472
|
+
*/
|
|
473
|
+
export function reapStaleBudgetFiles({
|
|
474
|
+
repoRoot,
|
|
475
|
+
sessionId = null,
|
|
476
|
+
maxAgeDays = BUDGET_REAP_MAX_AGE_DAYS,
|
|
477
|
+
now = Date.now(),
|
|
478
|
+
} = {}) {
|
|
479
|
+
const removed = [];
|
|
480
|
+
const kept = [];
|
|
481
|
+
try {
|
|
482
|
+
const dir = path.join(repoRoot, BUDGET_STATE_DIR_REL);
|
|
483
|
+
const cutoff = now - maxAgeDays * 24 * 60 * 60 * 1000;
|
|
484
|
+
const ownFile =
|
|
485
|
+
typeof sessionId === 'string' && sessionId.length > 0
|
|
486
|
+
? budgetStatePath(repoRoot, sessionId)
|
|
487
|
+
: null;
|
|
488
|
+
|
|
489
|
+
let names;
|
|
490
|
+
try {
|
|
491
|
+
names = readdirSync(dir);
|
|
492
|
+
} catch {
|
|
493
|
+
// No directory yet (or unreadable) — nothing to reap, not an error.
|
|
494
|
+
return { removed, kept };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const name of names) {
|
|
498
|
+
// Unrecognised entries are not part of this reaper's population at all:
|
|
499
|
+
// neither removed nor reported as kept.
|
|
500
|
+
if (!BUDGET_FILE_NAME_RE.test(name)) continue;
|
|
501
|
+
const file = path.join(dir, name);
|
|
502
|
+
if (file === ownFile) {
|
|
503
|
+
kept.push(file);
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
if (statSync(file).mtimeMs >= cutoff) {
|
|
508
|
+
kept.push(file);
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
unlinkSync(file);
|
|
512
|
+
removed.push(file);
|
|
513
|
+
} catch {
|
|
514
|
+
// Vanished under us (a peer's own reaper), or permission-denied.
|
|
515
|
+
kept.push(file);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
} catch {
|
|
519
|
+
// Unreachable in practice; the contract is "never throws", not "never fails".
|
|
520
|
+
}
|
|
521
|
+
return { removed, kept };
|
|
522
|
+
}
|