@event4u/agent-config 14.4.0 → 14.5.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.
@@ -7765,6 +7765,10 @@ function claudeAdditionalContext(event, text) {
7765
7765
  })}
7766
7766
  `;
7767
7767
  }
7768
+ function emissionCarriesReasons(platform, severity) {
7769
+ if (!VERIFIED_PLATFORMS.has(platform)) return false;
7770
+ return severity !== "allow";
7771
+ }
7768
7772
  function emitFor(platform, event, severity, reasons, legacyExit) {
7769
7773
  if (!VERIFIED_PLATFORMS.has(platform)) {
7770
7774
  return { exit: legacyExit, stdout: "", stderr: "" };
@@ -7790,6 +7794,13 @@ function emitFor(platform, event, severity, reasons, legacyExit) {
7790
7794
 
7791
7795
  // src/scripts/hooks/injection_budget.ts
7792
7796
  var TURN_START_EVENT = "user_prompt_submit";
7797
+ var RC_ALLOW = 0;
7798
+ var RC_WARN = 2;
7799
+ function _severityForRc(rc) {
7800
+ if (rc === RC_ALLOW) return "allow";
7801
+ if (rc === RC_WARN) return "warn";
7802
+ return "block";
7803
+ }
7793
7804
  function isExempt(c) {
7794
7805
  if (c.failClosed) return true;
7795
7806
  return c.severity.trim().toLowerCase() !== "advisory";
@@ -7870,13 +7881,43 @@ function shapeEmissions(candidates, opts) {
7870
7881
  ceilingCause
7871
7882
  };
7872
7883
  }
7873
- var TURN_SPEND_BASENAME = "injection-turn.json";
7874
- function turnSpendPath(workspaceRoot2) {
7875
- return path3.join(workspaceRoot2, "agents", "runtime", "state", TURN_SPEND_BASENAME);
7884
+ var TURN_SPEND_DIR_REL = path3.join("agents", "runtime", "state", "injection-turn");
7885
+ var TURN_SPEND_MAX_FILES = 64;
7886
+ function turnSpendKey(session) {
7887
+ const safe = session.replace(/[^A-Za-z0-9._-]/g, "_");
7888
+ let h = 2166136261;
7889
+ for (let i = 0; i < session.length; i += 1) {
7890
+ h ^= session.charCodeAt(i);
7891
+ h = Math.imul(h, 16777619) >>> 0;
7892
+ }
7893
+ const digest = h.toString(16).padStart(8, "0");
7894
+ return `${safe.slice(0, 96)}.${digest}`;
7895
+ }
7896
+ function turnSpendPath(workspaceRoot2, session) {
7897
+ return path3.join(workspaceRoot2, TURN_SPEND_DIR_REL, `${turnSpendKey(session)}.json`);
7898
+ }
7899
+ function _pruneTurnSpend(dir) {
7900
+ try {
7901
+ const entries = fs3.readdirSync(dir).filter((n) => n.endsWith(".json") || n.endsWith(".tmp")).map((n) => {
7902
+ const p = path3.join(dir, n);
7903
+ try {
7904
+ return { p, mtime: fs3.statSync(p).mtimeMs };
7905
+ } catch {
7906
+ return { p, mtime: 0 };
7907
+ }
7908
+ }).sort((a, b) => b.mtime - a.mtime);
7909
+ for (const stale of entries.slice(TURN_SPEND_MAX_FILES)) {
7910
+ try {
7911
+ fs3.unlinkSync(stale.p);
7912
+ } catch {
7913
+ }
7914
+ }
7915
+ } catch {
7916
+ }
7876
7917
  }
7877
7918
  function readTurnSpend(workspaceRoot2, session) {
7878
7919
  try {
7879
- const raw = fs3.readFileSync(turnSpendPath(workspaceRoot2), "utf-8");
7920
+ const raw = fs3.readFileSync(turnSpendPath(workspaceRoot2, session), "utf-8");
7880
7921
  const parsed = JSON.parse(raw);
7881
7922
  if (parsed.session !== session) return 0;
7882
7923
  return typeof parsed.bytes === "number" && Number.isFinite(parsed.bytes) && parsed.bytes >= 0 ? parsed.bytes : 0;
@@ -7889,9 +7930,13 @@ function recordTurnSpend(workspaceRoot2, session, bytes, opts = {}) {
7889
7930
  const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot2, session);
7890
7931
  const entry = { session, bytes: previous + Math.max(0, bytes) };
7891
7932
  try {
7892
- const target = turnSpendPath(workspaceRoot2);
7893
- fs3.mkdirSync(path3.dirname(target), { recursive: true });
7894
- fs3.writeFileSync(target, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
7933
+ const target = turnSpendPath(workspaceRoot2, session);
7934
+ const dir = path3.dirname(target);
7935
+ fs3.mkdirSync(dir, { recursive: true });
7936
+ const tmp = `${target}.${String(process.pid)}.tmp`;
7937
+ fs3.writeFileSync(tmp, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
7938
+ fs3.renameSync(tmp, target);
7939
+ _pruneTurnSpend(dir);
7895
7940
  } catch {
7896
7941
  }
7897
7942
  }
@@ -7957,12 +8002,15 @@ function shapeAndRecord(ctx, messages, finalRc) {
7957
8002
  nudgeRank: typeof rank2 === "number" ? rank2 : null
7958
8003
  };
7959
8004
  });
7960
- if (!VERIFIED_PLATFORMS.has(ctx.platform)) {
7961
- return textOf(new Set(candidates.map((c) => c.concern)));
7962
- }
7963
8005
  const workspaceRoot2 = _workspaceRoot(ctx);
7964
8006
  const sessionKey = _sessionKey(ctx) ?? "";
7965
8007
  const cap = resolveVolumeCap(ctx);
8008
+ if (!emissionCarriesReasons(ctx.platform, _severityForRc(finalRc))) {
8009
+ if (cap !== null && ctx.event === TURN_START_EVENT) {
8010
+ recordTurnSpend(workspaceRoot2, sessionKey, 0, { reset: true });
8011
+ }
8012
+ return textOf(new Set(candidates.map((c) => c.concern)));
8013
+ }
7966
8014
  const turnStartsHere = ctx.event === TURN_START_EVENT;
7967
8015
  const spentBefore = cap === null || turnStartsHere ? 0 : readTurnSpend(workspaceRoot2, sessionKey);
7968
8016
  const shaped = shapeEmissions(candidates, { capBytes: cap, spentBytes: spentBefore });
@@ -21913,9 +21961,9 @@ function resolveAvailability(cwd, env) {
21913
21961
  return null;
21914
21962
  }
21915
21963
  const exists = fs41.existsSync(p);
21916
- const provenance = env[COUNCIL_CONFIG_ENV] ? "env-override" : "user-global";
21964
+ const provenance2 = env[COUNCIL_CONFIG_ENV] ? "env-override" : "user-global";
21917
21965
  if (!exists) {
21918
- return { configured: false, path: p, exists: false, provenance, membersEnabled: 0, memberNames: [] };
21966
+ return { configured: false, path: p, exists: false, provenance: provenance2, membersEnabled: 0, memberNames: [] };
21919
21967
  }
21920
21968
  try {
21921
21969
  const cfg = load_council_config(p);
@@ -21924,12 +21972,12 @@ function resolveAvailability(cwd, env) {
21924
21972
  configured: cfg.enabled && enabled4.length > 0,
21925
21973
  path: p,
21926
21974
  exists: true,
21927
- provenance,
21975
+ provenance: provenance2,
21928
21976
  membersEnabled: enabled4.length,
21929
21977
  memberNames: enabled4
21930
21978
  };
21931
21979
  } catch {
21932
- return { configured: false, path: p, exists: true, provenance, membersEnabled: 0, memberNames: [] };
21980
+ return { configured: false, path: p, exists: true, provenance: provenance2, membersEnabled: 0, memberNames: [] };
21933
21981
  }
21934
21982
  }
21935
21983
  function buildFact(a) {
@@ -23828,20 +23876,24 @@ function _read_claim_file(file) {
23828
23876
  return null;
23829
23877
  }
23830
23878
  }
23831
- function read_claimed_slug(workspace_root, session_id) {
23879
+ function resolve_claim(workspace_root, session_id) {
23832
23880
  const own = roadmap_claim_rel(session_id);
23833
23881
  if (own !== ROADMAP_CLAIM_REL) {
23834
23882
  for (const p of claim_read_paths(workspace_root, session_id)) {
23835
23883
  if (path52.basename(p) === path52.basename(ROADMAP_CLAIM_REL)) continue;
23836
23884
  const mine = _read_claim_file(p);
23837
- if (mine !== null) return mine.slug;
23885
+ if (mine !== null) return { slug: mine.slug, path: p };
23838
23886
  }
23839
23887
  }
23840
23888
  let legacy = null;
23889
+ let legacy_path = "";
23841
23890
  for (const p of claim_read_paths(workspace_root, session_id)) {
23842
23891
  if (path52.basename(p) !== path52.basename(ROADMAP_CLAIM_REL)) continue;
23843
23892
  legacy = _read_claim_file(p);
23844
- if (legacy !== null) break;
23893
+ if (legacy !== null) {
23894
+ legacy_path = p;
23895
+ break;
23896
+ }
23845
23897
  }
23846
23898
  if (legacy === null) {
23847
23899
  return null;
@@ -23850,7 +23902,10 @@ function read_claimed_slug(workspace_root, session_id) {
23850
23902
  if (id !== "") {
23851
23903
  return null;
23852
23904
  }
23853
- return legacy.slug;
23905
+ return { slug: legacy.slug, path: legacy_path };
23906
+ }
23907
+ function read_claimed_slug(workspace_root, session_id) {
23908
+ return resolve_claim(workspace_root, session_id)?.slug ?? null;
23854
23909
  }
23855
23910
  function claim_is_stale(workspace_root, slug, peer_worktree) {
23856
23911
  if (slug === null || slug.trim() === "") {
@@ -26119,7 +26174,7 @@ import { fileURLToPath as fileURLToPath39, pathToFileURL as pathToFileURL32 } fr
26119
26174
 
26120
26175
  // src/install/rtkDetection.ts
26121
26176
  import { spawnSync as spawnSync5 } from "node:child_process";
26122
- import { existsSync as existsSync11, statSync as statSync15, readFileSync as readFileSync27, writeFileSync as writeFileSync13, mkdirSync as mkdirSync15 } from "node:fs";
26177
+ import { existsSync as existsSync11, statSync as statSync16, readFileSync as readFileSync27, writeFileSync as writeFileSync13, mkdirSync as mkdirSync15 } from "node:fs";
26123
26178
  import { homedir as homedir5 } from "node:os";
26124
26179
  import { delimiter as delimiter2, join as join26, dirname as dirname16 } from "node:path";
26125
26180
  var TOKEN_KILLER_GAIN_SIGNATURE = /RTK Token Savings/;
@@ -26190,7 +26245,7 @@ function detectRtkCached(options = {}) {
26190
26245
  if (binPath === null) return { present: false };
26191
26246
  let stat = null;
26192
26247
  try {
26193
- const st = statSync15(binPath);
26248
+ const st = statSync16(binPath);
26194
26249
  stat = { mtimeMs: st.mtimeMs, size: st.size };
26195
26250
  } catch {
26196
26251
  stat = null;
@@ -32092,6 +32147,14 @@ function readState4(file) {
32092
32147
  return null;
32093
32148
  }
32094
32149
  }
32150
+ function provenance(workspaceRoot2, claimPath) {
32151
+ return {
32152
+ workspace_root: workspaceRoot2,
32153
+ git_dir: git_dir(workspaceRoot2) ?? "",
32154
+ git_common_dir: git_common_dir(workspaceRoot2) ?? "",
32155
+ claim_path: claimPath
32156
+ };
32157
+ }
32095
32158
  function appendEvent2(workspaceRoot2, record) {
32096
32159
  try {
32097
32160
  const file = path85.join(workspaceRoot2, EVENTS_RELPATH);
@@ -32125,8 +32188,10 @@ function main55() {
32125
32188
  }
32126
32189
  const workspaceRoot2 = str10(envelope["workspace_root"]) || process.cwd();
32127
32190
  const sessionId2 = str10(payload["session_id"]) || str10(envelope["session_id"]);
32128
- const slug = read_claimed_slug(workspaceRoot2, sessionId2 || null);
32129
- if (slug === null) return EXIT_ALLOW27;
32191
+ const claim = resolve_claim(workspaceRoot2, sessionId2 || null);
32192
+ if (claim === null) return EXIT_ALLOW27;
32193
+ const slug = claim.slug;
32194
+ const roots = provenance(workspaceRoot2, claim.path);
32130
32195
  const roadmapPath2 = path85.join(workspaceRoot2, "agents", "roadmaps", `${slug}.md`);
32131
32196
  let roadmapText;
32132
32197
  try {
@@ -32159,7 +32224,8 @@ function main55() {
32159
32224
  run_id: runId,
32160
32225
  roadmap: slug,
32161
32226
  turn: turnOrdinal,
32162
- at: (/* @__PURE__ */ new Date()).toISOString()
32227
+ at: (/* @__PURE__ */ new Date()).toISOString(),
32228
+ ...roots
32163
32229
  });
32164
32230
  return EXIT_ALLOW27;
32165
32231
  }
@@ -32186,7 +32252,8 @@ function main55() {
32186
32252
  iterations: state.iterations,
32187
32253
  open: scan.open,
32188
32254
  blocked: scan.blocked,
32189
- at: (/* @__PURE__ */ new Date()).toISOString()
32255
+ at: (/* @__PURE__ */ new Date()).toISOString(),
32256
+ ...roots
32190
32257
  });
32191
32258
  if (action === "complete") {
32192
32259
  try {
@@ -32225,7 +32292,8 @@ function main55() {
32225
32292
  iteration: state.iterations,
32226
32293
  open: scan.open,
32227
32294
  blocked: scan.blocked,
32228
- at: (/* @__PURE__ */ new Date()).toISOString()
32295
+ at: (/* @__PURE__ */ new Date()).toISOString(),
32296
+ ...roots
32229
32297
  });
32230
32298
  process.stderr.write(_continuationText(slug, scan, state.iterations));
32231
32299
  return EXIT_BLOCK5;
@@ -9,7 +9,7 @@
9
9
  "homepage": "https://event4u-app.github.io/agent-config",
10
10
  "name": "@event4u/agent-config",
11
11
  "repository": "https://github.com/event4u-app/agent-config",
12
- "version": "14.4.0"
12
+ "version": "14.5.0"
13
13
  },
14
14
  "registries": [
15
15
  {
@@ -9,12 +9,12 @@
9
9
  "transport": {
10
10
  "type": "stdio"
11
11
  },
12
- "version": "14.4.0"
12
+ "version": "14.5.0"
13
13
  }
14
14
  ],
15
15
  "repository": {
16
16
  "source": "github",
17
17
  "url": "https://github.com/event4u-app/agent-config"
18
18
  },
19
- "version": "14.4.0"
19
+ "version": "14.5.0"
20
20
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.4.0",
4
+ "version": "14.5.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -18,7 +18,7 @@
18
18
  "baseline": {
19
19
  "active_roadmaps": 33,
20
20
  "later_roadmaps": 50,
21
- "open_blockers": 70
21
+ "open_blockers": 71
22
22
  },
23
23
  "baseline_history": [
24
24
  {
@@ -104,6 +104,13 @@
104
104
  "later_roadmaps": 50,
105
105
  "open_blockers": 70,
106
106
  "why": "WALKED DOWN by 1 blocker: worktree-claim-root-split resolved in the change that found it. The blocker recorded a measured defect \u2014 the roadmap claim and the stop-slot concern resolved different roots, so run-continuation was inert in every worktree session \u2014 and the claim now lives in the git common dir beside the session register. Active and later are unchanged and net zero by construction: road-to-long-horizon-execution archived and road-to-run-continuation-observation was created in the same change to carry its last deferred criterion, per the preservation test the same change added to roadmap-progress-sync. Walked here rather than banked because the gate reported the slack itself, and a baseline above the measurement is headroom a later change spends with nothing objecting."
107
+ },
108
+ {
109
+ "at": "2026-08-19",
110
+ "active_roadmaps": 33,
111
+ "later_roadmaps": 50,
112
+ "open_blockers": 71,
113
+ "why": "RAISED by 1 blocker, and the raise is the alternative to a silent drop rather than room made for new growth. road-to-run-continuation-observation step 0.1 carries the parent criterion \"a process-full contract run finishes a 3-phase roadmap with zero synchronous contacts, re-engaging across turns, and opens the PR\". Its sibling step 0.0 closed on a real engagement observed 2026-08-19, and 0.1 did NOT: this roadmap has one phase, so the three-phase half cannot be read off that run however the rest of it went, and closing it there would be the attribution error the step's own text warns against. The choice was therefore between recording the gap as three-phase-contract-run, where the dashboard and this ratchet both see it, and leaving it as prose in a step nobody counts \u2014 which is the invisible-gate failure road-to-solution-minimalism recorded when its own halt lived in prose and the generated dashboard published zero blockers for a roadmap stopped for four days. Active and later are unchanged, which is the check that this is not a park wearing a tightening's clothes. The entry is class 3 rather than 0 on purpose and that bounds what the raise buys: an agent does the work, but choosing which roadmap gets worked next is the operator's, so no command produces a qualifying run and this number walks back down on the first three-phase autonomous roadmap anyone finishes from a worktree \u2014 as a side effect, with no dedicated effort."
107
114
  }
108
115
  ],
109
116
  "target": {
@@ -17,10 +17,27 @@
17
17
  * the fix is a registered decision, not a silent default bump
18
18
  * - per-slot SUM over all bound concerns > the slot cap → RED
19
19
  *
20
+ * NOT FULLY DETERMINISTIC — this paragraph replaces a claim that said it was.
21
+ * The fixture path was described as "deterministic, CI-reproducible". For most
22
+ * concerns it is; they read only the committed envelope. But `end-review-nudge`
23
+ * decides whether to fire from the LIVE working-tree diff plus a once-per-session
24
+ * latch, so one commit reads 1,140 B with uncommitted changes present and 922 B
25
+ * without them. Two values, observed three times in one session as the tree went
26
+ * dirty, clean and dirty again — the earlier wording here said "three different
27
+ * values", which counted the readings and not the values, and an R2 review was
28
+ * right to call that an unbacked number inside the paragraph that exists to
29
+ * replace an unbacked claim.
30
+ * Two consequences. A single run is a reading of THIS checkout right now, so its
31
+ * number must never be pinned as acceptance evidence (a roadmap note tried, and
32
+ * had to be corrected). And the BREACH verdict is unaffected regardless, because
33
+ * a state-dependent concern can only push the sum UP: a green run is still a
34
+ * green run. Documented rather than fixed, because making this hermetic means
35
+ * changing what the concern reads — a change to the concern, not to its bench.
36
+ *
20
37
  * HONEST SCOPE, stated up front: concerns are conditional-silence by design —
21
38
  * a generic fixture triggers few of them, so most measure 0 bytes here. The
22
39
  * gate therefore enforces the cap on whatever DOES fire under the committed
23
- * fixtures (deterministic, CI-reproducible), while the census mode
40
+ * fixtures, while the census mode
24
41
  * (`--record`) measures REAL sessions: it appends counts-only lines
25
42
  * ({ts, slot, concern, bytes}) to agents/runtime/state/injection-census.jsonl
26
43
  * — the repeat-injection-census blocker's instrument. Claiming the fixture
@@ -97,6 +97,34 @@ export function claudeAdditionalContext(event: string, text: string): string {
97
97
  })}\n`;
