session-orchestrator 5.11.0 → 5.12.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 (45) hide show
  1. package/.claude-plugin/marketplace.json +12 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.cursor-plugin/plugin.json +1 -1
  5. package/CHANGELOG.md +19 -0
  6. package/README.md +22 -12
  7. package/commands/session.md +3 -1
  8. package/docs/install.md +12 -8
  9. package/docs/session-config-reference.md +6 -6
  10. package/docs/session-config-template.md +2 -2
  11. package/hooks/hooks-codex.json +1 -1
  12. package/hooks/hooks.json +1 -1
  13. package/hooks/on-session-start.mjs +19 -10
  14. package/package.json +1 -2
  15. package/scripts/fleet-checkin.mjs +61 -3
  16. package/scripts/lib/config/remote-hosts.mjs +2 -2
  17. package/scripts/lib/config.mjs +1 -1
  18. package/scripts/lib/fleet-protocol.mjs +17 -0
  19. package/scripts/lib/session-end/vault-orphans.mjs +205 -0
  20. package/scripts/lib/session-invocation.mjs +14 -3
  21. package/scripts/lib/session-registry.mjs +14 -4
  22. package/scripts/lib/so-hud/collect.mjs +130 -0
  23. package/scripts/lib/so-hud/runtime.mjs +76 -0
  24. package/scripts/lib/state-md/idle-reset.mjs +239 -0
  25. package/scripts/lib/state-md/recommendations.mjs +26 -4
  26. package/scripts/lib/vault-mirror/namespace.mjs +3 -2
  27. package/scripts/lib/vault-mirror/process.mjs +143 -40
  28. package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
  29. package/scripts/lib/vault-mirror/utils.mjs +27 -13
  30. package/scripts/lib/vault-status/board-writer.mjs +32 -4
  31. package/scripts/resolve-session-invocation.mjs +64 -8
  32. package/scripts/so-hud.mjs +45 -0
  33. package/scripts/state-md-idle-reset.mjs +81 -0
  34. package/scripts/vault-orphans.mjs +171 -0
  35. package/skills/_shared/fleet-protocol.md +24 -2
  36. package/skills/_shared/state-ownership.md +3 -3
  37. package/skills/autopilot/SKILL.md +3 -2
  38. package/skills/session-end/SKILL.md +3 -2
  39. package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
  40. package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
  41. package/skills/session-end/references/session-summary-template.md +4 -0
  42. package/skills/session-start/SKILL.md +1 -1
  43. package/skills/session-start/phase-7-5-mode-selector.md +3 -1
  44. package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
  45. package/AGENTS.md +0 -207
