@event4u/agent-config 14.1.0 → 14.4.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/CHANGELOG.md +291 -181
- package/agents/templates/.ai-council.yml.example +35 -0
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
- package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
- package/dist/agent-src/scripts/resume_probe.ts +154 -9
- package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
- package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
- package/dist/agent-src/templates/agent-settings.md +70 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/dist/cli/python/workspace_drive.js +8 -3
- package/dist/cli/python/workspace_drive.js.map +1 -1
- package/dist/cli/python/workspace_hosts.js +16 -12
- package/dist/cli/python/workspace_hosts.js.map +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
- package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
- package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
- package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +26 -11
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +17 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +3773 -1731
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1070 -408
- package/dist/scripts/_lib/host_launch.js +66 -0
- package/dist/scripts/_lib/host_launch.js.map +1 -0
- package/dist/server/io/yamlIO.js +175 -3
- package/dist/server/io/yamlIO.js.map +1 -1
- package/dist/server/routes/wizard.js +20 -1
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/shared/settingsConsent.js +25 -0
- package/dist/shared/settingsConsent.js.map +1 -1
- package/docs/contracts/settings-classes.md +11 -0
- package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/resume_probe.ts +154 -9
- package/src/agent-src/scripts/roadmap_gates.ts +210 -6
- package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
- package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
- package/src/config/estate-count-budget.json +120 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +32 -4
- package/src/config/gate-violation-baselines.json +63 -63
- package/src/config/gitignore-block.txt +5 -0
- package/src/config/hook-latency-budget.json +40 -1
- package/src/config/hook-token-budget.json +22 -0
- package/src/config/rule-stub-ceilings.json +11 -3
- package/src/scripts/_dispatch.bash +42 -1
- package/src/scripts/_lib/council_fallback_posture.ts +81 -0
- package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
- package/src/scripts/_lib/council_settings_block.ts +152 -0
- package/src/scripts/_lib/headless_invocation.ts +285 -0
- package/src/scripts/_lib/host_launch.ts +72 -0
- package/src/scripts/_lib/map_to_object.ts +16 -0
- package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
- package/src/scripts/_lib/run_checkpoint.ts +409 -0
- package/src/scripts/_lib/session_register.ts +32 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/_lib/stdin.ts +35 -1
- package/src/scripts/_lib/turn_end_refusals.ts +12 -4
- package/src/scripts/_lib/unattended_guard.ts +371 -0
- package/src/scripts/ai_council/config.ts +27 -1
- package/src/scripts/ai_council/events_log.ts +12 -1
- package/src/scripts/ai_council/fallback_config.ts +114 -0
- package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
- package/src/scripts/ai_council/orchestrator.ts +251 -292
- package/src/scripts/ai_council/py_parity.ts +139 -0
- package/src/scripts/ai_council/response_render.ts +99 -0
- package/src/scripts/ai_council/spend_gate.ts +123 -0
- package/src/scripts/ai_council/transport_resolver.ts +81 -13
- package/src/scripts/bench_hook_injection.ts +100 -6
- package/src/scripts/bench_hook_latency.ts +442 -16
- package/src/scripts/check_estate_count.ts +924 -0
- package/src/scripts/compile_hook_manifest.ts +67 -0
- package/src/scripts/council_cli.ts +100 -125
- package/src/scripts/decision_memo.ts +325 -0
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/hook_manifest.json +1 -0
- package/src/scripts/hook_manifest.yaml +172 -25
- package/src/scripts/hooks/concern_registry.ts +6 -0
- package/src/scripts/hooks/dispatch_hook.ts +183 -10
- package/src/scripts/hooks/dispatch_issues.ts +26 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
- package/src/scripts/hooks/hook_stdin.ts +111 -5
- package/src/scripts/hooks/injection_budget.ts +595 -0
- package/src/scripts/hooks/payload_stub.ts +446 -0
- package/src/scripts/hooks/run_continuation_hook.ts +627 -0
- package/src/scripts/hooks/session_eol_hook.ts +52 -0
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
- package/src/scripts/hooks_doctor.ts +16 -2
- package/src/scripts/install-hooks.sh +26 -0
- package/src/scripts/interruption_report.ts +330 -13
- package/src/scripts/lint_hook_manifest.ts +167 -0
- package/src/scripts/roadmap_progress_hook.ts +254 -46
- package/src/scripts/run_supervise.ts +623 -0
- package/src/scripts/session_register_hook.ts +108 -8
- package/src/scripts/sessions_cli.ts +18 -6
- package/src/scripts/telemetry_disclosure_hook.ts +244 -0
- package/src/server/io/yamlIO.ts +167 -3
- package/src/shared/settingsConsent.ts +49 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
|
@@ -0,0 +1,595 @@
|
|
|
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 } 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
|
+
/** Why a candidate did not survive shaping. */
|
|
85
|
+
export type DropReason = "nudge_interference" | "injection_budget";
|
|
86
|
+
|
|
87
|
+
/** One concern's would-be emission, as the dispatcher already knows it. */
|
|
88
|
+
export interface EmissionCandidate {
|
|
89
|
+
/** Manifest concern name — the id used in drop records. */
|
|
90
|
+
readonly concern: string;
|
|
91
|
+
/** Manifest `severity`. Anything but `advisory` is exempt. */
|
|
92
|
+
readonly severity: string;
|
|
93
|
+
/** Manifest `fail_closed`. True is exempt. */
|
|
94
|
+
readonly failClosed: boolean;
|
|
95
|
+
/** The concern's reduced exit code on the internal ladder. */
|
|
96
|
+
readonly rc: number;
|
|
97
|
+
/** Bytes this emission would contribute to the slot payload. */
|
|
98
|
+
readonly bytes: number;
|
|
99
|
+
/** Manifest `nudge_rank`; `null` when the concern is not nudge-class. */
|
|
100
|
+
readonly nudgeRank: number | null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface EmissionDrop {
|
|
104
|
+
readonly concern: string;
|
|
105
|
+
readonly bytes: number;
|
|
106
|
+
readonly reason: DropReason;
|
|
107
|
+
/** One line, safe for a dispatch-issues `detail` field. Ids and counts only. */
|
|
108
|
+
readonly detail: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Why the ceiling is over, when it is. Kept as a discriminated value rather
|
|
113
|
+
* than a boolean because the two causes want different operator messages and
|
|
114
|
+
* point at different fixes: `exempt-floor` is a budget-row question, and
|
|
115
|
+
* `carried-spend` is a question about what earlier dispatches in this turn
|
|
116
|
+
* already emitted. A single boolean made the dispatcher blame the exempt set
|
|
117
|
+
* for an overflow that was pure accumulation.
|
|
118
|
+
*/
|
|
119
|
+
export type CeilingCause = "exempt-floor" | "carried-spend";
|
|
120
|
+
|
|
121
|
+
export interface ShapeResult {
|
|
122
|
+
/** Concern names that survive, in the input order. */
|
|
123
|
+
readonly kept: readonly string[];
|
|
124
|
+
readonly dropped: readonly EmissionDrop[];
|
|
125
|
+
/** Bytes the surviving set contributes. */
|
|
126
|
+
readonly keptBytes: number;
|
|
127
|
+
/**
|
|
128
|
+
* True when the ceiling is over after shaping. See `ceilingCause` for which
|
|
129
|
+
* of the two reasons applies. Never silent.
|
|
130
|
+
*/
|
|
131
|
+
readonly ceilingExceeded: boolean;
|
|
132
|
+
/** Set exactly when `ceilingExceeded` is true. */
|
|
133
|
+
readonly ceilingCause: CeilingCause | null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export interface ShapeOptions {
|
|
137
|
+
/**
|
|
138
|
+
* Per-turn ceiling in bytes, or `null` to skip the budget policy entirely
|
|
139
|
+
* (the `session_start` case — a one-shot restore slot the roadmap excludes
|
|
140
|
+
* from the per-turn cap by name).
|
|
141
|
+
*/
|
|
142
|
+
readonly capBytes: number | null;
|
|
143
|
+
/** Bytes already emitted earlier in the same turn. Defaults to 0. */
|
|
144
|
+
readonly spentBytes?: number;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Exempt from BOTH policies. Kept as its own predicate so a caller can assert
|
|
149
|
+
* the exemption directly rather than inferring it from a shaping outcome.
|
|
150
|
+
*/
|
|
151
|
+
export function isExempt(c: EmissionCandidate): boolean {
|
|
152
|
+
if (c.failClosed) return true;
|
|
153
|
+
return c.severity.trim().toLowerCase() !== "advisory";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Eviction order among droppable candidates, lowest-value first:
|
|
158
|
+
*
|
|
159
|
+
* 1. `rc` ascending — an advisory that returned ALLOW is a quieter signal
|
|
160
|
+
* than one that returned WARN, so the ALLOW goes first. This is the
|
|
161
|
+
* "lowest-severity advisories first" the step asks for, read against the
|
|
162
|
+
* only severity axis a candidate actually varies on (manifest `severity`
|
|
163
|
+
* is `advisory` for every droppable one by construction).
|
|
164
|
+
* 2. `bytes` DESCENDING — free the ceiling in the fewest drops. The
|
|
165
|
+
* alternative (smallest first) keeps the biggest single line and loses
|
|
166
|
+
* more distinct advisories; fewer-drops was chosen because each drop is a
|
|
167
|
+
* whole advisory the model never sees, and the count is what the reader
|
|
168
|
+
* of the dispatch-issues log has to reason about.
|
|
169
|
+
* 3. `concern` ascending — a total order, so two runs on one input never
|
|
170
|
+
* disagree about which line survived.
|
|
171
|
+
*/
|
|
172
|
+
export function evictionOrder(
|
|
173
|
+
a: EmissionCandidate,
|
|
174
|
+
b: EmissionCandidate,
|
|
175
|
+
): number {
|
|
176
|
+
if (a.rc !== b.rc) return a.rc - b.rc;
|
|
177
|
+
if (a.bytes !== b.bytes) return b.bytes - a.bytes;
|
|
178
|
+
return a.concern < b.concern ? -1 : a.concern > b.concern ? 1 : 0;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Nudge exclusivity: keep the lowest `nudge_rank`, drop every other nudge.
|
|
183
|
+
*
|
|
184
|
+
* Ties break on concern name so the winner is stable; a tie is a manifest
|
|
185
|
+
* defect rather than a runtime one, and `lint_hook_manifest` is where it should
|
|
186
|
+
* eventually be caught — that check does not exist yet and this comment is the
|
|
187
|
+
* honest statement of the gap, not a claim that it is covered.
|
|
188
|
+
*/
|
|
189
|
+
function _selectNudge(
|
|
190
|
+
candidates: readonly EmissionCandidate[],
|
|
191
|
+
): { winner: string | null; winnerRank: number | null; losers: EmissionCandidate[] } {
|
|
192
|
+
const nudges = candidates.filter(
|
|
193
|
+
(c) => c.nudgeRank !== null && !isExempt(c),
|
|
194
|
+
);
|
|
195
|
+
if (nudges.length <= 1) {
|
|
196
|
+
return {
|
|
197
|
+
winner: nudges[0]?.concern ?? null,
|
|
198
|
+
winnerRank: nudges[0]?.nudgeRank ?? null,
|
|
199
|
+
losers: [],
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
const sorted = [...nudges].sort((a, b) => {
|
|
203
|
+
const ra = a.nudgeRank as number;
|
|
204
|
+
const rb = b.nudgeRank as number;
|
|
205
|
+
if (ra !== rb) return ra - rb;
|
|
206
|
+
return a.concern < b.concern ? -1 : a.concern > b.concern ? 1 : 0;
|
|
207
|
+
});
|
|
208
|
+
const winner = sorted[0] as EmissionCandidate;
|
|
209
|
+
return { winner: winner.concern, winnerRank: winner.nudgeRank, losers: sorted.slice(1) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Apply both policies and report what leaves.
|
|
214
|
+
*
|
|
215
|
+
* Total by construction: an empty candidate list, a `null` cap, or a cap the
|
|
216
|
+
* input is already under all resolve to "keep everything, drop nothing".
|
|
217
|
+
*/
|
|
218
|
+
export function shapeEmissions(
|
|
219
|
+
candidates: readonly EmissionCandidate[],
|
|
220
|
+
opts: ShapeOptions,
|
|
221
|
+
): ShapeResult {
|
|
222
|
+
const dropped: EmissionDrop[] = [];
|
|
223
|
+
const droppedNames = new Set<string>();
|
|
224
|
+
|
|
225
|
+
// ── Policy 1: nudge exclusivity ──────────────────────────────────────────
|
|
226
|
+
const { winner, winnerRank, losers } = _selectNudge(candidates);
|
|
227
|
+
for (const l of losers) {
|
|
228
|
+
droppedNames.add(l.concern);
|
|
229
|
+
dropped.push({
|
|
230
|
+
concern: l.concern,
|
|
231
|
+
bytes: l.bytes,
|
|
232
|
+
reason: "nudge_interference",
|
|
233
|
+
detail:
|
|
234
|
+
`nudge-class advisory suppressed: '${String(winner)}' carries the lower ` +
|
|
235
|
+
`nudge_rank on this slot (${String(winnerRank)} beats ${String(l.nudgeRank)})`,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let survivors = candidates.filter((c) => !droppedNames.has(c.concern));
|
|
240
|
+
const total = (set: readonly EmissionCandidate[]): number =>
|
|
241
|
+
set.reduce((n, c) => n + c.bytes, 0);
|
|
242
|
+
|
|
243
|
+
// ── Policy 2: byte budget ────────────────────────────────────────────────
|
|
244
|
+
const spent = opts.spentBytes ?? 0;
|
|
245
|
+
let ceilingExceeded = false;
|
|
246
|
+
let ceilingCause: CeilingCause | null = null;
|
|
247
|
+
if (opts.capBytes !== null) {
|
|
248
|
+
const cap = opts.capBytes;
|
|
249
|
+
const droppable = survivors.filter((c) => !isExempt(c)).sort(evictionOrder);
|
|
250
|
+
// THE IRREDUCIBLE FLOOR — what the turn costs even after every droppable
|
|
251
|
+
// advisory is gone: the spend already carried in, plus this dispatch's
|
|
252
|
+
// exempt concerns.
|
|
253
|
+
const floor = spent + total(survivors.filter(isExempt));
|
|
254
|
+
|
|
255
|
+
// FUTILITY CHECK, and it is the whole point of this block. Dropping is
|
|
256
|
+
// only justified when it can actually bring the total under the cap. If
|
|
257
|
+
// the floor is already over, no sequence of drops helps, and deleting
|
|
258
|
+
// advisories anyway costs the model every one of them and buys nothing —
|
|
259
|
+
// while the header promises "nothing dropped". The first version of this
|
|
260
|
+
// function had no such check and one of its own tests pinned the wrong
|
|
261
|
+
// behaviour, so the doc and the code disagreed; found by the R2
|
|
262
|
+
// completion review, 2026-08-19.
|
|
263
|
+
if (floor > cap) {
|
|
264
|
+
ceilingExceeded = true;
|
|
265
|
+
// Attribute to whichever component is on its own responsible. The
|
|
266
|
+
// two point at different fixes — a budget-row question vs a question
|
|
267
|
+
// about what this turn already emitted — so a single label would
|
|
268
|
+
// misdirect exactly one of them.
|
|
269
|
+
ceilingCause = spent > cap ? "carried-spend" : "exempt-floor";
|
|
270
|
+
} else {
|
|
271
|
+
for (const victim of droppable) {
|
|
272
|
+
if (spent + total(survivors) <= cap) break;
|
|
273
|
+
droppedNames.add(victim.concern);
|
|
274
|
+
dropped.push({
|
|
275
|
+
concern: victim.concern,
|
|
276
|
+
bytes: victim.bytes,
|
|
277
|
+
reason: "injection_budget",
|
|
278
|
+
detail:
|
|
279
|
+
`advisory dropped to hold the per-turn injection ceiling ` +
|
|
280
|
+
`(${String(cap)} B): ${String(spent + total(survivors))} B would have been emitted this turn`,
|
|
281
|
+
});
|
|
282
|
+
survivors = survivors.filter((c) => c.concern !== victim.concern);
|
|
283
|
+
}
|
|
284
|
+
if (spent + total(survivors) > cap) {
|
|
285
|
+
// Defensive: the floor check above proves a full sweep of the
|
|
286
|
+
// droppable set gets under the cap, so this is unreachable. Kept
|
|
287
|
+
// because "unreachable" is an argument, and a silent overflow is
|
|
288
|
+
// the one outcome this module must never produce.
|
|
289
|
+
ceilingExceeded = true;
|
|
290
|
+
ceilingCause = spent > cap ? "carried-spend" : "exempt-floor";
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
kept: survivors.map((c) => c.concern),
|
|
297
|
+
dropped,
|
|
298
|
+
keptBytes: total(survivors),
|
|
299
|
+
ceilingExceeded,
|
|
300
|
+
ceilingCause,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Per-turn accounting ─────────────────────────────────────────────────────
|
|
305
|
+
//
|
|
306
|
+
// The ceiling is per TURN, and a turn spans several dispatches: one
|
|
307
|
+
// `user_prompt_submit`, a `pre_tool_use`/`post_tool_use` pair per tool call,
|
|
308
|
+
// and one `stop`. So the running total has to survive between processes, which
|
|
309
|
+
// is what this small state file is for.
|
|
310
|
+
//
|
|
311
|
+
// It holds one session id and one integer. It has NO field capable of holding a
|
|
312
|
+
// prompt, a file body, or an emitted line — the same PII-exclusion-by-
|
|
313
|
+
// construction the telemetry and dispatch-issues surfaces use, applied here so
|
|
314
|
+
// a byte counter can never quietly become a content log.
|
|
315
|
+
|
|
316
|
+
/** The turn's running injection total. Counts only — never content. */
|
|
317
|
+
export interface TurnSpend {
|
|
318
|
+
/** Session the count belongs to; a mismatch resets rather than accumulates. */
|
|
319
|
+
readonly session: string;
|
|
320
|
+
/** Bytes emitted so far in the current turn. */
|
|
321
|
+
readonly bytes: number;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export const TURN_SPEND_BASENAME = "injection-turn.json";
|
|
325
|
+
|
|
326
|
+
export function turnSpendPath(workspaceRoot: string): string {
|
|
327
|
+
return path.join(workspaceRoot, "agents", "runtime", "state", TURN_SPEND_BASENAME);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Read the turn's spend so far. Returns 0 for a missing, unreadable, malformed,
|
|
332
|
+
* or foreign-session file — every failure resolves to "this turn has spent
|
|
333
|
+
* nothing", which under-counts rather than over-drops. An accounting error must
|
|
334
|
+
* never be the reason an advisory disappears.
|
|
335
|
+
*/
|
|
336
|
+
export function readTurnSpend(workspaceRoot: string, session: string): number {
|
|
337
|
+
try {
|
|
338
|
+
const raw = fs.readFileSync(turnSpendPath(workspaceRoot), "utf-8");
|
|
339
|
+
const parsed = JSON.parse(raw) as Partial<TurnSpend>;
|
|
340
|
+
if (parsed.session !== session) return 0;
|
|
341
|
+
return typeof parsed.bytes === "number" &&
|
|
342
|
+
Number.isFinite(parsed.bytes) &&
|
|
343
|
+
parsed.bytes >= 0
|
|
344
|
+
? parsed.bytes
|
|
345
|
+
: 0;
|
|
346
|
+
} catch {
|
|
347
|
+
return 0;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Write the turn's spend. Best-effort and silent on failure — a counter that
|
|
353
|
+
* cannot be persisted degrades to per-dispatch enforcement, which is strictly
|
|
354
|
+
* weaker and never wrong in the dangerous direction.
|
|
355
|
+
*
|
|
356
|
+
* `reset` is passed on the slot that STARTS a turn (`user_prompt_submit`), so
|
|
357
|
+
* the count is bounded by one turn rather than growing across a session.
|
|
358
|
+
*/
|
|
359
|
+
export function recordTurnSpend(
|
|
360
|
+
workspaceRoot: string,
|
|
361
|
+
session: string,
|
|
362
|
+
bytes: number,
|
|
363
|
+
opts: { readonly reset?: boolean } = {},
|
|
364
|
+
): void {
|
|
365
|
+
if (process.env["AGENT_CONFIG_REPLAY"] === "1") return;
|
|
366
|
+
const previous = opts.reset === true ? 0 : readTurnSpend(workspaceRoot, session);
|
|
367
|
+
const entry: TurnSpend = { session, bytes: previous + Math.max(0, bytes) };
|
|
368
|
+
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" });
|
|
372
|
+
} catch {
|
|
373
|
+
/* observability-adjacent bookkeeping never breaks the agent loop */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ─── The dispatcher-facing entry point ───────────────────────────────────────
|
|
378
|
+
//
|
|
379
|
+
// Everything below used to live inline in `dispatch_hook.ts`. It moved here
|
|
380
|
+
// because it belongs here — resolving the ceiling, recording drops and writing
|
|
381
|
+
// the overflow line are emission-shaping concerns, not dispatch concerns — and
|
|
382
|
+
// because `check_source_size_budget` refused the inline version: dispatch_hook
|
|
383
|
+
// sat exactly on the 1,500-line ratchet ceiling, so 180 inline lines were 180
|
|
384
|
+
// new violations, and the gate says raising the baseline is a defect rather than
|
|
385
|
+
// a fix. A gate that forces the logic into the module that owns it is a gate
|
|
386
|
+
// doing its job.
|
|
387
|
+
//
|
|
388
|
+
// The context is passed IN rather than derived here, deliberately: the two facts
|
|
389
|
+
// only the dispatcher can cheaply answer (does this platform bind the turn-start
|
|
390
|
+
// event, did a real session id arrive) would otherwise need the manifest loader,
|
|
391
|
+
// and importing that from here would close an import cycle.
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* One collected concern message, as the dispatcher already holds it.
|
|
395
|
+
*
|
|
396
|
+
* `def` is the manifest concern definition passed through verbatim rather than
|
|
397
|
+
* unpacked at the call site: severity, `fail_closed` and `nudge_rank` are this
|
|
398
|
+
* module's vocabulary, so reading them here keeps the dispatcher from carrying a
|
|
399
|
+
* field list it has no other use for.
|
|
400
|
+
*/
|
|
401
|
+
export interface ConcernMessage {
|
|
402
|
+
readonly rc: number;
|
|
403
|
+
readonly text: string;
|
|
404
|
+
readonly def: Record<string, unknown>;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Everything `shapeAndRecord` needs that it cannot determine on its own.
|
|
409
|
+
*
|
|
410
|
+
* Deliberately five fields: the workspace root, the session key, whether a real
|
|
411
|
+
* session id arrived, and whether the platform emits at all are all DERIVED here
|
|
412
|
+
* from `envelope` and `platform` rather than passed. A caller that has to assemble
|
|
413
|
+
* seven correlated booleans is a caller that will eventually assemble one wrongly.
|
|
414
|
+
*/
|
|
415
|
+
export interface ShapingContext {
|
|
416
|
+
/** Package root — where the shipped budget row lives. */
|
|
417
|
+
readonly packageRoot: string;
|
|
418
|
+
/** The dispatch envelope; `workspace_root` and `session_id` are read from it. */
|
|
419
|
+
readonly envelope: Record<string, unknown>;
|
|
420
|
+
readonly platform: string;
|
|
421
|
+
/** agent-config event name for this dispatch. */
|
|
422
|
+
readonly event: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Does this platform bind the turn-start event?
|
|
427
|
+
*
|
|
428
|
+
* Without it the counter never resets, grows monotonically, and suppresses every
|
|
429
|
+
* droppable advisory for the rest of the session — so it is a precondition of the
|
|
430
|
+
* volume policy, not a detail.
|
|
431
|
+
*
|
|
432
|
+
* Read straight off the compiled manifest rather than through
|
|
433
|
+
* `dispatch_hook._resolve_concerns`, which would close an import cycle. The
|
|
434
|
+
* question here is narrower than that resolver's — "is this slot bound at all",
|
|
435
|
+
* with no role filtering — so a plain lookup is the honest tool and not a
|
|
436
|
+
* shortcut. Unreadable manifest → `false`, which switches the volume policy off:
|
|
437
|
+
* the fail-open direction for a policy whose job is to delete output.
|
|
438
|
+
*/
|
|
439
|
+
function _turnStartBound(packageRoot: string, platform: string): boolean {
|
|
440
|
+
for (const rel of [
|
|
441
|
+
["src", "scripts", "hook_manifest.json"],
|
|
442
|
+
["src", "scripts", "hook_manifest.yaml"],
|
|
443
|
+
]) {
|
|
444
|
+
try {
|
|
445
|
+
const raw = fs.readFileSync(path.join(packageRoot, ...rel), "utf-8");
|
|
446
|
+
if (rel[2] === "hook_manifest.json") {
|
|
447
|
+
const parsed = JSON.parse(raw) as { manifest?: Record<string, unknown> };
|
|
448
|
+
const platforms = parsed.manifest?.["platforms"] as
|
|
449
|
+
| Record<string, Record<string, unknown>>
|
|
450
|
+
| undefined;
|
|
451
|
+
const bound = platforms?.[platform]?.[TURN_START_EVENT];
|
|
452
|
+
return Array.isArray(bound) && bound.length > 0;
|
|
453
|
+
}
|
|
454
|
+
// YAML fallback: the binding is one ` user_prompt_submit: [...]`
|
|
455
|
+
// line under the platform key. A structural read needs the YAML
|
|
456
|
+
// parser, which this module deliberately does not pull in; absence of
|
|
457
|
+
// the compiled sibling is a build defect surfaced elsewhere, so the
|
|
458
|
+
// conservative answer is enough.
|
|
459
|
+
return false;
|
|
460
|
+
} catch {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
return false;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Consumer workspace — where the turn counter and the drop log are written. */
|
|
468
|
+
function _workspaceRoot(ctx: ShapingContext): string {
|
|
469
|
+
const v = ctx.envelope["workspace_root"];
|
|
470
|
+
return typeof v === "string" && v ? v : process.cwd();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* The session key, or `null` when no real `session_id` arrived. The dispatcher's
|
|
475
|
+
* fallback is unique per invocation by design, so a synthetic id makes the counter
|
|
476
|
+
* unreadable and would silently turn a per-TURN ceiling into a per-dispatch one.
|
|
477
|
+
*/
|
|
478
|
+
function _sessionKey(ctx: ShapingContext): string | null {
|
|
479
|
+
const v = ctx.envelope["session_id"];
|
|
480
|
+
return typeof v === "string" && v ? v : null;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* The per-turn ceiling for this dispatch, or `null` when the VOLUME policy must
|
|
485
|
+
* not run. `null` is the fail-open direction: a budget that cannot be read, or
|
|
486
|
+
* whose turn boundary cannot be observed, must never become a budget of zero.
|
|
487
|
+
*/
|
|
488
|
+
export function resolveVolumeCap(ctx: ShapingContext): number | null {
|
|
489
|
+
if (!VERIFIED_PLATFORMS.has(ctx.platform)) return null;
|
|
490
|
+
if (_sessionKey(ctx) === null) return null;
|
|
491
|
+
if (!_turnStartBound(ctx.packageRoot, ctx.platform)) return null;
|
|
492
|
+
try {
|
|
493
|
+
const raw = fs.readFileSync(
|
|
494
|
+
path.join(ctx.packageRoot, "src", "config", "hook-token-budget.json"),
|
|
495
|
+
"utf-8",
|
|
496
|
+
);
|
|
497
|
+
const cfg = (JSON.parse(raw) as Record<string, unknown>)["per_turn_aggregate_bytes"];
|
|
498
|
+
if (cfg === undefined || cfg === null || typeof cfg !== "object") return null;
|
|
499
|
+
const row = cfg as Record<string, unknown>;
|
|
500
|
+
const excluded = row["excluded_slots"];
|
|
501
|
+
if (Array.isArray(excluded) && excluded.map(String).includes(ctx.event)) return null;
|
|
502
|
+
const ceiling = row["ceiling_bytes"];
|
|
503
|
+
return typeof ceiling === "number" && ceiling > 0 ? ceiling : null;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Shape this dispatch's would-be emissions, record every suppression, update the
|
|
511
|
+
* turn counter, and return THE MESSAGE TEXTS THAT MAY LEAVE, in manifest order.
|
|
512
|
+
*
|
|
513
|
+
* It returns the texts rather than a name set so the caller cannot re-derive the
|
|
514
|
+
* candidate set differently: the deciding-severity filter is part of this module's
|
|
515
|
+
* contract (see the header's caller obligation), and a caller that applied it
|
|
516
|
+
* twice — or once, differently — would make the ceiling govern bytes nothing ever
|
|
517
|
+
* wrote.
|
|
518
|
+
*/
|
|
519
|
+
export function shapeAndRecord(
|
|
520
|
+
ctx: ShapingContext,
|
|
521
|
+
messages: readonly ConcernMessage[],
|
|
522
|
+
finalRc: number,
|
|
523
|
+
): string[] {
|
|
524
|
+
const deciding = messages.filter((m) => m.rc === finalRc);
|
|
525
|
+
const textOf = (kept: ReadonlySet<string>): string[] =>
|
|
526
|
+
deciding.filter((m) => kept.has(String(m.def["name"] ?? ""))).map((m) => m.text);
|
|
527
|
+
const candidates: EmissionCandidate[] = deciding
|
|
528
|
+
.map((m) => {
|
|
529
|
+
const rank = m.def["nudge_rank"];
|
|
530
|
+
return {
|
|
531
|
+
concern: String(m.def["name"] ?? ""),
|
|
532
|
+
severity: String(m.def["severity"] ?? ""),
|
|
533
|
+
failClosed: Boolean(m.def["fail_closed"]),
|
|
534
|
+
rc: m.rc,
|
|
535
|
+
bytes: Buffer.byteLength(m.text, "utf-8"),
|
|
536
|
+
nudgeRank: typeof rank === "number" ? rank : null,
|
|
537
|
+
};
|
|
538
|
+
});
|
|
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
|
+
const workspaceRoot = _workspaceRoot(ctx);
|
|
548
|
+
const sessionKey = _sessionKey(ctx) ?? "";
|
|
549
|
+
const cap = resolveVolumeCap(ctx);
|
|
550
|
+
// On the turn-START slot the carried total is zero by definition: this is the
|
|
551
|
+
// dispatch that begins the turn. Reading the stored value here budgeted the
|
|
552
|
+
// new turn against the one that just ended.
|
|
553
|
+
const turnStartsHere = ctx.event === TURN_START_EVENT;
|
|
554
|
+
const spentBefore =
|
|
555
|
+
cap === null || turnStartsHere ? 0 : readTurnSpend(workspaceRoot, sessionKey);
|
|
556
|
+
|
|
557
|
+
const shaped = shapeEmissions(candidates, { capBytes: cap, spentBytes: spentBefore });
|
|
558
|
+
|
|
559
|
+
for (const drop of shaped.dropped) {
|
|
560
|
+
log_dispatch_issue(
|
|
561
|
+
workspaceRoot,
|
|
562
|
+
drop.concern,
|
|
563
|
+
drop.reason === "nudge_interference"
|
|
564
|
+
? "nudge_interference_drop"
|
|
565
|
+
: "injection_budget_drop",
|
|
566
|
+
drop.detail,
|
|
567
|
+
"src/config/hook-token-budget.json § per_turn_aggregate_bytes",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (shaped.ceilingExceeded) {
|
|
572
|
+
// Stated, never swallowed — and the CAUSE is named, because the two point
|
|
573
|
+
// at different fixes. `exempt-floor`: the safety-relevant set alone is
|
|
574
|
+
// over, so the policy held its contract and the budget row is the decision
|
|
575
|
+
// surface. `carried-spend`: earlier dispatches in this turn already filled
|
|
576
|
+
// it, which is a question about the turn, not about the row.
|
|
577
|
+
const cause =
|
|
578
|
+
shaped.ceilingCause === "exempt-floor"
|
|
579
|
+
? "by exempt (blocking / fail_closed) concerns alone — nothing dropped, because dropping advisories could not bring it under"
|
|
580
|
+
: "by spend already carried from earlier dispatches in this turn — dropping advisories could not bring it under";
|
|
581
|
+
process.stderr.write(
|
|
582
|
+
`dispatch_hook: per-turn injection ceiling exceeded ${cause} ` +
|
|
583
|
+
`(${String(spentBefore + shaped.keptBytes)} B over ${String(cap)} B). ` +
|
|
584
|
+
`The row is the decision surface ` +
|
|
585
|
+
`(src/config/hook-token-budget.json § per_turn_aggregate_bytes).\n`,
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
if (cap !== null) {
|
|
590
|
+
recordTurnSpend(workspaceRoot, sessionKey, shaped.keptBytes, {
|
|
591
|
+
reset: turnStartsHere,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
return textOf(new Set(shaped.kept));
|
|
595
|
+
}
|