@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,446 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload-body stubs — the opt-in half of D-2
|
|
3
|
+
* (`road-to-per-turn-hook-economy` Phase 2, step 2.1).
|
|
4
|
+
*
|
|
5
|
+
* ## The cost this removes
|
|
6
|
+
*
|
|
7
|
+
* The dispatcher hands each concern the whole envelope as text, once per
|
|
8
|
+
* concern. On claude's `post_tool_use` that is ELEVEN serialisations of the
|
|
9
|
+
* same payload per tool call, and the payload's dominant term is the tool
|
|
10
|
+
* result — routinely hundreds of kilobytes, measured above two megabytes on a
|
|
11
|
+
* large `Read` or `Bash`.
|
|
12
|
+
*
|
|
13
|
+
* Phase 1 tried to remove the repetition by serialising once and sharing the
|
|
14
|
+
* string. It was measured against a pre-registered bar, the two runs disagreed
|
|
15
|
+
* in sign, and the null is published in `_run_concern_inproc`'s header. This
|
|
16
|
+
* is the other lever, and it is orthogonal: it does not change HOW MANY times
|
|
17
|
+
* the envelope is serialised, it changes WHAT IS IN IT. A concern that never
|
|
18
|
+
* reads the result body has no reason to be handed it.
|
|
19
|
+
*
|
|
20
|
+
* ## Two classes, not one flag — the audit forced this
|
|
21
|
+
*
|
|
22
|
+
* The step was drafted as a single boolean ("does this concern need the
|
|
23
|
+
* tool-response body"). The per-concern audit that assigned it refuted the
|
|
24
|
+
* shape: of the eleven concerns on claude's `post_tool_use`, only THREE read
|
|
25
|
+
* no tool payload at all, so a single flag would have bought almost nothing.
|
|
26
|
+
* Six of them read `tool_input` — a path, a command, a range — while reading
|
|
27
|
+
* nothing from the multi-megabyte `tool_response`.
|
|
28
|
+
*
|
|
29
|
+
* So the declaration names CLASSES: `input`, `result`, or both. That is where
|
|
30
|
+
* the saving actually lives — six of eleven post concerns now receive the 2 MB
|
|
31
|
+
* result as a ~120-byte stub while keeping the small input they do read.
|
|
32
|
+
*
|
|
33
|
+
* ## Absent means omitted, and omitted is visible
|
|
34
|
+
*
|
|
35
|
+
* A concern declares `needs_payload_bodies: [input, result]` in
|
|
36
|
+
* `hook_manifest.yaml` for the classes it reads. An undeclared class arrives
|
|
37
|
+
* as a stub carrying the key's NAME, the value's SHAPE, its UTF-8 BYTE LENGTH,
|
|
38
|
+
* and (for an object) its top-level key COUNT. Nothing that could hold file
|
|
39
|
+
* contents, command output, or an API response survives into the stub — no
|
|
40
|
+
* field of `PayloadStub` is able to carry a payload-derived string, which is
|
|
41
|
+
* the same PII-exclusion-by-construction the sibling instruments use rather
|
|
42
|
+
* than a scrubber that could fail.
|
|
43
|
+
*
|
|
44
|
+
* `lint_hook_manifest` derives the requirement from SOURCE, not from trust: a
|
|
45
|
+
* concern bound on a tool slot whose script references a body key must declare
|
|
46
|
+
* that class, or carry a `payload-bodies-waiver:` line saying why it does not.
|
|
47
|
+
* That is the authoring-time half; without it, an added concern that forgets
|
|
48
|
+
* the declaration reads `undefined` in silence.
|
|
49
|
+
*
|
|
50
|
+
* The stub is deliberately a self-describing object rather than `null` or a
|
|
51
|
+
* missing key. A concern that silently depended on a body sees a marked object
|
|
52
|
+
* it can detect, and the dispatcher counts every stub it served, so the
|
|
53
|
+
* failure mode is a NUMBER rather than a bug report — which is step 2.1's own
|
|
54
|
+
* `verify:`.
|
|
55
|
+
*
|
|
56
|
+
* ## Byte-length fidelity is a contract, not a convenience
|
|
57
|
+
*
|
|
58
|
+
* `tool-result-bytes` is an instrument whose entire output is a byte count.
|
|
59
|
+
* Serving it a stub without an exact length would not make it fail — it would
|
|
60
|
+
* make it publish a census of ~120-byte tool results, which is worse than
|
|
61
|
+
* failing. So `bytes` is computed EXACTLY the way that concern computes it
|
|
62
|
+
* (`Buffer.byteLength` of the string, or of `JSON.stringify` for a container,
|
|
63
|
+
* `null` when unserialisable) and the concern reads it back through
|
|
64
|
+
* `stubbedBytes`. That pairing is step 2.2.
|
|
65
|
+
*
|
|
66
|
+
* The absent-vs-empty distinction is preserved on purpose: a payload with no
|
|
67
|
+
* result key under any name produces NO stub, so a concern still sees "absent"
|
|
68
|
+
* rather than "present and zero bytes". Two concerns document that difference
|
|
69
|
+
* as load-bearing (`before_complete_hook`'s `_extract_output`, and
|
|
70
|
+
* `tool_result_bytes_hook`'s `measurable: false`), and a stub that flattened
|
|
71
|
+
* it would turn a known-unknown into wrong-but-plausible data.
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
export type JsonValue =
|
|
75
|
+
| string
|
|
76
|
+
| number
|
|
77
|
+
| boolean
|
|
78
|
+
| null
|
|
79
|
+
| JsonValue[]
|
|
80
|
+
| { [key: string]: JsonValue };
|
|
81
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The marker key. Long and namespaced on purpose: it must not collide with a
|
|
85
|
+
* field a host might put inside a real `tool_response`, because a false
|
|
86
|
+
* positive here makes a concern treat live data as an omission.
|
|
87
|
+
*/
|
|
88
|
+
export const STUB_MARKER = "_agent_config_body_omitted" as const;
|
|
89
|
+
|
|
90
|
+
/** The two body classes a concern can declare. */
|
|
91
|
+
export type BodyClass = "input" | "result";
|
|
92
|
+
|
|
93
|
+
export const BODY_CLASSES: readonly BodyClass[] = ["input", "result"] as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Payload keys per class, across every host spelling this tree has observed.
|
|
97
|
+
* `tool_response` / `tool_input` are claude's; the camelCase and `tool_result`
|
|
98
|
+
* variants are the ones `tool_result_bytes_hook`'s own `RESULT_KEYS` reads and
|
|
99
|
+
* are stubbed here for the same reason they are read there.
|
|
100
|
+
*
|
|
101
|
+
* `tool_name` is deliberately ABSENT from both lists: it is an id-shaped enum
|
|
102
|
+
* every concern's `tools:` filter depends on, it cannot carry content, and
|
|
103
|
+
* stubbing it would silently stop `_concern_matches_tool` from filtering.
|
|
104
|
+
* Same for `transcript_path`, `prompt`, `cwd`, `session_id`,
|
|
105
|
+
* `stop_hook_active`, `agent_id` and `payload.source` — all live payload reads
|
|
106
|
+
* in concerns that touch no tool body, and all left untouched because this
|
|
107
|
+
* module stubs the two tool-body classes and nothing else.
|
|
108
|
+
*/
|
|
109
|
+
export const BODY_KEYS: Readonly<Record<BodyClass, readonly string[]>> = {
|
|
110
|
+
input: ["tool_input", "toolInput"],
|
|
111
|
+
result: ["tool_response", "toolResponse", "tool_result", "toolUseResult"],
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export interface PayloadStub extends JsonObject {
|
|
115
|
+
[STUB_MARKER]: true;
|
|
116
|
+
/** The payload key this stub stands in for, e.g. `tool_response`. */
|
|
117
|
+
key: string;
|
|
118
|
+
/** `input` | `result` — which class was omitted. */
|
|
119
|
+
body_class: string;
|
|
120
|
+
/** UTF-8 byte length of the omitted value, or `null` if unserialisable. */
|
|
121
|
+
bytes: number | null;
|
|
122
|
+
/** `string` | `object` | `array` | `other` — the omitted value's shape. */
|
|
123
|
+
value_type: string;
|
|
124
|
+
/**
|
|
125
|
+
* Number of top-level keys of an omitted object. Absent for other shapes.
|
|
126
|
+
*
|
|
127
|
+
* A COUNT, not the key names. The names were carried verbatim in the first
|
|
128
|
+
* version of this module and the review caught it against the header's own
|
|
129
|
+
* absolute claim: an object result keyed by ids, filenames or addresses puts
|
|
130
|
+
* payload-derived strings back into the stub, and no concern read the field.
|
|
131
|
+
* A count keeps the shape information the name-and-sizes stub is for while
|
|
132
|
+
* the type again has no field able to hold content.
|
|
133
|
+
*/
|
|
134
|
+
key_count?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function isObject(v: unknown): v is JsonObject {
|
|
138
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* UTF-8 byte length of a payload body, measured the way
|
|
143
|
+
* `tool_result_bytes_hook._resultBytes` measures it so a stub and a real body
|
|
144
|
+
* produce the same census number.
|
|
145
|
+
*/
|
|
146
|
+
export function bodyBytes(value: JsonValue): number | null {
|
|
147
|
+
if (typeof value === "string") return Buffer.byteLength(value, "utf8");
|
|
148
|
+
try {
|
|
149
|
+
return Buffer.byteLength(JSON.stringify(value), "utf8");
|
|
150
|
+
} catch {
|
|
151
|
+
// Circular or otherwise unserialisable — unmeasurable, never zero.
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Measure every body this envelope carries, ONCE.
|
|
158
|
+
*
|
|
159
|
+
* `bodyBytes` serialises the value it measures, so calling it per stub meant
|
|
160
|
+
* re-serialising a 2 MB result once per keep-set — up to four extra full
|
|
161
|
+
* `JSON.stringify` passes per dispatch, on the hot path, in a change whose
|
|
162
|
+
* whole subject is serialisation cost. The review caught it, and noted it may
|
|
163
|
+
* partly explain the published null. The measurement is therefore taken once
|
|
164
|
+
* per dispatch and threaded through, and `stubPayloadBodies` REQUIRES the map
|
|
165
|
+
* rather than accepting an optional one — an optional parameter is how the
|
|
166
|
+
* per-stub re-serialisation would silently come back.
|
|
167
|
+
*
|
|
168
|
+
* `only` narrows it further, to the classes some concern on this slot actually
|
|
169
|
+
* loses. A body every concern declares is never stubbed, so measuring it is
|
|
170
|
+
* pure waste: on claude's `pre_tool_use` all twelve concerns declare `input`,
|
|
171
|
+
* which makes the whole measurement step disappear rather than merely shrink.
|
|
172
|
+
*/
|
|
173
|
+
export function measureBodies(
|
|
174
|
+
envelope: JsonObject,
|
|
175
|
+
only?: ReadonlySet<BodyClass>,
|
|
176
|
+
): Map<string, number | null> {
|
|
177
|
+
const measured = new Map<string, number | null>();
|
|
178
|
+
const payload = envelope["payload"];
|
|
179
|
+
if (!isObject(payload)) return measured;
|
|
180
|
+
for (const cls of BODY_CLASSES) {
|
|
181
|
+
if (only !== undefined && !only.has(cls)) continue;
|
|
182
|
+
for (const key of BODY_KEYS[cls]) {
|
|
183
|
+
const v = payload[key];
|
|
184
|
+
if (v === undefined || v === null) continue;
|
|
185
|
+
if (isPayloadStub(v)) continue;
|
|
186
|
+
measured.set(key, bodyBytes(v));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return measured;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Build the stub that stands in for `value` under `key`.
|
|
194
|
+
*
|
|
195
|
+
* `bytes` comes from the pre-measured map, never from re-serialising here.
|
|
196
|
+
* A key absent from the map is a caller bug rather than an unmeasurable body,
|
|
197
|
+
* and it degrades to `null` (unmeasurable) rather than to a wrong number.
|
|
198
|
+
*/
|
|
199
|
+
export function makePayloadStub(
|
|
200
|
+
key: string,
|
|
201
|
+
body_class: BodyClass,
|
|
202
|
+
value: JsonValue,
|
|
203
|
+
measured: ReadonlyMap<string, number | null>,
|
|
204
|
+
): PayloadStub {
|
|
205
|
+
const bytes = measured.has(key) ? (measured.get(key) ?? null) : null;
|
|
206
|
+
const stub: PayloadStub = {
|
|
207
|
+
[STUB_MARKER]: true,
|
|
208
|
+
key,
|
|
209
|
+
body_class,
|
|
210
|
+
bytes,
|
|
211
|
+
value_type: Array.isArray(value)
|
|
212
|
+
? "array"
|
|
213
|
+
: typeof value === "string"
|
|
214
|
+
? "string"
|
|
215
|
+
: isObject(value)
|
|
216
|
+
? "object"
|
|
217
|
+
: "other",
|
|
218
|
+
};
|
|
219
|
+
if (isObject(value)) {
|
|
220
|
+
stub.key_count = Object.keys(value).length;
|
|
221
|
+
}
|
|
222
|
+
return stub;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** True when `v` is a dispatcher-served stub rather than a real body. */
|
|
226
|
+
export function isPayloadStub(v: unknown): v is PayloadStub {
|
|
227
|
+
return isObject(v) && v[STUB_MARKER] === true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* The omitted body's byte length when `v` is a stub, `undefined` when it is
|
|
232
|
+
* not — so a caller can distinguish "omitted, and this many bytes" from
|
|
233
|
+
* "present, measure it yourself".
|
|
234
|
+
*/
|
|
235
|
+
export function stubbedBytes(v: unknown): number | null | undefined {
|
|
236
|
+
if (!isPayloadStub(v)) return undefined;
|
|
237
|
+
return typeof v.bytes === "number" ? v.bytes : null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Body classes this envelope actually carries — the stubbable set. */
|
|
241
|
+
export function presentBodyClasses(envelope: JsonObject): Set<BodyClass> {
|
|
242
|
+
const found = new Set<BodyClass>();
|
|
243
|
+
const payload = envelope["payload"];
|
|
244
|
+
if (!isObject(payload)) return found;
|
|
245
|
+
for (const cls of BODY_CLASSES) {
|
|
246
|
+
for (const key of BODY_KEYS[cls]) {
|
|
247
|
+
const v = payload[key];
|
|
248
|
+
if (v === undefined || v === null) continue;
|
|
249
|
+
if (isPayloadStub(v)) continue;
|
|
250
|
+
found.add(cls);
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return found;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Return an envelope whose payload bodies are stubbed for every class NOT in
|
|
259
|
+
* `keep`.
|
|
260
|
+
*
|
|
261
|
+
* Shallow-clones the envelope and its payload; every other value is shared
|
|
262
|
+
* with the caller's object. That is safe because the dispatcher serialises the
|
|
263
|
+
* result immediately and never hands the object itself to a concern — the
|
|
264
|
+
* isolation boundary the Phase 1 null asked to preserve is the SERIALISED
|
|
265
|
+
* STRING, and it is unchanged here.
|
|
266
|
+
*
|
|
267
|
+
* Returns the input unchanged (same reference) when there is nothing to stub,
|
|
268
|
+
* so the common no-body event — `stop`, `session_start`, `user_prompt_submit`
|
|
269
|
+
* — costs one property read and no allocation.
|
|
270
|
+
*/
|
|
271
|
+
export function stubPayloadBodies(
|
|
272
|
+
envelope: JsonObject,
|
|
273
|
+
keep: ReadonlySet<BodyClass>,
|
|
274
|
+
measured: ReadonlyMap<string, number | null>,
|
|
275
|
+
): JsonObject {
|
|
276
|
+
const payload = envelope["payload"];
|
|
277
|
+
if (!isObject(payload)) return envelope;
|
|
278
|
+
const drop = BODY_CLASSES.filter((c) => !keep.has(c));
|
|
279
|
+
if (drop.length === 0) return envelope;
|
|
280
|
+
let hit = false;
|
|
281
|
+
for (const cls of drop) {
|
|
282
|
+
for (const key of BODY_KEYS[cls]) {
|
|
283
|
+
const v = payload[key];
|
|
284
|
+
if (v === undefined || v === null) continue;
|
|
285
|
+
if (isPayloadStub(v)) continue;
|
|
286
|
+
hit = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (hit) break;
|
|
290
|
+
}
|
|
291
|
+
if (!hit) return envelope;
|
|
292
|
+
const nextPayload: JsonObject = { ...payload };
|
|
293
|
+
for (const cls of drop) {
|
|
294
|
+
for (const key of BODY_KEYS[cls]) {
|
|
295
|
+
const v = nextPayload[key];
|
|
296
|
+
if (v === undefined || v === null) continue;
|
|
297
|
+
if (isPayloadStub(v)) continue;
|
|
298
|
+
nextPayload[key] = makePayloadStub(key, cls, v, measured);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return { ...envelope, payload: nextPayload };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** How many payload keys `stubPayloadBodies` would replace, given `keep`. */
|
|
305
|
+
export function countStubbedKeys(
|
|
306
|
+
envelope: JsonObject,
|
|
307
|
+
keep: ReadonlySet<BodyClass>,
|
|
308
|
+
): number {
|
|
309
|
+
const payload = envelope["payload"];
|
|
310
|
+
if (!isObject(payload)) return 0;
|
|
311
|
+
let n = 0;
|
|
312
|
+
for (const cls of BODY_CLASSES) {
|
|
313
|
+
if (keep.has(cls)) continue;
|
|
314
|
+
for (const key of BODY_KEYS[cls]) {
|
|
315
|
+
const v = payload[key];
|
|
316
|
+
if (v === undefined || v === null) continue;
|
|
317
|
+
if (isPayloadStub(v)) continue;
|
|
318
|
+
n += 1;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return n;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Parse a concern's `needs_payload_bodies` declaration into a keep-set.
|
|
326
|
+
*
|
|
327
|
+
* Unknown entries are IGNORED rather than treated as "keep everything": the
|
|
328
|
+
* lint rejects them at authoring time, and a runtime that widened on a typo
|
|
329
|
+
* would make the lint's job undoable. A malformed value therefore behaves like
|
|
330
|
+
* the absent case, which the guard floor in the dispatcher covers for every
|
|
331
|
+
* concern where that would be unsafe.
|
|
332
|
+
*/
|
|
333
|
+
export function parseBodyClasses(raw: unknown): Set<BodyClass> {
|
|
334
|
+
const keep = new Set<BodyClass>();
|
|
335
|
+
if (!Array.isArray(raw)) return keep;
|
|
336
|
+
for (const entry of raw) {
|
|
337
|
+
if (entry === "input" || entry === "result") keep.add(entry);
|
|
338
|
+
}
|
|
339
|
+
return keep;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Every class — the guard floor and the "declared both" case. */
|
|
343
|
+
export function allBodyClasses(): Set<BodyClass> {
|
|
344
|
+
return new Set<BodyClass>(BODY_CLASSES);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The payload-body classes a concern receives in full; every other class
|
|
349
|
+
* arrives as a stub (`road-to-per-turn-hook-economy` Phase 2, step 2.1).
|
|
350
|
+
*
|
|
351
|
+
* The default is EMPTY, deliberately: a concern that reads a body says so,
|
|
352
|
+
* rather than every concern paying for the bodies because one of them might.
|
|
353
|
+
* The audit that assigned the declarations is in the PR that added the field;
|
|
354
|
+
* the dispatcher's stub counter is what catches an assignment that was wrong.
|
|
355
|
+
*
|
|
356
|
+
* ## Why a blocking or fail-closed concern can never be stubbed
|
|
357
|
+
*
|
|
358
|
+
* For an advisory concern a missing declaration costs a finding: it reads
|
|
359
|
+
* `undefined` where it expected a body and stays quiet. For a guard it costs
|
|
360
|
+
* an ALLOW — `block-no-verify` reads `tool_input.command`, and a stub makes
|
|
361
|
+
* that `undefined`, after which the guard has nothing to match and exits 0.
|
|
362
|
+
* That is the identical shape as the measured stdin bypass this same roadmap
|
|
363
|
+
* fixed in Phase 1 (an empty payload silently disarming every guard on the
|
|
364
|
+
* event), and it would be re-introduced by a single omitted YAML line.
|
|
365
|
+
*
|
|
366
|
+
* So the declaration is not the only path to the bodies: `fail_closed` or
|
|
367
|
+
* `severity: blocking` keeps ALL classes, structurally, regardless of what the
|
|
368
|
+
* manifest says. The lint additionally requires those concerns to declare
|
|
369
|
+
* their classes explicitly so a reader of the manifest sees the truth — but
|
|
370
|
+
* the dispatcher does not depend on the lint having run. The cost of the floor
|
|
371
|
+
* is zero on the one blocking concern that reads no body: `turn-end-gate`
|
|
372
|
+
* binds `stop`, which carries no tool bodies to stub.
|
|
373
|
+
*/
|
|
374
|
+
export function _concern_body_classes(concern: JsonObject): Set<BodyClass> {
|
|
375
|
+
if (concern["fail_closed"] === true || concern["severity"] === "blocking") {
|
|
376
|
+
return allBodyClasses();
|
|
377
|
+
}
|
|
378
|
+
return parseBodyClasses(concern["needs_payload_bodies"]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The per-dispatch shaping plan. Built once, read per concern. */
|
|
382
|
+
export interface PayloadShapePlan {
|
|
383
|
+
/** The envelope a concern with this keep-set receives. */
|
|
384
|
+
shapeFor(keep: ReadonlySet<BodyClass>): JsonObject;
|
|
385
|
+
/** How many payload keys were omitted for that concern. */
|
|
386
|
+
stubsFor(keep: ReadonlySet<BodyClass>): number;
|
|
387
|
+
/** The classes actually SERVED — declared AND present. Sorted. */
|
|
388
|
+
servedBy(keep: ReadonlySet<BodyClass>): string[];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Plan every envelope shape this dispatch needs, in one pass.
|
|
393
|
+
*
|
|
394
|
+
* There are at most FOUR distinct shapes per event (keep neither / input /
|
|
395
|
+
* result / both), so they are memoised by keep-set: concerns sharing a
|
|
396
|
+
* declaration share one clone, and building one per concern would re-pay the
|
|
397
|
+
* allocation the stub exists to avoid.
|
|
398
|
+
*
|
|
399
|
+
* The measurement is scoped twice over, because both scopes matter:
|
|
400
|
+
* — to the classes the event actually CARRIES (`present`), so a body-less
|
|
401
|
+
* event such as `stop` or `user_prompt_submit` costs one property read;
|
|
402
|
+
* — to the classes at least one concern LOSES, because a class every concern
|
|
403
|
+
* declares is never stubbed and measuring it is pure waste. On claude's
|
|
404
|
+
* `pre_tool_use` all twelve concerns declare `input`, which makes the
|
|
405
|
+
* measurement pass vanish rather than merely shrink.
|
|
406
|
+
*
|
|
407
|
+
* Extracted from the dispatcher loop rather than inlined there: the decision
|
|
408
|
+
* has four moving parts that are only correct together, and the source-size
|
|
409
|
+
* ratchet was right to object to a dispatcher carrying them.
|
|
410
|
+
*/
|
|
411
|
+
export function planPayloadShapes(
|
|
412
|
+
envelope: JsonObject,
|
|
413
|
+
keepSets: Iterable<ReadonlySet<BodyClass>>,
|
|
414
|
+
): PayloadShapePlan {
|
|
415
|
+
const present = presentBodyClasses(envelope);
|
|
416
|
+
const stubbedSomewhere = new Set<BodyClass>();
|
|
417
|
+
for (const keep of keepSets) {
|
|
418
|
+
for (const cls of present) {
|
|
419
|
+
if (!keep.has(cls)) stubbedSomewhere.add(cls);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const measured =
|
|
423
|
+
stubbedSomewhere.size > 0
|
|
424
|
+
? measureBodies(envelope, stubbedSomewhere)
|
|
425
|
+
: new Map<string, number | null>();
|
|
426
|
+
const shaped = new Map<string, JsonObject>();
|
|
427
|
+
const cacheKey = (keep: ReadonlySet<BodyClass>): string =>
|
|
428
|
+
[...keep].sort().join(",");
|
|
429
|
+
return {
|
|
430
|
+
shapeFor(keep) {
|
|
431
|
+
if (present.size === 0) return envelope;
|
|
432
|
+
const key = cacheKey(keep);
|
|
433
|
+
const hit = shaped.get(key);
|
|
434
|
+
if (hit !== undefined) return hit;
|
|
435
|
+
const built = stubPayloadBodies(envelope, keep, measured);
|
|
436
|
+
shaped.set(key, built);
|
|
437
|
+
return built;
|
|
438
|
+
},
|
|
439
|
+
stubsFor(keep) {
|
|
440
|
+
return countStubbedKeys(envelope, keep);
|
|
441
|
+
},
|
|
442
|
+
servedBy(keep) {
|
|
443
|
+
return [...keep].filter((c) => present.has(c)).sort();
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
}
|