@event4u/agent-config 14.2.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.
Files changed (117) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -252
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3754 -1721
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +127 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +16 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/stdin.ts +35 -1
  75. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  76. package/src/scripts/_lib/unattended_guard.ts +371 -0
  77. package/src/scripts/ai_council/config.ts +27 -1
  78. package/src/scripts/ai_council/events_log.ts +12 -1
  79. package/src/scripts/ai_council/fallback_config.ts +114 -0
  80. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  81. package/src/scripts/ai_council/orchestrator.ts +251 -292
  82. package/src/scripts/ai_council/py_parity.ts +139 -0
  83. package/src/scripts/ai_council/response_render.ts +99 -0
  84. package/src/scripts/ai_council/spend_gate.ts +123 -0
  85. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  86. package/src/scripts/bench_hook_injection.ts +118 -7
  87. package/src/scripts/bench_hook_latency.ts +442 -16
  88. package/src/scripts/check_estate_count.ts +924 -0
  89. package/src/scripts/compile_hook_manifest.ts +67 -0
  90. package/src/scripts/council_cli.ts +100 -125
  91. package/src/scripts/decision_memo.ts +325 -0
  92. package/src/scripts/hook_manifest.json +1 -0
  93. package/src/scripts/hook_manifest.yaml +172 -25
  94. package/src/scripts/hooks/concern_registry.ts +6 -0
  95. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  96. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  97. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  98. package/src/scripts/hooks/hook_stdin.ts +111 -5
  99. package/src/scripts/hooks/host_semantics.ts +28 -0
  100. package/src/scripts/hooks/injection_budget.ts +731 -0
  101. package/src/scripts/hooks/payload_stub.ts +446 -0
  102. package/src/scripts/hooks/run_continuation_hook.ts +683 -0
  103. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  104. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  105. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  107. package/src/scripts/hooks_doctor.ts +16 -2
  108. package/src/scripts/interruption_report.ts +330 -13
  109. package/src/scripts/lint_hook_manifest.ts +167 -0
  110. package/src/scripts/roadmap_progress_hook.ts +254 -46
  111. package/src/scripts/run_supervise.ts +623 -0
  112. package/src/scripts/session_register_hook.ts +146 -12
  113. package/src/scripts/sessions_cli.ts +18 -6
  114. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  115. package/src/server/io/yamlIO.ts +167 -3
  116. package/src/shared/settingsConsent.ts +49 -2
  117. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -0,0 +1,731 @@