98
98
  }
99
99
 
100
+ /**
101
+ * Will `emitFor` actually put the reasons on a stream?
102
+ *
103
+ * The per-turn injection ceiling (`injection_budget.ts`) may only charge bytes
104
+ * the host receives. Two branches of `emitFor` below emit nothing REGARDLESS of
105
+ * the reasons, and those are the two this predicate answers for: an unverified
106
+ * platform (legacy pass-through) and `severity: allow`. The budget module
107
+ * originally covered only the first, which let an `allow` dispatch spend the turn
108
+ * on output nobody got — and a crashed non-`fail_closed` concern is fail-opened
109
+ * to exactly that verdict with its stack trace as the largest candidate.
110
+ *
111
+ * A THIRD non-emitting case exists and this predicate deliberately does not claim
112
+ * it: a `warn` whose joined reason comes out empty, because `_joinReasons` trims
113
+ * and drops blanks, so a whitespace-only advisory text emits nothing either. It
114
+ * is content-dependent rather than a property of `(platform, severity)`, so a
115
+ * predicate with this signature cannot decide it — stated here rather than left
116
+ * to be discovered, since an R2 review found the earlier "TWO branches" wording
117
+ * asserting a completeness it did not have.
118
+ *
119
+ * Kept beside `emitFor` deliberately rather than re-derived at the call site:
120
+ * the predicate is only correct while it mirrors the function below it, and a
121
+ * copy one module away is a copy that drifts.
122
+ */
123
+ export function emissionCarriesReasons(platform: string, severity: Severity): boolean {
124
+ if (!VERIFIED_PLATFORMS.has(platform)) return false;
125
+ return severity !== "allow";
126
+ }
127
+
100
128
  /**
101
129
  * Translate (platform, event, severity, reasons) into a native emission.
102
130
  *
@@ -67,7 +67,7 @@ import * as fs from "node:fs";
67
67
  import * as path from "node:path";
68
68
 
69
69
  import { log_dispatch_issue } from "./dispatch_issues.js";
70
- import { VERIFIED_PLATFORMS } from "./host_semantics.js";
70
+ import { VERIFIED_PLATFORMS, emissionCarriesReasons, type Severity } from "./host_semantics.js";
71
71
 
72
72
  /**
73
73
  * The event that STARTS a turn, and therefore the one that resets the per-turn
@@ -81,6 +81,20 @@ export const RC_ALLOW = 0;
81
81
  export const RC_BLOCK = 1;
82
82
  export const RC_WARN = 2;
83
83
 
84
+ /**
85
+ * The host-facing severity for a reduced exit code, mirroring
86
+ * `dispatch_hook._severity_for`. Local rather than imported: this module is
87
+ * imported BY the dispatcher, so reaching back would close an import cycle.
88
+ * Anything outside the ladder reads as `block`, the conservative direction —
89
+ * an unknown verdict must never be treated as "emits nothing" and silently
90
+ * skip the ceiling.
91
+ */
92
+ function _severityForRc(rc: number): Severity {
93
+ if (rc === RC_ALLOW) return "allow";
94
+ if (rc === RC_WARN) return "warn";
95
+ return "block";
96
+ }
97
+
84
98
  /** Why a candidate did not survive shaping. */
