session-orchestrator 3.23.0 → 3.24.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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/skills/remote-offload/SKILL.md +13 -0
- package/CHANGELOG.md +278 -0
- package/README.md +16 -14
- package/agents/db-specialist.md +0 -1
- package/docs/ci-setup.md +180 -25
- package/docs/codex-setup.md +1 -1
- package/docs/components.md +3 -3
- package/docs/events-schema.md +46 -8
- package/docs/scope-collision-guard.md +4 -4
- package/docs/session-config-reference.md +64 -6
- package/docs/session-config-template.md +34 -4
- package/docs/telemetry/telemetry-claims.md +11 -10
- package/docs/telemetry.md +30 -1
- package/hooks/_lib/atomic-json.mjs +111 -0
- package/hooks/_lib/subagent-paths.mjs +143 -0
- package/hooks/cwd-change-restore.mjs +9 -29
- package/hooks/enforce-scope.mjs +35 -6
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-end.mjs +278 -12
- package/hooks/on-session-start.mjs +50 -2
- package/hooks/on-stop.mjs +349 -20
- package/hooks/post-bash-write-verify.mjs +104 -4
- package/hooks/post-subagent-discovery-validator.mjs +148 -18
- package/hooks/post-tool-batch-wave-signal.mjs +154 -40
- package/hooks/post-tool-failure-corrective-context.mjs +9 -32
- package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
- package/hooks/subagent-telemetry.mjs +11 -26
- package/package.json +1 -1
- package/scripts/autopilot.mjs +26 -12
- package/scripts/backfill-abandoned-sessions.mjs +80 -11
- package/scripts/emit-event.mjs +10 -2
- package/scripts/lib/auq/parse.mjs +5 -29
- package/scripts/lib/auto-dialectic.mjs +68 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
- package/scripts/lib/build-live-signals.mjs +25 -22
- package/scripts/lib/cold-start-detector.mjs +23 -14
- package/scripts/lib/config/block-header.mjs +55 -0
- package/scripts/lib/config/discovery-validator.mjs +7 -2
- package/scripts/lib/config/health-endpoints.mjs +383 -0
- package/scripts/lib/config/remote-hosts.mjs +233 -0
- package/scripts/lib/config.mjs +31 -3
- package/scripts/lib/dispatcher/enumerate.mjs +2 -17
- package/scripts/lib/events-schema.mjs +48 -0
- package/scripts/lib/events.mjs +238 -5
- package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
- package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
- package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
- package/scripts/lib/memory-banner.mjs +20 -8
- package/scripts/lib/peer-discovery.mjs +20 -2
- package/scripts/lib/reconcile/engine.mjs +236 -5
- package/scripts/lib/scope-gate.mjs +36 -0
- package/scripts/lib/session-close-backfill.mjs +59 -10
- package/scripts/lib/session-discovery.mjs +57 -3
- package/scripts/lib/session-end/phase-skip.mjs +2 -2
- package/scripts/lib/session-identity/own-session.mjs +62 -1
- package/scripts/lib/session-transition.mjs +1 -1
- package/scripts/lib/sessions-canonical.mjs +446 -0
- package/scripts/lib/telemetry/schema.mjs +74 -8
- package/scripts/lib/telemetry/sync.mjs +49 -12
- package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
- package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
- package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
- package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
- package/scripts/lib/validate/check-unwired-features.mjs +0 -7
- package/scripts/lib/validate/check-validator-registration.mjs +248 -0
- package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
- package/scripts/lib/validate/markdown-fences.mjs +196 -0
- package/scripts/lib/vault-status/board-lock.mjs +185 -0
- package/scripts/lib/vault-status/board-writer.mjs +174 -135
- package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
- package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
- package/scripts/lib/wave-resource-gate.mjs +127 -7
- package/scripts/lib/wave-transcript-tail.mjs +24 -4
- package/scripts/materialize-wave-scope.mjs +20 -4
- package/scripts/memory-propose.mjs +132 -8
- package/scripts/promote-vault-strict.mjs +4 -15
- package/scripts/site-numbers.mjs +36 -4
- package/scripts/validate-plugin.mjs +26 -0
- package/scripts/vault-consolidate.mjs +3 -11
- package/scripts/vault-integration-watcher.mjs +2 -4
- package/scripts/vault-mirror.mjs +111 -26
- package/skills/_shared/parallel-aware-auq.md +31 -2
- package/skills/_shared/parallel-aware-preamble.md +17 -4
- package/skills/_shared/state-ownership.md +1 -1
- package/skills/contract-version-bump/SKILL.md +1 -1
- package/skills/ecosystem-health/SKILL.md +4 -1
- package/skills/ecosystem-health/wizard.md +5 -0
- package/skills/evolve/SKILL.md +38 -1
- package/skills/journey-audit/SKILL.md +6 -5
- package/skills/reconcile/SKILL.md +5 -2
- package/skills/remote-offload/SKILL.md +89 -0
- package/skills/session-end/phase-3-6-tail.md +9 -6
- package/skills/session-start/SKILL.md +26 -3
- package/skills/wave-executor/SKILL.md +1 -1
- package/skills/wave-executor/wave-loop.md +43 -5
package/hooks/on-stop.mjs
CHANGED
|
@@ -16,15 +16,32 @@
|
|
|
16
16
|
* JSONL format (`.orchestrator/metrics/events.jsonl`) — emitted via the canonical
|
|
17
17
|
* `emitEvent()` so the JSONL record and the optional Clank webhook always carry the
|
|
18
18
|
* SAME dotted event name (was: bare `stop`/`subagent_stop` in JSONL vs dotted in webhook):
|
|
19
|
-
* Stop: {"timestamp":<ISO>,"event":"orchestrator.session.stopped","session_id":"...","semantic_session_id":"...","wave":<int>,"branch":"...","commit":"...","duration_ms":<int
|
|
20
|
-
* (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
|
|
21
|
-
*
|
|
19
|
+
* Stop: {"timestamp":<ISO>,"event":"orchestrator.session.stopped","session_id":"...","semantic_session_id":"...","wave":<int>,"branch":"...","commit":"...","duration_ms":<int>,"duration_source":"session-lock"}
|
|
20
|
+
* (`session_id` / `semantic_session_id` are omitted when unresolvable — #1068 AC1.
|
|
21
|
+
* `duration_ms` + `duration_source` are omitted TOGETHER when no OWNED
|
|
22
|
+
* session.lock is readable — never a fabricated 0, see K5 below.)
|
|
23
|
+
* SubagentStop: {"timestamp":<ISO>,"event":"orchestrator.agent.stopped","agent":"<name>","agent_id":"...",
|
|
24
|
+
* "agent_type_meta":"...","tool_use_id":"...","transcript_found":true,
|
|
25
|
+
* "duration_ms":<int>,"duration_source":"meta-birthtime","status":"done"}
|
|
26
|
+
* (#1190 — every key after `event` is OPTIONAL and OMITTED when the
|
|
27
|
+
* measurement could not be made; `agent` too. See docs/events-schema.md.)
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
30
|
import path from 'node:path';
|
|
25
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
promises as fs,
|
|
33
|
+
closeSync,
|
|
34
|
+
existsSync,
|
|
35
|
+
fstatSync,
|
|
36
|
+
openSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
readSync,
|
|
39
|
+
statSync,
|
|
40
|
+
writeFileSync,
|
|
41
|
+
} from 'node:fs';
|
|
26
42
|
|
|
27
43
|
import { shouldRunHook } from './_lib/profile-gate.mjs';
|
|
44
|
+
import { AGENT_ID_RE, resolveSubagentSidecar } from './_lib/subagent-paths.mjs';
|
|
28
45
|
// #211: exit 0 immediately (silent allow) when this hook is disabled via profile/env
|
|
29
46
|
if (!shouldRunHook('on-stop')) process.exit(0);
|
|
30
47
|
|
|
@@ -32,7 +49,7 @@ import { emitEvent } from '../scripts/lib/events.mjs';
|
|
|
32
49
|
import { SO_PROJECT_DIR } from '../scripts/lib/platform.mjs';
|
|
33
50
|
import { parseSessionId } from '../scripts/lib/session-id.mjs';
|
|
34
51
|
import { heartbeat, logSweepEvent } from '../scripts/lib/session-registry.mjs';
|
|
35
|
-
import { updateHeartbeat } from '../scripts/lib/session-lock.mjs';
|
|
52
|
+
import { readLock, updateHeartbeat } from '../scripts/lib/session-lock.mjs';
|
|
36
53
|
|
|
37
54
|
// ---------------------------------------------------------------------------
|
|
38
55
|
// stdin reading (inline — no io.mjs because Stop hooks exit 0 always, never deny)
|
|
@@ -247,7 +264,8 @@ async function readWaveNumber(projectRoot) {
|
|
|
247
264
|
*/
|
|
248
265
|
async function resolveSessionId(input, projectRoot) {
|
|
249
266
|
const fromStdin = input?.session_id ?? input?.sessionId ?? null;
|
|
250
|
-
|
|
267
|
+
const rawStdinId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
|
|
268
|
+
let sessionId = rawStdinId;
|
|
251
269
|
|
|
252
270
|
let recordedId = null;
|
|
253
271
|
let semanticSessionId = null;
|
|
@@ -265,15 +283,84 @@ async function resolveSessionId(input, projectRoot) {
|
|
|
265
283
|
}
|
|
266
284
|
} catch { /* missing or unparseable is fine */ }
|
|
267
285
|
|
|
286
|
+
// Actor-identity fallback ONLY — `sessionId` drives the registry heartbeat and
|
|
287
|
+
// the lock heartbeat, both of which need an id even when stdin carried none.
|
|
268
288
|
if (sessionId === null) sessionId = recordedId;
|
|
269
289
|
|
|
270
|
-
|
|
290
|
+
// W4a review F-A, second site of the same defect (census: exactly 2, both in
|
|
291
|
+
// hooks/, `grep -rn "= recordedId;" hooks/ scripts/` 2026-09-02). Comparing
|
|
292
|
+
// the RESOLVED id against `recordedId` is self-fulfilling: the line above may
|
|
293
|
+
// have just copied one into the other, so an id-less or non-UUID turn-end
|
|
294
|
+
// always read as "I am the recorded session" and inherited a live PEER's
|
|
295
|
+
// semantic identity — the exact #863 defect (c) contamination this gate
|
|
296
|
+
// exists to refuse. Decided on the RAW stdin UUID instead.
|
|
297
|
+
const isRecordedSession = rawStdinId !== null && rawStdinId === recordedId;
|
|
271
298
|
return {
|
|
272
299
|
sessionId,
|
|
273
300
|
semanticSessionId: isRecordedSession ? semanticSessionId : null,
|
|
274
301
|
};
|
|
275
302
|
}
|
|
276
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Resolve an elapsed span for this Stop event — and WHICH span depends on the
|
|
306
|
+
* source, which is why `duration_source` ships beside the number (W4a F-C).
|
|
307
|
+
*
|
|
308
|
+
* `session-lock` — SESSION-elapsed, measured at this turn's end. Stop fires
|
|
309
|
+
* once per TURN, and `started_at` is stamped once per
|
|
310
|
+
* SESSION, so this number GROWS MONOTONICALLY across the
|
|
311
|
+
* turns of one session: the last Stop of a 3-hour session
|
|
312
|
+
* reports ~3 hours, not the length of its final turn.
|
|
313
|
+
* `stdin-start-ms` — TURN-elapsed, the only first-party measurement of the
|
|
314
|
+
* turn itself. Measured today: the harness never sends it,
|
|
315
|
+
* so this source has never appeared in the fleet stream.
|
|
316
|
+
*
|
|
317
|
+
* Consumers must therefore not aggregate the two: summing `session-lock` spans
|
|
318
|
+
* over a session double-counts, where summing `stdin-start-ms` spans would not.
|
|
319
|
+
*
|
|
320
|
+
* OWNERSHIP-GATED, on the raw stdin `session_id` only. A lock in this working
|
|
321
|
+
* copy may name a PEER session (two windows share one checkout routinely), and
|
|
322
|
+
* a working-copy artefact is not a process-local identity witness — so the
|
|
323
|
+
* lock's `session_id` must match the id the STOPPING session was handed on
|
|
324
|
+
* stdin. `resolveSessionId()`'s resolved id is deliberately NOT used here: it
|
|
325
|
+
* falls back to `current-session.json`, which is exactly the foreign-identity
|
|
326
|
+
* inheritance this guard exists to refuse.
|
|
327
|
+
*
|
|
328
|
+
* Returns an object to SPREAD into the payload: `{}` when nothing is
|
|
329
|
+
* attestable, so `duration_ms` and `duration_source` are omitted together
|
|
330
|
+
* rather than written as a fabricated 0 (K5).
|
|
331
|
+
*
|
|
332
|
+
* `start_ms` is honoured first when the harness ever starts sending it — it is
|
|
333
|
+
* the only first-party measurement of the turn itself; measured today it is
|
|
334
|
+
* never present.
|
|
335
|
+
*
|
|
336
|
+
* @param {object|null} input parsed stdin payload
|
|
337
|
+
* @param {string} projectRoot
|
|
338
|
+
* @returns {{duration_ms?: number, duration_source?: string}}
|
|
339
|
+
*/
|
|
340
|
+
function resolveStopDuration(input, projectRoot) {
|
|
341
|
+
if (typeof input?.start_ms === 'number' && Number.isFinite(input.start_ms)) {
|
|
342
|
+
const ms = Date.now() - input.start_ms;
|
|
343
|
+
if (Number.isFinite(ms) && ms >= 0) {
|
|
344
|
+
return { duration_ms: Math.round(ms), duration_source: 'stdin-start-ms' };
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const stdinId = input?.session_id ?? input?.sessionId ?? null;
|
|
349
|
+
if (typeof stdinId !== 'string' || stdinId.length === 0) return {};
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
const lock = readLock({ repoRoot: projectRoot });
|
|
353
|
+
if (lock === null || lock.session_id !== stdinId) return {};
|
|
354
|
+
const startedAt = Date.parse(lock.started_at);
|
|
355
|
+
if (Number.isNaN(startedAt)) return {};
|
|
356
|
+
const ms = Date.now() - startedAt;
|
|
357
|
+
if (!Number.isFinite(ms) || ms < 0) return {};
|
|
358
|
+
return { duration_ms: Math.round(ms), duration_source: 'session-lock' };
|
|
359
|
+
} catch {
|
|
360
|
+
return {}; // best-effort — an unreadable lock measures nothing
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
277
364
|
/**
|
|
278
365
|
* Handle a Stop event. Reads wave from scope file + git info, appends JSONL.
|
|
279
366
|
* @param {object|null} input
|
|
@@ -350,9 +437,15 @@ async function handleStop(input) {
|
|
|
350
437
|
} catch { /* best-effort */ }
|
|
351
438
|
}
|
|
352
439
|
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
440
|
+
// K5 — `duration_ms` was literally 0 in 8.127 of 8.127 fleet
|
|
441
|
+
// `orchestrator.session.stopped` records (measured 2026-09-02): the old
|
|
442
|
+
// expression fell back to a hard 0 because the harness never sends
|
|
443
|
+
// `start_ms`. A fabricated 0 is the exact failure class
|
|
444
|
+
// `scripts/lib/telemetry/subagents-schema.mjs:20-35` documents — it reads as
|
|
445
|
+
// a MEASURED zero-length session and is indistinguishable from one. So the
|
|
446
|
+
// span now comes from the session.lock's `started_at`, and BOTH keys are
|
|
447
|
+
// omitted when it cannot be measured.
|
|
448
|
+
const duration = resolveStopDuration(input, projectRoot);
|
|
356
449
|
|
|
357
450
|
// Single emission path: emitEvent writes the canonical {timestamp, event, ...payload}
|
|
358
451
|
// JSONL record AND fires the optional Clank webhook with the SAME event name — no
|
|
@@ -361,14 +454,19 @@ async function handleStop(input) {
|
|
|
361
454
|
// turn-end outcome is joinable by identity from events.jsonl alone. Omitted
|
|
362
455
|
// (never `""`/`null`) when unattested: an unresolved identity stays visibly
|
|
363
456
|
// unresolved rather than becoming a guessed id.
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
457
|
+
// #1183 — a malformed record throws EventValidationError BEFORE any side
|
|
458
|
+
// effect (scripts/lib/events.mjs); this hook must never abort on that, so
|
|
459
|
+
// the emit is wrapped rather than left to propagate.
|
|
460
|
+
try {
|
|
461
|
+
await emitEvent('orchestrator.session.stopped', {
|
|
462
|
+
...(sessionId !== null ? { session_id: sessionId } : {}),
|
|
463
|
+
...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
|
|
464
|
+
wave,
|
|
465
|
+
...(branch !== null ? { branch } : {}),
|
|
466
|
+
...(commit !== null ? { commit } : {}),
|
|
467
|
+
...duration,
|
|
468
|
+
});
|
|
469
|
+
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
372
470
|
}
|
|
373
471
|
|
|
374
472
|
/**
|
|
@@ -380,11 +478,242 @@ async function handleStop(input) {
|
|
|
380
478
|
* @returns {Promise<string|null>} additionalContext or null
|
|
381
479
|
*/
|
|
382
480
|
async function handleSubagentStop(input) {
|
|
383
|
-
|
|
384
|
-
|
|
481
|
+
/** @type {Record<string, unknown>} */
|
|
482
|
+
const payload = {};
|
|
483
|
+
|
|
484
|
+
// `agent` — the #1190 headline fix. The harness sends the EMPTY STRING, which
|
|
485
|
+
// `?? 'unknown'` never caught: 89.991 of 103.763 fleet records (86,7%,
|
|
486
|
+
// measured 2026-09-02) carry `agent: ""`. Trim, and emit the key ONLY when a
|
|
487
|
+
// real value remains — an unmeasured type stays visibly unmeasured rather
|
|
488
|
+
// than becoming `""` or a guessed `'unknown'`.
|
|
489
|
+
//
|
|
490
|
+
// Clamped with the SAME regex as `agent_type_meta` below (Q1-LOW-F3): this is
|
|
491
|
+
// the identical value class from a different source, it lands in the ledger
|
|
492
|
+
// and travels the optional Clank webhook, and it was the only unclamped
|
|
493
|
+
// string in this payload. A mismatch OMITS the key — never a truncation,
|
|
494
|
+
// which would look like a measured (but wrong) agent type.
|
|
495
|
+
const rawAgent = firstNonEmptyString(input, ['agent_type', 'subagent_type']);
|
|
496
|
+
const agent = rawAgent !== null && AGENT_TYPE_META_RE.test(rawAgent) ? rawAgent : null;
|
|
497
|
+
if (agent !== null) payload.agent = agent;
|
|
498
|
+
|
|
499
|
+
// `agent_id` — reader shape copied from hooks/subagent-telemetry.mjs (both
|
|
500
|
+
// naming conventions the harness may use). Charset-guarded before it is ever
|
|
501
|
+
// interpolated into a path (same precedent, resolveSubagentTranscriptPath)
|
|
502
|
+
// AND before it is emitted: an unbounded, unvalidated id (`../../etc/passwd`,
|
|
503
|
+
// or a 10 KB blob) would otherwise land verbatim in the ledger and travel
|
|
504
|
+
// over the optional Clank webhook unredacted. A rejected id is OMITTED — the
|
|
505
|
+
// same omission contract every other field in this payload uses — never
|
|
506
|
+
// sanitised into a different id that looks measured.
|
|
507
|
+
const rawAgentId = firstNonEmptyString(input, ['agent_id', 'subagent_id']);
|
|
508
|
+
const agentId = rawAgentId !== null && AGENT_ID_RE.test(rawAgentId) ? rawAgentId : null;
|
|
509
|
+
if (agentId !== null) payload.agent_id = agentId;
|
|
510
|
+
|
|
511
|
+
// Sidecar-derived fields. Every derivation is individually wrapped: a failure
|
|
512
|
+
// omits its own field and nothing else, and this function must never throw —
|
|
513
|
+
// a throw here would skip the emit and lose the record that already works.
|
|
514
|
+
// Consolidated derivation (#1196): hooks/_lib/subagent-paths.mjs now also
|
|
515
|
+
// rejects the literal 'unknown' agentId — this file's own AGENT_ID_RE-only
|
|
516
|
+
// check (above, for the emitted `agent_id` field) never did.
|
|
517
|
+
const sidecar = resolveSubagentSidecar({ transcriptPath: input?.transcript_path, agentId });
|
|
518
|
+
if (sidecar !== null) {
|
|
519
|
+
const transcriptPath = sidecar.transcript;
|
|
520
|
+
const metaPath = sidecar.meta;
|
|
521
|
+
|
|
522
|
+
let transcriptFound = false;
|
|
523
|
+
try {
|
|
524
|
+
transcriptFound = existsSync(transcriptPath);
|
|
525
|
+
payload.transcript_found = transcriptFound;
|
|
526
|
+
} catch { /* probe failed — omit rather than assert `false` */ }
|
|
527
|
+
|
|
528
|
+
try {
|
|
529
|
+
// One small read, one parse, two fields. `description` is operator prose
|
|
530
|
+
// and is deliberately NOT carried: this payload also travels over the
|
|
531
|
+
// optional Clank webhook unredacted.
|
|
532
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
533
|
+
// Both fields are CHARSET+LENGTH CLAMPED before they enter the payload,
|
|
534
|
+
// for the same reason `agent_id` above is: this record is appended to the
|
|
535
|
+
// ledger AND travels over the optional Clank webhook UNREDACTED, and
|
|
536
|
+
// meta.json is written by the harness, not by us. An oversized or
|
|
537
|
+
// structured value would land verbatim in both. A rejected value is
|
|
538
|
+
// OMITTED, never truncated into a shorter value that still looks measured.
|
|
539
|
+
if (typeof meta?.toolUseId === 'string' && TOOL_USE_ID_RE.test(meta.toolUseId.trim())) {
|
|
540
|
+
payload.tool_use_id = meta.toolUseId.trim();
|
|
541
|
+
}
|
|
542
|
+
// A SECOND witness for the type — never merged into `agent`, so the
|
|
543
|
+
// empty-`agent_type` rate stays measurable.
|
|
544
|
+
if (typeof meta?.agentType === 'string' && AGENT_TYPE_META_RE.test(meta.agentType.trim())) {
|
|
545
|
+
payload.agent_type_meta = meta.agentType.trim();
|
|
546
|
+
}
|
|
547
|
+
} catch { /* absent or corrupt sidecar meta — omit both fields */ }
|
|
548
|
+
|
|
549
|
+
try {
|
|
550
|
+
// meta.json carries no spawn timestamp; the harness writes the file AT
|
|
551
|
+
// spawn, so its birthtime IS the spawn moment. `duration_source` puts
|
|
552
|
+
// that provenance in the record instead of implying a measured span.
|
|
553
|
+
const span = spanFromBirthtime(Date.now(), statSync(metaPath).birthtimeMs);
|
|
554
|
+
if (span !== null) {
|
|
555
|
+
payload.duration_ms = span;
|
|
556
|
+
payload.duration_source = 'meta-birthtime';
|
|
557
|
+
}
|
|
558
|
+
} catch { /* stat failed — omit both keys (never a fabricated 0) */ }
|
|
559
|
+
|
|
560
|
+
if (transcriptFound) {
|
|
561
|
+
const status = readStatusFromTranscriptTail(transcriptPath);
|
|
562
|
+
if (status !== null) payload.status = status;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// #1183 — a malformed record throws EventValidationError BEFORE any side
|
|
567
|
+
// effect (scripts/lib/events.mjs); this hook must never abort on that, so
|
|
568
|
+
// the emit is wrapped rather than left to propagate.
|
|
569
|
+
try {
|
|
570
|
+
await emitEvent('orchestrator.agent.stopped', payload);
|
|
571
|
+
} catch { /* telemetry never blocks the hook (#1183) */ }
|
|
385
572
|
return null;
|
|
386
573
|
}
|
|
387
574
|
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// SubagentStop payload derivations (#1190)
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Harness tool-use ids, measured on-disk 2026-09-02 in a real sidecar meta.json:
|
|
581
|
+
* `toolu_01Xj6qZ3ApsVfomhByNftmxj` (25 chars, alphanumeric + one underscore).
|
|
582
|
+
*/
|
|
583
|
+
const TOOL_USE_ID_RE = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Agent types carry a plugin qualifier, so the COLON is part of the real shape —
|
|
587
|
+
* measured in the same file: `session-orchestrator:code-implementer` (37 chars).
|
|
588
|
+
* A `tool_use_id`-identical charset would have rejected every plugin-qualified
|
|
589
|
+
* agent type in this repo, i.e. silently removed the field it clamps.
|
|
590
|
+
*/
|
|
591
|
+
const AGENT_TYPE_META_RE = /^[A-Za-z0-9_.:-]{1,64}$/;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Upper bound on a `meta-birthtime` span, in ms. NAMED CEILING (BV-004): 7 days.
|
|
595
|
+
*
|
|
596
|
+
* No subagent runs for a week — the longest recorded here is minutes — so a
|
|
597
|
+
* span above this is not a long agent, it is a birthtime the filesystem did not
|
|
598
|
+
* supply. Node documents `birthtimeMs` as unavailable on filesystems that do
|
|
599
|
+
* not record it (overlayfs and some CI images among them), where it surfaces as
|
|
600
|
+
* 0 / 1970-01-01; the old `>= 0` lower bound accepted that verbatim and would
|
|
601
|
+
* ship a ~55-YEAR span stamped `duration_source: 'meta-birthtime'` — a
|
|
602
|
+
* fabricated measurement of exactly the class K5 removed.
|
|
603
|
+
*
|
|
604
|
+
* REVISIT TRIGGER: if a legitimately long-running agent class ever appears
|
|
605
|
+
* (a background agent measured in hours-to-days), raise this — but raise it to
|
|
606
|
+
* a measured bound, never remove it.
|
|
607
|
+
*/
|
|
608
|
+
const META_BIRTHTIME_MAX_SPAN_MS = 7 * 24 * 60 * 60 * 1000;
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Span between a birthtime and now, or `null` when the birthtime is not a
|
|
612
|
+
* usable measurement.
|
|
613
|
+
*
|
|
614
|
+
* Rejects a non-positive `birthtimeMs` (the "filesystem records no birthtime"
|
|
615
|
+
* signal) and any span outside `(0, META_BIRTHTIME_MAX_SPAN_MS)`. `null` means
|
|
616
|
+
* "omit both keys" at the call site — never a fabricated number.
|
|
617
|
+
*
|
|
618
|
+
* Pure, and separate from the call site so the two out-of-band cases are
|
|
619
|
+
* testable without a filesystem that can forge a birthtime.
|
|
620
|
+
*
|
|
621
|
+
* @param {number} nowMs
|
|
622
|
+
* @param {number} birthtimeMs
|
|
623
|
+
* @returns {number|null} rounded span in ms, or null
|
|
624
|
+
*/
|
|
625
|
+
function spanFromBirthtime(nowMs, birthtimeMs) {
|
|
626
|
+
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) return null;
|
|
627
|
+
const span = nowMs - birthtimeMs;
|
|
628
|
+
if (!Number.isFinite(span) || span < 0 || span > META_BIRTHTIME_MAX_SPAN_MS) return null;
|
|
629
|
+
return Math.round(span);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/** Tail window for the STATUS scan — sidecars measured at 113–528 KB. */
|
|
633
|
+
const STATUS_TAIL_BYTES = 64 * 1024;
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* STATUS literals an agent reports at the START of a line (optionally bold).
|
|
637
|
+
*
|
|
638
|
+
* LINE-ANCHORED on purpose — the rationale and the measurement are at
|
|
639
|
+
* `scripts/lib/wave-transcript-tail.mjs:105-112`: a free-floating
|
|
640
|
+
* `/STATUS: partial/` fires on any agent that merely QUOTES the marker
|
|
641
|
+
* (2 of 2 live hits in that measurement were quotations, 0 were reports).
|
|
642
|
+
* NOT imported from that module because the two alternations are DIFFERENT
|
|
643
|
+
* literal sets, not two copies of one: the tailer's `STATUS_RE` matches only
|
|
644
|
+
* `partial|blocked|failed` (it classifies a single `status-partial` attention
|
|
645
|
+
* pattern), while this hook records the agent's SELF-REPORTED status and must
|
|
646
|
+
* therefore also match `done` and `no-tests-needed`. Importing its constant
|
|
647
|
+
* would silently drop the two success literals. (Import cost is NOT the reason —
|
|
648
|
+
* measured 2026-09-02 as equal.)
|
|
649
|
+
*/
|
|
650
|
+
const AGENT_STATUS_RE = /^[ \t*_]*STATUS:\s*(done|partial|blocked|failed|no-tests-needed)\b/im;
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
* Pick the first non-empty trimmed string among `keys` on `input`.
|
|
654
|
+
* @param {object|null|undefined} input
|
|
655
|
+
* @param {string[]} keys
|
|
656
|
+
* @returns {string|null}
|
|
657
|
+
*/
|
|
658
|
+
function firstNonEmptyString(input, keys) {
|
|
659
|
+
for (const k of keys) {
|
|
660
|
+
const v = input?.[k];
|
|
661
|
+
if (typeof v === 'string' && v.trim()) return v.trim();
|
|
662
|
+
}
|
|
663
|
+
return null;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Read the LAST `STATUS_TAIL_BYTES` of an agent transcript and return the most
|
|
668
|
+
* recent line-anchored STATUS literal, or null when none is present.
|
|
669
|
+
*
|
|
670
|
+
* Never reads the whole file: measured sidecar sizes are 113–528 KB and this
|
|
671
|
+
* hook fires on every SubagentStop. Absence means NOT FOUND, never success —
|
|
672
|
+
* measured 2026-09-02, only 1 of 4 live sidecars carried a STATUS token in its
|
|
673
|
+
* last 64 KiB, so the miss is the common path.
|
|
674
|
+
*
|
|
675
|
+
* @param {string} transcriptPath
|
|
676
|
+
* @returns {string|null}
|
|
677
|
+
*/
|
|
678
|
+
function readStatusFromTranscriptTail(transcriptPath) {
|
|
679
|
+
let fd = null;
|
|
680
|
+
try {
|
|
681
|
+
fd = openSync(transcriptPath, 'r');
|
|
682
|
+
const size = fstatSync(fd).size;
|
|
683
|
+
const length = Math.min(size, STATUS_TAIL_BYTES);
|
|
684
|
+
const start = size - length;
|
|
685
|
+
const buf = Buffer.alloc(length);
|
|
686
|
+
const read = readSync(fd, buf, 0, length, start);
|
|
687
|
+
let lines = buf.subarray(0, read).toString('utf8').split('\n');
|
|
688
|
+
// Drop the leading partial line when the window did not start at byte 0.
|
|
689
|
+
if (start > 0) lines = lines.slice(1);
|
|
690
|
+
|
|
691
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
692
|
+
const line = lines[i].trim();
|
|
693
|
+
if (!line) continue;
|
|
694
|
+
let rec;
|
|
695
|
+
try {
|
|
696
|
+
rec = JSON.parse(line);
|
|
697
|
+
} catch { continue; }
|
|
698
|
+
const content = rec?.message?.content;
|
|
699
|
+
if (!Array.isArray(content)) continue;
|
|
700
|
+
for (let j = content.length - 1; j >= 0; j -= 1) {
|
|
701
|
+
const block = content[j];
|
|
702
|
+
if (typeof block?.text !== 'string') continue;
|
|
703
|
+
const m = AGENT_STATUS_RE.exec(block.text);
|
|
704
|
+
if (m) return m[1].toLowerCase();
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return null;
|
|
708
|
+
} catch {
|
|
709
|
+
return null;
|
|
710
|
+
} finally {
|
|
711
|
+
if (fd !== null) {
|
|
712
|
+
try { closeSync(fd); } catch { /* best-effort */ }
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
388
717
|
// ---------------------------------------------------------------------------
|
|
389
718
|
// main
|
|
390
719
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* post-bash-write-verify.mjs — PostToolUse hook (matcher `Bash`): report
|
|
4
|
-
* working-tree changes a Bash call made OUTSIDE the wave's `allowedPaths
|
|
4
|
+
* working-tree changes a Bash call made OUTSIDE the wave's `allowedPaths` —
|
|
5
|
+
* except a path a PEER session declared through a `peer-session-*` record in
|
|
6
|
+
* the wave's aggregate scope sidecar (#1195), which is named as a peer write.
|
|
5
7
|
*
|
|
6
8
|
* ## Why this hook exists (#915, follow-up to #906 / #800)
|
|
7
9
|
*
|
|
@@ -160,7 +162,7 @@ import { findScopeFile, pathMatchesPattern } from '../scripts/lib/hardening.mjs'
|
|
|
160
162
|
// tests/hooks/post-bash-write-verify.test.mjs, which imports the named export —
|
|
161
163
|
// is unchanged. Two byte-identical copies of a clock is exactly the one-fact-two-
|
|
162
164
|
// copies class this repo keeps paying for.
|
|
163
|
-
import { sessionAgeMs } from '../scripts/lib/scope-gate.mjs';
|
|
165
|
+
import { sessionAgeMs, PEER_RECORD_PREFIX, isPeerRecordId } from '../scripts/lib/scope-gate.mjs';
|
|
164
166
|
|
|
165
167
|
// ---------------------------------------------------------------------------
|
|
166
168
|
// Constants
|
|
@@ -322,6 +324,90 @@ export function isInScope(relPath, allowedPaths) {
|
|
|
322
324
|
return allowedPaths.some((p) => typeof p === 'string' && pathMatchesPattern(relPath, p));
|
|
323
325
|
}
|
|
324
326
|
|
|
327
|
+
/**
|
|
328
|
+
* Re-exported for readers of this hook: the prefix that marks an
|
|
329
|
+
* aggregate-sidecar record as a PEER SESSION's declared scope rather than one
|
|
330
|
+
* of this wave's own agents (#1195). The DEFINITION lives in
|
|
331
|
+
* `scripts/lib/scope-gate.mjs`, next to `unionFileScopes`, which must exclude
|
|
332
|
+
* exactly these records from `allowedPaths` for the peer branch below to be
|
|
333
|
+
* reachable at all.
|
|
334
|
+
*/
|
|
335
|
+
export { PEER_RECORD_PREFIX };
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* The peer-session records of a wave's aggregate scope sidecar.
|
|
339
|
+
*
|
|
340
|
+
* Shape, as written by `scripts/materialize-wave-scope.mjs`
|
|
341
|
+
* (`<state-dir>/filescopes/wave-<N>.scopes.json`): ONE JSON array of
|
|
342
|
+
* `{ id, files }` records — the per-agent files under
|
|
343
|
+
* `<state-dir>/filescopes/wave-<N>/<id>.json` are path STRINGS and are a
|
|
344
|
+
* different artefact (see `docs/scope-collision-guard.md` § 2.2). Only records
|
|
345
|
+
* whose `id` starts with `peer-session-` are returned: those are paths a peer
|
|
346
|
+
* session announced and this session's coordinator carried into the manifest
|
|
347
|
+
* union (`skills/wave-executor/wave-loop.md` § Scope Manifest).
|
|
348
|
+
*
|
|
349
|
+
* Absent, unreadable or malformed sidecar ⇒ `[]`, which restores the exact
|
|
350
|
+
* pre-#1195 behaviour. Never throws.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} stateDir absolute path of the harness state dir (the
|
|
353
|
+
* directory holding `wave-scope.json`)
|
|
354
|
+
* @param {unknown} wave the manifest's `wave` field
|
|
355
|
+
* @returns {Array<{ id: string, files: string[] }>}
|
|
356
|
+
*/
|
|
357
|
+
export function readPeerScopeRecords(stateDir, wave) {
|
|
358
|
+
if (typeof stateDir !== 'string' || stateDir === '') return [];
|
|
359
|
+
if (typeof wave !== 'number' || !Number.isInteger(wave) || wave <= 0) return [];
|
|
360
|
+
let parsed;
|
|
361
|
+
try {
|
|
362
|
+
parsed = JSON.parse(
|
|
363
|
+
readFileSync(path.join(stateDir, 'filescopes', `wave-${wave}.scopes.json`), 'utf8'),
|
|
364
|
+
);
|
|
365
|
+
} catch {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
if (!Array.isArray(parsed)) return [];
|
|
369
|
+
return parsed
|
|
370
|
+
.filter(
|
|
371
|
+
(r) => r && typeof r === 'object'
|
|
372
|
+
&& isPeerRecordId(r.id)
|
|
373
|
+
&& Array.isArray(r.files),
|
|
374
|
+
)
|
|
375
|
+
.map((r) => ({ id: r.id, files: r.files.filter((f) => typeof f === 'string') }));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Which peer record — if any — declared this path?
|
|
380
|
+
*
|
|
381
|
+
* Same matcher as `isInScope`, so a peer's declaration is read exactly the way
|
|
382
|
+
* the wave's own `allowedPaths` are.
|
|
383
|
+
*
|
|
384
|
+
* @param {string} relPath
|
|
385
|
+
* @param {Array<{ id: string, files: string[] }>} peerRecords
|
|
386
|
+
* @returns {string|null} the peer record id, or null
|
|
387
|
+
*/
|
|
388
|
+
export function peerRecordFor(relPath, peerRecords) {
|
|
389
|
+
if (!Array.isArray(peerRecords)) return null;
|
|
390
|
+
for (const record of peerRecords) {
|
|
391
|
+
if (record.files.some((p) => pathMatchesPattern(relPath, p))) return record.id;
|
|
392
|
+
}
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Render the peer-write notice (#1195) — deliberately a NOTICE and not part of
|
|
398
|
+
* the violation report: a peer record is EXCLUDED from the union that computes
|
|
399
|
+
* `allowedPaths` (`unionFileScopes`, `scripts/lib/scope-gate.mjs`), so a peer
|
|
400
|
+
* path is outside `allowedPaths` and reaches this branch; naming it as a
|
|
401
|
+
* violation would re-file an agreed peer write as an alarm.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} relPath
|
|
404
|
+
* @param {string} peerId the `peer-session-<id>` record id
|
|
405
|
+
* @returns {string}
|
|
406
|
+
*/
|
|
407
|
+
export function formatPeerWriteNotice(relPath, peerId) {
|
|
408
|
+
return `bash-write-verify: ${relPath} inside ${peerId} scope — peer write, not a violation`;
|
|
409
|
+
}
|
|
410
|
+
|
|
325
411
|
/**
|
|
326
412
|
* Stable signature of the wave's scope. A change means a new wave, which must
|
|
327
413
|
* re-baseline silently rather than blame the next Bash call for the previous
|
|
@@ -915,8 +1001,22 @@ async function main() {
|
|
|
915
1001
|
// first run is named, not silently trusted. Computed before the G4/G5 gate
|
|
916
1002
|
// above so the gate cannot swallow it.
|
|
917
1003
|
if (missingSnapshotNotice) messages.push(missingSnapshotNotice);
|
|
918
|
-
|
|
919
|
-
|
|
1004
|
+
// #1195 — a path a PEER session declared (a `peer-session-*` record in this
|
|
1005
|
+
// wave's aggregate sidecar) is outside `allowedPaths` because `--union`
|
|
1006
|
+
// EXCLUDES peer records when computing it (`unionFileScopes`,
|
|
1007
|
+
// `scripts/lib/scope-gate.mjs`) — that exclusion is what keeps this branch
|
|
1008
|
+
// reachable. Such a write is agreed, not a bypass. Split it out of the violation report and
|
|
1009
|
+
// name it, so the peer's file is countable without being an alarm. Sidecar
|
|
1010
|
+
// absent ⇒ `peerRecords` is empty ⇒ every path stays a violation, unchanged.
|
|
1011
|
+
const peerRecords = readPeerScopeRecords(path.dirname(scopePath), scope.wave);
|
|
1012
|
+
const violations = [];
|
|
1013
|
+
for (const relPath of report) {
|
|
1014
|
+
const peerId = peerRecordFor(relPath, peerRecords);
|
|
1015
|
+
if (peerId) messages.push(formatPeerWriteNotice(relPath, peerId));
|
|
1016
|
+
else violations.push(relPath);
|
|
1017
|
+
}
|
|
1018
|
+
if (violations.length > 0) {
|
|
1019
|
+
messages.push(formatMessage(violations, allowedPaths.length));
|
|
920
1020
|
warn = true;
|
|
921
1021
|
}
|
|
922
1022
|
if (messages.length === 0) return; // silence is the common case (0.91 % fire rate)
|