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