85
99
  export type DropReason = "nudge_interference" | "injection_budget";
86
100
 
@@ -321,10 +335,91 @@ export interface TurnSpend {
321
335
  readonly bytes: number;
322
336
  }
323
337
 
324
- export const TURN_SPEND_BASENAME = "injection-turn.json";
338
+ /** The per-session directory the counters live in. */
339
+ export const TURN_SPEND_DIR_REL = path.join("agents", "runtime", "state", "injection-turn");
340
+
341
+ /** Keep the directory bounded; oldest files are pruned past this many. */
342
+ export const TURN_SPEND_MAX_FILES = 64;
343
+
344
+ /**
345
+ * A session id reduced to ONE safe path segment.
346
+ *
347
+ * Two jobs, and the second is why a plain `replace` is not enough: the id is
348
+ * host-supplied, so it must not escape the directory (`../`), and it must not
349
+ * blow the filesystem's name limit. Anything outside a conservative charset is
350
+ * replaced, and the basename carries a short digest of the ORIGINAL id so two
351
+ * ids that sanitise to the same characters still land on different counters.
352
+ *
353
+ * The digest is a 32-bit FNV-1a-style mix over the id's UTF-16 code units — not
354
+ * over its encoded bytes, and not a cryptographic hash. So the collision claim
355
+ * is a PROBABILITY, not an absolute: ~2^-32 per pair, and the consequence of one
356
+ * is exactly the shared-file defect this layout removes (the foreign read returns
357
+ * 0, then the write clobbers). That is the honest bound; "can never collide" was
358
+ * the first version of this sentence and an R2 review was right to reject it.
359
+ */
360
+ export function turnSpendKey(session: string): string {
361
+ const safe = session.replace(/[^A-Za-z0-9._-]/g, "_");
362
+ let h = 0x811c9dc5;
363
+ for (let i = 0; i < session.length; i += 1) {
364
+ h ^= session.charCodeAt(i);
365
+ h = Math.imul(h, 0x01000193) >>> 0;
366
+ }
367
+ const digest = h.toString(16).padStart(8, "0");
368
+ // The digest is unconditional rather than only-when-needed: a conditional
369
+ // suffix means two code paths can produce the same basename for different
370
+ // ids, which is the collision it exists to prevent.
371
+ return `${safe.slice(0, 96)}.${digest}`;
372
+ }
325
373
 
