session-orchestrator 3.20.0 → 3.22.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/rules/000-session-orchestrator.mdc +3 -2
- package/.cursor/rules/030-wave-execution.mdc +10 -8
- package/.cursor/rules/040-discovery.mdc +6 -6
- package/.cursor/rules/050-plan.mdc +8 -8
- package/CHANGELOG.md +515 -0
- package/README.md +16 -11
- package/agents/analyst.md +1 -1
- package/agents/architect-reviewer.md +1 -1
- package/agents/code-implementer.md +4 -2
- package/agents/db-specialist.md +1 -1
- package/agents/dialectic-deriver.md +1 -1
- package/agents/docs-writer.md +1 -1
- package/agents/memory-proposal-collector.md +7 -5
- package/agents/qa-strategist.md +1 -1
- package/agents/security-reviewer.md +1 -1
- package/agents/session-reviewer.md +42 -1
- package/agents/skill-applied-judge.md +1 -1
- package/agents/test-writer.md +1 -1
- package/agents/ui-developer.md +1 -1
- package/agents/ux-evaluator.md +1 -1
- package/commands/eli5.md +33 -0
- package/commands/release.md +62 -0
- package/commands/test.md +2 -2
- package/docs/components.md +6 -5
- package/docs/migration-v3.md +9 -6
- package/docs/persona-panel.md +3 -1
- package/docs/scope-collision-guard.md +167 -0
- package/docs/session-config-reference.md +31 -8
- package/hooks/_lib/lock-bootstrap.mjs +19 -13
- package/hooks/enforce-scope.mjs +103 -3
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +21 -1
- package/hooks/on-session-end.mjs +76 -97
- package/hooks/on-session-start.mjs +195 -104
- package/hooks/on-stop.mjs +127 -12
- package/hooks/post-bash-write-verify.mjs +8 -32
- package/hooks/pre-auq-clarity.mjs +787 -0
- package/hooks/pre-bash-issue-budget.mjs +17 -18
- package/hooks/pre-task-scope-disjoint.mjs +1042 -0
- package/package.json +3 -1
- package/pi/prompts/eli5.md +12 -0
- package/pi/prompts/release.md +12 -0
- package/scripts/auq-audit.mjs +825 -0
- package/scripts/autopilot.mjs +10 -9
- package/scripts/emit-session.mjs +42 -0
- package/scripts/export-hw-learnings.mjs +61 -2
- 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 +5 -5
- package/scripts/lib/backlog-scan.mjs +106 -15
- package/scripts/lib/build-live-signals.mjs +7 -3
- package/scripts/lib/ci-status-banner.mjs +267 -77
- package/scripts/lib/config/dispatcher-autonomy-capture.mjs +32 -9
- package/scripts/lib/config/vault-integration.mjs +12 -1
- package/scripts/lib/dispatcher/rank.mjs +4 -7
- package/scripts/lib/gates/gate-full.mjs +3 -3
- package/scripts/lib/gates/gate-helpers.mjs +17 -6
- package/scripts/lib/git-config-drift.mjs +471 -0
- package/scripts/lib/harness-audit/categories/category6.mjs +65 -12
- package/scripts/lib/io.mjs +432 -7
- package/scripts/lib/issue-budget.mjs +63 -9
- package/scripts/lib/learnings/select.mjs +157 -3
- package/scripts/lib/memory-cleanup-stamp.mjs +132 -8
- package/scripts/lib/mirror-issues-banner.mjs +266 -0
- package/scripts/lib/named-vault-resolver.mjs +105 -16
- package/scripts/lib/owner-interview.mjs +78 -32
- package/scripts/lib/peer-cards/schema.mjs +6 -2
- package/scripts/lib/peer-discovery.mjs +73 -22
- package/scripts/lib/project-hygiene.mjs +64 -4
- package/scripts/lib/reconcile/renderer.mjs +17 -4
- package/scripts/lib/reconcile/writer.mjs +69 -30
- package/scripts/lib/redact-spans.mjs +89 -0
- 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/scope-baseline.mjs +77 -17
- package/scripts/lib/scope-gate.mjs +658 -0
- package/scripts/lib/secret-masker.mjs +262 -0
- package/scripts/lib/session-lock.mjs +34 -10
- package/scripts/lib/session-registry.mjs +9 -1
- package/scripts/lib/spiral-carryover.mjs +23 -2
- package/scripts/lib/state-md/mission-status.mjs +164 -58
- package/scripts/lib/tmux-layout/vcs-detector.mjs +108 -4
- package/scripts/lib/validate/check-agents.mjs +77 -5
- package/scripts/lib/validate/check-auq-clarity.mjs +274 -0
- package/scripts/lib/validate/check-commands.mjs +2 -20
- package/scripts/lib/validate/check-doc-cli-commands.mjs +514 -0
- package/scripts/lib/validate/check-hooks-symmetry.mjs +48 -0
- package/scripts/lib/validate/check-owner-leakage.mjs +185 -17
- package/scripts/lib/validate/check-rules.mjs +153 -9
- package/scripts/lib/validate/check-skills.mjs +191 -0
- package/scripts/lib/validate/check-test-git-config-target.mjs +665 -0
- package/scripts/lib/validate/check-unicode-safety.mjs +22 -2
- package/scripts/lib/validate/check-untracked-test-deps.mjs +925 -0
- package/scripts/lib/validate/check-unwired-features.mjs +219 -11
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +965 -0
- package/scripts/lib/validate/frontmatter-block.mjs +61 -0
- package/scripts/lib/validate/tier-inference.mjs +46 -8
- package/scripts/lib/vault-backfill/glab.mjs +91 -58
- package/scripts/lib/vault-backfill/manifest.mjs +28 -8
- package/scripts/lib/vault-mirror/namespace.mjs +146 -1
- package/scripts/lib/vault-mirror/process.mjs +264 -31
- package/scripts/lib/vault-mirror/render-sessions.mjs +115 -4
- package/scripts/lib/vault-status/board-writer.mjs +300 -56
- package/scripts/lib/vault-status/narrative-mirror.mjs +119 -5
- package/scripts/lib/vcs-repo-spec.mjs +680 -30
- package/scripts/lib/wave-resource-gate.mjs +67 -73
- package/scripts/materialize-wave-scope.mjs +281 -0
- package/scripts/print-learnings-index.mjs +30 -3
- package/scripts/release.mjs +983 -107
- package/scripts/run-quality-gate.mjs +14 -0
- package/scripts/site-numbers.mjs +1049 -0
- package/scripts/validate-plugin.mjs +64 -0
- package/scripts/validate-wave-scope.mjs +286 -12
- package/scripts/vault-backfill.mjs +32 -5
- package/scripts/vault-mirror.mjs +26 -1
- package/skills/_shared/monitor-patterns.md +24 -4
- package/skills/_shared/parallel-aware-auq.md +30 -24
- package/skills/_shared/parallel-aware-preamble.md +31 -2
- package/skills/_shared/state-ownership.md +49 -6
- package/skills/bootstrap/SKILL.md +2 -1
- package/skills/brainstorm/SKILL.md +18 -18
- package/skills/brainstorm/soul.md +12 -0
- package/skills/claude-md-drift-check/SKILL.md +9 -1
- package/skills/debug/SKILL.md +4 -1
- package/skills/discovery/SKILL.md +28 -24
- package/skills/discovery/issue-templates.md +4 -4
- package/skills/discovery/probes-code.md +2 -2
- package/skills/discovery/probes-feature.md +6 -6
- package/skills/discovery/probes-infra.md +2 -2
- package/skills/discovery/probes-session.md +5 -5
- package/skills/dispatcher/SKILL.md +10 -1
- package/skills/eli5/SKILL.md +43 -0
- package/skills/evolve/SKILL.md +8 -9
- package/skills/frontmatter-guard/SKILL.md +9 -1
- package/skills/gitlab-ops/SKILL.md +73 -59
- package/skills/gitlab-portfolio/SKILL.md +10 -1
- package/skills/grill/SKILL.md +6 -6
- package/skills/grill/soul.md +16 -0
- package/skills/memory-cleanup/SKILL.md +20 -7
- package/skills/npm-publish/SKILL.md +23 -51
- package/skills/peekaboo-driver/SKILL.md +3 -3
- package/skills/persona-panel/SKILL.md +3 -1
- package/skills/plan/SKILL.md +18 -16
- package/skills/plan/mode-feature.md +1 -1
- package/skills/plan/mode-new.md +42 -12
- package/skills/plan/soul.md +12 -0
- package/skills/reconcile/SKILL.md +3 -3
- package/skills/repo-audit/SKILL.md +10 -1
- package/skills/session-end/SKILL.md +97 -22
- package/skills/session-end/metrics-collection.md +1 -1
- package/skills/session-end/phase-3-6-tail.md +37 -2
- package/skills/session-end/session-metrics-write.md +4 -10
- package/skills/session-plan/SKILL.md +2 -2
- package/skills/session-plan/wave-template.md +1 -1
- package/skills/session-start/SKILL.md +82 -36
- 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/soul.md +110 -0
- package/skills/spinout/SKILL.md +5 -1
- package/skills/sunset-review/SKILL.md +11 -1
- package/skills/test-runner/SKILL.md +2 -2
- package/skills/tmux-layout/SKILL.md +7 -2
- package/skills/using-orchestrator/SKILL.md +1 -1
- package/skills/vault-mirror/SKILL.md +10 -1
- package/skills/vault-sync/SKILL.md +10 -1
- package/skills/vault-sync/validator.mjs +55 -6
- package/skills/wave-executor/wave-loop.md +64 -12
- package/skills/write-executable-plan/SKILL.md +6 -6
- package/scripts/lib/mission-status-schema.mjs +0 -114
- 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/scripts/lib/io.mjs
CHANGED
|
@@ -17,7 +17,16 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { writeFile, rename, mkdir } from 'node:fs/promises';
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
mkdirSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
renameSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
writeSync,
|
|
27
|
+
copyFileSync,
|
|
28
|
+
unlinkSync,
|
|
29
|
+
} from 'node:fs';
|
|
21
30
|
import path, { dirname } from 'node:path';
|
|
22
31
|
import { randomBytes, randomUUID } from 'node:crypto';
|
|
23
32
|
|
|
@@ -89,6 +98,45 @@ const DENY_REASON_MAX = 16_000;
|
|
|
89
98
|
*/
|
|
90
99
|
const WARN_MESSAGE_MAX = 16_000;
|
|
91
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
|
+
|
|
92
140
|
/**
|
|
93
141
|
* Reason substituted when a caller denies without supplying one.
|
|
94
142
|
*
|
|
@@ -480,6 +528,206 @@ export function emitWarn(message) {
|
|
|
480
528
|
process.exit(0);
|
|
481
529
|
}
|
|
482
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
|
+
|
|
483
731
|
/**
|
|
484
732
|
* Inject a system message into the hook response without exiting.
|
|
485
733
|
* @param {string} msg Message text wrapped in a systemMessage JSON envelope on stdout.
|
|
@@ -556,6 +804,26 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
556
804
|
* used from any layer without violating the layering rule in
|
|
557
805
|
* `scripts/lib/hardening.mjs`.
|
|
558
806
|
*
|
|
807
|
+
* Implementation: this is the JSON-serializing façade over
|
|
808
|
+
* {@link atomicWriteWithBackup} (`backup: false`) — the two functions carried
|
|
809
|
+
* the identical `mkdir → tmp → write → rename → catch` body 60 lines apart,
|
|
810
|
+
* which is the very duplication `atomicWriteWithBackup` was introduced to end.
|
|
811
|
+
* Everything below the `JSON.stringify` is the delegate's; the wrapper exists
|
|
812
|
+
* for the serialization step and for NARROWING the envelope (see @returns).
|
|
813
|
+
*
|
|
814
|
+
* Two things are deliberately kept here rather than pushed into the delegate:
|
|
815
|
+
*
|
|
816
|
+
* - **The stringify stays inside a try.** `JSON.stringify` throws on a cycle
|
|
817
|
+
* (and on a BigInt). Hoisting it above the delegation call would turn that
|
|
818
|
+
* into an escaping TypeError, and 4 of the 9 caller files (`loop-guard`,
|
|
819
|
+
* `lock-bootstrap`, `file-lock`, `issue-budget`) invoke this without a
|
|
820
|
+
* try/catch of their own. A serialization failure reports as `fs-error`
|
|
821
|
+
* because that is what this helper has always returned for it.
|
|
822
|
+
* - **The success envelope is narrowed back to `{ ok: true }`.** The delegate
|
|
823
|
+
* also reports `path`/`bytes`/`backupPath`; `session-lock.mjs#writeOwnerProof`
|
|
824
|
+
* propagates THIS object verbatim on failure (`if (!w.ok) return w`), so the
|
|
825
|
+
* key set is part of a contract that reaches further than this file.
|
|
826
|
+
*
|
|
559
827
|
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
560
828
|
* @param {*} data JSON-serializable value.
|
|
561
829
|
* @param {object} [opts]
|
|
@@ -565,15 +833,172 @@ export async function writeJsonAtomic(filePath, value, opts = {}) {
|
|
|
565
833
|
*/
|
|
566
834
|
export function writeJsonAtomicSync(filePath, data, opts = {}) {
|
|
567
835
|
const { indent = 2, tmpPrefix = '.tmp' } = opts;
|
|
836
|
+
|
|
837
|
+
let body;
|
|
838
|
+
try {
|
|
839
|
+
body = JSON.stringify(data, null, indent) + '\n';
|
|
840
|
+
} catch (err) {
|
|
841
|
+
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const res = atomicWriteWithBackup(filePath, body, { tmpPrefix });
|
|
845
|
+
return res.ok ? { ok: true } : res;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* Content-agnostic sibling of {@link writeJsonAtomicSync}: atomically replace a
|
|
850
|
+
* file with an arbitrary string/Buffer body, optionally snapshotting the
|
|
851
|
+
* previous contents to a timestamped `.bak-<ISO>` sidecar first (issue #734).
|
|
852
|
+
*
|
|
853
|
+
* The repo carries the copy→tmp→rename idiom in ~25 hand-rolled places
|
|
854
|
+
* (`learnings/io.mjs#rewriteLearnings`, `session-record-repair.mjs`,
|
|
855
|
+
* `owner-interview.mjs`, `backfill-learnings.mjs`, …). Each spelling differs in
|
|
856
|
+
* small ways — some take the backup, some do not; some `mkdir -p`, some assume
|
|
857
|
+
* the directory exists — which is exactly how a crash-safety guarantee rots.
|
|
858
|
+
* This helper is the shared spelling; **validation policy stays with the
|
|
859
|
+
* callers** (this function never inspects `body`).
|
|
860
|
+
*
|
|
861
|
+
* Crash-safety: write `<dir>/<tmpPrefix>.<rand>`, then `renameSync` over the
|
|
862
|
+
* target. Same-filesystem rename is atomic on POSIX, so an observer sees either
|
|
863
|
+
* the previous contents or the new ones — never a half-written file. The tmp
|
|
864
|
+
* file is created as a SIBLING of the target on purpose: a tmp in `os.tmpdir()`
|
|
865
|
+
* may sit on a different filesystem, where `rename` degrades to a non-atomic
|
|
866
|
+
* copy (EXDEV).
|
|
867
|
+
*
|
|
868
|
+
* Backup (`backup: true`) copies the CURRENT file to `<filePath>.bak-<ISO>`
|
|
869
|
+
* before the rename, with `:`/`.` swapped for `-` so a lexical sort of the
|
|
870
|
+
* siblings is chronological (same convention as `learnings/io.mjs`). A
|
|
871
|
+
* first-time write has nothing to lose, so no backup is taken when the target
|
|
872
|
+
* does not exist. **Rotation is deliberately NOT done here** — how many
|
|
873
|
+
* snapshots a store is worth is a per-caller policy (`learnings/io.mjs` keeps
|
|
874
|
+
* 3; a re-derivable file wants 0), and a keep-N default baked into the
|
|
875
|
+
* primitive would silently unlink a caller's snapshots.
|
|
876
|
+
*
|
|
877
|
+
* Caller is responsible for path-confinement — this helper does NOT validate
|
|
878
|
+
* that `filePath` lives inside the project (mirrors {@link writeJsonAtomic} /
|
|
879
|
+
* {@link writeJsonAtomicSync}).
|
|
880
|
+
*
|
|
881
|
+
* On a FAILED write the tmp sibling is unlinked (best-effort) before the error
|
|
882
|
+
* envelope is returned. Without that, every failed write leaves a
|
|
883
|
+
* `<tmpPrefix>.<hex>` behind — the board writer's failure mode is a retry loop,
|
|
884
|
+
* so the litter accumulates in the operator's vault directory. The cleanup is
|
|
885
|
+
* attempted only when the write actually created the tmp file, and its own
|
|
886
|
+
* failure is swallowed: a leaked tmp is worse than a silent unlink miss, and
|
|
887
|
+
* neither may mask the original error.
|
|
888
|
+
*
|
|
889
|
+
* ── BV-004 ceiling + revisit trigger ────────────────────────────────────────
|
|
890
|
+
* TWO PRODUCTION CALL-SITES: `vault-status/board-writer.mjs#writeBoard`
|
|
891
|
+
* (`backup: false`) and {@link writeJsonAtomicSync}, which carries 12 further
|
|
892
|
+
* call-sites across 9 files behind it. That second one is the load-bearing
|
|
893
|
+
* evidence — the previous revision of this note recorded ONE call-site and
|
|
894
|
+
* concluded the signature was unproven, while the function with an identical
|
|
895
|
+
* body sat 60 lines above in this same file, unmigrated. The cheapest possible
|
|
896
|
+
* migration going unmade is not a neutral fact about a helper: it is the
|
|
897
|
+
* measurement that the helper is not paying for itself.
|
|
898
|
+
*
|
|
899
|
+
* What the second call-site does NOT prove: `writeJsonAtomicSync` passes
|
|
900
|
+
* `backup: false` and no `fs`, so the backup half and the injection seam still
|
|
901
|
+
* rest on tests plus one board-writer flag. REVISIT TRIGGER — when a sweep
|
|
902
|
+
* migrates the remaining hand-rolled sites, re-check before widening:
|
|
903
|
+
* (a) whether an `async` twin is needed rather than bolting a promise mode onto
|
|
904
|
+
* this one (three known sites are `fs/promises`), and (b) whether rotation
|
|
905
|
+
* belongs here after all (it does only if ≥2 migrated callers want the SAME
|
|
906
|
+
* keep-N). If NO caller ever passes `backup: true` in production, that half is
|
|
907
|
+
* still the part to shrink back.
|
|
908
|
+
*
|
|
909
|
+
* @param {string} filePath Target path; parent dirs created with mkdir -p semantics.
|
|
910
|
+
* @param {string|Buffer} body Bytes to write, verbatim. Never inspected.
|
|
911
|
+
* @param {object} [opts]
|
|
912
|
+
* @param {BufferEncoding} [opts.encoding='utf8'] Encoding for a string `body`.
|
|
913
|
+
* @param {boolean} [opts.backup=false] Snapshot the existing file to `.bak-<ISO>` first.
|
|
914
|
+
* @param {string} [opts.tmpPrefix='.tmp'] Tmp-file prefix (callers pick their domain prefix).
|
|
915
|
+
* @param {Date} [opts.now] Clock seam for the backup stamp (tests).
|
|
916
|
+
* @param {{ mkdirSync?: Function, writeFileSync?: Function, renameSync?: Function,
|
|
917
|
+
* copyFileSync?: Function, existsSync?: Function, unlinkSync?: Function }} [opts.fs]
|
|
918
|
+
* Injectable fs (tests). Omitted methods fall back to `node:fs` — EXCEPT on
|
|
919
|
+
* the backup path, which fails closed (see below).
|
|
920
|
+
* @returns {{ ok: true, path: string, bytes: number, backupPath: string|null }
|
|
921
|
+
* | { ok: false, reason: 'fs-error', error: string }}
|
|
922
|
+
*/
|
|
923
|
+
export function atomicWriteWithBackup(filePath, body, opts = {}) {
|
|
924
|
+
const {
|
|
925
|
+
encoding = 'utf8',
|
|
926
|
+
backup = false,
|
|
927
|
+
tmpPrefix = '.tmp',
|
|
928
|
+
now = new Date(),
|
|
929
|
+
fs: injectedFs,
|
|
930
|
+
} = opts;
|
|
931
|
+
|
|
932
|
+
const fsMkdir = injectedFs?.mkdirSync ?? mkdirSync;
|
|
933
|
+
const fsWriteFile = injectedFs?.writeFileSync ?? writeFileSync;
|
|
934
|
+
const fsRename = injectedFs?.renameSync ?? renameSync;
|
|
935
|
+
const fsCopyFile = injectedFs?.copyFileSync ?? copyFileSync;
|
|
936
|
+
const fsExists = injectedFs?.existsSync ?? existsSync;
|
|
937
|
+
const fsUnlink = injectedFs?.unlinkSync ?? unlinkSync;
|
|
938
|
+
|
|
939
|
+
// ── Partial-adapter fail-closed, backup path only ──────────────────────────
|
|
940
|
+
//
|
|
941
|
+
// Per-method fallback to the real `node:fs` is the right default for the
|
|
942
|
+
// three ALWAYS-used methods: `board-writer.mjs#writeBoard` passes an fs object
|
|
943
|
+
// on EVERY call, including production, where `renameSync`/`copyFileSync` are
|
|
944
|
+
// present-but-`undefined` because nothing was injected. "An injected object
|
|
945
|
+
// must be total" would therefore reject the only real caller — the shape is
|
|
946
|
+
// not evidence of a fake.
|
|
947
|
+
//
|
|
948
|
+
// The backup path is different in kind. It runs ONLY under `backup: true`, and
|
|
949
|
+
// there a missing method routes a real `copyFileSync`/`existsSync` at the real
|
|
950
|
+
// filesystem while the write goes to the fake: a suite that believes itself
|
|
951
|
+
// hermetic drops `.bak-<ISO>` files into the repo, and nothing says so. Both
|
|
952
|
+
// methods are guarded, not just `copyFileSync` — a missing `existsSync` probes
|
|
953
|
+
// the real target and silently decides the backup branch from it, which is the
|
|
954
|
+
// same escape one step earlier.
|
|
955
|
+
if (backup && injectedFs) {
|
|
956
|
+
for (const method of ['existsSync', 'copyFileSync']) {
|
|
957
|
+
if (typeof injectedFs[method] !== 'function') {
|
|
958
|
+
return {
|
|
959
|
+
ok: false,
|
|
960
|
+
reason: 'fs-error',
|
|
961
|
+
error: `partial fs adapter: ${method} required for backup`,
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
let tmpFile = null;
|
|
968
|
+
let tmpCreated = false;
|
|
969
|
+
|
|
568
970
|
try {
|
|
569
971
|
const dir = dirname(filePath);
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
972
|
+
fsMkdir(dir, { recursive: true });
|
|
973
|
+
|
|
974
|
+
let backupPath = null;
|
|
975
|
+
if (backup && fsExists(filePath)) {
|
|
976
|
+
const stamp = (now instanceof Date ? now : new Date()).toISOString().replace(/[:.]/g, '-');
|
|
977
|
+
backupPath = `${filePath}.bak-${stamp}`;
|
|
978
|
+
fsCopyFile(filePath, backupPath);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
tmpFile = path.join(dir, `${tmpPrefix}.${randomBytes(6).toString('hex')}`);
|
|
982
|
+
fsWriteFile(tmpFile, body, encoding);
|
|
983
|
+
tmpCreated = true;
|
|
984
|
+
fsRename(tmpFile, filePath);
|
|
985
|
+
|
|
986
|
+
return {
|
|
987
|
+
ok: true,
|
|
988
|
+
path: filePath,
|
|
989
|
+
bytes: Buffer.isBuffer(body) ? body.length : Buffer.byteLength(String(body), encoding),
|
|
990
|
+
backupPath,
|
|
991
|
+
};
|
|
576
992
|
} catch (err) {
|
|
993
|
+
// Only when the write got far enough to create it. The name carries 12 hex
|
|
994
|
+
// chars of entropy, so this cannot collide with a caller's real file.
|
|
995
|
+
if (tmpCreated) {
|
|
996
|
+
try {
|
|
997
|
+
fsUnlink(tmpFile);
|
|
998
|
+
} catch {
|
|
999
|
+
// Best-effort: never let cleanup replace the error the caller needs.
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
577
1002
|
return { ok: false, reason: 'fs-error', error: err?.message ?? String(err) };
|
|
578
1003
|
}
|
|
579
1004
|
}
|
|
@@ -37,6 +37,36 @@ import { _parseIssueBudget } from './config/issue-budget.mjs';
|
|
|
37
37
|
/** Runtime counter file, relative to the repo root. */
|
|
38
38
|
export const BUDGET_STATE_REL = '.orchestrator/runtime/issue-budget.json';
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the accounting key for a native session id.
|
|
42
|
+
*
|
|
43
|
+
* A semantic id is an accounting continuity bridge only after the native id
|
|
44
|
+
* proves that `current-session.json` belongs to this invocation. This neither
|
|
45
|
+
* establishes lock ownership nor bridges a host rotation that changes both ids.
|
|
46
|
+
*
|
|
47
|
+
* @param {string|null|undefined} candidateRawSessionId native hook/env session id
|
|
48
|
+
* @param {unknown} currentSession parsed `.orchestrator/current-session.json`
|
|
49
|
+
* @returns {string|null} semantic key for a verified pair, otherwise raw key
|
|
50
|
+
*/
|
|
51
|
+
export function resolveIssueBudgetSessionId(candidateRawSessionId, currentSession) {
|
|
52
|
+
const rawSessionId =
|
|
53
|
+
typeof candidateRawSessionId === 'string' && candidateRawSessionId.length > 0
|
|
54
|
+
? candidateRawSessionId
|
|
55
|
+
: null;
|
|
56
|
+
if (
|
|
57
|
+
rawSessionId === null ||
|
|
58
|
+
!currentSession ||
|
|
59
|
+
typeof currentSession !== 'object' ||
|
|
60
|
+
Array.isArray(currentSession) ||
|
|
61
|
+
currentSession.session_id !== rawSessionId ||
|
|
62
|
+
typeof currentSession.semantic_session_id !== 'string' ||
|
|
63
|
+
currentSession.semantic_session_id.length === 0
|
|
64
|
+
) {
|
|
65
|
+
return rawSessionId;
|
|
66
|
+
}
|
|
67
|
+
return currentSession.semantic_session_id;
|
|
68
|
+
}
|
|
69
|
+
|
|
40
70
|
/**
|
|
41
71
|
* Commands whose issue creation MUST NOT be blocked, with the reason recorded
|
|
42
72
|
* for the stderr trace and for the overflow bookkeeping.
|
|
@@ -109,20 +139,34 @@ export function budgetStatePath(repoRoot) {
|
|
|
109
139
|
* a fresh zeroed state for `sessionId` — the counter is per session by
|
|
110
140
|
* construction, so a new session never inherits the previous session's spend.
|
|
111
141
|
*
|
|
142
|
+
* An identity-less invocation always gets a fresh state and never reads a
|
|
143
|
+
* persisted budget. It therefore cannot provide durable per-session continuity,
|
|
144
|
+
* but avoiding cross-session budget and overflow attribution wins over a
|
|
145
|
+
* continuity guess without a verified native identity.
|
|
146
|
+
*
|
|
147
|
+
* The counter file is SHARED across invocations, so this read-side isolation is
|
|
148
|
+
* only half the contract: an identity-less charge must also never PERSIST its
|
|
149
|
+
* fresh state, or it silently zeroes a live session's count and deletes its
|
|
150
|
+
* parked overflow records. `chargeIssueBudget` enforces that write-side half.
|
|
151
|
+
*
|
|
112
152
|
* @param {string} repoRoot
|
|
113
153
|
* @param {string|null} sessionId
|
|
114
154
|
* @returns {{ sessionId: string|null, count: number, exempt: number, overflow: object[] }}
|
|
115
155
|
*/
|
|
116
156
|
export function readBudgetState(repoRoot, sessionId) {
|
|
117
|
-
const
|
|
157
|
+
const accountingSessionId =
|
|
158
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
159
|
+
const fresh = { sessionId: accountingSessionId, count: 0, exempt: 0, overflow: [] };
|
|
160
|
+
if (accountingSessionId === null) return fresh;
|
|
161
|
+
|
|
118
162
|
const file = budgetStatePath(repoRoot);
|
|
119
163
|
if (!existsSync(file)) return fresh;
|
|
120
164
|
try {
|
|
121
165
|
const data = JSON.parse(readFileSync(file, 'utf8'));
|
|
122
166
|
if (!data || typeof data !== 'object') return fresh;
|
|
123
|
-
if (
|
|
167
|
+
if (data.sessionId !== accountingSessionId) return fresh;
|
|
124
168
|
return {
|
|
125
|
-
sessionId:
|
|
169
|
+
sessionId: accountingSessionId,
|
|
126
170
|
count: Number.isInteger(data.count) && data.count >= 0 ? data.count : 0,
|
|
127
171
|
exempt: Number.isInteger(data.exempt) && data.exempt >= 0 ? data.exempt : 0,
|
|
128
172
|
overflow: Array.isArray(data.overflow) ? data.overflow : [],
|
|
@@ -200,13 +244,23 @@ export function chargeIssueBudget({
|
|
|
200
244
|
return { ...base, decision: 'off', count: 0, overflowCount: 0, reason: null };
|
|
201
245
|
}
|
|
202
246
|
|
|
203
|
-
const
|
|
204
|
-
|
|
247
|
+
const accountingSessionId =
|
|
248
|
+
typeof sessionId === 'string' && sessionId.length > 0 ? sessionId : null;
|
|
249
|
+
const state = readBudgetState(repoRoot, accountingSessionId);
|
|
250
|
+
state.sessionId = accountingSessionId;
|
|
251
|
+
|
|
252
|
+
// An identity-less charge has no key to account under, so it must not touch
|
|
253
|
+
// the SHARED counter file at all. Writing its fresh state would reset a live
|
|
254
|
+
// session's count to 0 AND drop its parked overflow[] entries — breaking the
|
|
255
|
+
// strict cap (a single identity-less call clears it) and session-end's
|
|
256
|
+
// "nothing is lost" promise. Read-side isolation alone does not cover this.
|
|
257
|
+
const persist = (next) =>
|
|
258
|
+
accountingSessionId === null ? false : writeBudgetState(repoRoot, next);
|
|
205
259
|
|
|
206
260
|
const { exempt, reason } = classifyExemption(command);
|
|
207
261
|
if (exempt) {
|
|
208
262
|
state.exempt += 1;
|
|
209
|
-
|
|
263
|
+
persist(state);
|
|
210
264
|
return {
|
|
211
265
|
...base,
|
|
212
266
|
decision: 'exempt',
|
|
@@ -218,13 +272,13 @@ export function chargeIssueBudget({
|
|
|
218
272
|
|
|
219
273
|
if (state.count < max) {
|
|
220
274
|
state.count += 1;
|
|
221
|
-
|
|
275
|
+
persist(state);
|
|
222
276
|
return { ...base, decision: 'allow', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
223
277
|
}
|
|
224
278
|
|
|
225
279
|
if (mode === 'warn') {
|
|
226
280
|
state.count += 1;
|
|
227
|
-
|
|
281
|
+
persist(state);
|
|
228
282
|
return { ...base, decision: 'warn', count: state.count, overflowCount: state.overflow.length, reason: null };
|
|
229
283
|
}
|
|
230
284
|
|
|
@@ -234,7 +288,7 @@ export function chargeIssueBudget({
|
|
|
234
288
|
command: String(command).slice(0, 500),
|
|
235
289
|
at: now,
|
|
236
290
|
});
|
|
237
|
-
|
|
291
|
+
persist(state);
|
|
238
292
|
return {
|
|
239
293
|
...base,
|
|
240
294
|
decision: 'block',
|