@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +147 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze.md +2 -1
- package/dist/agent-src/commands/council/debate.md +13 -4
- package/dist/agent-src/commands/council/default.md +26 -11
- package/dist/agent-src/commands/council.md +1 -1
- package/dist/agent-src/commands/design-system/capture.md +44 -0
- package/dist/agent-src/commands/design-system/generate.md +75 -0
- package/dist/agent-src/commands/design-system/import.md +63 -0
- package/dist/agent-src/commands/design-system.md +66 -0
- package/dist/agent-src/commands/pr/create.md +51 -26
- package/dist/agent-src/commands/worktree/create.md +5 -3
- package/dist/agent-src/rules/settings-ask-protocol.md +14 -9
- package/dist/agent-src/skills/ai-council/SKILL.md +24 -6
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +12 -1
- package/dist/agent-src/skills/corpus-grounding/scripts/decision_engine.ts +215 -7
- package/dist/agent-src/skills/corpus-grounding/scripts/ground.ts +39 -1
- package/dist/agent-src/skills/design-intelligence/ATTRIBUTION.md +19 -2
- package/dist/agent-src/skills/design-intelligence/data/manifest.json +36 -1
- package/dist/agent-src/skills/design-intelligence/data/motion.csv +17 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +9 -0
- package/dist/agent-src/skills/design-system-capture/references/design-system-json.md +70 -0
- package/dist/agent-src/skills/git-workflow/SKILL.md +9 -3
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +9 -6
- package/dist/agent-src/skills/using-git-worktrees/SKILL.md +25 -21
- package/dist/agent-src/templates/agent-settings.md +0 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/dist/cli-delegate/{chunk-BV3WR67D.js → chunk-55WZN5VF.js} +3 -6
- package/dist/cli-delegate/{chunk-X3VOARZD.js → chunk-L565YFXH.js} +3 -3
- package/dist/cli-delegate/{chunk-JMQZ7MM5.js → chunk-MNWRDMR5.js} +1 -1
- package/dist/cli-delegate/{chunk-IR3IVRVC.js → chunk-NLP54U3J.js} +2 -1
- package/dist/cli-delegate/{chunk-FV34EGEX.js → chunk-NN4KW4HZ.js} +1 -1
- package/dist/cli-delegate/{chunk-6ENSXSBW.js → chunk-PPQO4NGR.js} +1 -1
- package/dist/cli-delegate/chunk-RLN73PR5.js +157 -0
- package/dist/cli-delegate/{chunk-6LOPHJLD.js → chunk-RYNADUFG.js} +1 -1
- package/dist/cli-delegate/{chunk-I5EAFRII.js → chunk-ZPSXJTLI.js} +2 -154
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +673 -215
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +10 -8
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +6 -4
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +127 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +9 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +8 -4
- package/dist/hooks/dispatch.js +994 -399
- package/dist/install/install.mjs +5 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1104 -593
- package/dist/scripts/_lib/agent_settings.js +8 -0
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/server/schemas/settings.js +3 -4
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/{index-5fXOHoVh.js → index-B0aMJnuz.js} +2 -2
- package/dist/ui/assets/{index-5fXOHoVh.js.map → index-B0aMJnuz.js.map} +1 -1
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +12 -9
- package/package.json +1 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +4 -1
- package/src/config/agent-settings.template.yml +15 -18
- package/src/config/gate-coverage.yml +33 -2
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/pack-size-budget.json +3 -2
- package/src/scripts/_lib/agent_settings.ts +8 -0
- package/src/scripts/_lib/design_system_import.ts +987 -0
- package/src/scripts/check_branch_freshness.ts +238 -6
- package/src/scripts/design_system_import.ts +264 -0
- package/src/scripts/external_sources_denylist.json +7 -2
- package/src/scripts/hook_manifest.yaml +71 -2
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +10 -0
- package/src/scripts/hooks/host_semantics.ts +2 -0
- package/src/scripts/hooks/spawn_guard_shadow_hook.ts +212 -0
- package/src/scripts/hooks/subagent_ledger_hook.ts +723 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +39 -2
- package/src/scripts/lint_hook_manifest.ts +2 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +194 -13
- package/src/scripts/session_register_hook.ts +120 -10
- package/src/server/schemas/settings.ts +3 -6
- package/tests/fixtures/hooks/subagent_start.json +7 -0
- 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());
|