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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. 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
- * SubagentStop: {"timestamp":<ISO>,"event":"orchestrator.agent.stopped","agent":"<name>"}
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 { promises as fs, statSync, writeFileSync } from 'node:fs';
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
- let sessionId = parseSessionId(fromStdin)?.format === 'uuid' ? fromStdin : null;
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
- const isRecordedSession = sessionId !== null && sessionId === recordedId;
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
- // duration_ms: if input provides a start time we compute from it, else 0
354
- const durationMs =
355
- typeof input?.start_ms === 'number' ? Date.now() - input.start_ms : 0;
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
- await emitEvent('orchestrator.session.stopped', {
365
- ...(sessionId !== null ? { session_id: sessionId } : {}),
366
- ...(semanticSessionId !== null ? { semantic_session_id: semanticSessionId } : {}),
367
- wave,
368
- ...(branch !== null ? { branch } : {}),
369
- ...(commit !== null ? { commit } : {}),
370
- duration_ms: durationMs,
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
- const agent = input?.agent_type ?? 'unknown';
384
- await emitEvent('orchestrator.agent.stopped', { agent });
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
- if (report.length > 0) {
919
- messages.push(formatMessage(report, allowedPaths.length));
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)