session-orchestrator 3.22.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 (316) 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/commands/autopilot-multi.md +14 -0
  5. package/.cursor/commands/autopilot.md +14 -0
  6. package/.cursor/commands/bootstrap.md +14 -0
  7. package/.cursor/commands/brainstorm.md +14 -0
  8. package/.cursor/commands/close.md +13 -0
  9. package/.cursor/commands/contract-version-bump.md +14 -0
  10. package/.cursor/commands/debug.md +14 -0
  11. package/.cursor/commands/discovery.md +14 -0
  12. package/.cursor/commands/dispatcher.md +14 -0
  13. package/.cursor/commands/eli5.md +14 -0
  14. package/.cursor/commands/eval.md +14 -0
  15. package/.cursor/commands/evolve.md +14 -0
  16. package/.cursor/commands/go.md +14 -0
  17. package/.cursor/commands/grill.md +14 -0
  18. package/.cursor/commands/harness-audit.md +13 -0
  19. package/.cursor/commands/journey-audit.md +14 -0
  20. package/.cursor/commands/memory-cleanup.md +14 -0
  21. package/.cursor/commands/persona-panel.md +14 -0
  22. package/.cursor/commands/plan.md +14 -0
  23. package/.cursor/commands/portfolio.md +14 -0
  24. package/.cursor/commands/reconcile.md +14 -0
  25. package/.cursor/commands/release.md +14 -0
  26. package/.cursor/commands/repo-audit.md +13 -0
  27. package/.cursor/commands/session.md +14 -0
  28. package/.cursor/commands/spinout.md +14 -0
  29. package/.cursor/commands/sunset-review.md +14 -0
  30. package/.cursor/commands/templates-ack.md +14 -0
  31. package/.cursor/commands/test.md +14 -0
  32. package/.cursor/hooks.json +60 -0
  33. package/.cursor/rules/000-session-orchestrator.mdc +8 -0
  34. package/.cursor/rules/010-session-workflow.mdc +9 -1
  35. package/.cursor/rules/020-quality-gates.mdc +1 -1
  36. package/.cursor/rules/030-wave-execution.mdc +1 -1
  37. package/.cursor/rules/050-plan.mdc +2 -2
  38. package/.cursor/rules/070-gitlab-ops.mdc +73 -57
  39. package/.cursor/rules/080-ecosystem-health.mdc +7 -7
  40. package/.cursor/skills/architecture/SKILL.md +13 -0
  41. package/.cursor/skills/autopilot/SKILL.md +12 -0
  42. package/.cursor/skills/bootstrap/SKILL.md +12 -0
  43. package/.cursor/skills/brainstorm/SKILL.md +13 -0
  44. package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
  45. package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
  46. package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
  47. package/.cursor/skills/daily/SKILL.md +12 -0
  48. package/.cursor/skills/debug/SKILL.md +13 -0
  49. package/.cursor/skills/discovery/SKILL.md +13 -0
  50. package/.cursor/skills/dispatcher/SKILL.md +13 -0
  51. package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
  52. package/.cursor/skills/domain-model/SKILL.md +13 -0
  53. package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
  54. package/.cursor/skills/eli5/SKILL.md +13 -0
  55. package/.cursor/skills/eval/SKILL.md +12 -0
  56. package/.cursor/skills/evolve/SKILL.md +13 -0
  57. package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
  58. package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
  59. package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
  60. package/.cursor/skills/grill/SKILL.md +13 -0
  61. package/.cursor/skills/hook-development/SKILL.md +13 -0
  62. package/.cursor/skills/journey-audit/SKILL.md +13 -0
  63. package/.cursor/skills/mcp-builder/SKILL.md +13 -0
  64. package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
  65. package/.cursor/skills/mode-selector/SKILL.md +13 -0
  66. package/.cursor/skills/npm-publish/SKILL.md +12 -0
  67. package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
  68. package/.cursor/skills/persona-panel/SKILL.md +12 -0
  69. package/.cursor/skills/plan/SKILL.md +13 -0
  70. package/.cursor/skills/playwright-driver/SKILL.md +13 -0
  71. package/.cursor/skills/quality-gates/SKILL.md +13 -0
  72. package/.cursor/skills/reconcile/SKILL.md +12 -0
  73. package/.cursor/skills/remote-offload/SKILL.md +13 -0
  74. package/.cursor/skills/repo-audit/SKILL.md +13 -0
  75. package/.cursor/skills/session-end/SKILL.md +13 -0
  76. package/.cursor/skills/session-plan/SKILL.md +13 -0
  77. package/.cursor/skills/session-start/SKILL.md +13 -0
  78. package/.cursor/skills/skill-creator/SKILL.md +13 -0
  79. package/.cursor/skills/spinout/SKILL.md +12 -0
  80. package/.cursor/skills/sunset-review/SKILL.md +13 -0
  81. package/.cursor/skills/test-runner/SKILL.md +13 -0
  82. package/.cursor/skills/tmux-layout/SKILL.md +13 -0
  83. package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
  84. package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
  85. package/.cursor/skills/vault-mirror/SKILL.md +13 -0
  86. package/.cursor/skills/vault-sync/SKILL.md +13 -0
  87. package/.cursor/skills/wave-executor/SKILL.md +13 -0
  88. package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
  89. package/.mcp.json +4 -1
  90. package/CHANGELOG.md +446 -0
  91. package/README.md +22 -17
  92. package/agents/AGENTS.md +23 -4
  93. package/agents/code-implementer.md +2 -1
  94. package/agents/db-specialist.md +2 -2
  95. package/agents/docs-writer.md +3 -1
  96. package/agents/eval-judge.md +1 -1
  97. package/agents/session-reviewer.md +7 -1
  98. package/agents/test-writer.md +2 -1
  99. package/agents/ui-developer.md +2 -1
  100. package/commands/bootstrap.md +2 -2
  101. package/commands/close.md +3 -1
  102. package/commands/go.md +1 -1
  103. package/commands/journey-audit.md +43 -0
  104. package/docs/USER-GUIDE.md +2 -2
  105. package/docs/ci-setup.md +194 -25
  106. package/docs/codex-setup.md +64 -0
  107. package/docs/components.md +7 -7
  108. package/docs/cursor-setup.md +26 -47
  109. package/docs/events-schema.md +120 -10
  110. package/docs/github-mirror-protection.md +197 -0
  111. package/docs/pi-setup.md +2 -0
  112. package/docs/rule-authoring.md +3 -1
  113. package/docs/scope-collision-guard.md +49 -2
  114. package/docs/session-config-reference.md +89 -9
  115. package/docs/session-config-template.md +38 -7
  116. package/docs/telemetry/telemetry-claims.md +11 -10
  117. package/docs/telemetry.md +52 -1
  118. package/hooks/_lib/atomic-json.mjs +111 -0
  119. package/hooks/_lib/lock-bootstrap.mjs +8 -4
  120. package/hooks/_lib/subagent-paths.mjs +143 -0
  121. package/hooks/_lib/vcs-create-matcher.mjs +397 -38
  122. package/hooks/cwd-change-restore.mjs +9 -29
  123. package/hooks/enforce-scope.mjs +93 -0
  124. package/hooks/hooks-codex.json +1 -1
  125. package/hooks/hooks-cursor.json +201 -20
  126. package/hooks/hooks-pi.json +1 -1
  127. package/hooks/hooks.json +2 -2
  128. package/hooks/on-session-end.mjs +486 -19
  129. package/hooks/on-session-start.mjs +263 -12
  130. package/hooks/on-stop.mjs +392 -24
  131. package/hooks/post-bash-write-verify.mjs +104 -4
  132. package/hooks/post-subagent-discovery-validator.mjs +182 -21
  133. package/hooks/post-tool-batch-wave-signal.mjs +165 -42
  134. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  135. package/hooks/pre-bash-issue-budget.mjs +117 -4
  136. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  137. package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
  138. package/hooks/pre-bash-staging-fence.mjs +4 -0
  139. package/hooks/pre-task-scope-disjoint.mjs +368 -35
  140. package/hooks/skill-invocation-telemetry.mjs +21 -10
  141. package/hooks/subagent-telemetry.mjs +11 -26
  142. package/monitors/monitors.json +6 -0
  143. package/package.json +1 -1
  144. package/pi/prompts/journey-audit.md +12 -0
  145. package/rules/_index.md +9 -1
  146. package/rules/always-on/ask-via-tool.md +62 -0
  147. package/rules/always-on/bash-harness-pitfalls.md +168 -0
  148. package/rules/always-on/build-value.md +47 -0
  149. package/rules/always-on/cross-session-messaging.md +59 -0
  150. package/rules/always-on/loop-and-monitor.md +221 -0
  151. package/rules/always-on/parallel-sessions.md +142 -12
  152. package/rules/always-on/receiving-review.md +108 -0
  153. package/rules/always-on/test-value.md +40 -0
  154. package/rules/always-on/verification-before-completion.md +77 -0
  155. package/scripts/archive-closed-prds.mjs +258 -18
  156. package/scripts/autopilot.mjs +31 -12
  157. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  158. package/scripts/backfill-evidence-digest.mjs +376 -0
  159. package/scripts/cursor-install.mjs +89 -48
  160. package/scripts/emit-event.mjs +10 -2
  161. package/scripts/export-hw-learnings.mjs +143 -2
  162. package/scripts/express-path.mjs +299 -0
  163. package/scripts/generate-cursor-adapter.mjs +253 -0
  164. package/scripts/github-protection-audit.mjs +358 -0
  165. package/scripts/lib/auq/parse.mjs +5 -29
  166. package/scripts/lib/auto-dialectic.mjs +68 -0
  167. package/scripts/lib/autopilot/worktree-pipeline.mjs +318 -18
  168. package/scripts/lib/build-live-signals.mjs +49 -27
  169. package/scripts/lib/ci-status-banner.mjs +158 -11
  170. package/scripts/lib/cold-start-detector.mjs +23 -14
  171. package/scripts/lib/command-blocker.mjs +70 -0
  172. package/scripts/lib/config/block-header.mjs +55 -0
  173. package/scripts/lib/config/discovery-validator.mjs +7 -2
  174. package/scripts/lib/config/health-endpoints.mjs +383 -0
  175. package/scripts/lib/config/reconcile.mjs +79 -4
  176. package/scripts/lib/config/remote-hosts.mjs +233 -0
  177. package/scripts/lib/config/section-extractor.mjs +235 -36
  178. package/scripts/lib/config-schema.mjs +9 -1
  179. package/scripts/lib/config.mjs +87 -8
  180. package/scripts/lib/convergence-monitor.mjs +13 -2
  181. package/scripts/lib/cursor-hook-bridge.mjs +443 -0
  182. package/scripts/lib/dispatcher/cli.mjs +2 -2
  183. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  184. package/scripts/lib/events-schema.mjs +48 -0
  185. package/scripts/lib/events.mjs +238 -5
  186. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  187. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  188. package/scripts/lib/express-path.mjs +327 -0
  189. package/scripts/lib/file-lock.mjs +22 -4
  190. package/scripts/lib/gates/gate-full.mjs +81 -8
  191. package/scripts/lib/gates/gate-helpers.mjs +76 -15
  192. package/scripts/lib/git-config-drift.mjs +134 -5
  193. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  194. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  195. package/scripts/lib/host-identity.mjs +247 -2
  196. package/scripts/lib/instruction-budget-guard.mjs +31 -1
  197. package/scripts/lib/issue-budget.mjs +229 -30
  198. package/scripts/lib/learnings/io.mjs +55 -10
  199. package/scripts/lib/learnings/schema.mjs +95 -28
  200. package/scripts/lib/lock-reaper.mjs +7 -1
  201. package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
  202. package/scripts/lib/locks/state-md-lock.mjs +8 -1
  203. package/scripts/lib/memory-banner.mjs +25 -10
  204. package/scripts/lib/memory-paths.mjs +15 -6
  205. package/scripts/lib/mode-selector/scoring.mjs +53 -6
  206. package/scripts/lib/peer-discovery.mjs +20 -2
  207. package/scripts/lib/platform.mjs +72 -9
  208. package/scripts/lib/plugin-root.mjs +143 -19
  209. package/scripts/lib/project-hygiene.mjs +43 -3
  210. package/scripts/lib/quality-gate.mjs +271 -13
  211. package/scripts/lib/reconcile/emitter.mjs +87 -19
  212. package/scripts/lib/reconcile/engine.mjs +517 -18
  213. package/scripts/lib/reconcile/idempotency.mjs +102 -1
  214. package/scripts/lib/reconcile/renderer.mjs +148 -3
  215. package/scripts/lib/reconcile/sanitize.mjs +40 -17
  216. package/scripts/lib/reconcile/writer.mjs +415 -84
  217. package/scripts/lib/rule-loader.mjs +37 -2
  218. package/scripts/lib/rules-sync.mjs +51 -8
  219. package/scripts/lib/scope-gate.mjs +126 -0
  220. package/scripts/lib/session-close-backfill.mjs +427 -37
  221. package/scripts/lib/session-discovery.mjs +69 -5
  222. package/scripts/lib/session-end/phase-skip.mjs +38 -5
  223. package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
  224. package/scripts/lib/session-id.mjs +30 -14
  225. package/scripts/lib/session-identity/own-session.mjs +220 -0
  226. package/scripts/lib/session-lock.mjs +85 -30
  227. package/scripts/lib/session-schema/normalizer.mjs +70 -3
  228. package/scripts/lib/session-schema/validator.mjs +40 -0
  229. package/scripts/lib/session-start-probes.mjs +608 -0
  230. package/scripts/lib/session-transition.mjs +277 -0
  231. package/scripts/lib/sessions-canonical.mjs +446 -0
  232. package/scripts/lib/sessions-staleness-banner.mjs +124 -57
  233. package/scripts/lib/spiral-carryover.mjs +90 -9
  234. package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
  235. package/scripts/lib/state-md/mission-status.mjs +350 -52
  236. package/scripts/lib/state-md/yaml-parser.mjs +145 -16
  237. package/scripts/lib/state-md.mjs +12 -2
  238. package/scripts/lib/telemetry/schema.mjs +74 -8
  239. package/scripts/lib/telemetry/sync.mjs +91 -16
  240. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  241. package/scripts/lib/validate/check-agents.mjs +66 -0
  242. package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
  243. package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
  244. package/scripts/lib/validate/check-doc-cli-commands.mjs +25 -65
  245. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  246. package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
  247. package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
  248. package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
  249. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  250. package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
  251. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  252. package/scripts/lib/validate/check-unwired-features.mjs +333 -32
  253. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  254. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  255. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  256. package/scripts/lib/validate/repo-files.mjs +275 -0
  257. package/scripts/lib/validate-vendored-rules.mjs +229 -7
  258. package/scripts/lib/vault-mirror/process.mjs +99 -43
  259. package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
  260. package/scripts/lib/vault-staleness-banner.mjs +76 -6
  261. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  262. package/scripts/lib/vault-status/board-writer.mjs +381 -141
  263. package/scripts/lib/vault-status/narrative-mirror.mjs +190 -27
  264. package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
  265. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  266. package/scripts/lib/wave-resource-gate.mjs +127 -7
  267. package/scripts/lib/wave-transcript-tail.mjs +889 -0
  268. package/scripts/materialize-wave-scope.mjs +228 -15
  269. package/scripts/mcp-server.sh +11 -2
  270. package/scripts/memory-propose.mjs +132 -8
  271. package/scripts/parse-config.mjs +65 -0
  272. package/scripts/promote-vault-strict.mjs +4 -15
  273. package/scripts/site-numbers.mjs +36 -4
  274. package/scripts/token-audit.sh +9 -2
  275. package/scripts/validate-plugin.mjs +29 -0
  276. package/scripts/validate-wave-scope.mjs +67 -0
  277. package/scripts/vault-consolidate.mjs +3 -11
  278. package/scripts/vault-integration-watcher.mjs +2 -4
  279. package/scripts/vault-mirror.mjs +305 -51
  280. package/skills/_shared/monitor-patterns.md +31 -5
  281. package/skills/_shared/parallel-aware-auq.md +31 -2
  282. package/skills/_shared/parallel-aware-preamble.md +19 -4
  283. package/skills/_shared/platform-tools.md +11 -5
  284. package/skills/_shared/state-ownership.md +29 -2
  285. package/skills/autopilot/SKILL.md +5 -1
  286. package/skills/bootstrap/SKILL.md +3 -3
  287. package/skills/bootstrap/_shared-template.md +18 -10
  288. package/skills/bootstrap/deep-template.md +10 -6
  289. package/skills/bootstrap/fast-template.md +15 -8
  290. package/skills/bootstrap/standard-template.md +10 -6
  291. package/skills/claude-md-drift-check/checker.mjs +39 -11
  292. package/skills/contract-version-bump/SKILL.md +1 -1
  293. package/skills/dispatcher/SKILL.md +1 -1
  294. package/skills/ecosystem-health/SKILL.md +4 -1
  295. package/skills/ecosystem-health/wizard.md +5 -0
  296. package/skills/evolve/SKILL.md +38 -1
  297. package/skills/journey-audit/SKILL.md +270 -0
  298. package/skills/peekaboo-driver/SKILL.md +15 -3
  299. package/skills/persona-panel/SKILL.md +1 -1
  300. package/skills/reconcile/SKILL.md +46 -3
  301. package/skills/remote-offload/SKILL.md +89 -0
  302. package/skills/session-end/SKILL.md +17 -4
  303. package/skills/session-end/metrics-collection.md +7 -4
  304. package/skills/session-end/phase-3-6-tail.md +20 -9
  305. package/skills/session-end/phase-3-7a-recommendations.md +16 -2
  306. package/skills/session-plan/SKILL.md +6 -1
  307. package/skills/session-plan/wave-template.md +1 -0
  308. package/skills/session-start/SKILL.md +54 -17
  309. package/skills/session-start/phase-7-5-mode-selector.md +15 -3
  310. package/skills/session-start/phase-8-5-express-path.md +77 -12
  311. package/skills/vault-sync/validator.mjs +31 -0
  312. package/skills/wave-executor/SKILL.md +5 -3
  313. package/skills/wave-executor/circuit-breaker.md +34 -9
  314. package/skills/wave-executor/wave-loop.md +143 -22
  315. package/templates/_shared/journey-manifest.md +110 -0
  316. package/templates/_shared/rules/parallel-sessions.md +0 -77
