@azure-id/orc 1.8.2 → 1.9.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/CHANGELOG.md +166 -0
- package/README-id.md +69 -51
- package/README.md +62 -45
- package/bin/cli.js +76 -16
- package/bin/verify-contracts.js +163 -62
- package/bin/verify-package.js +15 -1
- package/mock-run/orc-quick.md +141 -113
- package/package.json +1 -1
- package/templates/agents/MODEL-MAPPING.md +15 -5
- package/templates/agents/orc-executor-haiku-4-5.md +17 -0
- package/templates/agents/orc-executor-opus-4-7-high.md +17 -0
- package/templates/agents/orc-executor-opus-4-7-med.md +17 -0
- package/templates/agents/orc-executor-opus-4-8-high.md +17 -0
- package/templates/agents/orc-executor-opus-5-high.md +17 -0
- package/templates/agents/orc-executor-opus-5-low.md +17 -0
- package/templates/agents/orc-executor-opus-5-med.md +17 -0
- package/templates/agents/orc-executor-sonnet-4-6-high.md +17 -0
- package/templates/agents/orc-executor-sonnet-4-6-med.md +17 -0
- package/templates/agents/orc-executor-sonnet-5-high.md +17 -0
- package/templates/agents/orc-planner-mini-opus-5-med.md +75 -69
- package/templates/agents/orc-planner-mini-sonnet-5-high.md +73 -67
- package/templates/agents/orc-recon-opus-5-low.md +99 -0
- package/templates/agents/orc-recon-sonnet-4-6-med.md +99 -0
- package/templates/commands/orc-mini.md +10 -12
- package/templates/commands/orc-quick.md +20 -33
- package/templates/hooks/orc-trace.js +476 -471
- package/templates/skills/_shared/phases/rules.md +172 -159
- package/templates/skills/_shared/phases/trace.md +2 -1
- package/templates/skills/_shared/phases/wiki-consult.md +9 -5
- package/templates/skills/_shared/return-validation.md +22 -0
- package/templates/skills/context-combiner/SKILL.md +13 -13
- package/templates/skills/orc/subskills/orc-execution/core.md +171 -159
- package/templates/skills/orc-mini/SKILL.md +148 -136
- package/templates/skills/orc-mini/examples/mini-run-mock.md +64 -50
- package/templates/skills/orc-mini/references/complexity.md +105 -0
- package/templates/skills/orc-quick/README.md +495 -423
- package/templates/skills/orc-quick/SKILL.md +157 -211
- package/templates/skills/orc-quick/references/context-doc.md +145 -114
- package/templates/skills/orc-quick/references/defect.md +101 -0
- package/templates/skills/orc-quick/references/dispatch-gate.md +55 -24
- package/templates/skills/orc-quick/references/gh-mode.md +148 -127
- package/templates/skills/orc-quick/references/look.md +107 -0
|
@@ -1,471 +1,476 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
"use strict";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* ORC trace hook — the deterministic BACKBONE of ORC behavior-trace logging.
|
|
6
|
-
*
|
|
7
|
-
* ORC is not a runtime; the orchestrator (a Claude session following the orc
|
|
8
|
-
* skill) writes the RICH markers. This hook writes the compaction-proof
|
|
9
|
-
* SKELETON: a `SPAWN` line when an agent is dispatched (PreToolUse on the
|
|
10
|
-
* Task/Agent tool) and a
|
|
11
|
-
* `RETURN` line when a subagent finishes (SubagentStop). Even if the model
|
|
12
|
-
* forgets to narrate after a compaction, the flow shape survives here.
|
|
13
|
-
*
|
|
14
|
-
* IMPORTANT — this hook is intentionally BLIND to the dispatched model. Claude
|
|
15
|
-
* Code does not expose a subagent's model id to hooks (it lives only in the
|
|
16
|
-
* subagent's own system prompt). The claimed-vs-actual model check is done by
|
|
17
|
-
* the orchestrator from each agent's `actual_model` return field, NOT here.
|
|
18
|
-
*
|
|
19
|
-
* Logging is PERMANENT (always on) — no config toggle. This hook is the
|
|
20
|
-
* DETERMINISTIC guarantee that a trace exists: on the first ORC-agent dispatch
|
|
21
|
-
* it BOOTSTRAPS the log folder + run pointer itself, so a `.txt` is created for
|
|
22
|
-
* every run even if the orchestrator never writes a single rich marker. That
|
|
23
|
-
* removes the old failure where nothing was ever created because the folder /
|
|
24
|
-
* pointer depended entirely on the model remembering to make them.
|
|
25
|
-
*
|
|
26
|
-
* Gating (so non-ORC Tasks are never logged, enforced in code):
|
|
27
|
-
* - a SPAWN is written (and the folder+pointer bootstrapped if missing) only
|
|
28
|
-
* when the dispatched agent name starts with `orc` — a real ORC run.
|
|
29
|
-
* - a RETURN is written only when a run pointer already exists, the run is
|
|
30
|
-
* not STALE, and the file has fewer RETURNs than SPAWNs (a RETURN can never
|
|
31
|
-
* exist without a matching observed dispatch — this is what stops stray
|
|
32
|
-
* SubagentStops from unrelated sessions bleeding into an old trace).
|
|
33
|
-
* - a SubagentStop that carries a non-ORC `agent_type` is dropped, mirroring
|
|
34
|
-
* the SPAWN gate (an unrelated subagent must never claim an ORC RETURN).
|
|
35
|
-
*
|
|
36
|
-
* Attributable RETURN (v0.31.0): the bare `RETURN` skeleton is unattributable
|
|
37
|
-
* once ≥2 agents are in flight. Each SPAWN now also pushes a pending record into
|
|
38
|
-
* a sidecar next to the trace (`<trace>.pending.json` — `[{agent, desc, ts}]`).
|
|
39
|
-
* On SubagentStop the hook resolves the finishing agent from `data.agent_type`
|
|
40
|
-
* (Claude Code's SubagentStop payload) — or, on older Claude Code that omits it,
|
|
41
|
-
* pops the oldest pending record FIFO and marks the attribution approximate
|
|
42
|
-
* (`~`). It emits `RETURN <agent> :: <desc> dur=<m>m<s>s` and, when
|
|
43
|
-
* `last_assistant_message` carries the agent's `actual_model`, appends
|
|
44
|
-
* ` model=<id>` — so the claimed-vs-actual check is deterministic even if the
|
|
45
|
-
* orchestrator forgets its VERIFY line. The sidecar is best-effort: a missing or
|
|
46
|
-
* corrupt sidecar degrades to a bare RETURN (never throws, always exit 0).
|
|
47
|
-
*
|
|
48
|
-
* Attribution hardening (v0.32.0) — three bugs a real 8-task run proved:
|
|
49
|
-
* 1. DOUBLE RETURN. A stop that carried no `agent_type` FIFO-popped another
|
|
50
|
-
* agent's pending record; the REAL `agent_type` stop then found no record
|
|
51
|
-
* and wrote a second, desc-less RETURN for the same agent. Now: an
|
|
52
|
-
* `agent_type` stop with NO matching record while OTHER records are still
|
|
53
|
-
* in flight is a DUPLICATE (its slot was already consumed) and is dropped.
|
|
54
|
-
* An empty sidecar is NOT a duplicate signal — it still degrades to a bare
|
|
55
|
-
* RETURN, and the `returns >= spawns` guard covers the empty-list repeat.
|
|
56
|
-
* 2. MISSING RETURN. The blind FIFO pop above starved the agent whose record
|
|
57
|
-
* was stolen. Now an `agent_type`-less stop only CONSUMES a record when
|
|
58
|
-
* exactly ONE is in flight; with ≥2 it writes `RETURN ~agent :: unattributed`
|
|
59
|
-
* WITHOUT consuming, so the real `agent_type` stop can still claim its own.
|
|
60
|
-
* 3. RETRO SELF-POLLUTION. `/orc-retro` dispatches `orc-retro-*`, which matched
|
|
61
|
-
* the `/^orc/` gate — the trace miner was generating trace data. `orc-retro`
|
|
62
|
-
* is now on an explicit ignore-list: never bootstraps, never SPAWN/RETURN.
|
|
63
|
-
*
|
|
64
|
-
* A FOREIGN dispatch produces NOTHING here, and that is by design (v0.50.0).
|
|
65
|
-
* `orc extra dispatch` runs a non-Claude worker through Bash, so there is no
|
|
66
|
-
* `Agent`/`Task` call for this hook to observe: no SPAWN, no RETURN, no
|
|
67
|
-
* PHASE-EDGE, and therefore no contribution to NARRATION COVERAGE either. That
|
|
68
|
-
* is the same gap `/orc-quick`'s ad-hoc recon has (P7 — Extra ships zero new
|
|
69
|
-
* agents, so a foreign worker has no agent file to name).
|
|
70
|
-
*
|
|
71
|
-
* The record instead comes entirely from orchestrator-written lines: `DISPATCH
|
|
72
|
-
* … via=extra:<profile>`, `SCORE … via=extra:<profile>` and the `EXTRA` verb,
|
|
73
|
-
* whose text `orc extra dispatch --json` composes so no lane writes a second
|
|
74
|
-
* wording. NOTHING IS ADDED TO THIS FILE FOR THEM — a hook that tried to infer
|
|
75
|
-
* a foreign dispatch from a Bash command line would be guessing, and a guessed
|
|
76
|
-
* SPAWN is worse than an absent one because `/orc-retro` would count it.
|
|
77
|
-
*
|
|
78
|
-
* Deterministic phase inference (v0.32.0): ORC agent NAMES encode their role, so
|
|
79
|
-
* the hook can segment a run into phases with zero model cooperation. When a
|
|
80
|
-
* SPAWN's role family differs from the previous SPAWN's, the hook writes one
|
|
81
|
-
* extra line first — `PHASE-EDGE <role-family> :: first=<agent>` — so even a run
|
|
82
|
-
* where every narration dispatch was forgotten still reads as
|
|
83
|
-
* planning → execution → review → verify. `/orc-retro` reads these to compute
|
|
84
|
-
* NARRATION COVERAGE (phase edges with a trace-writer SPAWN between them).
|
|
85
|
-
*
|
|
86
|
-
* Caveat a retro must read this metric with (v0.34.2): CONTINUING an existing
|
|
87
|
-
* agent emits no PreToolUse/SubagentStop pair, so the hook cannot see it. A lane
|
|
88
|
-
* driven by continuing one agent under-counts real dispatch volume — the
|
|
89
|
-
* skeleton is a floor on what happened, never a census.
|
|
90
|
-
*
|
|
91
|
-
* Rich run filenames (v0.32.0): the canonical trace name is
|
|
92
|
-
* `run-<lane>-<slug>-<DDMMYY>-<HHMMSS>.txt`, written by the orchestrating lane at
|
|
93
|
-
* run start. The hook still bootstraps its own generic `run-<DDMMYY>-<HHMMSS>.txt`
|
|
94
|
-
* when the pointer is missing; the first `orc-trace-writer-haiku-4-5` dispatch
|
|
95
|
-
* RENAMES that bootstrap file (+ sidecars) and rewrites `.current`. The hook is
|
|
96
|
-
* name-agnostic (rotation/STALE logic is pointer-based) and holds no open
|
|
97
|
-
* handles, so a rename between two hook events is safe.
|
|
98
|
-
*
|
|
99
|
-
* Pointer clobber fix (v0.34.2) — the single largest defect in the trace corpus
|
|
100
|
-
* (9 lane-less stray files across 15 graded runs).
|
|
101
|
-
* `traceStats` returns null both for "pointer written two seconds ago, the lane
|
|
102
|
-
* has not created the file yet" and for "dangling pointer, run long over", and
|
|
103
|
-
* the hook rotated on both — so the lane's own run-start step reliably caused a
|
|
104
|
-
* SPLIT run: a generic bootstrap file holding the hook skeleton, plus a rich
|
|
105
|
-
* file holding every narrated line. Now the POINTER's own mtime decides: fresh
|
|
106
|
-
* (< STALE_MS) → honor the registered name and create the file under it; only a
|
|
107
|
-
* genuinely idle pointer rotates. The lanes also `touch` the trace file in the
|
|
108
|
-
* same step as `.current`, so both halves are fixed independently.
|
|
109
|
-
*
|
|
110
|
-
* Unbounded RETURN fix (v0.34.2): an `unattributed` RETURN claims no pending
|
|
111
|
-
* record, so it is excluded from the `returns` balance and the
|
|
112
|
-
* `returns >= spawns` guard could never stop it — every stop past the first
|
|
113
|
-
* wrote another line forever. The count is now bounded by the number of records
|
|
114
|
-
* actually in flight.
|
|
115
|
-
*
|
|
116
|
-
* Run rotation (v0.23.0): `.current` is a POINTER, not a lifetime lease. A run
|
|
117
|
-
* whose trace file has been idle longer than STALE_MS is considered ENDED — the
|
|
118
|
-
* next ORC dispatch rotates to a fresh `.txt` instead of appending days of
|
|
119
|
-
* unrelated sessions into one merged file.
|
|
120
|
-
*
|
|
121
|
-
* Wiring (installed by `orc init` into .claude/settings.json):
|
|
122
|
-
* hooks.PreToolUse[] { matcher:"Task|Agent", hooks:[{command:"node <..>/orc-trace.js"}] }
|
|
123
|
-
* hooks.SubagentStop[] { hooks:[{command:"node <..>/orc-trace.js"}] }
|
|
124
|
-
* (matcher covers both names — newer Claude Code dispatches subagents via the
|
|
125
|
-
* `Agent` tool, older via `Task`; matching only `Task` silently kills SPAWN.)
|
|
126
|
-
*
|
|
127
|
-
* Contract: read hook JSON from stdin. ALWAYS exit 0 (never block a tool call —
|
|
128
|
-
* tracing must never affect a run). Fail-silent on any error.
|
|
129
|
-
*/
|
|
130
|
-
|
|
131
|
-
const fs = require("fs");
|
|
132
|
-
const path = require("path");
|
|
133
|
-
|
|
134
|
-
// .claude/hooks/orc-trace.js → CLAUDE_DIR = .. → PROJECT_ROOT = ../..
|
|
135
|
-
const CLAUDE_DIR = path.join(__dirname, "..");
|
|
136
|
-
const PROJECT_ROOT = path.join(CLAUDE_DIR, "..");
|
|
137
|
-
|
|
138
|
-
// Minimal, tolerant YAML line-scan (repo is zero-dep — no YAML parser).
|
|
139
|
-
// Reads a top-level `key: value` scalar; ignores comments and quotes.
|
|
140
|
-
function readConfigScalar(key) {
|
|
141
|
-
const file = path.join(CLAUDE_DIR, "orc.config.yaml");
|
|
142
|
-
let text;
|
|
143
|
-
try {
|
|
144
|
-
text = fs.readFileSync(file, "utf8");
|
|
145
|
-
} catch (_) {
|
|
146
|
-
return null; // no override file → key absent (caller applies default)
|
|
147
|
-
}
|
|
148
|
-
// Anchor to column 0 — a top-level key only, matching how the CLI reads
|
|
149
|
-
// top-level scalars. Prevents an indented `log_dir:` nested under another map
|
|
150
|
-
// from being read as the global override.
|
|
151
|
-
const re = new RegExp("^" + key + "\\s*:\\s*(.+?)\\s*(?:#.*)?$", "m");
|
|
152
|
-
const m = text.match(re);
|
|
153
|
-
if (!m) return null;
|
|
154
|
-
return m[1].replace(/^['"]|['"]$/g, "").trim();
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function logDir() {
|
|
158
|
-
const override = readConfigScalar("log_dir");
|
|
159
|
-
const rel = override || ".claude/orc/logs";
|
|
160
|
-
return path.isAbsolute(rel) ? rel : path.join(PROJECT_ROOT, rel);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// DDMMYY HH:MM:SS.mmm — matches the decision-log timestamp style.
|
|
164
|
-
function stamp(d) {
|
|
165
|
-
const p = (n, w) => String(n).padStart(w || 2, "0");
|
|
166
|
-
const dd = p(d.getDate());
|
|
167
|
-
const mm = p(d.getMonth() + 1);
|
|
168
|
-
const yy = p(d.getFullYear() % 100);
|
|
169
|
-
const t =
|
|
170
|
-
p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()) +
|
|
171
|
-
"." + p(d.getMilliseconds(), 3);
|
|
172
|
-
return `${dd}${mm}${yy} ${t}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function appendLine(dir, file, actor, verb, tail) {
|
|
176
|
-
// Ensure the folder exists — the hook is the deterministic writer, so it
|
|
177
|
-
// must never depend on the orchestrator having created log_dir first.
|
|
178
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
-
// One complete line per append keeps concurrent SubagentStop writes (a wave
|
|
180
|
-
// of N parallel agents) from interleaving mid-line.
|
|
181
|
-
const line =
|
|
182
|
-
`[${stamp(new Date())}] ${String(actor).padEnd(8)} ${verb}` +
|
|
183
|
-
(tail ? ` :: ${tail}` : "") + "\n";
|
|
184
|
-
fs.appendFileSync(path.join(dir, file), line);
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// Generic run slug used only when the hook bootstraps a trace before the
|
|
188
|
-
// orchestrator wrote its own pointer (e.g. the model skipped run-start).
|
|
189
|
-
function bootstrapSlug(d) {
|
|
190
|
-
const p = (n) => String(n).padStart(2, "0");
|
|
191
|
-
const dd = p(d.getDate()), mm = p(d.getMonth() + 1), yy = p(d.getFullYear() % 100);
|
|
192
|
-
const t = p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
|
193
|
-
return `run-${dd}${mm}${yy}-${t}.txt`;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
// Lanes whose agents must NEVER be traced. `/orc-retro` MINES the traces — its
|
|
197
|
-
// hard rule 4 says it writes no trace of its own, so an `orc-retro-*` dispatch
|
|
198
|
-
// must not bootstrap a run or emit SPAWN/RETURN (that would pollute the very
|
|
199
|
-
// data it reads). Matched on the agent name, before every other gate.
|
|
200
|
-
const IGNORED_AGENTS = /^orc-retro\b/i;
|
|
201
|
-
|
|
202
|
-
// Role families for deterministic phase inference. ORC agent names encode the
|
|
203
|
-
// role, so a change of family between consecutive SPAWNs IS a phase edge.
|
|
204
|
-
// A name that maps to null never opens an edge (and never closes the previous
|
|
205
|
-
// one) — including the trace writer, whose whole job is narration.
|
|
206
|
-
function roleFamily(agent) {
|
|
207
|
-
const a = String(agent).toLowerCase();
|
|
208
|
-
if (/trace-writer/.test(a)) return null;
|
|
209
|
-
// context-combiner FIRST: it is its own phase (merging confirmed analyses),
|
|
210
|
-
// not analysis — without this the combine lane has no edge at all and is
|
|
211
|
-
// structurally invisible to /orc-retro.
|
|
212
|
-
if (/context-combiner/.test(a)) return "combine";
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (/
|
|
218
|
-
if (/
|
|
219
|
-
if (/
|
|
220
|
-
return
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
function
|
|
282
|
-
try {
|
|
283
|
-
fs.
|
|
284
|
-
} catch (_) {}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
//
|
|
342
|
-
if (
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
//
|
|
391
|
-
//
|
|
392
|
-
|
|
393
|
-
if (!
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
//
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
//
|
|
423
|
-
//
|
|
424
|
-
|
|
425
|
-
name =
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
//
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
//
|
|
434
|
-
//
|
|
435
|
-
//
|
|
436
|
-
//
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
unattributed
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
name = "agent";
|
|
444
|
-
approx = true;
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
})
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ORC trace hook — the deterministic BACKBONE of ORC behavior-trace logging.
|
|
6
|
+
*
|
|
7
|
+
* ORC is not a runtime; the orchestrator (a Claude session following the orc
|
|
8
|
+
* skill) writes the RICH markers. This hook writes the compaction-proof
|
|
9
|
+
* SKELETON: a `SPAWN` line when an agent is dispatched (PreToolUse on the
|
|
10
|
+
* Task/Agent tool) and a
|
|
11
|
+
* `RETURN` line when a subagent finishes (SubagentStop). Even if the model
|
|
12
|
+
* forgets to narrate after a compaction, the flow shape survives here.
|
|
13
|
+
*
|
|
14
|
+
* IMPORTANT — this hook is intentionally BLIND to the dispatched model. Claude
|
|
15
|
+
* Code does not expose a subagent's model id to hooks (it lives only in the
|
|
16
|
+
* subagent's own system prompt). The claimed-vs-actual model check is done by
|
|
17
|
+
* the orchestrator from each agent's `actual_model` return field, NOT here.
|
|
18
|
+
*
|
|
19
|
+
* Logging is PERMANENT (always on) — no config toggle. This hook is the
|
|
20
|
+
* DETERMINISTIC guarantee that a trace exists: on the first ORC-agent dispatch
|
|
21
|
+
* it BOOTSTRAPS the log folder + run pointer itself, so a `.txt` is created for
|
|
22
|
+
* every run even if the orchestrator never writes a single rich marker. That
|
|
23
|
+
* removes the old failure where nothing was ever created because the folder /
|
|
24
|
+
* pointer depended entirely on the model remembering to make them.
|
|
25
|
+
*
|
|
26
|
+
* Gating (so non-ORC Tasks are never logged, enforced in code):
|
|
27
|
+
* - a SPAWN is written (and the folder+pointer bootstrapped if missing) only
|
|
28
|
+
* when the dispatched agent name starts with `orc` — a real ORC run.
|
|
29
|
+
* - a RETURN is written only when a run pointer already exists, the run is
|
|
30
|
+
* not STALE, and the file has fewer RETURNs than SPAWNs (a RETURN can never
|
|
31
|
+
* exist without a matching observed dispatch — this is what stops stray
|
|
32
|
+
* SubagentStops from unrelated sessions bleeding into an old trace).
|
|
33
|
+
* - a SubagentStop that carries a non-ORC `agent_type` is dropped, mirroring
|
|
34
|
+
* the SPAWN gate (an unrelated subagent must never claim an ORC RETURN).
|
|
35
|
+
*
|
|
36
|
+
* Attributable RETURN (v0.31.0): the bare `RETURN` skeleton is unattributable
|
|
37
|
+
* once ≥2 agents are in flight. Each SPAWN now also pushes a pending record into
|
|
38
|
+
* a sidecar next to the trace (`<trace>.pending.json` — `[{agent, desc, ts}]`).
|
|
39
|
+
* On SubagentStop the hook resolves the finishing agent from `data.agent_type`
|
|
40
|
+
* (Claude Code's SubagentStop payload) — or, on older Claude Code that omits it,
|
|
41
|
+
* pops the oldest pending record FIFO and marks the attribution approximate
|
|
42
|
+
* (`~`). It emits `RETURN <agent> :: <desc> dur=<m>m<s>s` and, when
|
|
43
|
+
* `last_assistant_message` carries the agent's `actual_model`, appends
|
|
44
|
+
* ` model=<id>` — so the claimed-vs-actual check is deterministic even if the
|
|
45
|
+
* orchestrator forgets its VERIFY line. The sidecar is best-effort: a missing or
|
|
46
|
+
* corrupt sidecar degrades to a bare RETURN (never throws, always exit 0).
|
|
47
|
+
*
|
|
48
|
+
* Attribution hardening (v0.32.0) — three bugs a real 8-task run proved:
|
|
49
|
+
* 1. DOUBLE RETURN. A stop that carried no `agent_type` FIFO-popped another
|
|
50
|
+
* agent's pending record; the REAL `agent_type` stop then found no record
|
|
51
|
+
* and wrote a second, desc-less RETURN for the same agent. Now: an
|
|
52
|
+
* `agent_type` stop with NO matching record while OTHER records are still
|
|
53
|
+
* in flight is a DUPLICATE (its slot was already consumed) and is dropped.
|
|
54
|
+
* An empty sidecar is NOT a duplicate signal — it still degrades to a bare
|
|
55
|
+
* RETURN, and the `returns >= spawns` guard covers the empty-list repeat.
|
|
56
|
+
* 2. MISSING RETURN. The blind FIFO pop above starved the agent whose record
|
|
57
|
+
* was stolen. Now an `agent_type`-less stop only CONSUMES a record when
|
|
58
|
+
* exactly ONE is in flight; with ≥2 it writes `RETURN ~agent :: unattributed`
|
|
59
|
+
* WITHOUT consuming, so the real `agent_type` stop can still claim its own.
|
|
60
|
+
* 3. RETRO SELF-POLLUTION. `/orc-retro` dispatches `orc-retro-*`, which matched
|
|
61
|
+
* the `/^orc/` gate — the trace miner was generating trace data. `orc-retro`
|
|
62
|
+
* is now on an explicit ignore-list: never bootstraps, never SPAWN/RETURN.
|
|
63
|
+
*
|
|
64
|
+
* A FOREIGN dispatch produces NOTHING here, and that is by design (v0.50.0).
|
|
65
|
+
* `orc extra dispatch` runs a non-Claude worker through Bash, so there is no
|
|
66
|
+
* `Agent`/`Task` call for this hook to observe: no SPAWN, no RETURN, no
|
|
67
|
+
* PHASE-EDGE, and therefore no contribution to NARRATION COVERAGE either. That
|
|
68
|
+
* is the same gap `/orc-quick`'s ad-hoc recon has (P7 — Extra ships zero new
|
|
69
|
+
* agents, so a foreign worker has no agent file to name).
|
|
70
|
+
*
|
|
71
|
+
* The record instead comes entirely from orchestrator-written lines: `DISPATCH
|
|
72
|
+
* … via=extra:<profile>`, `SCORE … via=extra:<profile>` and the `EXTRA` verb,
|
|
73
|
+
* whose text `orc extra dispatch --json` composes so no lane writes a second
|
|
74
|
+
* wording. NOTHING IS ADDED TO THIS FILE FOR THEM — a hook that tried to infer
|
|
75
|
+
* a foreign dispatch from a Bash command line would be guessing, and a guessed
|
|
76
|
+
* SPAWN is worse than an absent one because `/orc-retro` would count it.
|
|
77
|
+
*
|
|
78
|
+
* Deterministic phase inference (v0.32.0): ORC agent NAMES encode their role, so
|
|
79
|
+
* the hook can segment a run into phases with zero model cooperation. When a
|
|
80
|
+
* SPAWN's role family differs from the previous SPAWN's, the hook writes one
|
|
81
|
+
* extra line first — `PHASE-EDGE <role-family> :: first=<agent>` — so even a run
|
|
82
|
+
* where every narration dispatch was forgotten still reads as
|
|
83
|
+
* planning → execution → review → verify. `/orc-retro` reads these to compute
|
|
84
|
+
* NARRATION COVERAGE (phase edges with a trace-writer SPAWN between them).
|
|
85
|
+
*
|
|
86
|
+
* Caveat a retro must read this metric with (v0.34.2): CONTINUING an existing
|
|
87
|
+
* agent emits no PreToolUse/SubagentStop pair, so the hook cannot see it. A lane
|
|
88
|
+
* driven by continuing one agent under-counts real dispatch volume — the
|
|
89
|
+
* skeleton is a floor on what happened, never a census.
|
|
90
|
+
*
|
|
91
|
+
* Rich run filenames (v0.32.0): the canonical trace name is
|
|
92
|
+
* `run-<lane>-<slug>-<DDMMYY>-<HHMMSS>.txt`, written by the orchestrating lane at
|
|
93
|
+
* run start. The hook still bootstraps its own generic `run-<DDMMYY>-<HHMMSS>.txt`
|
|
94
|
+
* when the pointer is missing; the first `orc-trace-writer-haiku-4-5` dispatch
|
|
95
|
+
* RENAMES that bootstrap file (+ sidecars) and rewrites `.current`. The hook is
|
|
96
|
+
* name-agnostic (rotation/STALE logic is pointer-based) and holds no open
|
|
97
|
+
* handles, so a rename between two hook events is safe.
|
|
98
|
+
*
|
|
99
|
+
* Pointer clobber fix (v0.34.2) — the single largest defect in the trace corpus
|
|
100
|
+
* (9 lane-less stray files across 15 graded runs).
|
|
101
|
+
* `traceStats` returns null both for "pointer written two seconds ago, the lane
|
|
102
|
+
* has not created the file yet" and for "dangling pointer, run long over", and
|
|
103
|
+
* the hook rotated on both — so the lane's own run-start step reliably caused a
|
|
104
|
+
* SPLIT run: a generic bootstrap file holding the hook skeleton, plus a rich
|
|
105
|
+
* file holding every narrated line. Now the POINTER's own mtime decides: fresh
|
|
106
|
+
* (< STALE_MS) → honor the registered name and create the file under it; only a
|
|
107
|
+
* genuinely idle pointer rotates. The lanes also `touch` the trace file in the
|
|
108
|
+
* same step as `.current`, so both halves are fixed independently.
|
|
109
|
+
*
|
|
110
|
+
* Unbounded RETURN fix (v0.34.2): an `unattributed` RETURN claims no pending
|
|
111
|
+
* record, so it is excluded from the `returns` balance and the
|
|
112
|
+
* `returns >= spawns` guard could never stop it — every stop past the first
|
|
113
|
+
* wrote another line forever. The count is now bounded by the number of records
|
|
114
|
+
* actually in flight.
|
|
115
|
+
*
|
|
116
|
+
* Run rotation (v0.23.0): `.current` is a POINTER, not a lifetime lease. A run
|
|
117
|
+
* whose trace file has been idle longer than STALE_MS is considered ENDED — the
|
|
118
|
+
* next ORC dispatch rotates to a fresh `.txt` instead of appending days of
|
|
119
|
+
* unrelated sessions into one merged file.
|
|
120
|
+
*
|
|
121
|
+
* Wiring (installed by `orc init` into .claude/settings.json):
|
|
122
|
+
* hooks.PreToolUse[] { matcher:"Task|Agent", hooks:[{command:"node <..>/orc-trace.js"}] }
|
|
123
|
+
* hooks.SubagentStop[] { hooks:[{command:"node <..>/orc-trace.js"}] }
|
|
124
|
+
* (matcher covers both names — newer Claude Code dispatches subagents via the
|
|
125
|
+
* `Agent` tool, older via `Task`; matching only `Task` silently kills SPAWN.)
|
|
126
|
+
*
|
|
127
|
+
* Contract: read hook JSON from stdin. ALWAYS exit 0 (never block a tool call —
|
|
128
|
+
* tracing must never affect a run). Fail-silent on any error.
|
|
129
|
+
*/
|
|
130
|
+
|
|
131
|
+
const fs = require("fs");
|
|
132
|
+
const path = require("path");
|
|
133
|
+
|
|
134
|
+
// .claude/hooks/orc-trace.js → CLAUDE_DIR = .. → PROJECT_ROOT = ../..
|
|
135
|
+
const CLAUDE_DIR = path.join(__dirname, "..");
|
|
136
|
+
const PROJECT_ROOT = path.join(CLAUDE_DIR, "..");
|
|
137
|
+
|
|
138
|
+
// Minimal, tolerant YAML line-scan (repo is zero-dep — no YAML parser).
|
|
139
|
+
// Reads a top-level `key: value` scalar; ignores comments and quotes.
|
|
140
|
+
function readConfigScalar(key) {
|
|
141
|
+
const file = path.join(CLAUDE_DIR, "orc.config.yaml");
|
|
142
|
+
let text;
|
|
143
|
+
try {
|
|
144
|
+
text = fs.readFileSync(file, "utf8");
|
|
145
|
+
} catch (_) {
|
|
146
|
+
return null; // no override file → key absent (caller applies default)
|
|
147
|
+
}
|
|
148
|
+
// Anchor to column 0 — a top-level key only, matching how the CLI reads
|
|
149
|
+
// top-level scalars. Prevents an indented `log_dir:` nested under another map
|
|
150
|
+
// from being read as the global override.
|
|
151
|
+
const re = new RegExp("^" + key + "\\s*:\\s*(.+?)\\s*(?:#.*)?$", "m");
|
|
152
|
+
const m = text.match(re);
|
|
153
|
+
if (!m) return null;
|
|
154
|
+
return m[1].replace(/^['"]|['"]$/g, "").trim();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function logDir() {
|
|
158
|
+
const override = readConfigScalar("log_dir");
|
|
159
|
+
const rel = override || ".claude/orc/logs";
|
|
160
|
+
return path.isAbsolute(rel) ? rel : path.join(PROJECT_ROOT, rel);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// DDMMYY HH:MM:SS.mmm — matches the decision-log timestamp style.
|
|
164
|
+
function stamp(d) {
|
|
165
|
+
const p = (n, w) => String(n).padStart(w || 2, "0");
|
|
166
|
+
const dd = p(d.getDate());
|
|
167
|
+
const mm = p(d.getMonth() + 1);
|
|
168
|
+
const yy = p(d.getFullYear() % 100);
|
|
169
|
+
const t =
|
|
170
|
+
p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()) +
|
|
171
|
+
"." + p(d.getMilliseconds(), 3);
|
|
172
|
+
return `${dd}${mm}${yy} ${t}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function appendLine(dir, file, actor, verb, tail) {
|
|
176
|
+
// Ensure the folder exists — the hook is the deterministic writer, so it
|
|
177
|
+
// must never depend on the orchestrator having created log_dir first.
|
|
178
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
179
|
+
// One complete line per append keeps concurrent SubagentStop writes (a wave
|
|
180
|
+
// of N parallel agents) from interleaving mid-line.
|
|
181
|
+
const line =
|
|
182
|
+
`[${stamp(new Date())}] ${String(actor).padEnd(8)} ${verb}` +
|
|
183
|
+
(tail ? ` :: ${tail}` : "") + "\n";
|
|
184
|
+
fs.appendFileSync(path.join(dir, file), line);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Generic run slug used only when the hook bootstraps a trace before the
|
|
188
|
+
// orchestrator wrote its own pointer (e.g. the model skipped run-start).
|
|
189
|
+
function bootstrapSlug(d) {
|
|
190
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
191
|
+
const dd = p(d.getDate()), mm = p(d.getMonth() + 1), yy = p(d.getFullYear() % 100);
|
|
192
|
+
const t = p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds());
|
|
193
|
+
return `run-${dd}${mm}${yy}-${t}.txt`;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Lanes whose agents must NEVER be traced. `/orc-retro` MINES the traces — its
|
|
197
|
+
// hard rule 4 says it writes no trace of its own, so an `orc-retro-*` dispatch
|
|
198
|
+
// must not bootstrap a run or emit SPAWN/RETURN (that would pollute the very
|
|
199
|
+
// data it reads). Matched on the agent name, before every other gate.
|
|
200
|
+
const IGNORED_AGENTS = /^orc-retro\b/i;
|
|
201
|
+
|
|
202
|
+
// Role families for deterministic phase inference. ORC agent names encode the
|
|
203
|
+
// role, so a change of family between consecutive SPAWNs IS a phase edge.
|
|
204
|
+
// A name that maps to null never opens an edge (and never closes the previous
|
|
205
|
+
// one) — including the trace writer, whose whole job is narration.
|
|
206
|
+
function roleFamily(agent) {
|
|
207
|
+
const a = String(agent).toLowerCase();
|
|
208
|
+
if (/trace-writer/.test(a)) return null;
|
|
209
|
+
// context-combiner FIRST: it is its own phase (merging confirmed analyses),
|
|
210
|
+
// not analysis — without this the combine lane has no edge at all and is
|
|
211
|
+
// structurally invisible to /orc-retro.
|
|
212
|
+
if (/context-combiner/.test(a)) return "combine";
|
|
213
|
+
// recon BEFORE analysis: `orc-recon-*` answers ONE question with evidence
|
|
214
|
+
// for a person, where an analyst produces a requirement spec for a planner.
|
|
215
|
+
// Counting them together would tell /orc-retro that /orc-quick runs an
|
|
216
|
+
// analysis phase it does not have (v1.9.0).
|
|
217
|
+
if (/recon/.test(a)) return "recon";
|
|
218
|
+
if (/analyst|analyze|scout/.test(a)) return "analysis";
|
|
219
|
+
if (/planner/.test(a)) return "planning";
|
|
220
|
+
if (/executor/.test(a)) return "execution";
|
|
221
|
+
if (/reviewer/.test(a)) return "review";
|
|
222
|
+
if (/verifier/.test(a)) return "verify";
|
|
223
|
+
if (/test-author/.test(a)) return "testgen";
|
|
224
|
+
if (/advisor|judge/.test(a)) return "ultra-gate";
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The role family of the LAST classified SPAWN in a trace. Derived from the file
|
|
229
|
+
// itself so phase inference needs no extra state file (and survives a rename).
|
|
230
|
+
function lastSpawnRole(dir, file) {
|
|
231
|
+
let text;
|
|
232
|
+
try {
|
|
233
|
+
text = fs.readFileSync(path.join(dir, file), "utf8");
|
|
234
|
+
} catch (_) {
|
|
235
|
+
return null; // new/unreadable trace → the next SPAWN opens the first edge
|
|
236
|
+
}
|
|
237
|
+
let last = null;
|
|
238
|
+
for (const m of text.matchAll(/\] hook\s+SPAWN (\S+)/g)) {
|
|
239
|
+
const fam = roleFamily(m[1]);
|
|
240
|
+
if (fam) last = fam;
|
|
241
|
+
}
|
|
242
|
+
return last;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Read the active run pointer, or null. Never throws.
|
|
246
|
+
function readPointer(dir) {
|
|
247
|
+
try {
|
|
248
|
+
const cur = fs.readFileSync(path.join(dir, ".current"), "utf8").trim();
|
|
249
|
+
return cur || null;
|
|
250
|
+
} catch (_) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Age of the POINTER itself (not the file it names), or null when absent.
|
|
256
|
+
// This is what tells a FRESH lane-registered pointer ("run start wrote
|
|
257
|
+
// `.current` seconds ago; the trace file is about to exist") apart from a
|
|
258
|
+
// DANGLING one left behind by a finished run — `traceStats` returns null for
|
|
259
|
+
// both, which is exactly how a run used to get split across two files.
|
|
260
|
+
function pointerAgeMs(dir) {
|
|
261
|
+
try {
|
|
262
|
+
return Date.now() - fs.statSync(path.join(dir, ".current")).mtimeMs;
|
|
263
|
+
} catch (_) {
|
|
264
|
+
return null;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Sidecar of pending SPAWN records, next to the trace file. Best-effort — every
|
|
269
|
+
// accessor swallows errors so a missing/corrupt sidecar never affects the run.
|
|
270
|
+
function sidecarPath(dir, file) {
|
|
271
|
+
return path.join(dir, file + ".pending.json");
|
|
272
|
+
}
|
|
273
|
+
function readPending(dir, file) {
|
|
274
|
+
try {
|
|
275
|
+
const arr = JSON.parse(fs.readFileSync(sidecarPath(dir, file), "utf8"));
|
|
276
|
+
return Array.isArray(arr) ? arr : [];
|
|
277
|
+
} catch (_) {
|
|
278
|
+
return [];
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function writePending(dir, file, arr) {
|
|
282
|
+
try {
|
|
283
|
+
fs.writeFileSync(sidecarPath(dir, file), JSON.stringify(arr));
|
|
284
|
+
} catch (_) {}
|
|
285
|
+
}
|
|
286
|
+
function dropPending(dir, file) {
|
|
287
|
+
try {
|
|
288
|
+
fs.unlinkSync(sidecarPath(dir, file));
|
|
289
|
+
} catch (_) {}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// A trace file idle longer than this is a FINISHED run — never append to it.
|
|
293
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
294
|
+
|
|
295
|
+
// Stats for the pointed-at trace file: idle age + hook SPAWN/RETURN balance.
|
|
296
|
+
// Returns null when the pointer is dangling (file missing/unreadable).
|
|
297
|
+
function traceStats(dir, file) {
|
|
298
|
+
try {
|
|
299
|
+
const full = path.join(dir, file);
|
|
300
|
+
const st = fs.statSync(full);
|
|
301
|
+
const text = fs.readFileSync(full, "utf8");
|
|
302
|
+
// Count only the hook's own skeleton lines (actor column = "hook") — the
|
|
303
|
+
// orchestrator's rich verbs (DISPATCH/VERIFY/…) never collide with these.
|
|
304
|
+
const spawns = (text.match(/\] hook\s+SPAWN /g) || []).length;
|
|
305
|
+
// `RETURN ~agent :: unattributed` claims no pending record (the ≥2-in-flight
|
|
306
|
+
// restraint above), so it must not count toward the balance either — if it
|
|
307
|
+
// did, it would starve the very RETURN it deliberately declined to steal.
|
|
308
|
+
const all = (text.match(/\] hook\s+RETURN/g) || []).length;
|
|
309
|
+
const loose = (text.match(/\] hook\s+RETURN ~agent :: unattributed/g) || []).length;
|
|
310
|
+
return { idleMs: Date.now() - st.mtimeMs, spawns, returns: all - loose, loose };
|
|
311
|
+
} catch (_) {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
let raw = "";
|
|
317
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
318
|
+
process.stdin.on("end", () => {
|
|
319
|
+
try {
|
|
320
|
+
// Logging is PERMANENT (always on) — no config gate.
|
|
321
|
+
const dir = logDir();
|
|
322
|
+
|
|
323
|
+
let data = {};
|
|
324
|
+
try {
|
|
325
|
+
data = JSON.parse(raw || "{}");
|
|
326
|
+
} catch (_) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const event = data.hook_event_name || "";
|
|
331
|
+
const tool = data.tool_name || "";
|
|
332
|
+
|
|
333
|
+
if (event === "PreToolUse" && (tool === "Task" || tool === "Agent")) {
|
|
334
|
+
// Agent dispatch — record the REQUESTED agent name (the claim). The
|
|
335
|
+
// orchestrator's DISPATCH/VERIFY lines carry the authoritative detail.
|
|
336
|
+
const input = data.tool_input || {};
|
|
337
|
+
const agent = input.subagent_type || input.subagentType || "agent";
|
|
338
|
+
// Only ORC-agent dispatches start/extend a trace — non-ORC Tasks (Explore,
|
|
339
|
+
// general-purpose, superpowers, …) are never logged.
|
|
340
|
+
if (!/^orc/i.test(String(agent))) return;
|
|
341
|
+
// …and the trace miner is exempt from its own instrument.
|
|
342
|
+
if (IGNORED_AGENTS.test(String(agent))) return;
|
|
343
|
+
let current = readPointer(dir);
|
|
344
|
+
// Rotate away from a finished run: a dangling pointer or a trace file
|
|
345
|
+
// idle past STALE_MS means the old run ended — start a fresh `.txt`
|
|
346
|
+
// instead of merging a new day's run into it.
|
|
347
|
+
if (current) {
|
|
348
|
+
const stats = traceStats(dir, current);
|
|
349
|
+
if (!stats) {
|
|
350
|
+
// Pointer names a file that does not exist. TWO different states —
|
|
351
|
+
// and rotating on both is what produced the split trace pairs:
|
|
352
|
+
// fresh pointer → the lane just registered its rich filename and
|
|
353
|
+
// has not written into it yet. HONOR it: append
|
|
354
|
+
// under that name, leave `.current` alone.
|
|
355
|
+
// stale/dangling → the run really is over. Rotate.
|
|
356
|
+
const age = pointerAgeMs(dir);
|
|
357
|
+
if (age === null || age > STALE_MS) {
|
|
358
|
+
dropPending(dir, current);
|
|
359
|
+
current = null;
|
|
360
|
+
}
|
|
361
|
+
} else if (stats.idleMs > STALE_MS) {
|
|
362
|
+
// The old run is over — clean up its pending sidecar so a new run
|
|
363
|
+
// never inherits stale in-flight records.
|
|
364
|
+
dropPending(dir, current);
|
|
365
|
+
current = null;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (!current) {
|
|
369
|
+
// Bootstrap: no live run pointer (never written, dangling, or stale).
|
|
370
|
+
// Create the folder + pointer so a trace exists regardless.
|
|
371
|
+
current = bootstrapSlug(new Date());
|
|
372
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
373
|
+
fs.writeFileSync(path.join(dir, ".current"), current + "\n");
|
|
374
|
+
}
|
|
375
|
+
const desc = (input.description || "").toString().slice(0, 80);
|
|
376
|
+
// Deterministic phase inference: a change of role family (or the first
|
|
377
|
+
// classified dispatch of the run) opens a phase edge. Written BEFORE the
|
|
378
|
+
// SPAWN so the edge reads as the header of the phase it opens.
|
|
379
|
+
const fam = roleFamily(agent);
|
|
380
|
+
if (fam && fam !== lastSpawnRole(dir, current))
|
|
381
|
+
appendLine(dir, current, "hook", `PHASE-EDGE ${fam}`, `first=${agent}`);
|
|
382
|
+
appendLine(dir, current, "hook", `SPAWN ${agent}`, desc);
|
|
383
|
+
// Push a pending record so the matching RETURN can be attributed (agent +
|
|
384
|
+
// desc + start time). Best-effort — a write failure degrades to a bare
|
|
385
|
+
// RETURN, never affects the run.
|
|
386
|
+
const pend = readPending(dir, current);
|
|
387
|
+
pend.push({ agent: String(agent), desc, ts: Date.now() });
|
|
388
|
+
writePending(dir, current, pend);
|
|
389
|
+
} else if (event === "SubagentStop" || event === "Stop") {
|
|
390
|
+
// A RETURN only makes sense inside an active run — don't manufacture a
|
|
391
|
+
// trace from a stray subagent stop that had no matching ORC dispatch.
|
|
392
|
+
const current = readPointer(dir);
|
|
393
|
+
if (!current) return;
|
|
394
|
+
const stats = traceStats(dir, current);
|
|
395
|
+
// Drop the RETURN when the run is over (stale/dangling) or when every
|
|
396
|
+
// observed SPAWN already has its RETURN — unrelated sessions' subagents
|
|
397
|
+
// must never bleed into an ORC trace.
|
|
398
|
+
if (!stats || stats.idleMs > STALE_MS) return;
|
|
399
|
+
if (stats.returns >= stats.spawns) return;
|
|
400
|
+
|
|
401
|
+
// Resolve WHICH agent finished. Claude Code's SubagentStop carries
|
|
402
|
+
// agent_type today; older builds omit it.
|
|
403
|
+
const agentType = (data.agent_type || data.agentType || "").toString();
|
|
404
|
+
// Non-ORC agent_type → drop, mirroring the SPAWN gate.
|
|
405
|
+
if (agentType && !/^orc/i.test(agentType)) return;
|
|
406
|
+
// The trace miner never returns into a trace either (hard rule 4).
|
|
407
|
+
if (agentType && IGNORED_AGENTS.test(agentType)) return;
|
|
408
|
+
|
|
409
|
+
const pend = readPending(dir, current);
|
|
410
|
+
let rec = null;
|
|
411
|
+
let name;
|
|
412
|
+
let approx = false;
|
|
413
|
+
let unattributed = false;
|
|
414
|
+
if (agentType) {
|
|
415
|
+
// Match the OLDEST pending record for this agent type (FIFO within name).
|
|
416
|
+
const idx = pend.findIndex((p) => p && p.agent === agentType);
|
|
417
|
+
if (idx >= 0) rec = pend.splice(idx, 1)[0];
|
|
418
|
+
// DUPLICATE STOP: no record for this agent, but others are still in
|
|
419
|
+
// flight → this agent's slot was already consumed (the classic
|
|
420
|
+
// approximate-pop-then-real-stop double fire). Drop it rather than
|
|
421
|
+
// write a second, desc-less RETURN for the same agent. An EMPTY sidecar
|
|
422
|
+
// is a lost/corrupt sidecar, not a duplicate — that case still degrades
|
|
423
|
+
// to a bare RETURN (and a genuine repeat is caught by returns>=spawns).
|
|
424
|
+
else if (pend.length) return;
|
|
425
|
+
name = agentType;
|
|
426
|
+
} else if (pend.length === 1) {
|
|
427
|
+
// Older Claude Code: no agent_type, and exactly ONE agent in flight —
|
|
428
|
+
// the FIFO pop is unambiguous. Mark it approximate (`~`) all the same.
|
|
429
|
+
rec = pend.shift();
|
|
430
|
+
name = rec.agent;
|
|
431
|
+
approx = true;
|
|
432
|
+
} else if (pend.length > 1) {
|
|
433
|
+
// ≥2 in flight and no agent_type: a blind FIFO pop would hand this stop
|
|
434
|
+
// another agent's record and STARVE that agent's real RETURN. Record
|
|
435
|
+
// that something finished, but consume NOTHING.
|
|
436
|
+
//
|
|
437
|
+
// BOUND them (v0.34.2). Because an `unattributed` line claims no record
|
|
438
|
+
// it is also excluded from `returns`, so the `returns >= spawns` guard
|
|
439
|
+
// can never stop it — on a wave of N the hook wrote a line for EVERY
|
|
440
|
+
// stop, forever. At most one unattributed line per in-flight record:
|
|
441
|
+
// a stray stop past that writes nothing.
|
|
442
|
+
if ((stats.loose || 0) >= pend.length) return;
|
|
443
|
+
name = "agent";
|
|
444
|
+
approx = true;
|
|
445
|
+
unattributed = true;
|
|
446
|
+
} else {
|
|
447
|
+
// No pending records at all (missing/corrupt sidecar) — bare RETURN.
|
|
448
|
+
name = "agent";
|
|
449
|
+
approx = true;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const parts = [];
|
|
453
|
+
if (unattributed) parts.push("unattributed");
|
|
454
|
+
if (rec && rec.desc) parts.push(String(rec.desc));
|
|
455
|
+
if (rec && typeof rec.ts === "number") {
|
|
456
|
+
const s = Math.max(0, Math.round((Date.now() - rec.ts) / 1000));
|
|
457
|
+
parts.push(`dur=${Math.floor(s / 60)}m${s % 60}s`);
|
|
458
|
+
}
|
|
459
|
+
// Opportunistic model capture from the finishing agent's last message —
|
|
460
|
+
// makes the downgrade check deterministic even without the VERIFY line.
|
|
461
|
+
const msg = (data.last_assistant_message || "").toString();
|
|
462
|
+
const mm =
|
|
463
|
+
msg.match(/actual_model["'\s:=]*["']?(claude-[a-z0-9-]+)/i) ||
|
|
464
|
+
msg.match(/\b(claude-[a-z0-9-]+)\b/);
|
|
465
|
+
if (mm) parts.push(`model=${mm[1]}`);
|
|
466
|
+
|
|
467
|
+
const label = approx ? `RETURN ~${name}` : `RETURN ${name}`;
|
|
468
|
+
appendLine(dir, current, "hook", label, parts.join(" "));
|
|
469
|
+
writePending(dir, current, pend);
|
|
470
|
+
}
|
|
471
|
+
} catch (_) {
|
|
472
|
+
// Tracing must never affect a run.
|
|
473
|
+
} finally {
|
|
474
|
+
process.exit(0);
|
|
475
|
+
}
|
|
476
|
+
});
|