session-orchestrator 3.16.0 → 3.17.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 (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/CHANGELOG.md +25 -0
  5. package/README.md +13 -11
  6. package/docs/README.md +2 -1
  7. package/docs/components.md +2 -2
  8. package/docs/pi-setup.md +1 -1
  9. package/docs/session-config-reference.md +65 -0
  10. package/docs/session-config-template.md +27 -0
  11. package/docs/telemetry/telemetry-claims.md +204 -0
  12. package/docs/telemetry.md +158 -0
  13. package/hooks/hooks-codex.json +1 -1
  14. package/hooks/hooks.json +1 -1
  15. package/hooks/skill-invocation-telemetry.mjs +109 -10
  16. package/package.json +12 -2
  17. package/scripts/compute-grounding-injection.sh +18 -3
  18. package/scripts/dialectic-deriver.mjs +7 -2
  19. package/scripts/lib/auto-dialectic.mjs +11 -2
  20. package/scripts/lib/auto-dream.mjs +16 -5
  21. package/scripts/lib/build-live-signals.mjs +7 -4
  22. package/scripts/lib/config/context-coverage.mjs +82 -0
  23. package/scripts/lib/config/moc-staleness.mjs +98 -0
  24. package/scripts/lib/config/worktree-orphans.mjs +138 -0
  25. package/scripts/lib/config.mjs +15 -0
  26. package/scripts/lib/context-coverage-banner.mjs +223 -0
  27. package/scripts/lib/dispatcher/enumerate.mjs +151 -31
  28. package/scripts/lib/dispatcher/rank.mjs +22 -8
  29. package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
  30. package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
  31. package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
  32. package/scripts/lib/moc-staleness-banner.mjs +267 -0
  33. package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
  34. package/scripts/lib/session-schema/filters.mjs +88 -0
  35. package/scripts/lib/session-schema.mjs +1 -0
  36. package/scripts/lib/skill-health/join.mjs +35 -9
  37. package/scripts/lib/telemetry/anon-id.mjs +141 -0
  38. package/scripts/lib/telemetry/consent.mjs +299 -0
  39. package/scripts/lib/telemetry/paths.mjs +27 -0
  40. package/scripts/lib/telemetry/queue.mjs +287 -0
  41. package/scripts/lib/telemetry/schema.mjs +384 -0
  42. package/scripts/lib/telemetry/sync.mjs +312 -0
  43. package/scripts/lib/vault-status/board-writer.mjs +63 -5
  44. package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
  45. package/scripts/mcp-server.sh +15 -3
  46. package/scripts/telemetry.mjs +250 -0
  47. package/skills/npm-publish/SKILL.md +81 -0
  48. package/skills/session-end/SKILL.md +74 -1
  49. package/skills/session-start/SKILL.md +77 -1
  50. package/skills/vault-sync/SKILL.md +1 -1
  51. package/skills/vault-sync/package-lock.json +3 -3
  52. package/skills/vault-sync/validator.mjs +121 -34
@@ -0,0 +1,267 @@
1
+ /**
2
+ * moc-staleness-banner.mjs — Session-start banner for issue #831 (building
3
+ * block B2).
4
+ *
5
+ * Surfaces a `warn` banner during session-start Phase 4 when a Meta-Vault
6
+ * MOC (map-of-content) index note — a `<vaultDir>/08-topics/*-moc.md` file —
7
+ * has an `updated:` frontmatter older than a configurable threshold (default
8
+ * 90 days). Nothing in this repo reads `08-topics/` before this module; it
9
+ * is the first consumer.
10
+ *
11
+ * Design notes:
12
+ * - Mirrors the contract used by every other Phase 4 banner
13
+ * (`scripts/lib/peer-cards/staleness-banner.mjs`, `scripts/lib/vault-staleness-banner.mjs`,
14
+ * `scripts/lib/loop-readiness-banner.mjs`, `scripts/lib/reconcile-nudge-banner.mjs`,
15
+ * and sibling building block `scripts/lib/context-coverage-banner.mjs` — B4
16
+ * of this same issue): a single `checkXxx()` entry point that returns
17
+ * `null` (silent no-op) or `{ severity, message, ... }` — never an array,
18
+ * never `undefined`, never a throw.
19
+ * - Synchronous — the probe only touches `existsSync`/`readdirSync`/`readFileSync`,
20
+ * so unlike the async peer-cards/reconcile-nudge probes this one needs no
21
+ * `await` at the call site (mirrors `checkLoopReadiness` / `checkContextCoverage`).
22
+ * - Never throws. Wrapped in an outermost defensive `try/catch`; every
23
+ * individually-fallible filesystem call additionally gets its own inner
24
+ * bare (no-binding) catch with a one-line explanatory comment.
25
+ * - `vault-dir` resolution mirrors the host-local-override pattern used
26
+ * throughout the plugin (issue #653): an injected `opts.vaultDir` test
27
+ * seam wins, then `config['vault-integration']['vault-dir']`, else the
28
+ * probe silently no-ops (no vault configured — nothing to check).
29
+ * - The committed repo default for `vault-integration.vault-dir` is
30
+ * tilde-prefixed (`~/Projects/vault`) and is NOT pre-expanded anywhere
31
+ * upstream of this module — `expandTilde()` from `./common.mjs` is applied
32
+ * unconditionally before the first `path.join`, exactly as
33
+ * `checkContextCoverage()` does for the same reason.
34
+ * - A `*-moc.md` file whose `updated:` frontmatter is missing or
35
+ * unparseable is EXCLUDED, not reported stale — mirrors
36
+ * `scripts/lib/peer-cards/staleness-banner.mjs` (~L61-76): the corrective
37
+ * action there is "fix the frontmatter", not this banner's "refresh the
38
+ * `updated:` date" remediation hint. Gated via `Number.isFinite(days)`.
39
+ *
40
+ * Cross-references:
41
+ * - `scripts/lib/config/moc-staleness.mjs` (`_parseMocStaleness`) — the
42
+ * `moc-staleness:` Session Config block parser. NOT wired into
43
+ * `scripts/lib/config.mjs` by this module — the coordinator registers it
44
+ * separately. The exact lines to add there:
45
+ *
46
+ * import { _parseMocStaleness } from './config/moc-staleness.mjs';
47
+ * // ... later, alongside the other top-level block parses:
48
+ * const mocStaleness = _parseMocStaleness(mdContent);
49
+ * // ... in the returned config object:
50
+ * 'moc-staleness': mocStaleness,
51
+ *
52
+ * - `scripts/lib/common.mjs` (`expandTilde`) — shared tilde-expansion helper.
53
+ * - `scripts/lib/context-coverage-banner.mjs` — sibling B4 building block for
54
+ * the same issue; this module mirrors its structure closely.
55
+ * - `skills/session-start/SKILL.md` Phase 4 — banner render site (wiring
56
+ * snippet supplied separately; this module does not edit that file).
57
+ * - Issue #831 (building block B2).
58
+ */
59
+
60
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
61
+ import path from 'node:path';
62
+ import YAML from 'js-yaml';
63
+
64
+ import { expandTilde } from './common.mjs';
65
+
66
+ /** Vault-relative directory holding MOC (map-of-content) index notes. */
67
+ const MOC_SUBDIR = '08-topics';
68
+
69
+ /** Filename suffix that identifies a MOC note within `08-topics/`. */
70
+ const MOC_SUFFIX = '-moc.md';
71
+
72
+ /** Fallback staleness threshold (days) when config supplies none/invalid. */
73
+ const DEFAULT_THRESHOLD_DAYS = 90;
74
+
75
+ /** Matches a leading `---\n...\n---` YAML frontmatter fence. */
76
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
77
+
78
+ /**
79
+ * Above this many stale filenames, the message truncates the name list and
80
+ * says so explicitly rather than silently dropping names past the limit.
81
+ */
82
+ const MAX_STALE_NAMES_IN_MESSAGE = 20;
83
+
84
+ /**
85
+ * Resolve the vault directory to scan.
86
+ *
87
+ * Precedence: `opts.vaultDir` (test seam) > `config['vault-integration']['vault-dir']` > null.
88
+ *
89
+ * @param {string|undefined} vaultDir
90
+ * @param {unknown} config
91
+ * @returns {string|null} raw (not-yet-tilde-expanded) vault dir, or null when unresolvable
92
+ */
93
+ function _resolveRawVaultDir(vaultDir, config) {
94
+ if (typeof vaultDir === 'string' && vaultDir.length > 0) return vaultDir;
95
+
96
+ if (config && typeof config === 'object') {
97
+ const vaultIntegration = /** @type {Record<string, unknown>} */ (config)['vault-integration'];
98
+ if (vaultIntegration && typeof vaultIntegration === 'object') {
99
+ const raw = /** @type {Record<string, unknown>} */ (vaultIntegration)['vault-dir'];
100
+ if (typeof raw === 'string' && raw.length > 0) return raw;
101
+ }
102
+ }
103
+
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Read a single MOC file and compute whole days since its `updated:`
109
+ * frontmatter field. Returns `NaN` (never throws) when the file cannot be
110
+ * read, has no frontmatter fence, the YAML fails to parse, `updated` is
111
+ * missing/non-string, or `updated` is not a parseable date — every one of
112
+ * these cases is the caller's signal to EXCLUDE the file, not report it
113
+ * stale.
114
+ *
115
+ * @param {string} filePath — absolute path to a `*-moc.md` file
116
+ * @param {number} nowMs — epoch ms clock
117
+ * @returns {number} whole days since `updated:`, or `NaN`
118
+ */
119
+ function _readMocStalenessDays(filePath, nowMs) {
120
+ let raw;
121
+ try {
122
+ raw = readFileSync(filePath, 'utf8');
123
+ } catch {
124
+ return NaN;
125
+ }
126
+
127
+ const m = raw.match(FRONTMATTER_RE);
128
+ if (!m) return NaN;
129
+
130
+ let fm;
131
+ try {
132
+ fm = YAML.load(m[1]);
133
+ } catch {
134
+ return NaN;
135
+ }
136
+
137
+ if (!fm || typeof fm !== 'object' || Array.isArray(fm)) return NaN;
138
+
139
+ // js-yaml's default schema auto-resolves an ISO-8601-shaped scalar (the
140
+ // canonical `updated:` form) into a native `Date`, NOT a string — verified:
141
+ // `YAML.load('updated: 2026-01-01T00:00:00Z').updated instanceof Date`.
142
+ // Accept both shapes; anything else (missing, number, array, …) excludes.
143
+ const rawUpdated = fm.updated;
144
+ let updatedMs;
145
+ if (rawUpdated instanceof Date) {
146
+ updatedMs = rawUpdated.getTime();
147
+ } else if (typeof rawUpdated === 'string') {
148
+ updatedMs = Date.parse(rawUpdated);
149
+ } else {
150
+ return NaN;
151
+ }
152
+ if (!Number.isFinite(updatedMs)) return NaN;
153
+
154
+ return Math.floor((nowMs - updatedMs) / 86_400_000);
155
+ }
156
+
157
+ /**
158
+ * Format the stale-filename list for the banner message, truncating (with an
159
+ * explicit note) past `MAX_STALE_NAMES_IN_MESSAGE`.
160
+ *
161
+ * @param {Array<{file: string, days: number}>} stale
162
+ * @returns {string}
163
+ */
164
+ function _formatStaleNames(stale) {
165
+ const parts = stale.map((s) => `${s.file} (${s.days}d)`);
166
+ if (parts.length <= MAX_STALE_NAMES_IN_MESSAGE) return parts.join(', ');
167
+ const shown = parts.slice(0, MAX_STALE_NAMES_IN_MESSAGE).join(', ');
168
+ const hiddenCount = parts.length - MAX_STALE_NAMES_IN_MESSAGE;
169
+ return `${shown}, and ${hiddenCount} more (name list truncated)`;
170
+ }
171
+
172
+ /**
173
+ * Check MOC staleness and produce a session-start banner.
174
+ *
175
+ * @param {object} [opts]
176
+ * @param {string} [opts.repoRoot] — REQUIRED absolute path to the repo root.
177
+ * @param {string} [opts.vaultDir] — test seam; overrides the config-resolved vault dir.
178
+ * @param {Date|number} [opts.now] — injectable clock for deterministic tests.
179
+ * @param {object} [opts.config] — optional already-parsed Session Config (avoids
180
+ * a second CLAUDE.md (or AGENTS.md on Codex CLI) read; caller passes `$CONFIG`, mirrors `checkContextCoverage`).
181
+ * Read keys: `config['moc-staleness']` (`.enabled`, `.mode`, `.thresholds.moc`)
182
+ * and `config['vault-integration']['vault-dir']`.
183
+ * @returns {null | { severity: 'warn', message: string, stale: Array<{file: string, days: number}> }}
184
+ */
185
+ export function checkMocStaleness({ repoRoot, vaultDir, now, config } = {}) {
186
+ try {
187
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
188
+
189
+ const cfg =
190
+ config &&
191
+ typeof config === 'object' &&
192
+ config['moc-staleness'] &&
193
+ typeof config['moc-staleness'] === 'object'
194
+ ? config['moc-staleness']
195
+ : {};
196
+
197
+ // Config gate — returns null BEFORE any filesystem I/O. Explicit opt-in
198
+ // required: `cfg.enabled` must be the literal `true`, not merely
199
+ // truthy/absent. A config block that is entirely absent (or present
200
+ // without an `enabled` key) must fail CLOSED, not open — see issue #831
201
+ // fail-open regression (a config carrying `vault-integration.vault-dir`
202
+ // but no `moc-staleness` block previously ran the probe unsolicited,
203
+ // because `undefined !== false`).
204
+ if (cfg?.enabled !== true || cfg?.mode === 'off') return null;
205
+
206
+ const rawVaultDir = _resolveRawVaultDir(vaultDir, config);
207
+ if (!rawVaultDir) return null;
208
+
209
+ const resolvedVaultDir = expandTilde(rawVaultDir);
210
+ const topicsDir = path.join(resolvedVaultDir, MOC_SUBDIR);
211
+
212
+ if (!existsSync(topicsDir)) return null;
213
+
214
+ let entries;
215
+ try {
216
+ entries = readdirSync(topicsDir);
217
+ } catch {
218
+ return null;
219
+ }
220
+ if (!Array.isArray(entries) || entries.length === 0) return null;
221
+
222
+ entries = [...entries].sort();
223
+
224
+ const mocFiles = entries.filter((f) => typeof f === 'string' && f.endsWith(MOC_SUFFIX));
225
+ if (mocFiles.length === 0) return null;
226
+
227
+ const nowMs =
228
+ now instanceof Date
229
+ ? now.getTime()
230
+ : typeof now === 'number' && Number.isFinite(now)
231
+ ? now
232
+ : Date.now();
233
+
234
+ const cfgThreshold =
235
+ cfg.thresholds && typeof cfg.thresholds === 'object' ? cfg.thresholds.moc : undefined;
236
+ const thresholdDays =
237
+ typeof cfgThreshold === 'number' && Number.isFinite(cfgThreshold) && cfgThreshold > 0
238
+ ? cfgThreshold
239
+ : DEFAULT_THRESHOLD_DAYS;
240
+
241
+ const stale = [];
242
+ for (const file of mocFiles) {
243
+ const filePath = path.join(topicsDir, file);
244
+ let days;
245
+ try {
246
+ days = _readMocStalenessDays(filePath, nowMs);
247
+ } catch {
248
+ days = NaN;
249
+ }
250
+ // Missing/unparseable `updated:` is EXCLUDED, not reported stale.
251
+ if (!Number.isFinite(days)) continue;
252
+ if (days > thresholdDays) stale.push({ file, days });
253
+ }
254
+
255
+ if (stale.length === 0) return null;
256
+
257
+ const subjectLabel = stale.length === 1 ? '1 MOC stale' : `${stale.length} MOCs stale`;
258
+ const finding = `${subjectLabel} (>${thresholdDays} days)`;
259
+ const remediation = 'review and refresh the `updated:` frontmatter.';
260
+ const message = `⚠ moc-staleness: ${finding} — ${_formatStaleNames(stale)} — ${remediation}`;
261
+
262
+ return { severity: 'warn', message, stale };
263
+ } catch {
264
+ // Defensive catch-all — banner must never throw.
265
+ return null;
266
+ }
267
+ }
@@ -0,0 +1,252 @@
1
+ /**
2
+ * worktree-orphan-sweep.mjs — Phase 4b Worktree-Orphan Sweep (#831 / B5).
3
+ *
4
+ * Identifies worktree branches with 0 commits ahead of the base branch —
5
+ * orphans left behind by finished sessions — and returns them as DATA.
6
+ *
7
+ * ┌──────────────────────────────────────────────────────────────────────┐
8
+ * │ THE IRON RULE: this module PROPOSES, it never DISPOSES. │
9
+ * │ It executes ZERO mutating commands — no `git worktree remove`, no │
10
+ * │ `git worktree prune`, no `git branch -d/-D`, no `git push --delete`, │
11
+ * │ no rm/rmSync/unlinkSync, no reset/clean/checkout --/stash. The only │
12
+ * │ git invocations it makes are read-only: `worktree list --porcelain`, │
13
+ * │ `rev-list --count --end-of-options`, and — via isWorktreeClean() — │
14
+ * │ `status --porcelain` plus `status --short --branch`. │
15
+ * └──────────────────────────────────────────────────────────────────────┘
16
+ *
17
+ * Grounding: `.claude/rules/parallel-sessions.md` § PSA-003 ("Never Destroy
18
+ * What You Didn't Create") — the test is *"Did I create this file/commit/
19
+ * change? If not, it is not mine to touch."* A sweep probe created none of the
20
+ * worktrees it inspects, so removal is categorically not its call. The return
21
+ * field is deliberately named `candidates` (not `orphans`, not `toDelete`, not
22
+ * `removals`): the name itself encodes "the coordinator decides". The operator
23
+ * AUQ is rendered by the coordinator at session-end Phase 4b, never here.
24
+ *
25
+ * CONSERVATIVE DEFAULT (safety-critical): any git error, unparseable output,
26
+ * detached HEAD, unresolvable branch, or ambiguity of any kind → the worktree
27
+ * is NOT reported as a candidate. Silence must never be read as "safe to
28
+ * delete". Precedent: `isWorktreeClean()` in ./worktree-cleanup.mjs returns
29
+ * false on any git error, documented as "conservative PSA-003 default: never
30
+ * auto-remove a worktree we could not verify".
31
+ *
32
+ * Banner contract: mirrors the other `checkXxx()` probes
33
+ * (`scripts/lib/peer-cards/staleness-banner.mjs`,
34
+ * `scripts/lib/vault-staleness-banner.mjs`) — a single entry point returning
35
+ * `null` (silent no-op) or one `{ severity, message, ... }` object. Never an
36
+ * array, never `undefined`, never a throw.
37
+ *
38
+ * DI seam (#580-DI-001): SYNCHRONOUS `execFileFn` (default `execFileSync`),
39
+ * matching its Phase 4a sibling ./worktree-cleanup.mjs and
40
+ * scripts/lib/memory-cleanup/worktree-sweep.mjs, because session-end Phase 4b
41
+ * runs in a synchronous coordinator step. The autopilot worktree driver
42
+ * (scripts/lib/autopilot/worktree-pipeline.mjs) deliberately uses an ASYNC
43
+ * `opts.$` (zx) seam instead; the seams are kept divergent on purpose —
44
+ * unifying them would break the sync/async boundary.
45
+ *
46
+ * #577 HARDEN-001: every git call is `execFileFn('git', [args…])` with an args
47
+ * ARRAY and no shell. Branch and path values are attacker-influenceable and are
48
+ * never interpolated into a shell string.
49
+ */
50
+
51
+ import path from 'node:path';
52
+ import { execFileSync } from 'node:child_process';
53
+
54
+ import { isWorktreeClean } from './worktree-cleanup.mjs';
55
+
56
+ /**
57
+ * Strip a `<mainRepoName>-` prefix from a worktree directory basename to
58
+ * recover the session id, mirroring the auto-promoted layout
59
+ * `<basePath>/<repo-name>-<sessionId>/` used by `enterWorktree()`.
60
+ *
61
+ * Falls back to the full basename when the prefix does not match — a worktree
62
+ * created by hand still deserves a stable identifier in the report.
63
+ *
64
+ * @param {string} wtPath
65
+ * @param {string} mainRepoName
66
+ * @returns {string}
67
+ */
68
+ function deriveSessionId(wtPath, mainRepoName) {
69
+ const basename = path.basename(wtPath);
70
+ if (mainRepoName && basename.startsWith(`${mainRepoName}-`)) {
71
+ return basename.slice(mainRepoName.length + 1);
72
+ }
73
+ return basename;
74
+ }
75
+
76
+ /**
77
+ * Sweep for worktree branches with 0 commits ahead of the base branch.
78
+ *
79
+ * @param {object} [opts]
80
+ * @param {string} opts.repoRoot - REQUIRED absolute path to the repo root.
81
+ * @param {string} [opts.mainCheckoutRoot] - Absolute path to the main checkout.
82
+ * When omitted it is derived from the FIRST `worktree ` line of the porcelain
83
+ * output. (`path.basename(repoRoot)` is explicitly NOT a correct derivation —
84
+ * see the W3 T2 finding documented in ./worktree-cleanup.mjs.)
85
+ * @param {object} [opts.config] - Config. BOTH shapes are accepted, because the
86
+ * two sibling probes take the FULL config and index their own block
87
+ * internally while this one historically took the already-indexed block —
88
+ * an asymmetry that made the natural copy-paste (`config: $CONFIG`) leave
89
+ * `cfg.enabled` undefined:
90
+ * • the FULL config — an object carrying a `worktree-orphans` key, which is
91
+ * then indexed here; or
92
+ * • the already-indexed BLOCK — `{ enabled, 'base-branch', mode }`.
93
+ * OPT-IN: absent, `undefined`, `{}`, or any shape without `enabled: true`
94
+ * returns `null` before a single git invocation.
95
+ * @param {Function} [opts.execFileFn] - Injectable execFileSync seam for tests.
96
+ * Signature: (file: string, args: string[], options) => string. Also handed
97
+ * to `isWorktreeClean()` so the dirty-check shares the same seam.
98
+ * @returns {null | {severity: 'warn', message: string,
99
+ * candidates: Array<{wtPath: string, branch: string, sessionId: string, aheadCount: 0}>}}
100
+ * `null` on bad input, when disabled, on any failure, or when nothing is
101
+ * found. Otherwise ONE object whose `candidates` are proposals only —
102
+ * nothing has been, or will be, removed by this module.
103
+ */
104
+ export function checkWorktreeOrphans({ repoRoot, mainCheckoutRoot, config, execFileFn } = {}) {
105
+ // Silent no-op on bad input (consistent with the other Phase 4/4b banners).
106
+ if (!repoRoot || typeof repoRoot !== 'string') return null;
107
+
108
+ try {
109
+ const raw = config && typeof config === 'object' ? config : {};
110
+ // Accept the FULL config or the already-indexed block (see @param config).
111
+ const nested = raw['worktree-orphans'];
112
+ const cfg = nested && typeof nested === 'object' ? nested : raw;
113
+
114
+ // Config gate BEFORE any git invocation — a disabled sweep costs nothing.
115
+ // MUST be `!== true`, never `=== false`: `undefined !== false`, so the
116
+ // latter fails OPEN and runs git for a repo that never opted in —
117
+ // contradicting the parser's "never pays a single git invocation"
118
+ // guarantee, with a deletion prompt as the downstream consequence.
119
+ if (cfg.enabled !== true || cfg.mode === 'off') return null;
120
+
121
+ const exec = typeof execFileFn === 'function' ? execFileFn : execFileSync;
122
+
123
+ const rawBase = cfg['base-branch'];
124
+ const baseBranch = typeof rawBase === 'string' && rawBase.trim() ? rawBase.trim() : 'main';
125
+
126
+ // Anchor for `git worktree list`: the injected main checkout when provided,
127
+ // otherwise repoRoot (git reports the same worktree set from any member).
128
+ const listAnchor =
129
+ mainCheckoutRoot && typeof mainCheckoutRoot === 'string' ? mainCheckoutRoot : repoRoot;
130
+
131
+ // ── READ-ONLY GIT CALL 1 of 2 ───────────────────────────────────────────
132
+ let out;
133
+ try {
134
+ out = exec('git', ['-C', listAnchor, 'worktree', 'list', '--porcelain'], {
135
+ encoding: 'utf8',
136
+ });
137
+ } catch {
138
+ // Not a git repo, or git unavailable → conservative no-op.
139
+ return null;
140
+ }
141
+ if (typeof out !== 'string' || out.trim().length === 0) return null;
142
+
143
+ const entries = out.split('\n\n').filter(Boolean);
144
+
145
+ // Derive the canonical main checkout from the FIRST `worktree ` line.
146
+ let resolvedMain = mainCheckoutRoot;
147
+ if (!resolvedMain || typeof resolvedMain !== 'string') {
148
+ const firstLine = out.split('\n').find((l) => l.startsWith('worktree '));
149
+ if (!firstLine) return null;
150
+ resolvedMain = firstLine.slice('worktree '.length).trim();
151
+ }
152
+ if (!resolvedMain) return null;
153
+
154
+ const mainRepoName = path.basename(resolvedMain);
155
+ const candidates = [];
156
+
157
+ for (const entry of entries) {
158
+ const wtMatch = entry.match(/^worktree (.+)$/m);
159
+ if (!wtMatch) continue;
160
+ const wtPath = wtMatch[1].trim();
161
+ if (!wtPath) continue;
162
+
163
+ // The main checkout is never a candidate.
164
+ let isMain = false;
165
+ try {
166
+ isMain = path.resolve(wtPath) === path.resolve(resolvedMain);
167
+ } catch {
168
+ // Unresolvable path → conservative: treat as main (i.e. skip it).
169
+ isMain = true;
170
+ }
171
+ if (isMain) continue;
172
+
173
+ // Branch must be resolvable. A detached HEAD has no `branch` line →
174
+ // conservative: not a candidate.
175
+ const branchMatch = entry.match(/^branch refs\/heads\/(.+)$/m);
176
+ if (!branchMatch) continue;
177
+ const branch = branchMatch[1].trim();
178
+ if (!branch) continue;
179
+
180
+ // ── READ-ONLY GIT CALL: ahead-count ───────────────────────────────────
181
+ // `--end-of-options` is load-bearing, not decoration. Without it a
182
+ // base-branch shaped like an option (`--glob=refs/heads/*`) is parsed by
183
+ // rev-list as an OPTION rather than a revision: it exits 0 with `0`, so
184
+ // `Number.isFinite(0)` holds, no error path fires, and EVERY worktree is
185
+ // silently reported as a 0-ahead orphan. With it, the same value yields
186
+ // rc=128 ("option must come before non-option arguments") and lands in
187
+ // the conservative `continue` below. The config parser rejects such
188
+ // values too (_isSafeBaseBranch); this is the second layer, for callers
189
+ // that construct the config block by hand.
190
+ let countOut;
191
+ try {
192
+ countOut = exec(
193
+ 'git',
194
+ ['-C', resolvedMain, 'rev-list', '--count', '--end-of-options', `${baseBranch}..${branch}`],
195
+ { encoding: 'utf8' },
196
+ );
197
+ } catch {
198
+ // Unknown base branch, missing ref, or any git error → conservative:
199
+ // not a candidate. A sibling worktree is unaffected by this failure.
200
+ continue;
201
+ }
202
+
203
+ if (typeof countOut !== 'string') continue;
204
+ const aheadCount = parseInt(countOut.trim(), 10);
205
+ // Unparseable output → conservative: not a candidate.
206
+ if (!Number.isFinite(aheadCount)) continue;
207
+ // Any work ahead of the base branch → definitively NOT an orphan.
208
+ if (aheadCount !== 0) continue;
209
+
210
+ // ── READ-ONLY GIT CALLS: uncommitted-work check ───────────────────────
211
+ // 0 commits ahead is NOT sufficient evidence of orphan-ness: a worktree
212
+ // can hold modified, staged, or untracked work that no commit-count ever
213
+ // sees. Reusing the Phase 4a sibling keeps both phases on one definition
214
+ // of "clean" (status --porcelain + ahead-scan, false on ANY git error —
215
+ // its documented conservative PSA-003 default, which maps exactly onto
216
+ // "not a candidate" here without needing a separate error branch).
217
+ //
218
+ // EXCLUDE rather than flag: the field is named `candidates` because the
219
+ // coordinator renders it as a deletion prompt, and a worktree holding
220
+ // live work is not a candidate for deletion review at all. A `dirty: true`
221
+ // flag would push that judgement into the prompt layer, where a
222
+ // mis-rendered label degrades straight into data loss.
223
+ if (!isWorktreeClean(wtPath, { execFileFn: exec })) continue;
224
+
225
+ candidates.push({
226
+ wtPath,
227
+ branch,
228
+ sessionId: deriveSessionId(wtPath, mainRepoName),
229
+ aheadCount: 0,
230
+ });
231
+ }
232
+
233
+ if (candidates.length === 0) return null;
234
+
235
+ const listed = candidates.map((c) => `${path.basename(c.wtPath)} (${c.branch})`).join(', ');
236
+ const subject =
237
+ candidates.length === 1
238
+ ? '1 worktree branch has 0 commits ahead of the base branch'
239
+ : `${candidates.length} worktree branches have 0 commits ahead of the base branch`;
240
+
241
+ // The trailing "nothing was removed" clause is MANDATORY — it is the
242
+ // operator-visible proof of the no-delete invariant above.
243
+ const message =
244
+ `⚠ worktree-orphans: ${subject} — ${listed} — ` +
245
+ `review via the cleanup prompt; nothing was removed.`;
246
+
247
+ return { severity: 'warn', message, candidates };
248
+ } catch {
249
+ // Defensive catch-all — banner must never throw.
250
+ return null;
251
+ }
252
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * session-schema/filters.mjs — abandoned-aware session filters (#834).
3
+ *
4
+ * Pure functions. Stdlib only. No imports from siblings or parent barrel
5
+ * (matches the Option 1 submodule-isolation convention of the other
6
+ * session-schema/* modules).
7
+ *
8
+ * ── WHY THIS EXISTS ────────────────────────────────────────────────────────
9
+ *
10
+ * `.orchestrator/metrics/sessions.jsonl` holds two kinds of record:
11
+ *
12
+ * REAL — a session that actually ran waves and produced work.
13
+ * ABANDONED — a phantom stub written by session-close-backfill from
14
+ * events.jsonl for a session that ended without a real close
15
+ * (0 waves, 0 agents, often seconds of runtime). The canonical
16
+ * marker is `status: 'abandoned'`.
17
+ *
18
+ * Phantoms are legitimate DATA (they record that a start happened), but they
19
+ * are not legitimate SIGNAL. A consumer that takes "the last N lines" of the
20
+ * ledger as "the last N sessions" silently shrinks its own analysis window by
21
+ * however many stubs happen to sit in the tail. Observed in this repo at the
22
+ * time of writing: 20 of 70 records abandoned (28.6%), and 6 of the last 10
23
+ * LINES were phantoms — so a `slice(-10)` window carried only 4 real sessions.
24
+ *
25
+ * `scripts/lib/eval/session-resolve.mjs` already filtered correctly and is the
26
+ * behavioural reference; this module generalizes that single inline check so
27
+ * the other consumers stop hand-rolling it (or, more commonly, omitting it).
28
+ *
29
+ * ── WHAT THIS DELIBERATELY DOES NOT DO ─────────────────────────────────────
30
+ *
31
+ * Not every reader SHOULD filter. Collision-avoidance id scans, ledger-touch
32
+ * staleness banners, lifetime "sessions ever" counters, peer-overlap windows,
33
+ * schema migrations and dedup checks all legitimately need the phantoms. Use
34
+ * these helpers only where the window is meant to represent REAL WORK.
35
+ *
36
+ * Exports: isRealSession, filterRealSessions, tailRealSessions
37
+ */
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Public API
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * True when a record represents REAL (non-phantom) work.
45
+ *
46
+ * Fail-open by design: anything that is not explicitly marked abandoned counts
47
+ * as real. Most historical records carry no `status` field at all, and those
48
+ * are genuine sessions — treating "absent" as "abandoned" would discard the
49
+ * majority of the ledger.
50
+ *
51
+ * @param {unknown} record — a parsed sessions.jsonl entry
52
+ * @returns {boolean}
53
+ */
54
+ export function isRealSession(record) {
55
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) return false;
56
+ return record.status !== 'abandoned';
57
+ }
58
+
59
+ /**
60
+ * Filter parsed records to REAL sessions, preserving source order.
61
+ *
62
+ * @param {unknown} records — array of parsed sessions.jsonl entries
63
+ * @returns {object[]} — never null; a non-array input yields []
64
+ */
65
+ export function filterRealSessions(records) {
66
+ if (!Array.isArray(records)) return [];
67
+ return records.filter(isRealSession);
68
+ }
69
+
70
+ /**
71
+ * Last N REAL sessions — the correct replacement for `arr.slice(-N)` on an
72
+ * unfiltered array.
73
+ *
74
+ * The semantic this fixes: `slice(-N)` means "last N LINES", which is only
75
+ * the same as "last N SESSIONS" when no phantom stubs sit in the tail.
76
+ *
77
+ * A non-positive or non-finite `n` yields [] rather than surprising the caller
78
+ * with JS's negative-index slice semantics.
79
+ *
80
+ * @param {unknown} records — array of parsed sessions.jsonl entries
81
+ * @param {number} n — how many REAL sessions to return from the tail
82
+ * @returns {object[]}
83
+ */
84
+ export function tailRealSessions(records, n) {
85
+ const real = filterRealSessions(records);
86
+ if (typeof n !== 'number' || !Number.isFinite(n) || n <= 0) return [];
87
+ return real.slice(-Math.floor(n));
88
+ }
@@ -69,3 +69,4 @@ export { ValidationError, validateSession } from './session-schema/validator.mjs
69
69
  export { normalizeSession } from './session-schema/normalizer.mjs';
70
70
  export { clampTimestampsMonotonic } from './session-schema/timestamps.mjs';
71
71
  export { aliasLegacyEndedAt } from './session-schema/aliases.mjs';
72
+ export { isRealSession, filterRealSessions, tailRealSessions } from './session-schema/filters.mjs';