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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +13 -11
- package/docs/README.md +2 -1
- package/docs/components.md +2 -2
- package/docs/pi-setup.md +1 -1
- package/docs/session-config-reference.md +65 -0
- package/docs/session-config-template.md +27 -0
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/package.json +12 -2
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config.mjs +15 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/dispatcher/enumerate.mjs +151 -31
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/mcp-server.sh +15 -3
- package/scripts/telemetry.mjs +250 -0
- package/skills/npm-publish/SKILL.md +81 -0
- package/skills/session-end/SKILL.md +74 -1
- package/skills/session-start/SKILL.md +77 -1
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- 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';
|