@event4u/agent-config 10.3.0 → 11.0.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 (107) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +147 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/analyze.md +2 -1
  5. package/dist/agent-src/commands/council/debate.md +13 -4
  6. package/dist/agent-src/commands/council/default.md +26 -11
  7. package/dist/agent-src/commands/council.md +1 -1
  8. package/dist/agent-src/commands/design-system/capture.md +44 -0
  9. package/dist/agent-src/commands/design-system/generate.md +75 -0
  10. package/dist/agent-src/commands/design-system/import.md +63 -0
  11. package/dist/agent-src/commands/design-system.md +66 -0
  12. package/dist/agent-src/commands/pr/create.md +51 -26
  13. package/dist/agent-src/commands/worktree/create.md +5 -3
  14. package/dist/agent-src/rules/settings-ask-protocol.md +14 -9
  15. package/dist/agent-src/skills/ai-council/SKILL.md +24 -6
  16. package/dist/agent-src/skills/corpus-grounding/SKILL.md +12 -1
  17. package/dist/agent-src/skills/corpus-grounding/scripts/decision_engine.ts +215 -7
  18. package/dist/agent-src/skills/corpus-grounding/scripts/ground.ts +39 -1
  19. package/dist/agent-src/skills/design-intelligence/ATTRIBUTION.md +19 -2
  20. package/dist/agent-src/skills/design-intelligence/data/manifest.json +36 -1
  21. package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -0
  22. package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
  23. package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
  24. package/dist/agent-src/skills/git-workflow/SKILL.md +9 -3
  25. package/dist/agent-src/skills/subagent-orchestration/SKILL.md +9 -6
  26. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +25 -21
  27. package/dist/agent-src/templates/agent-settings.md +0 -1
  28. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  29. package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  30. package/dist/cli-delegate/{chunk-BV3WR67D.js → chunk-55WZN5VF.js} +3 -6
  31. package/dist/cli-delegate/{chunk-X3VOARZD.js → chunk-L565YFXH.js} +3 -3
  32. package/dist/cli-delegate/{chunk-JMQZ7MM5.js → chunk-MNWRDMR5.js} +1 -1
  33. package/dist/cli-delegate/{chunk-IR3IVRVC.js → chunk-NLP54U3J.js} +2 -1
  34. package/dist/cli-delegate/{chunk-FV34EGEX.js → chunk-NN4KW4HZ.js} +1 -1
  35. package/dist/cli-delegate/{chunk-6ENSXSBW.js → chunk-PPQO4NGR.js} +1 -1
  36. package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
  37. package/dist/cli-delegate/{chunk-6LOPHJLD.js → chunk-RYNADUFG.js} +1 -1
  38. package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
  39. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  40. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  41. package/dist/cli-delegate/cmd_conformance.js +673 -215
  42. package/dist/cli-delegate/cmd_doctor.js +2 -2
  43. package/dist/cli-delegate/cmd_explain.js +2 -2
  44. package/dist/cli-delegate/cmd_export.js +4 -4
  45. package/dist/cli-delegate/cmd_handoff.js +10 -8
  46. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  47. package/dist/cli-delegate/cmd_migrate.js +2 -2
  48. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  49. package/dist/cli-delegate/cmd_prune.js +4 -4
  50. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  51. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  52. package/dist/cli-delegate/cmd_session_recycle.js +6 -4
  53. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  54. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  55. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  56. package/dist/cli-delegate/cmd_sync.js +4 -4
  57. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  58. package/dist/cli-delegate/cmd_update.js +1 -1
  59. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  60. package/dist/cli-delegate/cmd_validate.js +4 -4
  61. package/dist/cli-delegate/cmd_versions.js +1 -1
  62. package/dist/discovery/deprecation-report.md +1 -1
  63. package/dist/discovery/discovery-manifest.json +127 -22
  64. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  65. package/dist/discovery/discovery-manifest.summary.md +3 -3
  66. package/dist/discovery/dormancy-report.md +1 -1
  67. package/dist/discovery/orphan-report.md +1 -1
  68. package/dist/discovery/packs.json +9 -5
  69. package/dist/discovery/trust-report.md +2 -2
  70. package/dist/discovery/workspaces.json +8 -4
  71. package/dist/hooks/dispatch.js +994 -399
  72. package/dist/install/install.mjs +5 -7
  73. package/dist/mcp/registry-manifest.json +2 -2
  74. package/dist/mcp/server.json +2 -2
  75. package/dist/mcp/server.mjs +1104 -593
  76. package/dist/scripts/_lib/agent_settings.js +8 -0
  77. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  78. package/dist/server/schemas/settings.js +3 -4
  79. package/dist/server/schemas/settings.js.map +1 -1
  80. package/dist/ui/assets/{index-5fXOHoVh.js → index-B0aMJnuz.js} +2 -2
  81. package/dist/ui/assets/{index-5fXOHoVh.js.map → index-B0aMJnuz.js.map} +1 -1
  82. package/dist/ui/index.html +1 -1
  83. package/docs/contracts/settings-classes.md +12 -9
  84. package/package.json +1 -1
  85. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
  86. package/src/config/agent-settings.template.yml +15 -18
  87. package/src/config/gate-coverage.yml +33 -2
  88. package/src/config/gate-violation-baselines.json +2 -2
  89. package/src/config/pack-size-budget.json +3 -2
  90. package/src/scripts/_lib/agent_settings.ts +8 -0
  91. package/src/scripts/_lib/design_system_import.ts +987 -0
  92. package/src/scripts/check_branch_freshness.ts +238 -6
  93. package/src/scripts/design_system_import.ts +264 -0
  94. package/src/scripts/external_sources_denylist.json +7 -2
  95. package/src/scripts/hook_manifest.yaml +71 -2
  96. package/src/scripts/hooks/concern_registry.ts +4 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +10 -0
  98. package/src/scripts/hooks/host_semantics.ts +2 -0
  99. package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
  100. package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
  101. package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
  102. package/src/scripts/lint_hook_manifest.ts +2 -0
  103. package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
  104. package/src/scripts/session_register_hook.ts +120 -10
  105. package/src/server/schemas/settings.ts +3 -6
  106. package/tests/fixtures/hooks/subagent_start.json +7 -0
  107. package/tests/fixtures/hooks/subagent_stop.json +8 -0