@@ -0,0 +1,205 @@
1
+ /**
2
+ * vault-orphans.mjs - Phase 3.7e Vault Orphans (#1563).
3
+ *
4
+ * Lists the untracked files in the Meta-Vault that were modified since this
5
+ * session started, so the closing session can NAME what it may have left
6
+ * behind there.
7
+ *
8
+ * Why this exists (measured 2026-10-11): one vault checkout had accumulated 268
9
+ * untracked files over five days - website audits, session reports, reviews,
10
+ * lead handovers - written by sessions of OTHER repos into their configured
11
+ * `vault-dir`. None of them was ever committed: a session's close commits only
12
+ * in its own repo, and vault-mirror auto-commits only its own generator-marked
13
+ * files (`scripts/lib/vault-mirror/auto-commit.mjs`). The writing session is the
14
+ * only one that knows it wrote them, and nothing made it look.
15
+ *
16
+ * THE IRON RULE: this module PROPOSES, it never DISPOSES. Its only git
17
+ * invocation is the read-only `ls-files --others --exclude-standard -z`. No
18
+ * add, commit, rm, mv, clean, stash; no file is written, moved or deleted. The
19
+ * vault is another repo with its own rules (PSA-003).
20
+ *
21
+ * The return field is named `candidates`, not `orphans`: mtime is a HINT, not
22
+ * proof of ownership. A concurrent session of another repo may have written the
23
+ * same file in the same window. The coordinator names them and records one Open
24
+ * Question; it never acts on them.
25
+ *
26
+ * Three states, never two (a missing measurement must not look like zero):
27
+ * - `{ status: 'ok', total: 0 }` - measured, nothing found
28
+ * - `{ status: 'ok', total: N }` - measured, N candidates
29
+ * - `{ status: 'unavailable' }` - could not measure (no vault dir, not a
30
+ * git repo, git failed). Never reported as
31
+ * "no orphans".
32
+ *
33
+ * Exclusion - what another writer already owns: any file whose first 4 KB
34
+ * carries a `_generator: session-orchestrator-` frontmatter line. Census
35
+ * 2026-10-11 @ 686e2c12 (`rg -n "GENERATOR_MARKER\s*=" scripts/`): four marker
36
+ * values, all with that prefix - `vault-mirror@1` (40-learnings/, 50-sessions/,
37
+ * monthly rollups), `vault-status-narrative@1` (01-projects/<slug>/
38
+ * _session-narrative.md), `active-sessions@1` (01-projects/_active-sessions.md),
39
+ * `gitlab-portfolio@1`. The match is line-anchored so a hand-written note that
40
+ * merely QUOTES the marker in prose is still reported.
41
+ *
42
+ * There is deliberately NO blanket exclusion of `40-learnings/` / `50-sessions/`:
43
+ * vault-mirror owns only the MARKED files there. `autoCommitVaultMirror` treats
44
+ * an unmarked staged file in those dirs as an offender and skips its whole
45
+ * commit, so an unmarked untracked file there is owned by nobody AND blocks the
46
+ * mirror - the last thing to hide.
47
+ *
48
+ * Never throws. DI seams (#580-DI-001), synchronous like the Phase 4b sibling
49
+ * ./worktree-orphan-sweep.mjs: `execFileFn` (default `execFileSync`) and
50
+ * `statFn` (default `lstatSync` - a symlink is reported as itself, never
51
+ * followed out of the vault). Every git call passes an args ARRAY, no shell
52
+ * (#577 HARDEN-001).
53
+ */
54
+
55
+ import path from 'node:path';
56
+ import { execFileSync } from 'node:child_process';
57
+ import { closeSync, lstatSync, openSync, readSync } from 'node:fs';
58
+
59
+ import { redactHomeDir } from '../common.mjs';
60
+
61
+ /** Upper bound on the returned `candidates` list; `total` still counts all. */
62
+ export const MAX_CANDIDATES = 200;
63
+
64
+ /** Bytes read per file for the generator check - same window as auto-commit.mjs. */
65
+ const HEAD_BYTES = 4096;
66
+
67
+ /** A frontmatter line naming one of this plugin's generators. */
68
+ const GENERATOR_LINE_RE = /^_generator:[ \t]*session-orchestrator-/m;
69
+
70
+ /**
71
+ * Env vars that retarget git away from `-C <dir>`. An inherited `GIT_DIR` beats
72
+ * `-C` (incident 2026-08-19), which here would list ANOTHER repo's untracked
73
+ * files as vault candidates. Stripped for the one git call below.
74
+ */
75
+ const REPO_REDIRECT_ENV = ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE', 'GIT_COMMON_DIR'];
76
+
77
+ /**
78
+ * Ceiling for git's NUL-separated path list: 16 MiB is ~150k paths at ~100
79
+ * bytes. Past it, execFileSync throws ENOBUFS and the result is `unavailable`
80
+ * (could not measure), never a silently cut list. Revisit if a vault ever
81
+ * reports that reason.
82
+ */
83
+ const LS_FILES_MAX_BUFFER = 16 * 1024 * 1024;
84
+
85
+ /**
86
+ * A vault on a hung network filesystem must not stall /close: past this the
87
+ * call is killed and the result is `unavailable` (git-failed). 20k untracked
88
+ * files took 0.46 s (2026-10-11), so 20 s is a hang, not a slow vault.
89
+ */
90
+ const LS_FILES_TIMEOUT_MS = 20_000;
91
+
92
+ /** @param {string} absPath @returns {boolean} true when the head names one of our generators */
93
+ function isGeneratorOwned(absPath) {
94
+ let fd;
95
+ try {
96
+ fd = openSync(absPath, 'r');
97
+ const buf = Buffer.alloc(HEAD_BYTES);
98
+ const n = readSync(fd, buf, 0, HEAD_BYTES, 0);
99
+ return GENERATOR_LINE_RE.test(buf.subarray(0, n).toString('utf8'));
100
+ } catch {
101
+ // Unreadable -> not provably someone else's. This module only REPORTS, so
102
+ // the safe direction is to show the file, not to hide it.
103
+ return false;
104
+ } finally {
105
+ if (fd !== undefined) {
106
+ try {
107
+ closeSync(fd);
108
+ } catch {
109
+ /* already closed */
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * @param {string} reason
117
+ * @param {string} [detail]
118
+ * @returns {{status: 'unavailable', reason: string, detail?: string, candidates: []}}
119
+ */
120
+ function unavailable(reason, detail) {
121
+ return detail ? { status: 'unavailable', reason, detail, candidates: [] } : { status: 'unavailable', reason, candidates: [] };
122
+ }
123
+
124
+ /**
125
+ * Find untracked vault files modified at or after `sinceMs`.
126
+ *
127
+ * @param {object} [opts]
128
+ * @param {string} opts.vaultDir - Vault working-tree root (already tilde-expanded).
129
+ * @param {number} opts.sinceMs - Session start, epoch ms. Files with an older
130
+ * mtime are not candidates.
131
+ * @param {Function} [opts.execFileFn] - execFileSync seam: (file, args, options) => string.
132
+ * @param {Function} [opts.statFn] - lstatSync seam: (absPath) => { mtimeMs, isFile() }.
133
+ * @returns {{status: 'ok', candidates: Array<{path: string, mtimeMs: number}>,
134
+ * total: number, truncated: boolean, untracked: number}
135
+ * | {status: 'unavailable', reason: string, detail?: string, candidates: []}}
136
+ * `path` is vault-relative as git reports it. `total` counts every candidate,
137
+ * `candidates` holds at most MAX_CANDIDATES of them (sorted by path),
138
+ * `untracked` is everything git listed before the mtime and generator filters.
139
+ */
140
+ export function findVaultOrphans({ vaultDir, sinceMs, execFileFn, statFn } = {}) {
141
+ try {
142
+ if (typeof vaultDir !== 'string' || vaultDir.trim() === '') return unavailable('no-vault-dir');
143
+ if (typeof sinceMs !== 'number' || !Number.isFinite(sinceMs)) return unavailable('invalid-since');
144
+
145
+ const exec = typeof execFileFn === 'function' ? execFileFn : execFileSync;
146
+ const stat = typeof statFn === 'function' ? statFn : lstatSync;
147
+ const root = path.resolve(vaultDir);
148
+
149
+ const env = { ...process.env };
150
+ for (const key of REPO_REDIRECT_ENV) delete env[key];
151
+
152
+ // -- THE ONLY GIT CALL - read-only --------------------------------------
153
+ let out;
154
+ try {
155
+ out = exec('git', ['-C', root, 'ls-files', '--others', '--exclude-standard', '-z'], {
156
+ encoding: 'utf8',
157
+ env,
158
+ maxBuffer: LS_FILES_MAX_BUFFER,
159
+ timeout: LS_FILES_TIMEOUT_MS,
160
+ stdio: ['ignore', 'pipe', 'pipe'],
161
+ });
162
+ } catch (err) {
163
+ const stderr = typeof err?.stderr === 'string' ? err.stderr : String(err?.stderr ?? '');
164
+ const reason = /not a git repository/i.test(stderr) ? 'not-a-git-repo' : 'git-failed';
165
+ const firstLine = redactHomeDir(stderr.split('\n').find((l) => l.trim() !== '') ?? err?.code ?? '');
166
+ return unavailable(reason, typeof firstLine === 'string' ? firstLine.trim().slice(0, 200) : undefined);
167
+ }
168
+ if (typeof out !== 'string') return unavailable('git-failed', 'non-string git output');
169
+
170
+ const untrackedPaths = out.split('\0').filter((p) => p !== '');
171
+
172
+ // Cost is linear in the untracked count: one lstat per path, plus one 4 KB
173
+ // read per path that passes the mtime filter (cheapest filter first). Fine
174
+ // for the measured 268; revisit if a close reports more than ~10k untracked
175
+ // files, where the per-file read would start to show in /close time.
176
+ const all = [];
177
+ for (const rel of untrackedPaths) {
178
+ const abs = path.join(root, rel);
179
+ let st;
180
+ try {
181
+ st = stat(abs);
182
+ } catch {
183
+ // Vanished between listing and stat (a concurrent writer moved it):
184
+ // nothing left to name.
185
+ continue;
186
+ }
187
+ const mtimeMs = Number(st?.mtimeMs);
188
+ if (!Number.isFinite(mtimeMs) || mtimeMs < sinceMs) continue;
189
+ if (typeof st.isFile === 'function' && st.isFile() && isGeneratorOwned(abs)) continue;
190
+ all.push({ path: rel, mtimeMs });
191
+ }
192
+
193
+ all.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
194
+ return {
195
+ status: 'ok',
196
+ candidates: all.slice(0, MAX_CANDIDATES),
197
+ total: all.length,
198
+ truncated: all.length > MAX_CANDIDATES,
199
+ untracked: untrackedPaths.length,
200
+ };
201
+ } catch (err) {
202
+ // Defensive catch-all - this probe must never throw into /close.
203
+ return unavailable('internal-error', String(err?.message ?? err).slice(0, 200));
204
+ }
205
+ }
@@ -3,15 +3,26 @@
3
3
  * This is a text boundary, not a shell/option parser: quotes and command syntax
4
4
  * in the context remain data. `unknown` is a ledger value, never a live mode.
5
5
  *
6
+ * A bare `/session` defaults to `deep`. In a repo without any session history
7
+ * of its own that default is not silent: the result carries
8
+ * `defaultReason: 'no-session-history'`, and the caller asks for task and mode
9
+ * first (#1561). `hasHistory` left undefined means "not measured" and keeps
10
+ * the plain default.
11
+ *
6
12
  * @param {string} [argumentsText] Complete text following /session.
7
- * @returns {{sessionType: string, context: string, profile?: string, invalidMode?: string}}
13
+ * @param {{hasHistory?: boolean}} [options]
14
+ * @returns {{sessionType: string, context: string, profile?: string, invalidMode?: string, defaultReason?: string}}
8
15
  */
9
- export function resolveSessionInvocation(argumentsText = '') {
16
+ export function resolveSessionInvocation(argumentsText = '', { hasHistory } = {}) {
10
17
  if (typeof argumentsText !== 'string') {
11
18
  throw new TypeError('session invocation arguments must be a string');
12
19
  }
13
20
  const [_, mode, context = ''] = argumentsText.trimStart().match(/^(\S+)(?:\s+([\s\S]*))?$/u) ?? [];
14
- if (mode === undefined) return { sessionType: 'deep', context: '' };
21
+ if (mode === undefined) {
22
+ return hasHistory === false
23
+ ? { sessionType: 'deep', context: '', defaultReason: 'no-session-history' }
24
+ : { sessionType: 'deep', context: '' };
25
+ }
15
26
  if (mode === 'ultradeep') return { sessionType: 'deep', profile: 'ultradeep', context };
16
27
  if (['housekeeping', 'feature', 'deep'].includes(mode)) {
17
28
  return { sessionType: mode, context };
@@ -38,7 +38,7 @@ import crypto from 'node:crypto';
38
38
  import { promises as fs } from 'node:fs';
39
39
  import { digestSha256 } from './crypto-digest-utils.mjs';
40
40
 
41
- import { appendFileSync, mkdirSync } from 'node:fs';
41
+ import { appendFileSync, mkdirSync, realpathSync } from 'node:fs';
42
42
 
43
43
  import { utcTimestamp, appendJsonl } from './common.mjs';
44
44
  import { resolvePrivateConfigDir } from './config/private-config-dir.mjs';
@@ -89,14 +89,24 @@ function entryPath(sessionId) {
89
89
  // ---------------------------------------------------------------------------
90
90
 
91
91
  /**
92
- * Stable SHA-256 hex of an absolute path. Used so registry entries can be
92
+ * Stable SHA-256 hex of a canonical repo path. Used so registry entries can be
93
93
  * correlated by repo without exposing local filesystem layout.
94
94
  */
95
95
  export function repoPathHash(absPath) {
96
96
  if (typeof absPath !== 'string' || absPath.length === 0) {
97
97
  throw new TypeError('repoPathHash: absPath must be a non-empty string');
98
98
  }
99
- return digestSha256(path.resolve(absPath));
99
+ return digestSha256(resolveRepoPath(absPath));
100
+ }
101
+
102
+ /** Resolve symlinks and on-disk casing; unavailable paths keep their old identity. */
103
+ function resolveRepoPath(repoPath) {
104
+ const resolved = path.resolve(repoPath);
105
+ try {
106
+ return realpathSync.native(resolved);
107
+ } catch {
108
+ return resolved;
109
+ }
100
110
  }
101
111
 
102
112
  function _assertSessionId(sessionId) {
@@ -255,7 +265,7 @@ export async function registerSelf(opts) {
255
265
  pid: opts.pid ?? process.pid,
256
266
  platform: opts.platform ?? null,
257
267
  repo_path_hash: repoPathHash(projectRoot),
258
- repo_name: path.basename(path.resolve(projectRoot)),
268
+ repo_name: path.basename(resolveRepoPath(projectRoot)),
259
269
  branch: opts.branch ?? null,
260
270
  started_at: now,
261
271
  last_heartbeat: now,
@@ -0,0 +1,130 @@
1
+ import path from 'node:path';
2
+ import { parseStateMd } from '../state-md/yaml-parser.mjs';
3
+
4
+ export const EVENT_NAMES = new Set([
5
+ 'orchestrator.quality_gate.passed', 'orchestrator.quality_gate.failed',
6
+ 'orchestrator.vault.mirror_run_completed', 'orchestrator.vault.board_written',
7
+ ]);
8
+ const text = v => typeof v === 'string' && v.length ? v : null;
9
+ const number = v => typeof v === 'number' && Number.isFinite(v) ? v : null;
10
+ const positive = v => Number.isSafeInteger(v) && v > 0 ? v : null;
11
+ export const safeId = v => typeof v === 'string' && /^[a-zA-Z0-9_-]{1,200}$/.test(v);
12
+
13
+ export function parseEvents(tail) {
14
+ return tail.split('\n').flatMap(line => {
15
+ try {
16
+ const e = JSON.parse(line);
17
+ return EVENT_NAMES.has(e.event ?? e.event_type) ? [e] : [];
18
+ } catch { return []; }
19
+ }).sort((a, b) => (Date.parse(a.timestamp) || 0) - (Date.parse(b.timestamp) || 0));
20
+ }
21
+
22
+ export function gateFromEvent(e) {
23
+ if (!e) return null;
24
+ const failed = (e.event ?? e.event_type).endsWith('.failed') || e.check_failed === true || e.timed_out === true || (number(e.exit_code) !== null && e.exit_code !== 0);
25
+ const reporting = ['baseline', 'incremental', 'per-file'].includes(e.variant);
26
+ const proven = e.exit_code === 0 && (reporting ? e.check_failed === false : ['full-gate', 'auto-fix-loop'].includes(e.variant));
27
+ return { variant: text(e.variant), passed: failed ? false : proven ? true : null,
28
+ check_failed: typeof e.check_failed === 'boolean' ? e.check_failed : null,
29
+ exit_code: number(e.exit_code), at: text(e.timestamp) };
30
+ }
31
+
32
+ /** Both scope sources must be readable; incomplete dispatch never proves disjointness. */
33
+ export function scopePaths(aggregate, dispatch) {
34
+ if (!Array.isArray(aggregate) || !dispatch || !Array.isArray(dispatch.agents)) return null;
35
+ if ((dispatch.scopeSignals?.['marker-absent'] ?? 0) > 0 || (dispatch.scopeSignals?.unparseable ?? 0) > 0) return null;
36
+ const rows = [...aggregate, ...dispatch.agents];
37
+ if (rows.some(r => !r || !Array.isArray(r.files) || r.files.some(f => typeof f !== 'string'))) return null;
38
+ return [...new Set(rows.flatMap(r => r.files))];
39
+ }
40
+
41
+ /** All I/O and time are supplied. No writes, host discovery or defaults hidden in this fold. */
42
+ export async function collectSnapshot(repoRoot, readers) {
43
+ const degraded = [];
44
+ const read = async (file, json = false) => {
45
+ try {
46
+ const raw = await readers.readText(path.join(repoRoot, file));
47
+ return { value: json ? JSON.parse(raw) : raw, absent: false };
48
+ } catch (e) {
49
+ if (e.code !== 'ENOENT') degraded.push(`${file}:unreadable`);
50
+ return { value: null, absent: e.code === 'ENOENT' };
51
+ }
52
+ };
53
+ const now = readers.now();
54
+ const [lockResult, waveRead, stateRead, agentStatus, discovery, eventsRead, config, greenRead] = await Promise.all([
55
+ readers.lock(repoRoot), read('.claude/wave-scope.json', true),
56
+ readers.statePath ? read(readers.statePath) : read('.claude/STATE.md'),
57
+ readers.agents(repoRoot), readers.discover(repoRoot), readers.events(repoRoot), readers.config(repoRoot), read('.orchestrator/runtime/last-green-sha.txt'),
58
+ ]);
59
+ degraded.push(...(discovery.degraded ?? []), ...(eventsRead.degraded ?? []), ...(config.degraded ?? []));
60
+ if (!['ok', 'absent'].includes(lockResult.status)) degraded.push(`lock:${lockResult.status}`);
61
+ const lock = lockResult.lock ?? null;
62
+ const sid = readers.sessionId ?? lock?.session_id ?? null;
63
+ const wave = waveRead.value && typeof waveRead.value === 'object' && !Array.isArray(waveRead.value) ? waveRead.value : null;
64
+ const foreignWave = wave?.session_id && sid && wave.session_id !== sid;
65
+ if (foreignWave) degraded.push('wave:foreign-session');
66
+ const ownWave = foreignWave || !positive(wave?.wave) ? null : wave;
67
+ if (!waveRead.absent && !ownWave && !foreignWave) degraded.push('wave:invalid-or-unreadable');
68
+ const waveNumber = positive(ownWave?.wave);
69
+ const fm = parseStateMd(stateRead.value)?.frontmatter ?? {};
70
+ const foreignState = fm['session-id'] && sid && fm['session-id'] !== sid;
71
+ if (foreignState) degraded.push('state:foreign-session');
72
+ const state = foreignState ? {} : fm;
73
+ const dispatch = safeId(sid) ? (await read(`.orchestrator/wave-dispatch-scopes.${sid}.json`, true)).value : null;
74
+ const dispatchWave = positive(Number(String(dispatch?.waveKey ?? '').match(/\|w(\d+)\|/)?.[1]));
75
+ const currentDispatch = dispatch && waveNumber && dispatchWave === waveNumber && Array.isArray(dispatch.agents) ? dispatch : null;
76
+ const entries = new Map();
77
+ for (const a of currentDispatch?.agents ?? []) {
78
+ if (!text(a?.id)) continue;
79
+ entries.set(a.id, { id: a.id, typ: text(a.typ ?? a.agent_type ?? a.type), modellfamilie: text(a.modellfamilie ?? a.model), status: null, tokens: null, letzter_request_at: null, quelle: 'dispatch' });
80
+ }
81
+ for (const [id, a] of Object.entries(agentStatus.entries ?? {})) {
82
+ // Unbound/foreign status cannot make the current wave look alive.
83
+ if (!sid || (a.session_id ?? a.sessionId) !== sid) continue;
84
+ if (waveNumber && (a.wave ?? a.wave_number) !== waveNumber) continue;
85
+ const existing = entries.get(id);
86
+ entries.set(id, { id, typ: text(a.agent_type) ?? existing?.typ ?? null, modellfamilie: text(a.model) ?? existing?.modellfamilie ?? null,
87
+ status: text(a.status ?? a.text), tokens: null, letzter_request_at: null, quelle: existing ? 'dispatch+agent-status' : 'agent-status' });
88
+ }
89
+ if (agentStatus.degraded) degraded.push('agents:degraded');
90
+ const events = parseEvents(eventsRead.text ?? '');
91
+ const gates = events.filter(e => (e.event ?? e.event_type).startsWith('orchestrator.quality_gate.'));
92
+ const lastGate = gates.reduce((last, event) => {
93
+ const next = gateFromEvent(event);
94
+ return last?.passed === false && next.passed === null ? last : next;
95
+ }, null);
96
+ const lockLive = lock ? readers.isLockLive(lock, now) : false;
97
+ const sessionLockLive = lockLive && sid !== null && lock.session_id === sid;
98
+ const latest = name => events.filter(e => (e.event ?? e.event_type) === name && e.dry_run !== true).at(-1);
99
+ const mirror = latest('orchestrator.vault.mirror_run_completed');
100
+ const board = latest('orchestrator.vault.board_written');
101
+ const peers = await Promise.all((discovery.sessions ?? []).filter(s => s.sessionId !== sid).map(async s => {
102
+ const root = s.worktreePath;
103
+ const peerRead = async file => { try { return JSON.parse(await readers.readText(path.join(root, file))); } catch { return null; } };
104
+ const peerLock = (await readers.lock(root)).lock;
105
+ const peerStateRaw = await readers.readText(path.join(root, '.claude/STATE.md')).catch(() => null);
106
+ const peerFm = parseStateMd(peerStateRaw)?.frontmatter;
107
+ const bound = peerFm?.['session-id'] === s.sessionId;
108
+ const stateWave = bound ? positive(peerFm['current-wave']) : null;
109
+ const pd = safeId(s.sessionId) ? await peerRead(`.orchestrator/wave-dispatch-scopes.${s.sessionId}.json`) : null;
110
+ const dispatchNumber = pd?.waveKey?.startsWith(`${s.sessionId}|`) ? positive(Number(pd.waveKey.split('|')[1]?.slice(1))) : null;
111
+ const currentWave = stateWave ?? dispatchNumber;
112
+ const aggregate = currentWave ? await peerRead(`.claude/filescopes/wave-${currentWave}.scopes.json`) : null;
113
+ return { session_id: s.sessionId, branch: text(s.branch), status: bound ? text(peerFm.status) : null,
114
+ current_wave: currentWave, last_heartbeat: peerLock?.session_id === s.sessionId ? text(peerLock.last_heartbeat) : text(s.last_heartbeat),
115
+ gleiches_working_copy: path.resolve(root) === path.resolve(repoRoot), scope_paths: dispatchNumber !== null && dispatchNumber !== currentWave ? null : scopePaths(aggregate, pd) };
116
+ }));
117
+ return {
118
+ schema_version: 1, measured_at: new Date(now).toISOString(), repo: { root: repoRoot, name: path.basename(repoRoot) },
119
+ lock: lock ? { session_id: text(lock.session_id), live: lockLive, last_heartbeat: text(lock.last_heartbeat) } : null,
120
+ session: sid ? { session_id: sid, semantic_session_id: text(lock?.semantic_session_id) } : null,
121
+ wave: { running: waveRead.absent ? false : ownWave ? sessionLockLive : null, number: waveNumber, role: text(ownWave?.role),
122
+ agents_total: Array.isArray(currentDispatch?.agents) ? currentDispatch.agents.length : null, source: ownWave ? '.claude/wave-scope.json' : null },
123
+ agents: { entries: [...entries.values()], source: agentStatus.source ?? (currentDispatch ? 'dispatch' : null), at: agentStatus.at ?? text(currentDispatch?.updated), degraded: agentStatus.degraded ?? null },
124
+ state: { status: text(state.status), session_type: text(state['session-type'] ?? state.type ?? state.mode), current_wave: positive(state['current-wave']), total_waves: positive(state['total-waves'] ?? state.waves) },
125
+ gate: { last: lastGate, last_green_sha: typeof greenRead.value === 'string' && /^[a-f0-9]{7,64}$/i.test(greenRead.value.trim()) ? greenRead.value.trim() : null },
126
+ peers, vault: { enabled: config.enabled ?? null, mode: config.mode ?? null,
127
+ last_run: mirror ? { at: text(mirror.timestamp), created: number(mirror.created), updated: number(mirror.updated), failed: number(mirror.failed), aborted: mirror.aborted ?? null } : null,
128
+ board: board ? { action: text(board.action), at: text(board.timestamp) } : null }, degraded: [...new Set(degraded)],
129
+ };
130
+ }
@@ -0,0 +1,76 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { promisify } from 'node:util';
5
+ import { discoverActiveSessions } from '../session-discovery.mjs';
6
+ import { readRegistry, activeDir } from '../session-registry.mjs';
7
+ import { readCurrentStatus } from '../agent-status.mjs';
8
+ import { readLockDetailed, isLockLive } from '../session-lock.mjs';
9
+ import { readTailWindow } from '../tail-window.mjs';
10
+ import { parseSessionConfig } from '../config.mjs';
11
+ import { resolveStateMdPath } from '../state-md.mjs';
12
+ import { collectSnapshot } from './collect.mjs';
13
+ const exec = promisify(execFile);
14
+ export const MAX_EVENT_BYTES = 512 * 1024;
15
+
16
+ export function readEventTail(repoRoot) {
17
+ try {
18
+ const tail = readTailWindow(path.join(repoRoot, '.orchestrator/metrics/events.jsonl'), MAX_EVENT_BYTES);
19
+ return { text: tail.cut ? tail.text.slice(tail.text.indexOf('\n') + 1) : tail.text, degraded: tail.cut ? ['events:tail-only'] : [] };
20
+ } catch (e) { return { text: '', degraded: [e.code === 'ENOENT' ? 'events:absent' : 'events:unreadable'] }; }
21
+ }
22
+
23
+ async function registryWithHealth() {
24
+ // The canonical reader intentionally swallows failures; distinguish its directory first.
25
+ try { await fs.readdir(activeDir()); }
26
+ catch (e) { if (e.code === 'ENOENT') return []; throw e; }
27
+ return readRegistry();
28
+ }
29
+
30
+ /** Capture fallback at the injected boundaries, without intercepting global stderr. */
31
+ export async function discover(repoRoot, { run = exec, registry = registryWithHealth, discovery = discoverActiveSessions, now = Date.now() } = {}) {
32
+ const degraded = [];
33
+ let registryEntries = [];
34
+ const sessions = await discovery(repoRoot, {
35
+ now, timeoutMs: 1000,
36
+ listWorktreesImpl: async () => {
37
+ try {
38
+ const { stdout } = await run('git', ['-C', repoRoot, 'worktree', 'list', '--porcelain'], { timeout: 700, maxBuffer: 512 * 1024 });
39
+ return stdout.trim().split('\n\n').filter(Boolean).map(block => {
40
+ const lines = block.split('\n');
41
+ return { path: lines.find(l => l.startsWith('worktree '))?.slice(9), branch: lines.find(l => l.startsWith('branch '))?.slice(7).replace(/^refs\/heads\//, '') ?? '' };
42
+ }).filter(w => w.path);
43
+ } catch (e) {
44
+ degraded.push(e.killed || e.code === 'ETIMEDOUT' ? 'discovery-timeout' : 'discovery-fallback');
45
+ return [{ path: repoRoot, branch: '' }];
46
+ }
47
+ },
48
+ registryReader: async () => {
49
+ try { registryEntries = await registry(); return registryEntries; } catch { degraded.push('registry-unreadable'); return []; }
50
+ },
51
+ });
52
+ if (sessions.some(s => s.registryOnly)) degraded.push('discovery:registry-fallback');
53
+ return { sessions: sessions.map(s => ({ ...s, last_heartbeat: registryEntries.find(r => r.session_id === s.sessionId)?.last_heartbeat ?? null })), degraded };
54
+ }
55
+
56
+ async function config(repoRoot) {
57
+ for (const name of ['CLAUDE.md', 'AGENTS.md']) {
58
+ try {
59
+ const source = await fs.readFile(path.join(repoRoot, name), 'utf8');
60
+ if (!/^vault-integration:/m.test(source)) continue;
61
+ // No host-local owner.yaml reads and no vault path resolution outside this repo.
62
+ const parsed = parseSessionConfig(source, { hostPaths: { env: {}, ownerConfig: {} } });
63
+ const value = parsed['vault-integration'];
64
+ return { enabled: value?.enabled ?? null, mode: value?.mode ?? null };
65
+ } catch (e) { if (e.code !== 'ENOENT') return { degraded: ['config:unreadable'] }; }
66
+ }
67
+ return { enabled: null, mode: null };
68
+ }
69
+ export async function measure(repoRoot) {
70
+ return collectSnapshot(repoRoot, {
71
+ now: Date.now, readText: file => fs.readFile(file, 'utf8'), lock: root => readLockDetailed({ repoRoot: root }),
72
+ isLockLive, agents: root => readCurrentStatus({ repoRoot: root }), discover, events: readEventTail, config,
73
+ statePath: path.relative(repoRoot, resolveStateMdPath(repoRoot)),
74
+ sessionId: process.env.CLAUDE_SESSION_ID ?? process.env.SO_SESSION_ID ?? null,
75
+ });
76
+ }