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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/commands/autopilot-multi.md +14 -0
- package/.cursor/commands/autopilot.md +14 -0
- package/.cursor/commands/bootstrap.md +14 -0
- package/.cursor/commands/brainstorm.md +14 -0
- package/.cursor/commands/close.md +13 -0
- package/.cursor/commands/contract-version-bump.md +14 -0
- package/.cursor/commands/debug.md +14 -0
- package/.cursor/commands/discovery.md +14 -0
- package/.cursor/commands/dispatcher.md +14 -0
- package/.cursor/commands/eli5.md +14 -0
- package/.cursor/commands/eval.md +14 -0
- package/.cursor/commands/evolve.md +14 -0
- package/.cursor/commands/go.md +14 -0
- package/.cursor/commands/grill.md +14 -0
- package/.cursor/commands/harness-audit.md +13 -0
- package/.cursor/commands/journey-audit.md +14 -0
- package/.cursor/commands/memory-cleanup.md +14 -0
- package/.cursor/commands/persona-panel.md +14 -0
- package/.cursor/commands/plan.md +14 -0
- package/.cursor/commands/portfolio.md +14 -0
- package/.cursor/commands/reconcile.md +14 -0
- package/.cursor/commands/release.md +14 -0
- package/.cursor/commands/repo-audit.md +13 -0
- package/.cursor/commands/session.md +14 -0
- package/.cursor/commands/spinout.md +14 -0
- package/.cursor/commands/sunset-review.md +14 -0
- package/.cursor/commands/templates-ack.md +14 -0
- package/.cursor/commands/test.md +14 -0
- package/.cursor/hooks.json +60 -0
- package/.cursor/rules/000-session-orchestrator.mdc +8 -0
- package/.cursor/rules/010-session-workflow.mdc +9 -1
- package/.cursor/rules/020-quality-gates.mdc +1 -1
- package/.cursor/rules/030-wave-execution.mdc +1 -1
- package/.cursor/rules/050-plan.mdc +2 -2
- package/.cursor/rules/070-gitlab-ops.mdc +73 -57
- package/.cursor/rules/080-ecosystem-health.mdc +7 -7
- package/.cursor/skills/architecture/SKILL.md +13 -0
- package/.cursor/skills/autopilot/SKILL.md +12 -0
- package/.cursor/skills/bootstrap/SKILL.md +12 -0
- package/.cursor/skills/brainstorm/SKILL.md +13 -0
- package/.cursor/skills/claude-md-drift-check/SKILL.md +13 -0
- package/.cursor/skills/contract-version-bump/SKILL.md +12 -0
- package/.cursor/skills/convergence-monitoring/SKILL.md +12 -0
- package/.cursor/skills/daily/SKILL.md +12 -0
- package/.cursor/skills/debug/SKILL.md +13 -0
- package/.cursor/skills/discovery/SKILL.md +13 -0
- package/.cursor/skills/dispatcher/SKILL.md +13 -0
- package/.cursor/skills/docs-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/domain-model/SKILL.md +13 -0
- package/.cursor/skills/ecosystem-health/SKILL.md +13 -0
- package/.cursor/skills/eli5/SKILL.md +13 -0
- package/.cursor/skills/eval/SKILL.md +12 -0
- package/.cursor/skills/evolve/SKILL.md +13 -0
- package/.cursor/skills/frontmatter-guard/SKILL.md +13 -0
- package/.cursor/skills/gitlab-ops/SKILL.md +13 -0
- package/.cursor/skills/gitlab-portfolio/SKILL.md +13 -0
- package/.cursor/skills/grill/SKILL.md +13 -0
- package/.cursor/skills/hook-development/SKILL.md +13 -0
- package/.cursor/skills/journey-audit/SKILL.md +13 -0
- package/.cursor/skills/mcp-builder/SKILL.md +13 -0
- package/.cursor/skills/memory-cleanup/SKILL.md +12 -0
- package/.cursor/skills/mode-selector/SKILL.md +13 -0
- package/.cursor/skills/npm-publish/SKILL.md +12 -0
- package/.cursor/skills/peekaboo-driver/SKILL.md +13 -0
- package/.cursor/skills/persona-panel/SKILL.md +12 -0
- package/.cursor/skills/plan/SKILL.md +13 -0
- package/.cursor/skills/playwright-driver/SKILL.md +13 -0
- package/.cursor/skills/quality-gates/SKILL.md +13 -0
- package/.cursor/skills/reconcile/SKILL.md +12 -0
- package/.cursor/skills/repo-audit/SKILL.md +13 -0
- package/.cursor/skills/session-end/SKILL.md +13 -0
- package/.cursor/skills/session-plan/SKILL.md +13 -0
- package/.cursor/skills/session-start/SKILL.md +13 -0
- package/.cursor/skills/skill-creator/SKILL.md +13 -0
- package/.cursor/skills/spinout/SKILL.md +12 -0
- package/.cursor/skills/sunset-review/SKILL.md +13 -0
- package/.cursor/skills/test-runner/SKILL.md +13 -0
- package/.cursor/skills/tmux-layout/SKILL.md +13 -0
- package/.cursor/skills/ubiquitous-language/SKILL.md +13 -0
- package/.cursor/skills/using-orchestrator/SKILL.md +13 -0
- package/.cursor/skills/vault-mirror/SKILL.md +13 -0
- package/.cursor/skills/vault-sync/SKILL.md +13 -0
- package/.cursor/skills/wave-executor/SKILL.md +13 -0
- package/.cursor/skills/write-executable-plan/SKILL.md +13 -0
- package/.mcp.json +4 -1
- package/CHANGELOG.md +168 -0
- package/README.md +18 -15
- package/agents/AGENTS.md +23 -4
- package/agents/code-implementer.md +2 -1
- package/agents/db-specialist.md +2 -1
- package/agents/docs-writer.md +3 -1
- package/agents/eval-judge.md +1 -1
- package/agents/session-reviewer.md +7 -1
- package/agents/test-writer.md +2 -1
- package/agents/ui-developer.md +2 -1
- package/commands/bootstrap.md +2 -2
- package/commands/close.md +3 -1
- package/commands/go.md +1 -1
- package/commands/journey-audit.md +43 -0
- package/docs/USER-GUIDE.md +2 -2
- package/docs/ci-setup.md +14 -0
- package/docs/codex-setup.md +64 -0
- package/docs/components.md +6 -6
- package/docs/cursor-setup.md +26 -47
- package/docs/events-schema.md +76 -4
- package/docs/github-mirror-protection.md +197 -0
- package/docs/pi-setup.md +2 -0
- package/docs/rule-authoring.md +3 -1
- package/docs/scope-collision-guard.md +49 -2
- package/docs/session-config-reference.md +26 -4
- package/docs/session-config-template.md +4 -3
- package/docs/telemetry.md +22 -0
- package/hooks/_lib/lock-bootstrap.mjs +8 -4
- package/hooks/_lib/vcs-create-matcher.mjs +397 -38
- package/hooks/enforce-scope.mjs +64 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks-cursor.json +201 -20
- package/hooks/hooks-pi.json +1 -1
- package/hooks/hooks.json +2 -2
- package/hooks/on-session-end.mjs +211 -10
- package/hooks/on-session-start.mjs +214 -11
- package/hooks/on-stop.mjs +48 -9
- package/hooks/post-subagent-discovery-validator.mjs +34 -3
- package/hooks/post-tool-batch-wave-signal.mjs +11 -2
- package/hooks/pre-bash-issue-budget.mjs +117 -4
- package/hooks/pre-bash-sessions-ledger-guard.mjs +159 -0
- package/hooks/pre-bash-staging-fence.mjs +4 -0
- package/hooks/pre-task-scope-disjoint.mjs +368 -35
- package/hooks/skill-invocation-telemetry.mjs +21 -10
- package/monitors/monitors.json +6 -0
- package/package.json +1 -1
- package/pi/prompts/journey-audit.md +12 -0
- package/rules/_index.md +9 -1
- package/rules/always-on/ask-via-tool.md +62 -0
- package/rules/always-on/bash-harness-pitfalls.md +168 -0
- package/rules/always-on/build-value.md +47 -0
- package/rules/always-on/cross-session-messaging.md +59 -0
- package/rules/always-on/loop-and-monitor.md +221 -0
- package/rules/always-on/parallel-sessions.md +142 -12
- package/rules/always-on/receiving-review.md +108 -0
- package/rules/always-on/test-value.md +40 -0
- package/rules/always-on/verification-before-completion.md +77 -0
- package/scripts/archive-closed-prds.mjs +258 -18
- package/scripts/autopilot.mjs +5 -0
- package/scripts/backfill-evidence-digest.mjs +376 -0
- package/scripts/cursor-install.mjs +89 -48
- package/scripts/export-hw-learnings.mjs +143 -2
- package/scripts/express-path.mjs +299 -0
- package/scripts/generate-cursor-adapter.mjs +253 -0
- package/scripts/github-protection-audit.mjs +358 -0
- package/scripts/lib/autopilot/worktree-pipeline.mjs +240 -16
- package/scripts/lib/build-live-signals.mjs +24 -5
- package/scripts/lib/ci-status-banner.mjs +158 -11
- package/scripts/lib/command-blocker.mjs +70 -0
- package/scripts/lib/config/reconcile.mjs +79 -4
- package/scripts/lib/config/section-extractor.mjs +235 -36
- package/scripts/lib/config-schema.mjs +9 -1
- package/scripts/lib/config.mjs +57 -6
- package/scripts/lib/convergence-monitor.mjs +13 -2
- package/scripts/lib/cursor-hook-bridge.mjs +443 -0
- package/scripts/lib/dispatcher/cli.mjs +2 -2
- package/scripts/lib/express-path.mjs +327 -0
- package/scripts/lib/file-lock.mjs +22 -4
- package/scripts/lib/gates/gate-full.mjs +81 -8
- package/scripts/lib/gates/gate-helpers.mjs +76 -15
- package/scripts/lib/git-config-drift.mjs +134 -5
- package/scripts/lib/host-identity.mjs +247 -2
- package/scripts/lib/instruction-budget-guard.mjs +31 -1
- package/scripts/lib/issue-budget.mjs +229 -30
- package/scripts/lib/learnings/io.mjs +55 -10
- package/scripts/lib/learnings/schema.mjs +95 -28
- package/scripts/lib/lock-reaper.mjs +7 -1
- package/scripts/lib/locks/staging-fence-lock.mjs +5 -1
- package/scripts/lib/locks/state-md-lock.mjs +8 -1
- package/scripts/lib/memory-banner.mjs +5 -2
- package/scripts/lib/memory-paths.mjs +15 -6
- package/scripts/lib/mode-selector/scoring.mjs +53 -6
- package/scripts/lib/platform.mjs +72 -9
- package/scripts/lib/plugin-root.mjs +143 -19
- package/scripts/lib/project-hygiene.mjs +43 -3
- package/scripts/lib/quality-gate.mjs +271 -13
- package/scripts/lib/reconcile/emitter.mjs +87 -19
- package/scripts/lib/reconcile/engine.mjs +281 -13
- package/scripts/lib/reconcile/idempotency.mjs +102 -1
- package/scripts/lib/reconcile/renderer.mjs +148 -3
- package/scripts/lib/reconcile/sanitize.mjs +40 -17
- package/scripts/lib/reconcile/writer.mjs +415 -84
- package/scripts/lib/rule-loader.mjs +37 -2
- package/scripts/lib/rules-sync.mjs +51 -8
- package/scripts/lib/scope-gate.mjs +90 -0
- package/scripts/lib/session-close-backfill.mjs +369 -28
- package/scripts/lib/session-discovery.mjs +13 -3
- package/scripts/lib/session-end/phase-skip.mjs +37 -4
- package/scripts/lib/session-end/worktree-cleanup.mjs +154 -7
- package/scripts/lib/session-id.mjs +30 -14
- package/scripts/lib/session-identity/own-session.mjs +159 -0
- package/scripts/lib/session-lock.mjs +85 -30
- package/scripts/lib/session-schema/normalizer.mjs +70 -3
- package/scripts/lib/session-schema/validator.mjs +40 -0
- package/scripts/lib/session-start-probes.mjs +608 -0
- package/scripts/lib/session-transition.mjs +277 -0
- package/scripts/lib/sessions-staleness-banner.mjs +124 -57
- package/scripts/lib/spiral-carryover.mjs +90 -9
- package/scripts/lib/state-md/frontmatter-mutators.mjs +41 -8
- package/scripts/lib/state-md/mission-status.mjs +350 -52
- package/scripts/lib/state-md/yaml-parser.mjs +145 -16
- package/scripts/lib/state-md.mjs +12 -2
- package/scripts/lib/telemetry/sync.mjs +46 -8
- package/scripts/lib/validate/check-agents.mjs +66 -0
- package/scripts/lib/validate/check-cursor-adapter.mjs +102 -0
- package/scripts/lib/validate/check-dead-bridge.mjs +24 -2
- package/scripts/lib/validate/check-doc-cli-commands.mjs +16 -32
- package/scripts/lib/validate/check-hooks-symmetry.mjs +29 -63
- package/scripts/lib/validate/check-playwright-mcp-canary.mjs +13 -22
- package/scripts/lib/validate/check-plugin-monitors.mjs +10 -4
- package/scripts/lib/validate/check-test-value-bans.mjs +165 -17
- package/scripts/lib/validate/check-unwired-features.mjs +340 -32
- package/scripts/lib/validate/repo-files.mjs +275 -0
- package/scripts/lib/validate-vendored-rules.mjs +229 -7
- package/scripts/lib/vault-mirror/process.mjs +99 -43
- package/scripts/lib/vault-mirror/telemetry.mjs +210 -0
- package/scripts/lib/vault-staleness-banner.mjs +76 -6
- package/scripts/lib/vault-status/board-writer.mjs +211 -10
- package/scripts/lib/vault-status/narrative-mirror.mjs +188 -8
- package/scripts/lib/wave-executor/foreign-dispatch.mjs +832 -0
- package/scripts/lib/wave-transcript-tail.mjs +869 -0
- package/scripts/materialize-wave-scope.mjs +209 -12
- package/scripts/mcp-server.sh +11 -2
- package/scripts/parse-config.mjs +65 -0
- package/scripts/token-audit.sh +9 -2
- package/scripts/validate-plugin.mjs +3 -0
- package/scripts/validate-wave-scope.mjs +67 -0
- package/scripts/vault-mirror.mjs +203 -34
- package/skills/_shared/monitor-patterns.md +31 -5
- package/skills/_shared/parallel-aware-auq.md +1 -1
- package/skills/_shared/parallel-aware-preamble.md +4 -2
- package/skills/_shared/platform-tools.md +11 -5
- package/skills/_shared/state-ownership.md +29 -2
- package/skills/autopilot/SKILL.md +5 -1
- package/skills/bootstrap/SKILL.md +3 -3
- package/skills/bootstrap/_shared-template.md +18 -10
- package/skills/bootstrap/deep-template.md +10 -6
- package/skills/bootstrap/fast-template.md +15 -8
- package/skills/bootstrap/standard-template.md +10 -6
- package/skills/claude-md-drift-check/checker.mjs +39 -11
- package/skills/dispatcher/SKILL.md +1 -1
- package/skills/journey-audit/SKILL.md +269 -0
- package/skills/peekaboo-driver/SKILL.md +15 -3
- package/skills/persona-panel/SKILL.md +1 -1
- package/skills/reconcile/SKILL.md +41 -1
- package/skills/session-end/SKILL.md +17 -4
- package/skills/session-end/metrics-collection.md +7 -4
- package/skills/session-end/phase-3-6-tail.md +11 -3
- package/skills/session-end/phase-3-7a-recommendations.md +16 -2
- package/skills/session-plan/SKILL.md +6 -1
- package/skills/session-plan/wave-template.md +1 -0
- package/skills/session-start/SKILL.md +30 -16
- package/skills/session-start/phase-7-5-mode-selector.md +15 -3
- package/skills/session-start/phase-8-5-express-path.md +77 -12
- package/skills/vault-sync/validator.mjs +31 -0
- package/skills/wave-executor/SKILL.md +4 -2
- package/skills/wave-executor/circuit-breaker.md +34 -9
- package/skills/wave-executor/wave-loop.md +102 -19
- package/templates/_shared/journey-manifest.md +110 -0
- 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
|
+
}
|