@@ -0,0 +1,723 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Subagent lifecycle ledger — capture only, no behaviour change
4
+ * (road-to-subagent-lifecycle-integrity Phase 1, Steps 2 + 3).
5
+ *
6
+ * Why this exists. Two of the three operator-reported symptoms — subagents
7
+ * that never terminate, and subagents that finish and "signal" but whose
8
+ * result never reaches the orchestrator — share one structural root: **no
9
+ * subagent lifecycle event was registered anywhere in this tree**. The
10
+ * dispatcher's vocabulary carried eight events and none of them bracketed a
11
+ * single dispatch, so nothing could say how long a dispatch ran, how many ran
12
+ * at once, how deep they nested, or whether a return envelope arrived at all.
13
+ * Every claim about those symptoms was therefore model-carried anecdote.
14
+ *
15
+ * This concern is the instrument, and ONLY the instrument. It appends; it
16
+ * never decides. Phase 2 (`subagent-return-gate`) and Phase 3 (`spawn-guard`,
17
+ * stop-loss) are the mechanisms that may act on what it records, and both are
18
+ * pre-registered as gated on a baseline this file has to produce first. That
19
+ * ordering is deliberate: a guard built before the distribution is known is a
20
+ * threshold invented rather than measured, which is the failure this
21
+ * repository has recorded repeatedly.
22
+ *
23
+ * ── What is recorded, and what is refused ──────────────────────────────────
24
+ *
25
+ * PRIVACY BY CONSTRUCTION — never widen this file to read or emit free text.
26
+ *
27
+ * - `agent_id` is a host-supplied high-entropy opaque token. The sibling
28
+ * `orchestration_record_hook.ts` records the same class of value as a
29
+ * deliberate NON-goal ("`check_secret_leak` correctly flags [them] as
30
+ * candidate credentials"), so this ledger never writes one. It writes
31
+ * `ref` — the first 12 hex chars of a local SHA-256 of the id. That is
32
+ * enough to correlate a start with its stop and a child with its parent,
33
+ * and it is not the host's token.
34
+ * - `agent_type` IS recorded verbatim: it is an id-shaped enum
35
+ * (`Explore` / `general-purpose` / `production-validator` / …), the same
36
+ * single host string `orchestration_record_hook` already records.
37
+ * - `last_assistant_message` is free model prose and is NEVER recorded, in
38
+ * any form — not truncated, not hashed, not summarised. Only the three-way
39
+ * parse VERDICT (`ok` / `fail` / `absent`) and a validator error COUNT
40
+ * leave this function. A record type with no field able to hold prose has
41
+ * no scrubber that can fail.
42
+ *
43
+ * ── Observe mode is a property of this file, not a setting ────────────────
44
+ *
45
+ * `validateResponse` runs here, and its result is written down and dropped.
46
+ * The hook's exit code is ALWAYS 0 on every path — malformed envelope,
47
+ * unparseable payload, unwritable disk, failed validation. A `warn` (exit 2)
48
+ * is read as a hard BLOCK on this host, and this concern has nothing to say
49
+ * to the model, so it never warns either. Phase 1's falsifier can only be
50
+ * evaluated if the measurement window is uncontaminated by a mechanism
51
+ * reacting to it.
52
+ *
53
+ * ── An instrument that goes quiet is worse than one that reports nothing ──
54
+ *
55
+ * R2 finding 1. A payload carrying no `agent_id` used to make both handlers
56
+ * return before writing anything, so a host shape this tree has not yet ruled
57
+ * out (Phase 0 Step 2 is still open, and the string-table check in the
58
+ * evidence page is presence-only) would produce an empty ledger — byte-identical
59
+ * to "no dispatches happened", and the Phase-1 Step-4 baseline would read the
60
+ * two the same way. Both handlers now emit an `unidentified: true` line
61
+ * instead: no ref, no host token, and the absence is visible in the output.
62
+ *
63
+ * ── Open records are reaped, and the reaping is itself recorded ───────────
64
+ *
65
+ * R2 finding 2. An open record is created by a start and removed by its stop.
66
+ * A dispatch that never stops — symptom #1, the thing this instrument exists
67
+ * to catch — therefore leaves a file behind, and `concurrent_open` and `depth`
68
+ * would drift upward forever. Phase 3's pre-registered spawn guard reads this
69
+ * same set at `concurrent-open >= 4`, so four leaked records would eventually
70
+ * deny every spawn.
71
+ *
72
+ * So a record older than `OPEN_RECORD_TTL_MS` is reaped on the next event and
73
+ * a `subagent_reaped` line is written for it. That line is not bookkeeping —
74
+ * it is the closest thing this instrument has to a direct observation of a
75
+ * never-returning dispatch, which is why the leak is reported rather than
76
+ * quietly swept. `session_id` rides on the record so a future sweep can scope
77
+ * by session; nothing reads it yet and it is not claimed to.
78
+ *
79
+ * ── Depth and concurrency (Step 3) ────────────────────────────────────────
80
+ *
81
+ * Depth is derived from the open-record set, never asserted, and the basis is
82
+ * recorded alongside it in three values rather than two (R2 finding 5):
83
+ *
84
+ * `observed` — the named parent's record was open and its depth read.
85
+ * `asserted-parent` — the payload named a parent whose record is not open.
86
+ * Nesting is a FACT; the number is a floor, since a
87
+ * grandchild whose parent already closed is deeper.
88
+ * `assumed-root` — the payload named no parent at all.
89
+ *
90
+ * A consumer filtering for measured depths takes `observed` alone. Labelling
91
+ * the middle case `observed` would have fed a guess into exactly that filter.
92
+ *
93
+ * `concurrent_open` has ONE definition on both lines (R2 finding 8): **the
94
+ * number of open dispatches after this event is applied.** Start counts after
95
+ * its own record is written; stop counts after its own is removed.
96
+ *
97
+ * ── Writes go through the contract's atomic path ──────────────────────────
98
+ *
99
+ * R2 finding 3. `hook-architecture-v1.md` § Concurrency requires lock + tmp +
100
+ * rename for writes under `agents/runtime/state/`; `atomic_write_json` is that
101
+ * path and is now used. The read-modify-write is gone rather than merely
102
+ * serialised: the record is written FIRST and the directory counted AFTER, so
103
+ * N concurrent starts each see at least their own record instead of every one
104
+ * reading the pre-write set and recording `concurrent_open: 1` — the
105
+ * undercount landed precisely in parallel fan-out, the mode this instrument
106
+ * exists to measure.
107
+ */
108
+ import * as crypto from 'node:crypto';
109
+ import * as fs from 'node:fs';
110
+ import * as path from 'node:path';
111
+ import { fileURLToPath, pathToFileURL } from 'node:url';
112
+
113
+ import { validateResponse } from '../_lib/subagent_response.js';
114
+ import { atomic_write_json, is_replay_mode } from './state_io.js';
115
+ import { readHookStdin } from './hook_stdin.js';
116
+
117
+ const EXIT_ALLOW = 0;
118
+
119
+ type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
120
+ type JsonObject = { [k: string]: JsonValue };
121
+
122
+ function isObject(v: unknown): v is JsonObject {
123
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
124
+ }
125
+
126
+ /** Ledger root, relative to the consumer repo root. Gitignored via `/agents/runtime/`. */
127
+ export const LEDGER_DIR = path.join('agents', 'runtime', 'state', 'subagent-ledger');
128
+ /** Open-dispatch records live one file per ref so a crash cannot tear the set. */
129
+ export const OPEN_SUBDIR = 'open';
130
+
131
+ /**
132
+ * How long an open record may sit before it is treated as a dispatch that
133
+ * never returned. 24 h is a STATED DEFAULT, not a measured optimum — the
134
+ * distribution it should be derived from is what Phase 1 Step 4 publishes.
135
+ * It is deliberately far above any plausible dispatch so a reap is evidence
136
+ * of a real never-returning run rather than of a slow one.
137
+ *
138
+ * Revisit-if: the Phase-1 duration distribution shows a p99 within an order
139
+ * of magnitude of this value, or a reap line appears for a dispatch that did
140
+ * in fact return.
141
+ */
142
+ export const OPEN_RECORD_TTL_MS = 24 * 60 * 60 * 1000;
143
+
144
+ /**
145
+ * Stable, local, non-reversible correlation key for a host `agent_id`.
146
+ *
147
+ * Twelve hex chars is 48 bits — collision-free for any realistic count of
148
+ * concurrent dispatches, and short enough to read in a ledger line. The digest
149
+ * is unsalted ON PURPOSE: the value must be stable across the two separate
150
+ * hook invocations that bracket one dispatch (start and stop are different
151
+ * processes), so a per-process salt would break the only thing this key is
152
+ * for. Nothing is being protected against an offline guessing attack here —
153
+ * the goal is that the host's opaque token never lands in a file, not that
154
+ * the mapping is cryptographically hidden from someone who already holds it.
155
+ */
156
+ export function refFor(agentId: string): string {
157
+ return crypto.createHash('sha256').update(agentId, 'utf8').digest('hex').slice(0, 12);
158
+ }
159
+
160
+ /**
161
+ * Unwrap the dispatcher envelope (`{schema_version, platform, event,
162
+ * payload}`) down to the platform-native payload, falling back to the
163
+ * top-level object for direct/legacy invocation.
164
+ */
165
+ function unwrapPayload(envelope: JsonObject): JsonObject {
166
+ const inner = envelope['payload'];
167
+ return isObject(inner) ? inner : envelope;
168
+ }
169
+
170
+ function str(payload: JsonObject, ...keys: string[]): string | null {
171
+ for (const k of keys) {
172
+ const v = payload[k];
173
+ if (typeof v === 'string' && v) return v;
174
+ }
175
+ return null;
176
+ }
177
+
178
+ /**
179
+ * The PARENT agent's id, when the payload names one.
180
+ *
181
+ * Two shapes are accepted, and neither is invented: an explicit parent field,
182
+ * or a session-level `agent_id` that differs from the starting agent's own id
183
+ * (which is what a nested spawn looks like when the host reports the caller's
184
+ * context rather than a dedicated parent key). `null` means the payload said
185
+ * nothing — recorded as `assumed-root`, never as "this is a root".
186
+ */
187
+ function extractParentId(payload: JsonObject, ownId: string | null): string | null {
188
+ const explicit = str(payload, 'parent_agent_id', 'parentAgentId');
189
+ if (explicit) return explicit;
190
+ const session = payload['session'];
191
+ if (isObject(session)) {
192
+ const sessionAgent = str(session, 'agent_id', 'agentId');
193
+ if (sessionAgent && sessionAgent !== ownId) return sessionAgent;
194
+ }
195
+ return null;
196
+ }
197
+
198
+ export type EnvelopeParse = 'ok' | 'fail' | 'absent';
199
+
200
+ export interface ParseVerdict {
201
+ verdict: EnvelopeParse;
202
+ /** Count of validator errors. A COUNT — the messages never leave this function. */
203
+ error_count: number;
204
+ }
205
+
206
+ /**
207
+ * Classify a subagent's final message against the response-envelope contract.
208
+ *
209
+ * Three outcomes, and the distinction between the last two is the whole point
210
+ * of the measurement: `absent` means no envelope was found to judge, `fail`
211
+ * means one was found and did not satisfy `validateResponse`. Collapsing them
212
+ * would make the Phase-1 baseline unable to separate "the worker never
213
+ * returned a structured result" from "it returned a malformed one" — two
214
+ * different defects with two different fixes.
215
+ */
216
+ export function classifyEnvelope(message: string | null): ParseVerdict {
217
+ if (message === null || !message.trim()) return { verdict: 'absent', error_count: 0 };
218
+
219
+ const candidates = _jsonObjectCandidates(message);
220
+ if (candidates.length === 0) return { verdict: 'absent', error_count: 0 };
221
+
222
+ // A message may carry several object-shaped spans. The envelope is the one
223
+ // that validates, so a `fail` is only reported once every candidate has
224
+ // been tried — otherwise a stray leading `{}` would mask a valid envelope.
225
+ let best: ParseVerdict | null = null;
226
+ for (const candidate of candidates) {
227
+ const result = validateResponse(candidate);
228
+ if (result.valid) return { verdict: 'ok', error_count: 0 };
229
+ if (best === null) best = { verdict: 'fail', error_count: result.errors.length };
230
+ }
231
+ return best ?? { verdict: 'absent', error_count: 0 };
232
+ }
233
+
234
+ /**
235
+ * Every decoded JSON object the message contains, in the order a reader would
236
+ * meet them.
237
+ *
238
+ * R2 finding 4: the previous implementation took the span from the first `{`
239
+ * to the LAST `}`, so a valid envelope followed by any later brace in prose
240
+ * ("… — done. See {done}.") produced unparseable text and was reported
241
+ * `absent` — routing an extraction failure into the "never returned anything"
242
+ * bucket that the whole three-way verdict exists to keep separate, and biasing
243
+ * the Phase-1 return-rate falsifier downward.
244
+ *
245
+ * R2 finding 11: fenced blocks are now matched with an anchored, global scan
246
+ * rather than a first-match-anywhere regex, which used to be able to match at
247
+ * a *closing* fence and capture the prose between two blocks.
248
+ *
249
+ * The scanner walks brace depth and respects string literals and escapes, so a
250
+ * `}` inside a JSON string value cannot end a span early.
251
+ */
252
+ function _jsonObjectCandidates(message: string): unknown[] {
253
+ const texts: string[] = [];
254
+
255
+ for (const match of message.matchAll(/^[ \t]*```[ \t]*(?:json)?[ \t]*\r?\n([\s\S]*?)^[ \t]*```/gm)) {
256
+ if (match[1]) texts.push(match[1]);
257
+ }
258
+
259
+ let depth = 0;
260
+ let start = -1;
261
+ let inString = false;
262
+ let escaped = false;
263
+ for (let i = 0; i < message.length; i++) {
264
+ const ch = message[i]!;
265
+ if (inString) {
266
+ if (escaped) escaped = false;
267
+ else if (ch === '\\') escaped = true;
268
+ else if (ch === '"') inString = false;
269
+ continue;
270
+ }
271
+ if (ch === '"') inString = true;
272
+ else if (ch === '{') {
273
+ if (depth === 0) start = i;
274
+ depth++;
275
+ } else if (ch === '}') {
276
+ if (depth > 0) {
277
+ depth--;
278
+ if (depth === 0 && start !== -1) {
279
+ texts.push(message.slice(start, i + 1));
280
+ start = -1;
281
+ }
282
+ }
283
+ }
284
+ }
285
+
286
+ const out: unknown[] = [];
287
+ for (const text of texts) {
288
+ try {
289
+ const parsed: unknown = JSON.parse(text.trim());
290
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) out.push(parsed);
291
+ } catch {
292
+ // Not an object span after all — the next candidate may be.
293
+ }
294
+ }
295
+ return out;
296
+ }
297
+
298
+ /** How a recorded depth was arrived at. See the header for the three values. */
299
+ export type DepthBasis = 'observed' | 'asserted-parent' | 'assumed-root';
300
+
301
+ /** An open dispatch, as persisted between the start and stop invocations. */
302
+ export interface OpenRecord {
303
+ ref: string;
304
+ agent_type: string | null;
305
+ started_at: string;
306
+ parent_ref: string | null;
307
+ depth: number;
308
+ depth_basis: DepthBasis;
309
+ session_id: string | null;
310
+ }
311
+
312
+ function openDir(root: string): string {
313
+ return path.join(root, LEDGER_DIR, OPEN_SUBDIR);
314
+ }
315
+
316
+ function openFile(root: string, ref: string): string {
317
+ return path.join(openDir(root), `${ref}.json`);
318
+ }
319
+
320
+ const DEPTH_BASES: ReadonlySet<string> = new Set<DepthBasis>(['observed', 'asserted-parent', 'assumed-root']);
321
+
322
+ /**
323
+ * Validate a parsed open record fully before it is trusted.
324
+ *
325
+ * R2 finding 7: the previous version checked only that `ref` was a string and
326
+ * cast the rest, so a record missing `depth` produced `undefined + 1` = `NaN`,
327
+ * which serialises as `"depth": null` on a line still labelled
328
+ * `depth_basis: "observed"`. A record that does not satisfy the shape is
329
+ * treated as unreadable — the same class as a torn write — rather than
330
+ * half-believed.
331
+ */
332
+ function _asOpenRecord(value: unknown, expectedRef: string): OpenRecord | null {
333
+ if (!isObject(value)) return null;
334
+ const ref = value['ref'];
335
+ // R2 finding 7, second half: the map used to be keyed by the record's
336
+ // self-declared ref while removal unlinks `<ref>.json`, so a file whose
337
+ // contents disagreed with its filename was counted forever. The filename
338
+ // is the identity; a record that disagrees with it is not trusted.
339
+ if (typeof ref !== 'string' || ref !== expectedRef) return null;
340
+ if (typeof value['started_at'] !== 'string') return null;
341
+ if (typeof value['depth'] !== 'number' || !Number.isFinite(value['depth'])) return null;
342
+ if (typeof value['depth_basis'] !== 'string' || !DEPTH_BASES.has(value['depth_basis'])) return null;
343
+ const agentType = value['agent_type'];
344
+ const parentRef = value['parent_ref'];
345
+ const sessionId = value['session_id'];
346
+ return {
347
+ ref,
348
+ agent_type: typeof agentType === 'string' ? agentType : null,
349
+ started_at: value['started_at'],
350
+ parent_ref: typeof parentRef === 'string' ? parentRef : null,
351
+ depth: value['depth'],
352
+ depth_basis: value['depth_basis'] as DepthBasis,
353
+ session_id: typeof sessionId === 'string' ? sessionId : null,
354
+ };
355
+ }
356
+
357
+ /** Every currently-open record, keyed by ref. Unreadable entries are skipped. */
358
+ export function readOpenRecords(root: string): Map<string, OpenRecord> {
359
+ const out = new Map<string, OpenRecord>();
360
+ let names: string[];
361
+ try {
362
+ names = fs.readdirSync(openDir(root));
363
+ } catch {
364
+ return out;
365
+ }
366
+ for (const name of names) {
367
+ if (!name.endsWith('.json')) continue;
368
+ const expectedRef = name.slice(0, -'.json'.length);
369
+ try {
370
+ const raw = fs.readFileSync(path.join(openDir(root), name), 'utf8');
371
+ const rec = _asOpenRecord(JSON.parse(raw), expectedRef);
372
+ if (rec) out.set(rec.ref, rec);
373
+ } catch {
374
+ // A torn or hand-edited record is data we do not have, not a crash.
375
+ }
376
+ }
377
+ return out;
378
+ }
379
+
380
+ /**
381
+ * Resolve depth from the open-record set. See the header for why the middle
382
+ * case is `asserted-parent` and not `observed`.
383
+ */
384
+ export function resolveDepth(
385
+ parentRef: string | null,
386
+ open: ReadonlyMap<string, OpenRecord>,
387
+ ): { depth: number; depth_basis: DepthBasis } {
388
+ if (parentRef === null) return { depth: 1, depth_basis: 'assumed-root' };
389
+ const parent = open.get(parentRef);
390
+ if (!parent) return { depth: 2, depth_basis: 'asserted-parent' };
391
+ return { depth: parent.depth + 1, depth_basis: 'observed' };
392
+ }
393
+
394
+ /** `${ts.slice(0,7)}.jsonl` — the monthly-file convention the audit stream uses. */
395
+ function ledgerFileFor(root: string, ts: string): string {
396
+ return path.join(root, LEDGER_DIR, `${ts.slice(0, 7)}.jsonl`);
397
+ }
398
+
399
+ /**
400
+ * Append one line to the monthly ledger. Exported because `spawn-guard-shadow`
401
+ * (Phase 3 Step 1) writes its own observations into the SAME stream: one
402
+ * instrument, one file, so a reader correlates a would-deny against the
403
+ * dispatches around it without joining two corpora.
404
+ */
405
+ export function appendLedgerLine(root: string, ts: string, line: Record<string, unknown>): void {
406
+ if (is_replay_mode()) return;
407
+ const file = ledgerFileFor(root, ts);
408
+ fs.mkdirSync(path.dirname(file), { recursive: true });
409
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`, 'utf8');
410
+ }
411
+
412
+ /**
413
+ * Candidate wall-clock stop-loss arms — Phase 3 Step 3, shadow only.
414
+ *
415
+ * Recorded retrospectively rather than fired: a `subagent_stop` knows the real
416
+ * duration and a reap knows the age, so "which arm would have fired" is a fact
417
+ * at that moment rather than a timer that has to run. The never-returning case
418
+ * — the one the stop-loss actually targets — reaches this through the reap
419
+ * line, which is the only place it is observable at all.
420
+ *
421
+ * These are candidates under evaluation, not thresholds: per the activation
422
+ * policy the shipped value is derived from the observed distribution, and
423
+ * there is no distribution yet.
424
+ *
425
+ * The plan also names a tool-call-count arm. It is NOT implemented and not
426
+ * faked: a hook sees no per-dispatch tool-call count on any payload this tree
427
+ * has observed, and inventing a proxy is the move `capsule_trigger.ts` refuses
428
+ * in the same words ("NOTHING ACTS ON THESE").
429
+ */
430
+ export const WALL_CLOCK_ARMS_MS: ReadonlyArray<{ label: string; ms: number }> = [
431
+ { label: '5m', ms: 5 * 60 * 1000 },
432
+ { label: '15m', ms: 15 * 60 * 1000 },
433
+ { label: '30m', ms: 30 * 60 * 1000 },
434
+ { label: '2h', ms: 2 * 60 * 60 * 1000 },
435
+ ];
436
+
437
+ /** Which wall-clock arms an elapsed duration would have tripped. */
438
+ export function armsExceeded(elapsedMs: number | null): string[] {
439
+ if (elapsedMs === null || !Number.isFinite(elapsedMs)) return [];
440
+ return WALL_CLOCK_ARMS_MS.filter((a) => elapsedMs >= a.ms).map((a) => a.label);
441
+ }
442
+
443
+ export interface OpenStats {
444
+ /** Open records within the TTL. Stale ones are NOT counted. */
445
+ open_count: number;
446
+ /** Deepest depth among the counted records; 0 when none. */
447
+ max_depth: number;
448
+ /** Counted separately so a caller can see the leak rather than inherit it. */
449
+ stale_excluded: number;
450
+ /** `false` when the ledger directory does not exist at all. */
451
+ ledger_present: boolean;
452
+ }
453
+
454
+ /**
455
+ * Open-set summary for the pre-spawn shadow guard and the turn-end allow path.
456
+ *
457
+ * **TTL-aware, and that is load-bearing rather than tidy** (R2 round 2,
458
+ * finding 1). Reaping happens inside the ledger hook, on the next
459
+ * `subagent_start` / `subagent_stop`. A dispatch that never returns — the
460
+ * symptom this whole roadmap targets — leaves a record behind, and if no
461
+ * further subagent event ever occurs that record is never reaped. A consumer
462
+ * that reads the raw count then sees "a dispatch is open" forever. For the
463
+ * spawn guard that inflates a number; for the turn-end gate it silently
464
+ * disables the gate, because the open-dispatch branch is an ALLOW. The ledger
465
+ * already solved this leak for itself and the new consumers inherited it with
466
+ * the opposite polarity, so the filter belongs here, in the shared reader,
467
+ * rather than in each caller.
468
+ *
469
+ * `ledger_present` exists for finding 4: a quiet estate and a ledger this hook
470
+ * cannot see otherwise produce byte-identical readings, which is the
471
+ * instrument-goes-quiet failure the ledger's own round-1 finding 1 fixed one
472
+ * layer down. A caller can now tell "nothing is running" from "I can see
473
+ * nothing".
474
+ */
475
+ export function openRecordStats(root: string, nowMs: number = Date.now()): OpenStats {
476
+ const ledgerPresent = fs.existsSync(path.join(root, LEDGER_DIR));
477
+ const open = readOpenRecords(root);
478
+ let maxDepth = 0;
479
+ let counted = 0;
480
+ let stale = 0;
481
+ for (const rec of open.values()) {
482
+ const started = Date.parse(rec.started_at);
483
+ const isStale = Number.isFinite(started) && nowMs - started >= OPEN_RECORD_TTL_MS;
484
+ if (isStale) {
485
+ stale++;
486
+ continue;
487
+ }
488
+ counted++;
489
+ if (rec.depth > maxDepth) maxDepth = rec.depth;
490
+ }
491
+ return {
492
+ open_count: counted,
493
+ max_depth: maxDepth,
494
+ stale_excluded: stale,
495
+ ledger_present: ledgerPresent,
496
+ };
497
+ }
498
+
499
+ /** Contract § Concurrency: lock + tmp + rename, via the shared helper. */
500
+ function writeOpenRecord(root: string, rec: OpenRecord): void {
501
+ atomic_write_json(openFile(root, rec.ref), rec);
502
+ }
503
+
504
+ function removeOpenRecord(root: string, ref: string): void {
505
+ if (is_replay_mode()) return;
506
+ try {
507
+ fs.unlinkSync(openFile(root, ref));
508
+ } catch {
509
+ // Already gone — a duplicate stop is not an error worth reporting.
510
+ }
511
+ }
512
+
513
+ /** Count of open records on disk right now. Called AFTER our own write/remove. */
514
+ function countOpen(root: string): number {
515
+ try {
516
+ return fs.readdirSync(openDir(root)).filter((n) => n.endsWith('.json')).length;
517
+ } catch {
518
+ return 0;
519
+ }
520
+ }
521
+
522
+ /**
523
+ * Reap open records past the TTL and report each one.
524
+ *
525
+ * The reap line is the point, not the cleanup: a record that aged out is the
526
+ * instrument's only direct sighting of a dispatch that never returned.
527
+ * Returns the number reaped.
528
+ */
529
+ export function reapStaleOpenRecords(root: string, nowIso: string): number {
530
+ const now = Date.parse(nowIso);
531
+ if (!Number.isFinite(now)) return 0;
532
+ let reaped = 0;
533
+ for (const rec of readOpenRecords(root).values()) {
534
+ const started = Date.parse(rec.started_at);
535
+ if (!Number.isFinite(started) || now - started < OPEN_RECORD_TTL_MS) continue;
536
+ removeOpenRecord(root, rec.ref);
537
+ reaped++;
538
+ appendLedgerLine(root, nowIso, {
539
+ event: 'subagent_reaped',
540
+ ts: nowIso,
541
+ ref: rec.ref,
542
+ agent_type: rec.agent_type,
543
+ depth: rec.depth,
544
+ depth_basis: rec.depth_basis,
545
+ // R2 round 2, finding 5: the arm list is NOT repeated here. A reap
546
+ // fires only past OPEN_RECORD_TTL_MS (24 h), which exceeds every
547
+ // arm (largest: 2 h), so `stop_loss_arms_exceeded` would be a
548
+ // constant on this line — a field that cannot vary answers no
549
+ // question. `age_ms` carries strictly more information and is what
550
+ // a reader should aggregate over.
551
+ age_ms: now - started,
552
+ reason: 'open record exceeded OPEN_RECORD_TTL_MS without a stop',
553
+ });
554
+ }
555
+ return reaped;
556
+ }
557
+
558
+ /** The one line written when the host gave us nothing to correlate on. */
559
+ function appendUnidentified(root: string, event: string, nowIso: string, payload: JsonObject): void {
560
+ appendLedgerLine(root, nowIso, {
561
+ event,
562
+ ts: nowIso,
563
+ ref: null,
564
+ unidentified: true,
565
+ // The agent TYPE is an id-shaped enum and may still be present; it is
566
+ // the only thing salvageable from a payload with no id.
567
+ agent_type: str(payload, 'agent_type', 'agentType', 'subagent_type', 'subagentType'),
568
+ reason: 'payload carried no agent id — nothing to correlate start with stop',
569
+ });
570
+ }
571
+
572
+ function handleStart(root: string, payload: JsonObject, nowIso: string, sessionId: string | null): void {
573
+ const agentId = str(payload, 'agent_id', 'agentId');
574
+ if (agentId === null) {
575
+ appendUnidentified(root, 'subagent_start', nowIso, payload);
576
+ return;
577
+ }
578
+
579
+ const ref = refFor(agentId);
580
+ const parentId = extractParentId(payload, agentId);
581
+ const parentRef = parentId ? refFor(parentId) : null;
582
+
583
+ const { depth, depth_basis } = resolveDepth(parentRef, readOpenRecords(root));
584
+
585
+ const rec: OpenRecord = {
586
+ ref,
587
+ agent_type: str(payload, 'agent_type', 'agentType', 'subagent_type', 'subagentType'),
588
+ started_at: nowIso,
589
+ parent_ref: parentRef,
590
+ depth,
591
+ depth_basis,
592
+ session_id: sessionId,
593
+ };
594
+ // Write FIRST, count AFTER — see the header on R2 finding 3.
595
+ writeOpenRecord(root, rec);
596
+
597
+ appendLedgerLine(root, nowIso, {
598
+ event: 'subagent_start',
599
+ ts: nowIso,
600
+ ref,
601
+ agent_type: rec.agent_type,
602
+ parent_ref: parentRef,
603
+ depth,
604
+ depth_basis,
605
+ concurrent_open: countOpen(root),
606
+ });
607
+ }
608
+
609
+ function handleStop(root: string, payload: JsonObject, nowIso: string): void {
610
+ const agentId = str(payload, 'agent_id', 'agentId');
611
+ if (agentId === null) {
612
+ appendUnidentified(root, 'subagent_stop', nowIso, payload);
613
+ return;
614
+ }
615
+
616
+ const ref = refFor(agentId);
617
+ const rec = readOpenRecords(root).get(ref) ?? null;
618
+
619
+ let durationMs: number | null = null;
620
+ if (rec) {
621
+ const started = Date.parse(rec.started_at);
622
+ if (Number.isFinite(started)) durationMs = Math.max(0, Date.parse(nowIso) - started);
623
+ }
624
+
625
+ const parse = classifyEnvelope(str(payload, 'last_assistant_message', 'lastAssistantMessage'));
626
+
627
+ // Remove FIRST, count AFTER — same definition on both lines.
628
+ removeOpenRecord(root, ref);
629
+
630
+ appendLedgerLine(root, nowIso, {
631
+ event: 'subagent_stop',
632
+ ts: nowIso,
633
+ ref,
634
+ agent_type: rec?.agent_type ?? str(payload, 'agent_type', 'agentType', 'subagent_type', 'subagentType'),
635
+ depth: rec?.depth ?? null,
636
+ depth_basis: rec?.depth_basis ?? null,
637
+ // `null` = the matching start was never seen. That is a finding about
638
+ // the instrument's own coverage and is recorded as such, not as 0.
639
+ duration_ms: durationMs,
640
+ // Phase 3 Step 3, shadow: which wall-clock arms this dispatch would
641
+ // have tripped. Recorded, never acted on.
642
+ stop_loss_arms_exceeded: armsExceeded(durationMs),
643
+ start_seen: rec !== null,
644
+ envelope_parse: parse.verdict,
645
+ envelope_error_count: parse.error_count,
646
+ concurrent_open: countOpen(root),
647
+ });
648
+ }
649
+
650
+ /** Process an ALREADY-PARSED dispatcher envelope. Always returns EXIT_ALLOW. */
651
+ export function processEnvelope(envelope: JsonValue, consumerRoot: string): number {
652
+ try {
653
+ if (!isObject(envelope)) return EXIT_ALLOW;
654
+ const event = envelope['event'];
655
+ if (event !== 'subagent_start' && event !== 'subagent_stop') return EXIT_ALLOW;
656
+
657
+ const payload = unwrapPayload(envelope);
658
+ const nowIso = new Date().toISOString();
659
+ const sessionId = str(payload, 'session_id', 'sessionId') ?? str(envelope, 'session_id', 'sessionId');
660
+
661
+ reapStaleOpenRecords(consumerRoot, nowIso);
662
+
663
+ if (event === 'subagent_start') handleStart(consumerRoot, payload, nowIso, sessionId);
664
+ else handleStop(consumerRoot, payload, nowIso);
665
+ } catch {
666
+ // Malformed payload, unreadable disk, anything — never disturb the run.
667
+ return EXIT_ALLOW;
668
+ }
669
+ return EXIT_ALLOW;
670
+ }
671
+
672
+ /**
673
+ * Resolve the consumer repo root from the dispatcher envelope.
674
+ *
675
+ * R2 finding 10: the host's `cwd` lives under `payload`, not at the envelope
676
+ * top level, so a top-level `cwd` branch is unreachable on the dispatcher path.
677
+ * Both real positions are read here, envelope-level roots first.
678
+ *
679
+ * R2 round 2, finding 6: EXPORTED, and every consumer of this ledger uses it.
680
+ * The producer and its two readers had each grown their own precedence order,
681
+ * so a root that resolved one way for the writer could resolve another for the
682
+ * reader — and a reader looking in the wrong place finds an empty ledger,
683
+ * which every one of these consumers reads as "nothing is open".
684
+ */
685
+ export function resolveConsumerRoot(envelope: JsonValue): string {
686
+ if (isObject(envelope)) {
687
+ const pr = str(envelope, 'workspace_root', 'project_root');
688
+ if (pr) return pr;
689
+ const payload = unwrapPayload(envelope);
690
+ const cwd = str(payload, 'cwd');
691
+ if (cwd) return cwd;
692
+ }
693
+ return process.cwd();
694
+ }
695
+
696
+ export function main(): number {
697
+ const raw = readHookStdin();
698
+ let envelope: JsonValue = {};
699
+ try {
700
+ envelope = raw.trim() ? (JSON.parse(raw) as JsonValue) : {};
701
+ } catch {
702
+ return EXIT_ALLOW;
703
+ }
704
+ return processEnvelope(envelope, resolveConsumerRoot(envelope));
705
+ }
706
+
707
+ // Bundle-safety: never auto-run when inlined into an esbuild bundle, where
708
+ // every module shares the bundle's `import.meta.url` (see cmd_migrate.ts).
709
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
710
+ function _isCliEntry(): boolean {
711
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) {
712
+ return false;
713
+ }
714
+ if (process.argv[1] === undefined) return false;
715
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
716
+ if (import.meta.url === argvUrl) return true;
717
+ try {
718
+ return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+ if (_isCliEntry()) process.exit(main());