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.
- package/.claude-plugin/marketplace.json +12 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor-plugin/plugin.json +1 -1
- package/CHANGELOG.md +19 -0
- package/README.md +22 -12
- package/commands/session.md +3 -1
- package/docs/install.md +12 -8
- package/docs/session-config-reference.md +6 -6
- package/docs/session-config-template.md +2 -2
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/on-session-start.mjs +19 -10
- package/package.json +1 -2
- package/scripts/fleet-checkin.mjs +61 -3
- package/scripts/lib/config/remote-hosts.mjs +2 -2
- package/scripts/lib/config.mjs +1 -1
- package/scripts/lib/fleet-protocol.mjs +17 -0
- package/scripts/lib/session-end/vault-orphans.mjs +205 -0
- package/scripts/lib/session-invocation.mjs +14 -3
- package/scripts/lib/session-registry.mjs +14 -4
- package/scripts/lib/so-hud/collect.mjs +130 -0
- package/scripts/lib/so-hud/runtime.mjs +76 -0
- package/scripts/lib/state-md/idle-reset.mjs +239 -0
- package/scripts/lib/state-md/recommendations.mjs +26 -4
- package/scripts/lib/vault-mirror/namespace.mjs +3 -2
- package/scripts/lib/vault-mirror/process.mjs +143 -40
- package/scripts/lib/vault-mirror/render-learnings.mjs +6 -5
- package/scripts/lib/vault-mirror/utils.mjs +27 -13
- package/scripts/lib/vault-status/board-writer.mjs +32 -4
- package/scripts/resolve-session-invocation.mjs +64 -8
- package/scripts/so-hud.mjs +45 -0
- package/scripts/state-md-idle-reset.mjs +81 -0
- package/scripts/vault-orphans.mjs +171 -0
- package/skills/_shared/fleet-protocol.md +24 -2
- package/skills/_shared/state-ownership.md +3 -3
- package/skills/autopilot/SKILL.md +3 -2
- package/skills/session-end/SKILL.md +3 -2
- package/skills/session-end/references/phase-3-7e-vault-orphans.md +63 -0
- package/skills/session-end/references/phase-3-documentation-updates.md +11 -1
- package/skills/session-end/references/session-summary-template.md +4 -0
- package/skills/session-start/SKILL.md +1 -1
- package/skills/session-start/phase-7-5-mode-selector.md +3 -1
- package/skills/session-start/references/phase-1-5-session-continuity.md +12 -4
- 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
|
-
* @
|
|
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)
|
|
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
|
|
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(
|
|
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(
|
|
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
|
+
}
|