1
+ /**
2
+ * Emission shaping — the dispatcher's outbound half of the injection budget.
3
+ *
4
+ * WHY THIS EXISTS (road-to-standing-context-40k, D-4 "Advisory injectors stack
5
+ * on the same slots", Phase 4 steps 4.1 and 4.2).
6
+ *
7
+ * `src/config/hook-token-budget.json` has registered per-concern and per-slot
8
+ * byte caps since road-to-token-economy-cache Phase 3, and
9
+ * `bench_hook_injection` reds the build on a breach. That is an AUTHORING-time
10
+ * control: it stops a new injector from growing the budget unnoticed. Nothing
11
+ * acted at RUNTIME — a live session that actually produced more bytes than the
12
+ * registered ceiling emitted all of them anyway, and two concerns whose triggers
13
+ * happen to overlap on one prompt both injected, because no layer had ever been
14
+ * asked which one the model should get.
15
+ *
16
+ * This module is that layer, and it is deliberately PURE: the dispatcher hands
17
+ * it a list of candidate emissions and gets back what to keep, what to drop, and
18
+ * why. Every branch is decidable from the arguments, so the paired negative
19
+ * fixtures can force each drop path without a hook, a host, or a session.
20
+ *
21
+ * TWO POLICIES, APPLIED IN THIS ORDER, AND THE ORDER MATTERS.
22
+ *
23
+ * 1. NUDGE EXCLUSIVITY (4.2). At most one nudge-class advisory leaves per
24
+ * slot. Concerns opt in by carrying `nudge_rank: <n>` in the manifest;
25
+ * the LOWEST rank wins and the rest are dropped. Measured on this tree
26
+ * before the policy was written: the prompt
27
+ * "Use when starting parallel work in isolation from the current branch —
28
+ * spawn a git worktree … for src/a.ts, src/b.ts, src/c.ts and src/d.ts,
29
+ * each independently" fires `delegation-nudge` AND `skill-route` together,
30
+ * so the overlap the roadmap predicted is real rather than hypothetical.
31
+ * 2. BYTE BUDGET (4.1). Drop lowest-severity advisories until the per-turn
32
+ * running total is under the registered ceiling.
33
+ *
34
+ * Exclusivity runs first because it is a RELEVANCE decision and the budget is a
35
+ * VOLUME one: dropping the redundant nudge may put the slot under the ceiling on
36
+ * its own, and a budget that had already evicted the more useful line by size
37
+ * would have made the relevance call by accident.
38
+ *
39
+ * WHAT CAN NEVER BE DROPPED, BY CONSTRUCTION. A concern is exempt when its
40
+ * manifest `severity` is anything other than `advisory`, or when it is
41
+ * `fail_closed`. Neither policy can therefore silence a safety-relevant warning
42
+ * — the failure that would make this module worse than the problem it solves.
43
+ * When the exempt set alone exceeds the ceiling, the ceiling is reported as
44
+ * exceeded and nothing is dropped: an honest overflow beats a quiet one, and
45
+ * deleting advisories that cannot bring the total under the cap costs the model
46
+ * every one of them for no benefit. That futility check is asserted, not merely
47
+ * documented — the first version of this module omitted it and one of its own
48
+ * tests pinned the omission, so the doc and the code disagreed until the R2
49
+ * completion review caught it (2026-08-19).
50
+ *
51
+ * WHAT IS NOT CLAIMED. Dropping an advisory does not make the turn cheaper by
52
+ * the dropped byte count in any measured sense — nothing in this tree observes
53
+ * what the host does with an injected block. The claim is exactly the bounded
54
+ * one: what the dispatcher WRITES per turn now has a ceiling and a recorded
55
+ * reason for every line that did not survive it.
56
+ *
57
+ * WHAT THE CALLER MUST GUARANTEE, because this module cannot check it. The
58
+ * candidate list must be the set that would ACTUALLY be emitted, not every
59
+ * message collected during the dispatch. `keptBytes` becomes the persisted turn
60
+ * spend, so feeding in messages the dispatcher never writes makes the ceiling
61
+ * govern a quantity that was never injected — and can log a drop for a line that
62
+ * was never going out. `dispatch_hook` filters by the deciding severity BEFORE
63
+ * calling in; that filter is part of this contract, not a caller detail.
64
+ */
65
+
66
+ import * as fs from "node:fs";
67
+ import * as path from "node:path";
68
+
69
+ import { log_dispatch_issue } from "./dispatch_issues.js";
70
+ import { VERIFIED_PLATFORMS, emissionCarriesReasons, type Severity } from "./host_semantics.js";
71
+
72
+ /**
73
+ * The event that STARTS a turn, and therefore the one that resets the per-turn
74
+ * counter. A platform that does not bind it has no observable turn boundary,
75
+ * which is a precondition of the volume policy.
76
+ */
77
+ export const TURN_START_EVENT = "user_prompt_submit";
78
+
79
+ /** Internal severity ladder, mirroring dispatch_hook's EXIT_* constants. */
80
+ export const RC_ALLOW = 0;
81
+ export const RC_BLOCK = 1;
82
+ export const RC_WARN = 2;
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
+
98
+ /** Why a candidate did not survive shaping. */
99
+ export type DropReason = "nudge_interference" | "injection_budget";
100
+
101
+ /** One concern's would-be emission, as the dispatcher already knows it. */
102
+ export interface EmissionCandidate {
103
+ /** Manifest concern name — the id used in drop records. */
104
+ readonly concern: string;
105
+ /** Manifest `severity`. Anything but `advisory` is exempt. */
106
+ readonly severity: string;
107
+ /** Manifest `fail_closed`. True is exempt. */
108
+ readonly failClosed: boolean;
109
+ /** The concern's reduced exit code on the internal ladder. */
110
+ readonly rc: number;
111
+ /** Bytes this emission would contribute to the slot payload. */
112
+ readonly bytes: number;
113
+ /** Manifest `nudge_rank`; `null` when the concern is not nudge-class. */
114
+ readonly nudgeRank: number | null;
115
+ }
116
+
117
+ export interface EmissionDrop {
118
+ readonly concern: string;
119
+ readonly bytes: number;
120
+ readonly reason: DropReason;
121
+ /** One line, safe for a dispatch-issues `detail` field. Ids and counts only. */
122
+ readonly detail: string;
123
+ }
124
+
125
+ /**
126
+ * Why the ceiling is over, when it is. Kept as a discriminated value rather
127
+ * than a boolean because the two causes want different operator messages and
128
+ * point at different fixes: `exempt-floor` is a budget-row question, and
129
+ * `carried-spend` is a question about what earlier dispatches in this turn
130
+ * already emitted. A single boolean made the dispatcher blame the exempt set
131
+ * for an overflow that was pure accumulation.
132
+ */
133
+ export type CeilingCause = "exempt-floor" | "carried-spend";
134
+
135
+ export interface ShapeResult {
136
+ /** Concern names that survive, in the input order. */
137
+ readonly kept: readonly string[];
138
+ readonly dropped: readonly EmissionDrop[];
139
+ /** Bytes the surviving set contributes. */
140
+ readonly keptBytes: number;
141
+ /**
142
+ * True when the ceiling is over after shaping. See `ceilingCause` for which
143
+ * of the two reasons applies. Never silent.
144
+ */
145
+ readonly ceilingExceeded: boolean;
146
+ /** Set exactly when `ceilingExceeded` is true. */
147
+ readonly ceilingCause: CeilingCause | null;
148
+ }
149
+
150
+ export interface ShapeOptions {
151
+ /**
152
+ * Per-turn ceiling in bytes, or `null` to skip the budget policy entirely
153
+ * (the `session_start` case — a one-shot restore slot the roadmap excludes
154
+ * from the per-turn cap by name).
155
+ */
156
+ readonly capBytes: number | null;
157
+ /** Bytes already emitted earlier in the same turn. Defaults to 0. */
158
+ readonly spentBytes?: number;
159
+ }
160
+
161
+ /**
162
+ * Exempt from BOTH policies. Kept as its own predicate so a caller can assert
163
+ * the exemption directly rather than inferring it from a shaping outcome.
164
+ */
165
+ export function isExempt(c: EmissionCandidate): boolean {
166
+ if (c.failClosed) return true;
167
+ return c.severity.trim().toLowerCase() !== "advisory";
168
+ }
169
+
170
+ /**
171
+ * Eviction order among droppable candidates, lowest-value first:
172
+ *
173
+ * 1. `rc` ascending — an advisory that returned ALLOW is a quieter signal
174
+ * than one that returned WARN, so the ALLOW goes first. This is the
175
+ * "lowest-severity advisories first" the step asks for, read against the
176
+ * only severity axis a candidate actually varies on (manifest `severity`
177
+ * is `advisory` for every droppable one by construction).
178
+ * 2. `bytes` DESCENDING — free the ceiling in the fewest drops. The
179
+ * alternative (smallest first) keeps the biggest single line and loses
180
+ * more distinct advisories; fewer-drops was chosen because each drop is a
181
+ * whole advisory the model never sees, and the count is what the reader
182
+ * of the dispatch-issues log has to reason about.
183
+ * 3. `concern` ascending — a total order, so two runs on one input never
184
+ * disagree about which line survived.
185
+ */
186
+ export function evictionOrder(
187
+ a: EmissionCandidate,
188
+ b: EmissionCandidate,
189
+ ): number {
190
+ if (a.rc !== b.rc) return a.rc - b.rc;
191
+ if (a.bytes !== b.bytes) return b.bytes - a.bytes;
192
+ return a.concern < b.concern ? -1 : a.concern > b.concern ? 1 : 0;
193
+ }
194
+
195
+ /**
196
+ * Nudge exclusivity: keep the lowest `nudge_rank`, drop every other nudge.
197
+ *
198
+ * Ties break on concern name so the winner is stable; a tie is a manifest
199
+ * defect rather than a runtime one, and `lint_hook_manifest` is where it should
200
+ * eventually be caught — that check does not exist yet and this comment is the
201
+ * honest statement of the gap, not a claim that it is covered.
202
+ */
203
+ function _selectNudge(
204
+ candidates: readonly EmissionCandidate[],
205
+ ): { winner: string | null; winnerRank: number | null; losers: EmissionCandidate[] } {
206
+ const nudges = candidates.filter(
207
+ (c) => c.nudgeRank !== null && !isExempt(c),
208
+ );
209
+ if (nudges.length <= 1) {
210
+ return {
211
+ winner: nudges[0]?.concern ?? null,
212
+ winnerRank: nudges[0]?.nudgeRank ?? null,
213
+ losers: [],
214
+ };
215
+ }
216
+ const sorted = [...nudges].sort((a, b) => {
217
+ const ra = a.nudgeRank as number;
218
+ const rb = b.nudgeRank as number;
219
+ if (ra !== rb) return ra - rb;
220
+ return a.concern < b.concern ? -1 : a.concern > b.concern ? 1 : 0;
221
+ });
222
+ const winner = sorted[0] as EmissionCandidate;
223
+ return { winner: winner.concern, winnerRank: winner.nudgeRank, losers: sorted.slice(1) };
224
+ }
225
+
226
+ /**
227
+ * Apply both policies and report what leaves.
228
+ *
229
+ * Total by construction: an empty candidate list, a `null` cap, or a cap the
230
+ * input is already under all resolve to "keep everything, drop nothing".
231
+ */
232
+ export function shapeEmissions(
233
+ candidates: readonly EmissionCandidate[],
234
+ opts: ShapeOptions,
235
+ ): ShapeResult {
236
+ const dropped: EmissionDrop[] = [];
237
+ const droppedNames = new Set<string>();
238
+
239
+ // ── Policy 1: nudge exclusivity ──────────────────────────────────────────
240
+ const { winner, winnerRank, losers } = _selectNudge(candidates);
241
+ for (const l of losers) {
242
+ droppedNames.add(l.concern);
243
+ dropped.push({
244
+ concern: l.concern,
245
+ bytes: l.bytes,
246
+ reason: "nudge_interference",
247
+ detail:
248
+ `nudge-class advisory suppressed: '${String(winner)}' carries the lower ` +
249
+ `nudge_rank on this slot (${String(winnerRank)} beats ${String(l.nudgeRank)})`,
250
+ });
251
+ }
252
+
253
+ let survivors = candidates.filter((c) => !droppedNames.has(c.concern));
254
+ const total = (set: readonly EmissionCandidate[]): number =>
255
+ set.reduce((n, c) => n + c.bytes, 0);
256
+
257
+ // ── Policy 2: byte budget ────────────────────────────────────────────────
258
+ const spent = opts.spentBytes ?? 0;
259
+ let ceilingExceeded = false;
260
+ let ceilingCause: CeilingCause | null = null;
261
+ if (opts.capBytes !== null) {
262
+ const cap = opts.capBytes;
263
+ const droppable = survivors.filter((c) => !isExempt(c)).sort(evictionOrder);
264
+ // THE IRREDUCIBLE FLOOR — what the turn costs even after every droppable
265
+ // advisory is gone: the spend already carried in, plus this dispatch's
266
+ // exempt concerns.
267
+ const floor = spent + total(survivors.filter(isExempt));
268
+
269
+ // FUTILITY CHECK, and it is the whole point of this block. Dropping is
270
+ // only justified when it can actually bring the total under the cap. If
271
+ // the floor is already over, no sequence of drops helps, and deleting
272
+ // advisories anyway costs the model every one of them and buys nothing —
273
+ // while the header promises "nothing dropped". The first version of this
274
+ // function had no such check and one of its own tests pinned the wrong
275
+ // behaviour, so the doc and the code disagreed; found by the R2
276
+ // completion review, 2026-08-19.
277
+ if (floor > cap) {
278
+ ceilingExceeded = true;
279
+ // Attribute to whichever component is on its own responsible. The
280
+ // two point at different fixes — a budget-row question vs a question
281
+ // about what this turn already emitted — so a single label would
282
+ // misdirect exactly one of them.
283
+ ceilingCause = spent > cap ? "carried-spend" : "exempt-floor";
284
+ } else {
285
+ for (const victim of droppable) {
286
+ if (spent + total(survivors) <= cap) break;
287
+ droppedNames.add(victim.concern);
288
+ dropped.push({
289
+ concern: victim.concern,
290
+ bytes: victim.bytes,
291
+ reason: "injection_budget",
292
+ detail:
293
+ `advisory dropped to hold the per-turn injection ceiling ` +
294
+ `(${String(cap)} B): ${String(spent + total(survivors))} B would have been emitted this turn`,
295
+ });
296
+ survivors = survivors.filter((c) => c.concern !== victim.concern);
297
+ }
298
+ if (spent + total(survivors) > cap) {
299
+ // Defensive: the floor check above proves a full sweep of the
300
+ // droppable set gets under the cap, so this is unreachable. Kept
301
+ // because "unreachable" is an argument, and a silent overflow is
302
+ // the one outcome this module must never produce.
303
+ ceilingExceeded = true;
304
+ ceilingCause = spent > cap ? "carried-spend" : "exempt-floor";
305
+ }
306
+ }
307
+ }
308
+
309
+ return {
310
+ kept: survivors.map((c) => c.concern),
311
+ dropped,
312
+ keptBytes: total(survivors),
313
+ ceilingExceeded,
314
+ ceilingCause,
315
+ };
316
+ }
317
+
318
+ // ─── Per-turn accounting ─────────────────────────────────────────────────────
319
+ //
320
+ // The ceiling is per TURN, and a turn spans several dispatches: one
321
+ // `user_prompt_submit`, a `pre_tool_use`/`post_tool_use` pair per tool call,
322
+ // and one `stop`. So the running total has to survive between processes, which
323
+ // is what this small state file is for.
324
+ //
325
+ // It holds one session id and one integer. It has NO field capable of holding a
326
+ // prompt, a file body, or an emitted line — the same PII-exclusion-by-
327
+ // construction the telemetry and dispatch-issues surfaces use, applied here so
328
+ // a byte counter can never quietly become a content log.
329
+
330
+ /** The turn's running injection total. Counts only — never content. */
331
+ export interface TurnSpend {
332
+ /** Session the count belongs to; a mismatch resets rather than accumulates. */
333
+ readonly session: string;
334
+ /** Bytes emitted so far in the current turn. */
335
+ readonly bytes: number;
336
+ }
337
+
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
+ }
373
+
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
+ }
423
+ }
424
+
425
+ /**
426
+ * Read the turn's spend so far. Returns 0 for a missing, unreadable, malformed,
427
+ * or foreign-session file — every failure resolves to "this turn has spent
428
+ * nothing", which under-counts rather than over-drops. An accounting error must
429
+ * never be the reason an advisory disappears.
430
+ */
431
+ export function readTurnSpend(workspaceRoot: string, session: string): number {
432
+ try {
433
+ const raw = fs.readFileSync(turnSpendPath(workspaceRoot, session), "utf-8");
434
+ const parsed = JSON.parse(raw) as Partial<TurnSpend>;
435
+ if (parsed.session !== session) return 0;
436
+ return typeof parsed.bytes === "number" &&
437
+ Number.isFinite(parsed.bytes) &&
438
+ parsed.bytes >= 0
439
+ ? parsed.bytes
440
+ : 0;
441
+ } catch {
442
+ return 0;
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Write the turn's spend. Best-effort and silent on failure — a counter that
448
+ * cannot be persisted degrades to per-dispatch enforcement, which is strictly
449
+ * weaker and never wrong in the dangerous direction.
450
+ *
451
+ * `reset` is passed on the slot that STARTS a turn (`user_prompt_submit`), so
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.
463
+ */
464
+ export function recordTurnSpend(
465
+ workspaceRoot: string,
466
+ session: string,
467
+ bytes: number,
468
+ opts: { readonly reset?: boolean } = {},
469
+ ): void {
470
+ if (process.env["AGENT_CONFIG_REPLAY"] === "1") return;
471
+ const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot, session);
472
+ const entry: TurnSpend = { session, bytes: previous + Math.max(0, bytes) };
473
+ try {
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);
481
+ } catch {
482
+ /* observability-adjacent bookkeeping never breaks the agent loop */
483
+ }
484
+ }
485
+
486
+ // ─── The dispatcher-facing entry point ───────────────────────────────────────
487
+ //
488
+ // Everything below used to live inline in `dispatch_hook.ts`. It moved here
489
+ // because it belongs here — resolving the ceiling, recording drops and writing
490
+ // the overflow line are emission-shaping concerns, not dispatch concerns — and
491
+ // because `check_source_size_budget` refused the inline version: dispatch_hook
492
+ // sat exactly on the 1,500-line ratchet ceiling, so 180 inline lines were 180
493
+ // new violations, and the gate says raising the baseline is a defect rather than
494
+ // a fix. A gate that forces the logic into the module that owns it is a gate
495
+ // doing its job.
496
+ //
497
+ // The context is passed IN rather than derived here, deliberately: the two facts
498
+ // only the dispatcher can cheaply answer (does this platform bind the turn-start
499
+ // event, did a real session id arrive) would otherwise need the manifest loader,
500
+ // and importing that from here would close an import cycle.
501
+
502
+ /**
503
+ * One collected concern message, as the dispatcher already holds it.
504
+ *
505
+ * `def` is the manifest concern definition passed through verbatim rather than
506
+ * unpacked at the call site: severity, `fail_closed` and `nudge_rank` are this
507
+ * module's vocabulary, so reading them here keeps the dispatcher from carrying a
508
+ * field list it has no other use for.
509
+ */
510
+ export interface ConcernMessage {
511
+ readonly rc: number;
512
+ readonly text: string;
513
+ readonly def: Record<string, unknown>;
514
+ }
515
+
516
+ /**
517
+ * Everything `shapeAndRecord` needs that it cannot determine on its own.
518
+ *
519
+ * Deliberately five fields: the workspace root, the session key, whether a real
520
+ * session id arrived, and whether the platform emits at all are all DERIVED here
521
+ * from `envelope` and `platform` rather than passed. A caller that has to assemble
522
+ * seven correlated booleans is a caller that will eventually assemble one wrongly.
523
+ */
524
+ export interface ShapingContext {
525
+ /** Package root — where the shipped budget row lives. */
526
+ readonly packageRoot: string;
527
+ /** The dispatch envelope; `workspace_root` and `session_id` are read from it. */
528
+ readonly envelope: Record<string, unknown>;
529
+ readonly platform: string;
530
+ /** agent-config event name for this dispatch. */
531
+ readonly event: string;
532
+ }
533
+
534
+ /**
535
+ * Does this platform bind the turn-start event?
536
+ *
537
+ * Without it the counter never resets, grows monotonically, and suppresses every
538
+ * droppable advisory for the rest of the session — so it is a precondition of the
539
+ * volume policy, not a detail.
540
+ *
541
+ * Read straight off the compiled manifest rather than through
542
+ * `dispatch_hook._resolve_concerns`, which would close an import cycle. The
543
+ * question here is narrower than that resolver's — "is this slot bound at all",
544
+ * with no role filtering — so a plain lookup is the honest tool and not a
545
+ * shortcut. Unreadable manifest → `false`, which switches the volume policy off:
546
+ * the fail-open direction for a policy whose job is to delete output.
547
+ */
548
+ function _turnStartBound(packageRoot: string, platform: string): boolean {
549
+ for (const rel of [
550
+ ["src", "scripts", "hook_manifest.json"],
551
+ ["src", "scripts", "hook_manifest.yaml"],
552
+ ]) {
553
+ try {
554
+ const raw = fs.readFileSync(path.join(packageRoot, ...rel), "utf-8");
555
+ if (rel[2] === "hook_manifest.json") {
556
+ const parsed = JSON.parse(raw) as { manifest?: Record<string, unknown> };
557
+ const platforms = parsed.manifest?.["platforms"] as
558
+ | Record<string, Record<string, unknown>>
559
+ | undefined;
560
+ const bound = platforms?.[platform]?.[TURN_START_EVENT];
561
+ return Array.isArray(bound) && bound.length > 0;
562
+ }
563
+ // YAML fallback: the binding is one ` user_prompt_submit: [...]`
564
+ // line under the platform key. A structural read needs the YAML
565
+ // parser, which this module deliberately does not pull in; absence of
566
+ // the compiled sibling is a build defect surfaced elsewhere, so the
567
+ // conservative answer is enough.
568
+ return false;
569
+ } catch {
570
+ continue;
571
+ }
572
+ }
573
+ return false;
574
+ }
575
+
576
+ /** Consumer workspace — where the turn counter and the drop log are written. */
577
+ function _workspaceRoot(ctx: ShapingContext): string {
578
+ const v = ctx.envelope["workspace_root"];
579
+ return typeof v === "string" && v ? v : process.cwd();
580
+ }
581
+
582
+ /**
583
+ * The session key, or `null` when no real `session_id` arrived. The dispatcher's
584
+ * fallback is unique per invocation by design, so a synthetic id makes the counter
585
+ * unreadable and would silently turn a per-TURN ceiling into a per-dispatch one.
586
+ */
587
+ function _sessionKey(ctx: ShapingContext): string | null {
588
+ const v = ctx.envelope["session_id"];
589
+ return typeof v === "string" && v ? v : null;
590
+ }
591
+
592
+ /**
593
+ * The per-turn ceiling for this dispatch, or `null` when the VOLUME policy must
594
+ * not run. `null` is the fail-open direction: a budget that cannot be read, or
595
+ * whose turn boundary cannot be observed, must never become a budget of zero.
596
+ */
597
+ export function resolveVolumeCap(ctx: ShapingContext): number | null {
598
+ if (!VERIFIED_PLATFORMS.has(ctx.platform)) return null;
599
+ if (_sessionKey(ctx) === null) return null;
600
+ if (!_turnStartBound(ctx.packageRoot, ctx.platform)) return null;
601
+ try {
602
+ const raw = fs.readFileSync(
603
+ path.join(ctx.packageRoot, "src", "config", "hook-token-budget.json"),
604
+ "utf-8",
605
+ );
606
+ const cfg = (JSON.parse(raw) as Record<string, unknown>)["per_turn_aggregate_bytes"];
607
+ if (cfg === undefined || cfg === null || typeof cfg !== "object") return null;
608
+ const row = cfg as Record<string, unknown>;
609
+ const excluded = row["excluded_slots"];
610
+ if (Array.isArray(excluded) && excluded.map(String).includes(ctx.event)) return null;
611
+ const ceiling = row["ceiling_bytes"];
612
+ return typeof ceiling === "number" && ceiling > 0 ? ceiling : null;
613
+ } catch {
614
+ return null;
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Shape this dispatch's would-be emissions, record every suppression, update the
620
+ * turn counter, and return THE MESSAGE TEXTS THAT MAY LEAVE, in manifest order.
621
+ *
622
+ * It returns the texts rather than a name set so the caller cannot re-derive the
623
+ * candidate set differently: the deciding-severity filter is part of this module's
624
+ * contract (see the header's caller obligation), and a caller that applied it
625
+ * twice — or once, differently — would make the ceiling govern bytes nothing ever
626
+ * wrote.
627
+ */
628
+ export function shapeAndRecord(
629
+ ctx: ShapingContext,
630
+ messages: readonly ConcernMessage[],
631
+ finalRc: number,
632
+ ): string[] {
633
+ const deciding = messages.filter((m) => m.rc === finalRc);
634
+ const textOf = (kept: ReadonlySet<string>): string[] =>
635
+ deciding.filter((m) => kept.has(String(m.def["name"] ?? ""))).map((m) => m.text);
636
+ const candidates: EmissionCandidate[] = deciding
637
+ .map((m) => {
638
+ const rank = m.def["nudge_rank"];
639
+ return {
640
+ concern: String(m.def["name"] ?? ""),
641
+ severity: String(m.def["severity"] ?? ""),
642
+ failClosed: Boolean(m.def["fail_closed"]),
643
+ rc: m.rc,
644
+ bytes: Buffer.byteLength(m.text, "utf-8"),
645
+ nudgeRank: typeof rank === "number" ? rank : null,
646
+ };
647
+ });
648
+ const workspaceRoot = _workspaceRoot(ctx);
649
+ const sessionKey = _sessionKey(ctx) ?? "";
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
+ }
686
+ // On the turn-START slot the carried total is zero by definition: this is the
687
+ // dispatch that begins the turn. Reading the stored value here budgeted the
688
+ // new turn against the one that just ended.
689
+ const turnStartsHere = ctx.event === TURN_START_EVENT;
690
+ const spentBefore =
691
+ cap === null || turnStartsHere ? 0 : readTurnSpend(workspaceRoot, sessionKey);
692
+
693
+ const shaped = shapeEmissions(candidates, { capBytes: cap, spentBytes: spentBefore });
694
+
695
+ for (const drop of shaped.dropped) {
696
+ log_dispatch_issue(
697
+ workspaceRoot,
698
+ drop.concern,
699
+ drop.reason === "nudge_interference"
700
+ ? "nudge_interference_drop"
701
+ : "injection_budget_drop",
702
+ drop.detail,
703
+ "src/config/hook-token-budget.json § per_turn_aggregate_bytes",
704
+ );
705
+ }
706
+
707
+ if (shaped.ceilingExceeded) {
708
+ // Stated, never swallowed — and the CAUSE is named, because the two point
709
+ // at different fixes. `exempt-floor`: the safety-relevant set alone is
710
+ // over, so the policy held its contract and the budget row is the decision
711
+ // surface. `carried-spend`: earlier dispatches in this turn already filled
712
+ // it, which is a question about the turn, not about the row.
713
+ const cause =
714
+ shaped.ceilingCause === "exempt-floor"
715
+ ? "by exempt (blocking / fail_closed) concerns alone — nothing dropped, because dropping advisories could not bring it under"
716
+ : "by spend already carried from earlier dispatches in this turn — dropping advisories could not bring it under";
717
+ process.stderr.write(
718
+ `dispatch_hook: per-turn injection ceiling exceeded ${cause} ` +
719
+ `(${String(spentBefore + shaped.keptBytes)} B over ${String(cap)} B). ` +
720
+ `The row is the decision surface ` +
721
+ `(src/config/hook-token-budget.json § per_turn_aggregate_bytes).\n`,
722
+ );
723
+ }
724
+
725
+ if (cap !== null) {
726
+ recordTurnSpend(workspaceRoot, sessionKey, shaped.keptBytes, {
727
+ reset: turnStartsHere,
728
+ });
729
+ }
730
+ return textOf(new Set(shaped.kept));
731
+ }