@@ -0,0 +1,889 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * wave-transcript-tail.mjs — FA-1 wave supervision (#1114).
4
+ *
5
+ * Tails the LIVE subagent transcripts of the OWN session and brings the
6
+ * existing `stagnation_detected` event to fire DURING a wave, instead of only
7
+ * after it from the coordinator's post-wave review (see
8
+ * `skills/wave-executor/circuit-breaker.md`: *"these checks run … after the
9
+ * wave completes — not during the agent's execution"*).
10
+ *
11
+ * Substrate (measured 2026-08-25):
12
+ * ~/.claude/projects/<encoded-repo-path>/<session-uuid>/subagents/
13
+ * agent-<id>.jsonl — append-only NDJSON, flushed PER TURN
14
+ * agent-<id>.meta.json — {"agentType","description","toolUseId",…}
15
+ * The `subagents/` directory does not exist until the first spawn, so the
16
+ * tailer POLLS for it to appear rather than exiting on ENOENT.
17
+ *
18
+ * Three detectors (PRD docs/prd/2026-08-22-wellen-supervision.md § FA-1):
19
+ * - psa007-git-write — a subagent ran a git INDEX/HISTORY write (PSA-007).
20
+ * - error-echo — 3x the same error class on the same file.
21
+ * - status-partial — the agent wrote STATUS: partial|blocked|failed.
22
+ *
23
+ * Fail-open by construction: every failure path writes ONE stderr line and
24
+ * keeps polling (or exits 0). No agent is ever blocked — the tailer is an
25
+ * out-of-process observer with no channel back into the wave.
26
+ *
27
+ * Multi-session safety: only `<own-session-uuid>/subagents/` is read. A peer
28
+ * session's transcripts in the same projects directory are never touched.
29
+ *
30
+ * Single instance per working copy: the monitor starts on
31
+ * `on-skill-invoke:wave-executor`, which a deep session triggers repeatedly, so
32
+ * a second tailer would double every record. It claims a PID lockfile at
33
+ * `.orchestrator/wave-transcript-tail.lock` and exits 0 when another live
34
+ * tailer holds it.
35
+ *
36
+ * Flags:
37
+ * --tail Run the tail loop (required; no other mode supported).
38
+ * --interval=N Poll cadence in seconds (default 2).
39
+ * --help, -h Print usage to stderr and exit 0.
40
+ *
41
+ * Exit codes:
42
+ * 0 — clean shutdown (SIGTERM/SIGINT), or fail-open give-up.
43
+ * 1 — user/input error (unknown flag, --tail missing).
44
+ * 2 — system error inside the tail loop.
45
+ */
46
+
47
+ import {
48
+ existsSync,
49
+ statSync,
50
+ openSync,
51
+ readSync,
52
+ closeSync,
53
+ readFileSync,
54
+ readdirSync,
55
+ } from 'node:fs';
56
+ import { homedir } from 'node:os';
57
+ import { join, relative, isAbsolute } from 'node:path';
58
+ import { pathToFileURL } from 'node:url';
59
+
60
+ import { emitEvent, sessionAttribution } from './events.mjs';
61
+ import { tryAcquireFileLock, releaseFileLock } from './file-lock.mjs';
62
+ import { readLock, isLockLive } from './session-lock.mjs';
63
+ import { resolveSubagentSidecar } from '../../hooks/_lib/subagent-paths.mjs';
64
+
65
+ const DEFAULT_INTERVAL_S = 2;
66
+ const EVENTS_FILE_REL = '.orchestrator/metrics/events.jsonl';
67
+ const WAVE_SCOPE_REL = '.claude/wave-scope.json';
68
+
69
+ /**
70
+ * Single-instance guard. `monitors/monitors.json` starts this tailer on
71
+ * `on-skill-invoke:wave-executor`, and a deep session invokes wave-executor
72
+ * repeatedly — so without a guard N tailers run concurrently, each seeds its
73
+ * counters from byte 0, and each emits the same finding.
74
+ *
75
+ * Measured 2026-08-25 in this repo's own ledger: 4 of 5 `stagnation_detected`
76
+ * records were exact duplicates in two pairs, 6 ms and 8 ms apart.
77
+ */
78
+ const SINGLETON_LOCK_REL = '.orchestrator/wave-transcript-tail.lock';
79
+
80
+ /**
81
+ * Occurrence threshold at which a pattern first emits. `error-echo` needs the
82
+ * documented 3 repetitions (circuit-breaker.md § Decision Table); the other two
83
+ * are single-shot facts — one `git commit` is already the PSA-007 violation.
84
+ */
85
+ const EMIT_THRESHOLD = {
86
+ 'psa007-git-write': 1,
87
+ 'error-echo': 3,
88
+ 'status-partial': 1,
89
+ };
90
+
91
+ /**
92
+ * Monitor output is rate-limited, so `occurrences` is an AGGREGATION WINDOW:
93
+ * after the first emit a key re-emits only every N further hits.
94
+ */
95
+ const RE_EMIT_EVERY = 10;
96
+
97
+ /**
98
+ * git subcommands that mutate the shared index, the stash stack, or remote
99
+ * history — the PSA-007 prohibition list, verbatim.
100
+ */
101
+ const GIT_WRITE_RE = /^\s*git\s+(?:-[^\s]+\s+)*(add|commit|stash|push|mv|rm|reset|checkout\s+--)\b/;
102
+
103
+ /**
104
+ * STATUS literals that all report as pattern `status-partial`.
105
+ *
106
+ * LINE-ANCHORED on purpose. A free-floating `/STATUS: partial/` fires on any
107
+ * agent that merely QUOTES the marker — measured 2026-08-25 against this
108
+ * session's own transcripts: 2 of 2 live hits were Explore agents citing the
109
+ * PRD's acceptance criteria and describing this very detector, 0 were real
110
+ * status reports. Agents report the marker at the start of its own line
111
+ * (optionally bold); quotations sit mid-sentence or inside backticks.
112
+ */
113
+ const STATUS_RE = /^[ \t*_]*STATUS:\s*(partial|blocked|failed)\b/im;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Pure helpers
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Encode a repo path the way Claude Code names its projects directory:
121
+ * every `/` and `.` becomes `-`.
122
+ *
123
+ * Ceiling: derived from the observed encoding of paths without unusual
124
+ * characters (`-Users-…-session-orchestrator`). Revisit if a repo path with
125
+ * spaces or non-ASCII characters ever fails to resolve.
126
+ *
127
+ * @param {string} repoRoot
128
+ * @returns {string}
129
+ */
130
+ export function encodeProjectDir(repoRoot) {
131
+ return String(repoRoot).replace(/[/.]/g, '-');
132
+ }
133
+
134
+ /** @param {unknown} v @returns {string} */
135
+ function trimmedString(v) {
136
+ return typeof v === 'string' ? v.trim() : '';
137
+ }
138
+
139
+ /**
140
+ * Resolve the TWO session identities this tailer needs. They are different
141
+ * strings and neither substitutes for the other:
142
+ *
143
+ * - `sessionId` — the harness UUID. Names the transcript DIRECTORY
144
+ * (`<projects>/<encoded-repo>/<uuid>/subagents/`) and the `session_id`
145
+ * binding in `.claude/wave-scope.json`.
146
+ * - `semanticSessionId` — the id both consumers of `stagnation_detected`
147
+ * JOIN on: `scripts/compute-grounding-injection.sh` intersects `.session`
148
+ * against the `session_id`s of `.orchestrator/metrics/sessions.jsonl`, and
149
+ * `skills/session-end/metrics-collection.md` filters `.session == $sid`.
150
+ * Both of those are SEMANTIC ids (`main-2026-08-24-session-1`), so a record
151
+ * whose `session` carries the UUID joins with nothing and the feature
152
+ * measures as zero records — indistinguishable from the pre-#1114 dead
153
+ * state. Measured 2026-08-25: all 5 ledger records carried the UUID.
154
+ *
155
+ * Precedence for `sessionId`: the harness env var, then a LIVE session lock,
156
+ * then the newest session directory by mtime. Precedence for
157
+ * `semanticSessionId`: `sessionAttribution()` (the same field `report()` emits
158
+ * as `semantic_session_id`, so the two can never disagree), then a live lock,
159
+ * then the raw id with a stderr note.
160
+ *
161
+ * The lock tier is liveness-gated (`isLockLive`, heartbeat-based). A STALE lock
162
+ * names a session whose transcript directory stopped growing, so trusting it
163
+ * makes the tailer supervise nothing forever after one startup line — it falls
164
+ * through to the mtime probe instead.
165
+ *
166
+ * Returns null when no raw id resolves — the caller then fails open with a
167
+ * single stderr line.
168
+ *
169
+ * @param {object} opts
170
+ * @param {string} opts.repoRoot
171
+ * @param {Record<string, string|undefined>} [opts.env]
172
+ * @param {string} [opts.projectsDir] — `<projects>/<encoded-repo>` directory.
173
+ * @returns {{ sessionId: string, source: string, semanticSessionId: string, semanticSource: string } | null}
174
+ */
175
+ export function resolveSessionId({ repoRoot, env = process.env, projectsDir }) {
176
+ let lock = null;
177
+ try {
178
+ lock = readLock({ repoRoot });
179
+ } catch {
180
+ // Unreadable lock — `lock` stays null and every lock tier below is skipped.
181
+ }
182
+ const liveLock = lock && isLockLive(lock) ? lock : null;
183
+
184
+ /** @type {{sessionId: string, source: string} | null} */
185
+ let raw = null;
186
+ const fromEnv = trimmedString(env.CLAUDE_CODE_SESSION_ID);
187
+ if (fromEnv) {
188
+ raw = { sessionId: fromEnv, source: 'env' };
189
+ } else if (trimmedString(liveLock?.session_id)) {
190
+ raw = { sessionId: trimmedString(liveLock.session_id), source: 'session.lock' };
191
+ } else if (projectsDir && existsSync(projectsDir)) {
192
+ try {
193
+ const newest = readdirSync(projectsDir, { withFileTypes: true })
194
+ .filter((d) => d.isDirectory())
195
+ .map((d) => ({ name: d.name, mtime: statSync(join(projectsDir, d.name)).mtimeMs }))
196
+ .sort((a, b) => b.mtime - a.mtime)[0];
197
+ if (newest) raw = { sessionId: newest.name, source: 'newest-mtime' };
198
+ } catch {
199
+ // Unreadable projects dir — nothing left to try.
200
+ }
201
+ }
202
+ if (!raw) return null;
203
+
204
+ let semanticSessionId = '';
205
+ let semanticSource = '';
206
+ try {
207
+ semanticSessionId = trimmedString(sessionAttribution(repoRoot).semantic_session_id);
208
+ if (semanticSessionId) semanticSource = 'session-attribution';
209
+ } catch {
210
+ // Attribution unavailable — `semanticSessionId` stays '' and the lock tier runs.
211
+ }
212
+ if (!semanticSessionId && trimmedString(liveLock?.semantic_session_id)) {
213
+ semanticSessionId = trimmedString(liveLock.semantic_session_id);
214
+ semanticSource = 'session.lock';
215
+ }
216
+ if (!semanticSessionId) {
217
+ // Honest degradation: the raw id is a real identifier, just not the one the
218
+ // two consumers join on — so the record is still written, and the note says
219
+ // why it will not appear in a per-session roll-up.
220
+ semanticSessionId = raw.sessionId;
221
+ semanticSource = 'raw-fallback';
222
+ note(`no semantic session id resolvable — 'session' falls back to the raw id ${raw.sessionId}`);
223
+ }
224
+
225
+ return { ...raw, semanticSessionId, semanticSource };
226
+ }
227
+
228
+ /**
229
+ * Read the wave number, but ONLY when the scope file proves it belongs to this
230
+ * session. `.claude/wave-scope.json` binds to the WORKING COPY, not to a
231
+ * session (the #1082 class), so an unbound file is reported as `null` rather
232
+ * than attributed to whoever happens to be tailing.
233
+ *
234
+ * @param {string} repoRoot
235
+ * @param {string} sessionId
236
+ * @returns {number | null}
237
+ */
238
+ export function readWaveNumber(repoRoot, sessionId) {
239
+ try {
240
+ const p = join(repoRoot, WAVE_SCOPE_REL);
241
+ if (!existsSync(p)) return null;
242
+ const scope = JSON.parse(readFileSync(p, 'utf8'));
243
+ const bound = scope?.session_id ?? scope?.session ?? scope?.sessionId;
244
+ if (typeof bound !== 'string' || bound !== sessionId) return null;
245
+ const wave = scope?.wave ?? scope?.wave_number;
246
+ return typeof wave === 'number' && Number.isFinite(wave) ? Math.trunc(wave) : null;
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+
252
+ /**
253
+ * Map an error message onto the taxonomy in `circuit-breaker.md`
254
+ * § Error-Class Taxonomy.
255
+ *
256
+ * @param {string} text
257
+ * @returns {'edit-format-friction'|'scope-denied'|'command-blocked'|'other'}
258
+ */
259
+ export function classifyErrorClass(text) {
260
+ const t = String(text || '');
261
+ if (/String to replace not found|old_string|not unique|whitespace/i.test(t)) {
262
+ return 'edit-format-friction';
263
+ }
264
+ if (/scope[- ]violation|outside .{0,20}file scope|not in .{0,20}allowedPaths|allowedPaths/i.test(t)) {
265
+ return 'scope-denied';
266
+ }
267
+ if (/blocked[- ]command|destructive[- ]command|permissionDecision|command is blocked/i.test(t)) {
268
+ return 'command-blocked';
269
+ }
270
+ return 'other';
271
+ }
272
+
273
+ /**
274
+ * A git write inside a THROWAWAY fixture repo is not a PSA-007 breach — the
275
+ * shared index PSA-007 protects is this working copy's, and a `mktemp -d`
276
+ * scratch repo has its own. Measured 2026-08-25: the first live hit of this
277
+ * detector was a sibling agent running `cd "$(mktemp -d)" … git add seed.txt`
278
+ * to build a test fixture. Fixture seeding is routine in a test wave, so
279
+ * without this the signal would fire constantly and be learned as noise
280
+ * (host-resources.md HR-101: a signal may only warn if it is rare).
281
+ *
282
+ * Ceiling: recognises the temp-dir idioms actually in use (`mktemp`, `/tmp`,
283
+ * `/private/tmp`, `/var/folders`, `$TMPDIR`). A fixture repo created somewhere
284
+ * else is still reported — a false alarm, never a missed breach. Revisit if
285
+ * agents start seeding fixtures outside these paths.
286
+ */
287
+ const FIXTURE_CONTEXT_RE =
288
+ /\bmktemp\b|\bcd\s+["']?(?:\/private)?\/(?:tmp|var\/folders)\/|\bcd\s+["']?\$\{?(?:TMPDIR|TMP|SCRATCH)/;
289
+
290
+ /**
291
+ * True when the Bash command contains a git INDEX/HISTORY write in ANY
292
+ * segment — a `git commit` after `&&` is still a `git commit`.
293
+ *
294
+ * Ceiling: segment splitting is literal on `&&`, `||`, `;`, `|` and newline. A
295
+ * git write hidden inside a quoted string or a `$(…)` substitution is not
296
+ * detected. Revisit if a real PSA-007 violation is ever missed that way.
297
+ *
298
+ * @param {string} command
299
+ * @returns {boolean}
300
+ */
301
+ export function isGitWrite(command) {
302
+ const text = String(command || '');
303
+ if (FIXTURE_CONTEXT_RE.test(text)) return false;
304
+ return text.split(/&&|\|\||;|\||\n/).some((seg) => GIT_WRITE_RE.test(seg));
305
+ }
306
+
307
+ /**
308
+ * Fresh detector state. Held in memory only — a restart re-scans from byte 0
309
+ * and de-duplicates against events already in events.jsonl (see `seedFromEvents`).
310
+ *
311
+ * @returns {{toolUses: Map<string, {file: string|null}>, counts: Map<string, number>, wildcards: Map<string, number>}}
312
+ */
313
+ export function createState() {
314
+ return { toolUses: new Map(), counts: new Map(), wildcards: new Map() };
315
+ }
316
+
317
+ /**
318
+ * Dedup key: one counter per (agent, pattern, file, error_class).
319
+ * @param {{agent_id: string, pattern: string, file: string|null, error_class?: string}} hit
320
+ */
321
+ function dedupKey(hit) {
322
+ return [hit.agent_id, hit.pattern, hit.file ?? '-', hit.error_class ?? '-'].join('|');
323
+ }
324
+
325
+ /**
326
+ * Agent-agnostic key — the seed key for a record written before `agent_id`
327
+ * existed (see `seedFromEvents`).
328
+ * @param {{pattern: string, file: string|null, error_class?: string}} hit
329
+ */
330
+ function wildcardKey(hit) {
331
+ return [hit.pattern, hit.file ?? '-', hit.error_class ?? '-'].join('|');
332
+ }
333
+
334
+ /**
335
+ * Apply the threshold + aggregation-window rule to one raw hit.
336
+ * Returns the finding to emit, or null when it is swallowed by the window.
337
+ *
338
+ * The prior count is the MAX of the exact per-agent counter and the
339
+ * agent-agnostic wildcard counter, so a seed record that predates the
340
+ * `agent_id` field still suppresses the re-emit it was seeded for.
341
+ *
342
+ * @param {ReturnType<typeof createState>} state
343
+ * @param {{agent_id: string, pattern: string, file: string|null, error_class?: string}} hit
344
+ * @returns {object|null}
345
+ */
346
+ function recordHit(state, hit) {
347
+ const key = dedupKey(hit);
348
+ const prior = Math.max(
349
+ state.counts.get(key) ?? 0,
350
+ state.wildcards?.get(wildcardKey(hit)) ?? 0,
351
+ );
352
+ const count = prior + 1;
353
+ state.counts.set(key, count);
354
+ const threshold = EMIT_THRESHOLD[hit.pattern] ?? 1;
355
+ if (count < threshold) return null;
356
+ if (count > threshold && (count - threshold) % RE_EMIT_EVERY !== 0) return null;
357
+ return { ...hit, occurrences: count };
358
+ }
359
+
360
+ /**
361
+ * Detect stagnation patterns in ONE parsed transcript record.
362
+ *
363
+ * Mutates `state` (tool-use ledger + occurrence counters) and returns the
364
+ * findings that pass the threshold/aggregation window — i.e. exactly the
365
+ * records that should reach events.jsonl.
366
+ *
367
+ * @param {Record<string, any>} rec — a parsed `agent-<id>.jsonl` line.
368
+ * @param {ReturnType<typeof createState>} state
369
+ * @param {object} [opts]
370
+ * @param {string} [opts.repoRoot] — used to relativize absolute file paths.
371
+ * @returns {Array<{pattern: string, agent_id: string, file: string|null, error_class?: string, occurrences: number}>}
372
+ */
373
+ export function detectLine(rec, state, opts = {}) {
374
+ const findings = [];
375
+ if (!rec || typeof rec !== 'object') return findings;
376
+ const agentId = typeof rec.agentId === 'string' ? rec.agentId : 'unknown';
377
+ const content = Array.isArray(rec?.message?.content) ? rec.message.content : [];
378
+
379
+ if (rec.type === 'assistant') {
380
+ for (const block of content) {
381
+ if (block?.type === 'tool_use') {
382
+ // Ledger: a failed tool_result names only its tool_use_id, so the file
383
+ // path has to be remembered from the CALL that produced it.
384
+ if (typeof block.id === 'string') {
385
+ state.toolUses.set(block.id, {
386
+ file: relativizeFile(block?.input?.file_path ?? block?.input?.path, opts.repoRoot),
387
+ });
388
+ }
389
+ if (block.name === 'Bash' && isGitWrite(block?.input?.command)) {
390
+ const hit = recordHit(state, {
391
+ pattern: 'psa007-git-write',
392
+ agent_id: agentId,
393
+ file: null,
394
+ });
395
+ if (hit) findings.push(hit);
396
+ }
397
+ } else if (block?.type === 'text' && STATUS_RE.test(String(block.text ?? ''))) {
398
+ const hit = recordHit(state, {
399
+ pattern: 'status-partial',
400
+ agent_id: agentId,
401
+ file: null,
402
+ });
403
+ if (hit) findings.push(hit);
404
+ }
405
+ }
406
+ return findings;
407
+ }
408
+
409
+ if (rec.type === 'user') {
410
+ for (const block of content) {
411
+ if (block?.type !== 'tool_result') continue;
412
+ // Two independent failure markers: the block's own `is_error`, and the
413
+ // record-level `toolUseResult` degrading from object to STRING.
414
+ const isError = block.is_error === true || typeof rec.toolUseResult === 'string';
415
+ if (!isError) continue;
416
+ const call = typeof block.tool_use_id === 'string' ? state.toolUses.get(block.tool_use_id) : null;
417
+ const hit = recordHit(state, {
418
+ pattern: 'error-echo',
419
+ agent_id: agentId,
420
+ file: call?.file ?? null,
421
+ error_class: classifyErrorClass(blockText(block)),
422
+ });
423
+ if (hit) findings.push(hit);
424
+ }
425
+ }
426
+ return findings;
427
+ }
428
+
429
+ /**
430
+ * @param {any} block
431
+ * @returns {string}
432
+ */
433
+ function blockText(block) {
434
+ const c = block?.content;
435
+ if (typeof c === 'string') return c;
436
+ if (Array.isArray(c)) return c.map((x) => (typeof x?.text === 'string' ? x.text : '')).join('\n');
437
+ return '';
438
+ }
439
+
440
+ /**
441
+ * @param {unknown} p
442
+ * @param {string} [repoRoot]
443
+ * @returns {string|null}
444
+ */
445
+ function relativizeFile(p, repoRoot) {
446
+ if (typeof p !== 'string' || p.length === 0) return null;
447
+ if (repoRoot && isAbsolute(p)) {
448
+ const rel = relative(repoRoot, p);
449
+ return rel.startsWith('..') ? p : rel;
450
+ }
451
+ return p;
452
+ }
453
+
454
+ /**
455
+ * Restart-safety: seed the occurrence counters from `stagnation_detected`
456
+ * records already written for THIS session, so a re-scan from byte 0 does not
457
+ * re-emit findings that are already in events.jsonl.
458
+ *
459
+ * `sessionId` here is the SEMANTIC id — the same value `report()` writes into
460
+ * the record's `session` field, and the key both downstream consumers join on.
461
+ * Producer and reader must use ONE key; the round-trip test in
462
+ * `tests/lib/wave-transcript-tail.test.mjs` pins them together.
463
+ *
464
+ * VERSION BOUNDARY (`agent_id`, added #1114): records written before that field
465
+ * existed carry no agent. Keying them on the literal `'unknown'` would seed a
466
+ * counter no live hit can ever match, so a restart re-emits every one of them
467
+ * as new — measured 2026-08-25: 4 of the 5 records in this repo's ledger predate
468
+ * the field. Such a record therefore seeds the AGENT-AGNOSTIC wildcard counter
469
+ * on (pattern, file, error_class), which `recordHit` reads alongside the exact
470
+ * key. Records that DO carry `agent_id` keep the precise per-agent keying, so a
471
+ * sibling agent's genuine first finding is never suppressed. The wildcard tier
472
+ * shrinks to nothing on its own as pre-#1114 records age out of the ledger.
473
+ *
474
+ * @param {string[]} lines — raw events.jsonl lines.
475
+ * @param {string} sessionId — the SEMANTIC session id.
476
+ * @param {ReturnType<typeof createState>} state
477
+ * @returns {number} how many counters were seeded
478
+ */
479
+ export function seedFromEvents(lines, sessionId, state) {
480
+ let seeded = 0;
481
+ for (const line of lines) {
482
+ if (!line.includes('stagnation_detected')) continue;
483
+ let rec;
484
+ try {
485
+ rec = JSON.parse(line);
486
+ } catch {
487
+ continue;
488
+ }
489
+ if (rec?.event !== 'stagnation_detected') continue;
490
+ if (rec.source !== 'tail') continue;
491
+ if (rec.session !== sessionId) continue;
492
+ const shape = {
493
+ pattern: String(rec.pattern ?? ''),
494
+ file: rec.file ?? null,
495
+ error_class: rec.error_class,
496
+ };
497
+ const occ = Number(rec.occurrences);
498
+ if (!Number.isFinite(occ)) continue;
499
+
500
+ const agentId = trimmedString(rec.agent_id);
501
+ const bucket = agentId ? state.counts : state.wildcards;
502
+ const key = agentId ? dedupKey({ ...shape, agent_id: agentId }) : wildcardKey(shape);
503
+ if (!bucket) continue; // a hand-built state without the wildcard map
504
+ if (occ > (bucket.get(key) ?? 0)) {
505
+ bucket.set(key, Math.trunc(occ));
506
+ seeded += 1;
507
+ }
508
+ }
509
+ return seeded;
510
+ }
511
+
512
+ // ---------------------------------------------------------------------------
513
+ // I/O layer — thin shell around the pure detectors above
514
+ // ---------------------------------------------------------------------------
515
+
516
+ /** @param {string} msg */
517
+ function note(msg) {
518
+ process.stderr.write(`wave-transcript-tail: ${msg}\n`);
519
+ }
520
+
521
+ /**
522
+ * Single-instance guard: claim `<repoRoot>/.orchestrator/wave-transcript-tail.lock`.
523
+ *
524
+ * Reuses the repo's own POSIX lock primitive (`tryAcquireFileLock`) rather than
525
+ * hand-rolling one — the same `linkSync` create-or-fail skeleton the state-lock
526
+ * and staging-fence locks use. `staleCheck: 'pid'` is the correct policy HERE
527
+ * (unlike on `session.lock`, where the recorded pid is the ephemeral hook's):
528
+ * the tailer records its OWN long-lived pid, so a dead pid means a crashed
529
+ * tailer whose lock must be reclaimed.
530
+ *
531
+ * A losing instance exits 0 with one stderr line — NOT a rate limit. Rate
532
+ * limiting would still let two independently-seeded counters interleave; only
533
+ * refusing the second process makes the ledger's occurrence counts mean
534
+ * anything.
535
+ *
536
+ * @param {string} repoRoot
537
+ * @returns {{ ok: true, lockPath: string } | { ok: false, reason: string, lockPath: string }}
538
+ */
539
+ export function acquireSingleton(repoRoot) {
540
+ const lockPath = join(repoRoot, SINGLETON_LOCK_REL);
541
+ const res = tryAcquireFileLock(lockPath, {
542
+ staleCheck: 'pid',
543
+ holder: 'wave-transcript-tail',
544
+ tmpPrefix: '.wave-transcript-tail.lock',
545
+ warn: (msg) => note(msg),
546
+ warnMessage: (reason, lp) => `reclaiming stale tailer lock (${reason}) at ${lp}`,
547
+ });
548
+ if (res.acquired) return { ok: true, lockPath };
549
+ return { ok: false, reason: res.reason, lockPath };
550
+ }
551
+
552
+ /**
553
+ * Release the single-instance lock. Owner-guarded, so a tailer can never unlink
554
+ * a lock another live tailer holds (PSA-003).
555
+ * @param {string} repoRoot
556
+ */
557
+ export function releaseSingleton(repoRoot) {
558
+ releaseFileLock(join(repoRoot, SINGLETON_LOCK_REL), { holder: 'wave-transcript-tail' });
559
+ }
560
+
561
+ /**
562
+ * Read newly-appended bytes of an append-only file. Mirrors the offset
563
+ * discipline of `convergence-monitor.mjs` — fs.watch is unreliable for appends
564
+ * on macOS, so size polling is the robust pattern.
565
+ *
566
+ * @param {string} absPath
567
+ * @param {number} prevOffset
568
+ * @returns {{offset: number, lines: string[]}}
569
+ */
570
+ function tailRead(absPath, prevOffset) {
571
+ let fd = -1;
572
+ try {
573
+ const st = statSync(absPath);
574
+ const start = st.size < prevOffset ? 0 : prevOffset;
575
+ const toRead = st.size - start;
576
+ if (toRead <= 0) return { offset: st.size, lines: [] };
577
+ fd = openSync(absPath, 'r');
578
+ const buf = Buffer.alloc(toRead);
579
+ readSync(fd, buf, 0, toRead, start);
580
+ const text = buf.toString('utf8');
581
+ let workingText = text;
582
+ let newOffset = st.size;
583
+ if (!text.endsWith('\n')) {
584
+ const lastNl = text.lastIndexOf('\n');
585
+ if (lastNl === -1) return { offset: start, lines: [] };
586
+ workingText = text.slice(0, lastNl + 1);
587
+ newOffset = start + Buffer.byteLength(workingText, 'utf8');
588
+ }
589
+ return { offset: newOffset, lines: workingText.split('\n').filter((l) => l.length > 0) };
590
+ } catch (err) {
591
+ note(`read failed for ${absPath}: ${String(err?.message ?? err)}`);
592
+ return { offset: prevOffset, lines: [] };
593
+ } finally {
594
+ if (fd !== -1) {
595
+ try { closeSync(fd); } catch { /* ignore */ }
596
+ }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Cheap pre-filter before JSON.parse — most transcript lines carry neither a
602
+ * tool call nor a status marker.
603
+ * @param {string} line
604
+ */
605
+ function mayCarrySignal(line) {
606
+ return line.includes('"tool_use"') || line.includes('"tool_result"') || line.includes('STATUS:');
607
+ }
608
+
609
+ /**
610
+ * Read the agent TYPE from the sidecar `agent-<id>.meta.json` the harness
611
+ * writes next to the subagent transcript. `agentId` here comes from a
612
+ * `readdirSync()` filename match (tailLoop below), not from untrusted stdin —
613
+ * but it is validated through the SAME consolidated derivation (#1196) as the
614
+ * hook-side copies for consistency: an id readdirSync happened to list that
615
+ * fails `{1,64}`/charset/`'unknown'` short-circuits to the honest `'unknown'`
616
+ * return, same as any other lookup failure.
617
+ *
618
+ * `projectsDir`/`sessionId` reconstruct the PARENT transcript path
619
+ * (`<projectsDir>/<sessionId>.jsonl`) that `resolveSubagentSidecar()` expects —
620
+ * the real on-disk location of the coordinator's own transcript, sibling of
621
+ * the `subagents/` directory this function reads from.
622
+ *
623
+ * @param {string} projectsDir
624
+ * @param {string} sessionId
625
+ * @param {string} agentId
626
+ * @returns {string}
627
+ */
628
+ function readAgentType(projectsDir, sessionId, agentId) {
629
+ const sidecar = resolveSubagentSidecar({
630
+ transcriptPath: join(projectsDir, `${sessionId}.jsonl`),
631
+ agentId,
632
+ });
633
+ if (sidecar === null) return 'unknown';
634
+ try {
635
+ const meta = JSON.parse(readFileSync(sidecar.meta, 'utf8'));
636
+ const t = meta?.agentType;
637
+ return typeof t === 'string' && t ? t : 'unknown';
638
+ } catch {
639
+ return 'unknown';
640
+ }
641
+ }
642
+
643
+ /**
644
+ * Poll delay.
645
+ *
646
+ * The timer is deliberately NOT `unref()`d: an unref'd timer is the only handle
647
+ * this process holds, so the event loop would drain and node would exit 0 the
648
+ * instant the first tick was scheduled — a monitor that supervises nothing while
649
+ * looking like a clean shutdown. Measured 2026-08-25: the unref'd variant
650
+ * returned after ~0s instead of running until SIGTERM.
651
+ *
652
+ * @param {number} ms
653
+ */
654
+ function sleep(ms) {
655
+ return new Promise((resolve) => {
656
+ setTimeout(resolve, ms);
657
+ });
658
+ }
659
+
660
+ /**
661
+ * @param {object} args
662
+ * @param {number} args.intervalS
663
+ */
664
+ async function tailLoop({ intervalS }) {
665
+ const repoRoot = process.env.CLAUDE_PLUGIN_ROOT || process.cwd();
666
+
667
+ // The monitor fires once per wave-executor INVOCATION, and a deep session
668
+ // invokes it repeatedly — so refuse to be the second tailer.
669
+ const singleton = acquireSingleton(repoRoot);
670
+ if (!singleton.ok) {
671
+ note(`another tailer already holds ${singleton.lockPath} (${singleton.reason}) — exiting`);
672
+ process.exit(0);
673
+ }
674
+ process.on('exit', () => releaseSingleton(repoRoot));
675
+
676
+ const projectsDir = join(homedir(), '.claude', 'projects', encodeProjectDir(repoRoot));
677
+ const resolved = resolveSessionId({ repoRoot, projectsDir });
678
+ if (!resolved) {
679
+ note('cannot resolve session UUID (env, session.lock, mtime probe all empty) — exiting fail-open');
680
+ process.exit(0);
681
+ }
682
+ const { sessionId, source, semanticSessionId, semanticSource } = resolved;
683
+ const subagentsDir = join(projectsDir, sessionId, 'subagents');
684
+ note(
685
+ `tailing ${subagentsDir} (session via ${source}), ` +
686
+ `joining as ${semanticSessionId} (via ${semanticSource}), interval ${intervalS}s`,
687
+ );
688
+
689
+ const state = createState();
690
+ // Restart-safety: do not re-announce findings already recorded for this
691
+ // session. Seeded on the SEMANTIC id — the same key `report()` writes.
692
+ try {
693
+ const evPath = join(repoRoot, EVENTS_FILE_REL);
694
+ if (existsSync(evPath)) {
695
+ const seeded = seedFromEvents(readFileSync(evPath, 'utf8').split('\n'), semanticSessionId, state);
696
+ if (seeded > 0) note(`seeded ${seeded} prior finding counters from events.jsonl`);
697
+ }
698
+ } catch (err) {
699
+ note(`could not seed from events.jsonl: ${String(err?.message ?? err)}`);
700
+ }
701
+
702
+ /** @type {Map<string, number>} */
703
+ const offsets = new Map();
704
+ /** @type {Map<string, string>} */
705
+ const agentTypes = new Map();
706
+ let sawDir = false;
707
+
708
+ for (;;) {
709
+ await sleep(intervalS * 1000);
710
+ if (!existsSync(subagentsDir)) continue; // dir appears only at the first spawn
711
+ if (!sawDir) {
712
+ sawDir = true;
713
+ note('subagents directory appeared — supervision live');
714
+ }
715
+ let files;
716
+ try {
717
+ files = readdirSync(subagentsDir).filter((f) => f.startsWith('agent-') && f.endsWith('.jsonl'));
718
+ } catch (err) {
719
+ note(`readdir failed: ${String(err?.message ?? err)}`);
720
+ continue;
721
+ }
722
+ for (const file of files) {
723
+ const abs = join(subagentsDir, file);
724
+ const tick = tailRead(abs, offsets.get(file) ?? 0);
725
+ offsets.set(file, tick.offset);
726
+ if (tick.lines.length === 0) continue;
727
+ const agentId = file.slice('agent-'.length, -'.jsonl'.length);
728
+ if (!agentTypes.has(agentId)) agentTypes.set(agentId, readAgentType(projectsDir, sessionId, agentId));
729
+ for (const line of tick.lines) {
730
+ if (!mayCarrySignal(line)) continue;
731
+ let rec;
732
+ try {
733
+ rec = JSON.parse(line);
734
+ } catch {
735
+ continue;
736
+ }
737
+ for (const finding of detectLine(rec, state, { repoRoot })) {
738
+ await report(finding, {
739
+ repoRoot,
740
+ sessionId,
741
+ semanticSessionId,
742
+ agentType: agentTypes.get(agentId) ?? 'unknown',
743
+ });
744
+ }
745
+ }
746
+ }
747
+ }
748
+ }
749
+
750
+ /**
751
+ * Build the `stagnation_detected` record for one finding.
752
+ *
753
+ * Field set = the `wave-loop.md:578` coordinator record (session, wave, agent,
754
+ * pattern, error_class, file, occurrences) plus three additive identity fields:
755
+ * `session_id`/`semantic_session_id` from `sessionAttribution`, and `agent_id`.
756
+ *
757
+ * `session` carries the SEMANTIC id, NOT the UUID — it is the join key of both
758
+ * consumers (see `resolveSessionId`), and `seedFromEvents` reads it back on the
759
+ * next restart. The UUID still travels, as the additive `session_id` field.
760
+ *
761
+ * `agent_id` is load-bearing, not decoration: `agent` carries the agent TYPE,
762
+ * and a wave routinely runs several agents of the SAME type (measured: two
763
+ * `Explore` agents in this session). Without the per-agent id the restart-safety
764
+ * seed in `seedFromEvents` can only match by type, which would suppress a
765
+ * sibling agent's genuine first finding — a false NEGATIVE in a supervision
766
+ * tool. Keying on the id instead costs one additive field.
767
+ *
768
+ * @param {{pattern: string, agent_id: string, file: string|null, error_class?: string, occurrences: number}} finding
769
+ * @param {{repoRoot: string, sessionId: string, semanticSessionId: string, agentType: string}} ctx
770
+ * @returns {Record<string, any>}
771
+ */
772
+ export function buildStagnationPayload(finding, ctx) {
773
+ // The wave number is bound to the working copy by the RAW uuid (#1082), so
774
+ // this one lookup keeps the uuid while `session` below carries the semantic id.
775
+ const wave = readWaveNumber(ctx.repoRoot, ctx.sessionId);
776
+ const payload = {
777
+ ...sessionAttribution(ctx.repoRoot),
778
+ session: ctx.semanticSessionId,
779
+ wave,
780
+ agent: ctx.agentType,
781
+ agent_id: finding.agent_id,
782
+ pattern: finding.pattern,
783
+ source: 'tail',
784
+ file: finding.file,
785
+ occurrences: finding.occurrences,
786
+ };
787
+ if (finding.error_class) payload.error_class = finding.error_class;
788
+ return payload;
789
+ }
790
+
791
+ /**
792
+ * Write one finding: a short stdout line (the Monitor notification) plus the
793
+ * `stagnation_detected` record in events.jsonl.
794
+ *
795
+ * @param {{pattern: string, agent_id: string, file: string|null, error_class?: string, occurrences: number}} finding
796
+ * @param {{repoRoot: string, sessionId: string, semanticSessionId: string, agentType: string}} ctx
797
+ */
798
+ async function report(finding, ctx) {
799
+ const payload = buildStagnationPayload(finding, ctx);
800
+
801
+ process.stdout.write(
802
+ `stagnation_detected pattern=${finding.pattern} agent=${ctx.agentType} wave=${payload.wave ?? 'null'} ` +
803
+ `file=${finding.file ?? 'null'} occurrences=${finding.occurrences}` +
804
+ `${finding.error_class ? ` error_class=${finding.error_class}` : ''}\n`,
805
+ );
806
+ try {
807
+ await emitEvent('stagnation_detected', payload, { repoRoot: ctx.repoRoot });
808
+ } catch (err) {
809
+ note(`event write failed: ${String(err?.message ?? err)}`);
810
+ }
811
+ }
812
+
813
+ /**
814
+ * @param {string[]} argv
815
+ * @returns {{tail: boolean, intervalS: number, help: boolean}}
816
+ */
817
+ export function parseArgs(argv) {
818
+ let tail = false;
819
+ let intervalS = DEFAULT_INTERVAL_S;
820
+ let help = false;
821
+ for (const arg of argv) {
822
+ if (arg === '--tail') {
823
+ tail = true;
824
+ } else if (arg === '--help' || arg === '-h') {
825
+ help = true;
826
+ } else if (arg.startsWith('--interval=')) {
827
+ const v = Number(arg.slice('--interval='.length));
828
+ if (!Number.isFinite(v) || v <= 0) {
829
+ note(`invalid --interval value: ${arg}`);
830
+ process.exit(1);
831
+ }
832
+ intervalS = v;
833
+ } else {
834
+ note(`unknown flag: ${arg}`);
835
+ process.exit(1);
836
+ }
837
+ }
838
+ return { tail, intervalS, help };
839
+ }
840
+
841
+ function main() {
842
+ const args = parseArgs(process.argv.slice(2));
843
+ if (args.help) {
844
+ process.stderr.write(
845
+ [
846
+ 'Usage: wave-transcript-tail.mjs --tail [--interval=<seconds>]',
847
+ '',
848
+ 'FA-1 wave supervision (#1114). Tails the live subagent transcripts of',
849
+ 'the OWN session and emits stagnation_detected(source:tail) records to',
850
+ '.orchestrator/metrics/events.jsonl.',
851
+ '',
852
+ 'Patterns: psa007-git-write, error-echo, status-partial.',
853
+ '',
854
+ 'Flags:',
855
+ ' --tail Required. Run the tail loop.',
856
+ ' --interval=<s> Poll cadence in seconds (default 2).',
857
+ ' --help, -h Print this message.',
858
+ '',
859
+ 'Single instance per working copy (.orchestrator/wave-transcript-tail.lock):',
860
+ 'a second tailer exits 0 rather than double-emitting every record.',
861
+ '',
862
+ 'Exit codes: 0 clean/fail-open/already-running / 1 user-error / 2 system-error.',
863
+ ].join('\n') + '\n',
864
+ );
865
+ process.exit(0);
866
+ }
867
+ if (!args.tail) {
868
+ note('--tail is required');
869
+ process.exit(1);
870
+ }
871
+
872
+ const shutdown = (sig) => {
873
+ note(`shutdown on ${sig}`);
874
+ process.exit(0);
875
+ };
876
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
877
+ process.on('SIGINT', () => shutdown('SIGINT'));
878
+
879
+ tailLoop({ intervalS: args.intervalS }).catch((err) => {
880
+ note(`tail loop failed: ${String(err?.message ?? err)}`);
881
+ process.exit(2);
882
+ });
883
+ }
884
+
885
+ // Run only when executed as a script — importing for unit tests must not parse
886
+ // vitest's argv and exit 1.
887
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
888
+ main();
889
+ }