326
- export function turnSpendPath(workspaceRoot: string): string {
327
- return path.join(workspaceRoot, "agents", "runtime", "state", TURN_SPEND_BASENAME);
374
+ /**
375
+ * Path of ONE session's counter.
376
+ *
377
+ * Per session, not per workspace, and that was a defect rather than a
378
+ * refinement: with a single shared file, two concurrent sessions in one
379
+ * workspace read each other's record, saw a foreign id, resolved to "nothing
380
+ * spent", and then overwrote it — so the ceiling was effectively unenforced for
381
+ * both, every fire. The estate's existing pattern for per-session state is a
382
+ * directory (`agents/runtime/state/end-review-nudge/`); this follows it.
383
+ */
384
+ export function turnSpendPath(workspaceRoot: string, session: string): string {
385
+ return path.join(workspaceRoot, TURN_SPEND_DIR_REL, `${turnSpendKey(session)}.json`);
386
+ }
387
+
388
+ /**
389
+ * Drop the oldest counters once the directory passes its cap.
390
+ *
391
+ * A per-session file trades one unbounded counter for unbounded FILES, so the
392
+ * cap is part of the same change rather than a follow-up. Best-effort and
393
+ * silent: failing to prune must never affect a dispatch.
394
+ */
395
+ function _pruneTurnSpend(dir: string): void {
396
+ try {
397
+ // `.tmp` is included deliberately: the temp file is `<name>.json.<pid>.tmp`,
398
+ // which does NOT end in `.json`, so a `.json`-only filter left every temp
399
+ // leaked by a crash between the write and the rename to accumulate forever
400
+ // — unbounded growth in the directory this cap exists to bound.
401
+ const entries = fs
402
+ .readdirSync(dir)
403
+ .filter((n) => n.endsWith(".json") || n.endsWith(".tmp"))
404
+ .map((n) => {
405
+ const p = path.join(dir, n);
406
+ try {
407
+ return { p, mtime: fs.statSync(p).mtimeMs };
408
+ } catch {
409
+ return { p, mtime: 0 };
410
+ }
411
+ })
412
+ .sort((a, b) => b.mtime - a.mtime);
413
+ for (const stale of entries.slice(TURN_SPEND_MAX_FILES)) {
414
+ try {
415
+ fs.unlinkSync(stale.p);
416
+ } catch {
417
+ /* a file another process already removed is the desired state */
418
+ }
419
+ }
420
+ } catch {
421
+ /* unreadable directory — nothing to prune, nothing to report */
422
+ }
328
423
  }
329
424
 
330
425
  /**
@@ -335,7 +430,7 @@ export function turnSpendPath(workspaceRoot: string): string {
335
430
  */
336
431
  export function readTurnSpend(workspaceRoot: string, session: string): number {
337
432
  try {
338
- const raw = fs.readFileSync(turnSpendPath(workspaceRoot), "utf-8");
433
+ const raw = fs.readFileSync(turnSpendPath(workspaceRoot, session), "utf-8");
339
434
  const parsed = JSON.parse(raw) as Partial<TurnSpend>;
340
435
  if (parsed.session !== session) return 0;
341
436
  return typeof parsed.bytes === "number" &&
@@ -355,6 +450,16 @@ export function readTurnSpend(workspaceRoot: string, session: string): number {
355
450
  *
356
451
  * `reset` is passed on the slot that STARTS a turn (`user_prompt_submit`), so
357
452
  * the count is bounded by one turn rather than growing across a session.
453
+ *
454
+ * The write is a temp file plus a rename, which buys exactly one thing: no reader
455
+ * ever sees a TORN file. It does NOT serialise the read-modify-write — the read
456
+ * at the top of this function and the write below it are still two steps, so two
457
+ * interleaved dispatches of the same session can still lose an update. Claiming
458
+ * otherwise was an R2 finding on the first version of this docblock, and the
459
+ * narrower claim is the true one. The residual failure is under-counting, which
460
+ * is the safe direction: an accounting error must never be the reason an advisory
461
+ * disappears. The sibling counter in this estate (`rule-trips.json`) uses the
462
+ * same primitive for the same class of artefact in the same directory.
358
463
  */
359
464
  export function recordTurnSpend(
360
465
  workspaceRoot: string,
@@ -366,9 +471,13 @@ export function recordTurnSpend(
366
471
  const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot, session);
367
472
  const entry: TurnSpend = { session, bytes: previous + Math.max(0, bytes) };
368
473
  try {
369
- const target = turnSpendPath(workspaceRoot);
370
- fs.mkdirSync(path.dirname(target), { recursive: true });
371
- fs.writeFileSync(target, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
474
+ const target = turnSpendPath(workspaceRoot, session);
475
+ const dir = path.dirname(target);
476
+ fs.mkdirSync(dir, { recursive: true });
477
+ const tmp = `${target}.${String(process.pid)}.tmp`;
478
+ fs.writeFileSync(tmp, JSON.stringify(entry) + "\n", { encoding: "utf-8" });
479
+ fs.renameSync(tmp, target);
480
+ _pruneTurnSpend(dir);
372
481
  } catch {
373
482
  /* observability-adjacent bookkeeping never breaks the agent loop */
374
483
  }
@@ -536,17 +645,44 @@ export function shapeAndRecord(
536
645
  nudgeRank: typeof rank === "number" ? rank : null,
537
646
  };
538
647
  });
539
- // Skipped wholesale where the emission carries nothing: on an unverified
540
- // platform `emitFor` returns empty stdout AND stderr, so choosing between two
541
- // messages there would write advisory-suppression records into the consumer's
542
- // log for output that never left the process. Nothing to shape is not the
543
- // same as shaping to nothing.
544
- if (!VERIFIED_PLATFORMS.has(ctx.platform)) {
545
- return textOf(new Set(candidates.map((c) => c.concern)));
546
- }
547
648
  const workspaceRoot = _workspaceRoot(ctx);
548
649
  const sessionKey = _sessionKey(ctx) ?? "";
549
650
  const cap = resolveVolumeCap(ctx);
651
+ // Where the emission carries nothing, skip the CHARGE — never the reset.
652
+ // Two such cases, and one reason for both: this is an INJECTION ceiling, so
653
+ // it may only charge bytes the host actually receives. Shaping output that
654
+ // never left would write advisory-suppression records for nothing and —
655
+ // worse — spend the turn's budget, so a LATER dispatch whose advisory WOULD
656
+ // have been delivered gets dropped instead.
657
+ //
658
+ // 1. an unverified platform — `emitFor` returns the legacy pass-through,
659
+ // empty stdout AND stderr;
660
+ // 2. a reduced verdict of ALLOW — `emitFor` returns
661
+ // `{exit: 0, stdout: "", stderr: ""}` for `severity: allow`, on every
662
+ // platform including the verified one.
663
+ //
664
+ // Case 2 was missed when this module landed, and it is not theoretical. A
665
+ // non-`fail_closed` concern that CRASHES is fail-opened to `EXIT_ALLOW` by
666
+ // the dispatcher and its stderr then becomes the deciding message — usually
667
+ // the largest candidate in the set. Measured against this module before the
668
+ // fix: a 9,000-byte rc-0 message emitted 0 bytes and charged 9,000, so about
669
+ // five such dispatches exhausted a 47,104-byte turn ceiling on output nobody
670
+ // received. `emissionCarriesReasons` is the shared predicate, kept beside
671
+ // `emitFor` so it cannot drift from the function whose behaviour it reports.
672
+ //
673
+ // THE RESET STILL HAS TO RUN, and getting that wrong was an R2 finding on the
674
+ // first version of this fix. `_reduce([])` is `EXIT_ALLOW`, so a
675
+ // `user_prompt_submit` where no concern fires — the common case for
676
+ // conditional-silence concerns — takes this branch. Returning here without
677
+ // resetting left the PREVIOUS turn's total on disk, and later dispatches in
678
+ // the new turn then dropped advisories to pay for bytes belonging to an
679
+ // earlier one. Skipping a charge and skipping a boundary are different acts.
680
+ if (!emissionCarriesReasons(ctx.platform, _severityForRc(finalRc))) {
681
+ if (cap !== null && ctx.event === TURN_START_EVENT) {
682
+ recordTurnSpend(workspaceRoot, sessionKey, 0, { reset: true });
683
+ }
684
+ return textOf(new Set(candidates.map((c) => c.concern)));
685
+ }
550
686
  // On the turn-START slot the carried total is zero by definition: this is the
551
687
  // dispatch that begins the turn. Reading the stored value here budgeted the
552
688
  // new turn against the one that just ended.