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
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
* enumerate.mjs — Candidate-repo enumeration + free/busy resolution.
|
|
3
3
|
*
|
|
4
4
|
* Epic #673 Phase 2 (issue #676, PRD §2 P2.1+P2.2, §4). Enumerates candidate
|
|
5
|
-
* repos
|
|
6
|
-
* via its per-repo
|
|
5
|
+
* repos below a confinement root (recursive walk, depth-capped — see
|
|
6
|
+
* {@link DEFAULT_MAX_DEPTH}) and resolves each as free or busy via its per-repo
|
|
7
|
+
* `session.lock` v2 lease (heartbeat-based liveness).
|
|
7
8
|
*
|
|
8
9
|
* Source of truth for free/busy: the same lease semantics as
|
|
9
10
|
* `scripts/lib/vault-status/board-writer.mjs` collectRows —
|
|
@@ -15,8 +16,8 @@
|
|
|
15
16
|
* the {@link Candidate} contract defined here.
|
|
16
17
|
*
|
|
17
18
|
* Exports:
|
|
18
|
-
* enumerateCandidates —
|
|
19
|
-
* repo's free/busy status from its lease.
|
|
19
|
+
* enumerateCandidates — walk a startDir up to `maxDepth` levels deep, resolve
|
|
20
|
+
* each repo's free/busy status from its lease.
|
|
20
21
|
* freeCandidates — filter helper: keep only `free === true` candidates.
|
|
21
22
|
*
|
|
22
23
|
* No top-level side effects. All filesystem + lock access is dependency-injected
|
|
@@ -47,6 +48,67 @@ const STATUS_FREI = 'frei';
|
|
|
47
48
|
const STATUS_IN_PROGRESS = 'in-progress';
|
|
48
49
|
const STATUS_FORCE_CLOSED = 'force-closed';
|
|
49
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Default walk depth. `1` = immediate children of the scan root only (the
|
|
53
|
+
* pre-#832 behaviour); `2` additionally covers `<root>/<org>/<repo>`.
|
|
54
|
+
*
|
|
55
|
+
* Measured on the reference host (~/Projects, 2026-07-19; warm dentry cache —
|
|
56
|
+
* a cold first walk costs roughly 10x these figures at either depth):
|
|
57
|
+
* depth 1 → 1 of 47 repos (2%) — the real topology is `<org>/<repo>`,
|
|
58
|
+
* so the scan missed 46 repos including
|
|
59
|
+
* session-orchestrator itself.
|
|
60
|
+
* depth 2 → 45 of 47 repos, ~0.9-1.9ms
|
|
61
|
+
* depth 3 → 47 of 47 repos, ~8ms (node_modules is pruned, but the extra
|
|
62
|
+
* level still multiplies the node count)
|
|
63
|
+
* for two additional repos, both archived.
|
|
64
|
+
* Depth 2 is therefore the default: it recovers 96% of the host's repos at
|
|
65
|
+
* negligible cost, while depth 3 costs ~8x the walk for two dead repos.
|
|
66
|
+
*/
|
|
67
|
+
const DEFAULT_MAX_DEPTH = 2;
|
|
68
|
+
|
|
69
|
+
/** Hard bounds for {@link clampMaxDepth}. Depth 3 is the ceiling by measurement. */
|
|
70
|
+
const MIN_MAX_DEPTH = 1;
|
|
71
|
+
const MAX_MAX_DEPTH = 3;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Directory names never DESCENDED into during the walk.
|
|
75
|
+
*
|
|
76
|
+
* Applied to the descent decision ONLY — never to repo emission — so the
|
|
77
|
+
* depth-1 contract stays byte-identical to the pre-#832 scan (`.orchestrator`,
|
|
78
|
+
* `.claude` etc. are still probed for a `.git` marker at depth 1; they simply
|
|
79
|
+
* have none). `node_modules` is the dominant cost driver at depth 3 and can
|
|
80
|
+
* legitimately contain vendored `.git` directories that are not host repos.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} name — a single path segment (Dirent.name).
|
|
83
|
+
* @returns {boolean} true iff the walk may recurse into this directory.
|
|
84
|
+
*/
|
|
85
|
+
function shouldDescendInto(name) {
|
|
86
|
+
if (typeof name !== 'string' || name.length === 0) return false;
|
|
87
|
+
if (name === 'node_modules') return false;
|
|
88
|
+
// Dot-directories (.git, .claude, .orchestrator, .venv, …) hold no host repos.
|
|
89
|
+
if (name.startsWith('.')) return false;
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Normalise a caller-supplied `maxDepth` into the supported 1..3 range.
|
|
95
|
+
* Anything that is not a positive finite number falls back to
|
|
96
|
+
* {@link DEFAULT_MAX_DEPTH} — including `0`, negatives, `NaN`, and non-numbers
|
|
97
|
+
* such as the string `'3'` (no coercion: a string is a caller bug, and silently
|
|
98
|
+
* honouring it would make an unvalidated config value widen the walk).
|
|
99
|
+
*
|
|
100
|
+
* @param {unknown} value
|
|
101
|
+
* @returns {number} an integer in [MIN_MAX_DEPTH, MAX_MAX_DEPTH].
|
|
102
|
+
*/
|
|
103
|
+
function clampMaxDepth(value) {
|
|
104
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_MAX_DEPTH;
|
|
105
|
+
const truncated = Math.trunc(value);
|
|
106
|
+
if (truncated <= 0) return DEFAULT_MAX_DEPTH;
|
|
107
|
+
if (truncated < MIN_MAX_DEPTH) return MIN_MAX_DEPTH;
|
|
108
|
+
if (truncated > MAX_MAX_DEPTH) return MAX_MAX_DEPTH;
|
|
109
|
+
return truncated;
|
|
110
|
+
}
|
|
111
|
+
|
|
50
112
|
/**
|
|
51
113
|
* Expand a leading `~` to the current user's home directory. Mirrors the helper
|
|
52
114
|
* in board-writer.mjs (a shared extraction is deferred to a later epic). Used to
|
|
@@ -126,20 +188,32 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
126
188
|
* free or busy via its local lease.
|
|
127
189
|
*
|
|
128
190
|
* Algorithm:
|
|
129
|
-
* 1.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
191
|
+
* 1. Depth-first walk of `startDir`, up to `maxDepth` levels deep (depth 1 =
|
|
192
|
+
* immediate children). Every directory node is a repo candidate iff
|
|
193
|
+
* `<node>/.git` exists (dir or file — the file form covers worktrees).
|
|
194
|
+
* 2. Confinement guard (`validatePathInsideProject(nodeAbs, startDir)`) runs
|
|
195
|
+
* on EVERY node BEFORE it is emitted AND before it is opened — see the
|
|
196
|
+
* security notes on the walk body below.
|
|
133
197
|
* 3. OPTIONAL secondary source: union with `getCrossRepoProjects()`
|
|
134
198
|
* config-declared paths (leading `~/` expanded, then confinement-filtered),
|
|
135
199
|
* deduped by `path.resolve()`. Additive, applied AFTER the FS scan.
|
|
136
200
|
* 4. Resolve free/busy per repo from its `session.lock` lease.
|
|
137
201
|
*
|
|
202
|
+
* A git repo does NOT terminate the descent: on a measured reference host, an
|
|
203
|
+
* org-level directory one level under the confinement root was itself a git
|
|
204
|
+
* repo (a small umbrella notes repo) that CONTAINED 16 independent repos —
|
|
205
|
+
* this plugin's own checkout among them. "A repo's children are not separate
|
|
206
|
+
* repos" is empirically false there, and an early-exit-on-`.git` walk dropped
|
|
207
|
+
* 45 discoverable repos to 29. Pruning is therefore by NAME
|
|
208
|
+
* ({@link shouldDescendInto}), never by `.git` presence.
|
|
209
|
+
*
|
|
138
210
|
* ALL repos are returned (busy ones LISTED, not dropped — downstream rank.mjs
|
|
139
211
|
* filters). Returns a plain serialisable {@link Candidate}[].
|
|
140
212
|
*
|
|
141
213
|
* @param {object} [opts]
|
|
142
214
|
* @param {string} [opts.startDir] — scan root; defaults to {@link getConfinementRoot}().
|
|
215
|
+
* @param {number} [opts.maxDepth] — walk depth, clamped to 1..3; defaults to
|
|
216
|
+
* {@link DEFAULT_MAX_DEPTH} (2) for anything non-numeric, non-finite, or <= 0.
|
|
143
217
|
* @param {number} [opts.now] — clock seam in ms; defaults to Date.now().
|
|
144
218
|
* @param {object} [opts.deps] — dependency-injection seam (Wave-4 testability).
|
|
145
219
|
* @param {Function} [opts.deps.readdirSync] — node:fs readdirSync.
|
|
@@ -151,7 +225,7 @@ function isGitRepo(childAbs, existsSyncFn) {
|
|
|
151
225
|
* @param {Function} [opts.deps.now] — () => ms (overridden by opts.now when set).
|
|
152
226
|
* @returns {Promise<Candidate[]>}
|
|
153
227
|
*/
|
|
154
|
-
export async function enumerateCandidates({ startDir, now, deps } = {}) {
|
|
228
|
+
export async function enumerateCandidates({ startDir, now, maxDepth, deps } = {}) {
|
|
155
229
|
const d = deps ?? {};
|
|
156
230
|
const readdirSyncFn = d.readdirSync ?? readdirSync;
|
|
157
231
|
const existsSyncFn = d.existsSync ?? existsSync;
|
|
@@ -165,6 +239,7 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
|
|
|
165
239
|
? startDir
|
|
166
240
|
: getConfinementRoot();
|
|
167
241
|
const nowMs = typeof now === 'number' ? now : nowFn();
|
|
242
|
+
const depthCap = clampMaxDepth(maxDepth);
|
|
168
243
|
|
|
169
244
|
// Dedup set keyed by resolved absolute path; preserves first-seen ordering
|
|
170
245
|
// (FS-scan repos first, config-declared additions after).
|
|
@@ -179,32 +254,77 @@ export async function enumerateCandidates({ startDir, now, deps } = {}) {
|
|
|
179
254
|
repoPaths.push(resolved);
|
|
180
255
|
};
|
|
181
256
|
|
|
182
|
-
// ── 1+2. FS
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
257
|
+
// ── 1+2. Depth-capped FS walk, confinement-guarded at every node. ──
|
|
258
|
+
//
|
|
259
|
+
// SECURITY (three load-bearing invariants — do not relax without re-reading
|
|
260
|
+
// validatePathInsideProject at scripts/lib/path-utils.mjs):
|
|
261
|
+
//
|
|
262
|
+
// (i) Every node is validated against the ORIGINAL `root`, NEVER against
|
|
263
|
+
// its own parent. The guard's Phase 2 calls realpathSync, which
|
|
264
|
+
// resolves EVERY intermediate component — so validating a grandchild
|
|
265
|
+
// against the original root is both sufficient and complete at any
|
|
266
|
+
// depth. Re-rooting per level (`validate(grandchild, childDir)`) would
|
|
267
|
+
// validate a symlinked subtree against ITSELF and defeat the guard.
|
|
268
|
+
//
|
|
269
|
+
// (ii) The guard runs BEFORE `readdirSync`, not merely before emission.
|
|
270
|
+
// Pre-#832 the guard ran only after `isGitRepo` passed, which was safe
|
|
271
|
+
// because a non-repo directory was never opened. Under recursion an
|
|
272
|
+
// unguarded non-repo directory WOULD be opened, so an `ok:false` node
|
|
273
|
+
// must be refused for descent as well as for emission.
|
|
274
|
+
//
|
|
275
|
+
// (iii) The guard call is wrapped in try/catch. path-utils.mjs rethrows any
|
|
276
|
+
// non-ENOENT realpath error, so a single mode-000 directory under the
|
|
277
|
+
// scan root would otherwise throw straight out of enumerateCandidates
|
|
278
|
+
// — and runDispatch (scripts/lib/dispatcher/cli.mjs) has no try/catch
|
|
279
|
+
// around this call. The walk now validates ~52 nodes instead of 1, so
|
|
280
|
+
// a throwing guard is treated as "skip this node".
|
|
281
|
+
//
|
|
282
|
+
// Unbounded recursion is impossible: `Dirent.isDirectory()` is false for a
|
|
283
|
+
// symlink-to-directory (verified empirically), so symlink cycles never enter
|
|
284
|
+
// the walk — and `depthCap` bounds it regardless, including for stubbed
|
|
285
|
+
// entries that do not implement isDirectory().
|
|
286
|
+
const walk = (dirAbs, depth) => {
|
|
287
|
+
let entries;
|
|
288
|
+
try {
|
|
289
|
+
entries = readdirSyncFn(dirAbs, { withFileTypes: true });
|
|
290
|
+
} catch {
|
|
291
|
+
// Unreadable/absent directory → this subtree contributes nothing.
|
|
292
|
+
// Siblings and the config-declared secondary source are unaffected.
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
191
295
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
// Only directories can be repos. Dirent.isDirectory() guards against
|
|
298
|
+
// files, sockets, etc. A stubbed entry may not implement isDirectory —
|
|
299
|
+
// fall back to treating it as a directory candidate (the depth cap and
|
|
300
|
+
// existsSync('.git') gate the consequences).
|
|
301
|
+
const isDir = typeof entry?.isDirectory === 'function' ? entry.isDirectory() : true;
|
|
302
|
+
if (!isDir) continue;
|
|
198
303
|
|
|
199
|
-
|
|
200
|
-
if (!isGitRepo(childAbs, existsSyncFn)) continue;
|
|
304
|
+
const childAbs = path.join(dirAbs, entry.name);
|
|
201
305
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
306
|
+
// Confinement guard — invariants (i)+(ii)+(iii) above.
|
|
307
|
+
let guard;
|
|
308
|
+
try {
|
|
309
|
+
guard = validatePathInsideProjectFn(childAbs, root);
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!guard || guard.ok !== true) continue;
|
|
205
314
|
|
|
206
|
-
|
|
207
|
-
|
|
315
|
+
if (isGitRepo(childAbs, existsSyncFn)) addRepo(childAbs);
|
|
316
|
+
|
|
317
|
+
// Descent is INDEPENDENT of repo-ness: a repo may contain further repos
|
|
318
|
+
// (the umbrella-repo case documented above). Prune by name only, and only
|
|
319
|
+
// for the descent decision — emission above is untouched, which keeps the
|
|
320
|
+
// depth-1 contract byte-identical to the pre-#832 scan.
|
|
321
|
+
if (depth < depthCap && shouldDescendInto(entry.name)) {
|
|
322
|
+
walk(childAbs, depth + 1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
walk(root, 1);
|
|
208
328
|
|
|
209
329
|
// ── 3. Optional secondary source: config-declared cross-repo projects. ──
|
|
210
330
|
let declared;
|
|
@@ -35,6 +35,7 @@ import path from 'node:path';
|
|
|
35
35
|
import { scanBacklog } from '../backlog-scan.mjs';
|
|
36
36
|
import { checkCiStatus as realCheckCiStatus } from '../ci-status-banner.mjs';
|
|
37
37
|
import { probe as realProbe, evaluate as realEvaluate } from '../resource-probe.mjs';
|
|
38
|
+
import { isRealSession } from '../session-schema/filters.mjs';
|
|
38
39
|
|
|
39
40
|
/** Staleness cap (days). Beyond this, additional age does not raise the score. */
|
|
40
41
|
export const STALENESS_CAP_DAYS = 90;
|
|
@@ -156,13 +157,21 @@ async function defaultFetchPriority(repoRoot, nowMs) {
|
|
|
156
157
|
|
|
157
158
|
/**
|
|
158
159
|
* Default STALENESS source: read `<repoRoot>/.orchestrator/metrics/sessions.jsonl`,
|
|
159
|
-
*
|
|
160
|
-
* `started_at`). No file / no
|
|
161
|
-
* `STALENESS_CAP_DAYS` (treat as
|
|
160
|
+
* find the last REAL (non-phantom) record scanning backward from the tail, and
|
|
161
|
+
* compute days since `completed_at` (fallback `started_at`). No file / no
|
|
162
|
+
* parsable REAL record / no timestamp ⇒ `STALENESS_CAP_DAYS` (treat as
|
|
163
|
+
* maximally stale = most worthwhile).
|
|
164
|
+
*
|
|
165
|
+
* Scans backward PAST any trailing `status: 'abandoned'` phantom stubs (#834)
|
|
166
|
+
* — session-close-backfill writes these for sessions that ended without a real
|
|
167
|
+
* close (0 waves, seconds of runtime). Stopping at the raw last LINE would let
|
|
168
|
+
* a single recent phantom make a genuinely neglected repo look freshly
|
|
169
|
+
* touched, defeating the dispatcher's whole purpose (this is the N=1 extreme
|
|
170
|
+
* case of the phantom-tail problem — one stub is enough to zero out staleness).
|
|
162
171
|
*
|
|
163
172
|
* @param {string} repoRoot
|
|
164
173
|
* @param {number} nowMs
|
|
165
|
-
* @returns {Promise<number>} days since last session (≥ 0)
|
|
174
|
+
* @returns {Promise<number>} days since last REAL session (≥ 0)
|
|
166
175
|
*/
|
|
167
176
|
async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
168
177
|
try {
|
|
@@ -171,14 +180,19 @@ async function defaultStaleDaysFor(repoRoot, nowMs) {
|
|
|
171
180
|
const lines = raw.split('\n').map((l) => l.trim()).filter(Boolean);
|
|
172
181
|
if (lines.length === 0) return STALENESS_CAP_DAYS;
|
|
173
182
|
|
|
174
|
-
//
|
|
183
|
+
// Scan backward for the last REAL (non-abandoned) session record, skipping
|
|
184
|
+
// both corrupt lines and phantom stubs.
|
|
175
185
|
let last = null;
|
|
176
186
|
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
187
|
+
let parsed;
|
|
177
188
|
try {
|
|
178
|
-
|
|
179
|
-
break;
|
|
189
|
+
parsed = JSON.parse(lines[i]);
|
|
180
190
|
} catch {
|
|
181
|
-
// Skip a corrupt
|
|
191
|
+
continue; // Skip a corrupt line and try the previous one.
|
|
192
|
+
}
|
|
193
|
+
if (isRealSession(parsed)) {
|
|
194
|
+
last = parsed;
|
|
195
|
+
break;
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
if (!last || typeof last !== 'object') return STALENESS_CAP_DAYS;
|
|
@@ -223,6 +223,11 @@ function readinessConfidence(autopilotSummary, judgmentSummary, score) {
|
|
|
223
223
|
/**
|
|
224
224
|
* Summarize autopilot run history plus type-8 mode effectiveness rollups.
|
|
225
225
|
*
|
|
226
|
+
* Abandoned-session filtering (#834): `sessions` is passed straight through
|
|
227
|
+
* to `groupByMode()`, which filters phantom `status: 'abandoned'` stubs
|
|
228
|
+
* before bucketing — this function inherits that guarantee transitively and
|
|
229
|
+
* does not duplicate the filter. See `autopilot-effectiveness.mjs` `groupByMode()`.
|
|
230
|
+
*
|
|
226
231
|
* @param {Array} autopilotRuns
|
|
227
232
|
* @param {Array} sessions
|
|
228
233
|
* @returns {object}
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
|
|
27
27
|
import { randomUUID } from 'node:crypto';
|
|
28
28
|
|
|
29
|
+
import { filterRealSessions } from '../session-schema.mjs';
|
|
30
|
+
|
|
29
31
|
// ---------------------------------------------------------------------------
|
|
30
32
|
// Constants
|
|
31
33
|
// ---------------------------------------------------------------------------
|
|
@@ -69,18 +71,40 @@ function isoPlusDays(nowIso, days) {
|
|
|
69
71
|
}
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Return the `effectiveness` sub-object of a session record, or an empty object
|
|
76
|
+
* when absent/malformed. Session records NEST their effectiveness metrics under
|
|
77
|
+
* this key — see `skills/session-end/metrics-collection.md` (the writer) and
|
|
78
|
+
* `scripts/lib/eval/session-resolve.mjs` (a sibling reader).
|
|
79
|
+
*
|
|
80
|
+
* @param {object} s
|
|
81
|
+
* @returns {object} the nested block, or `{}` when there is none
|
|
82
|
+
*/
|
|
83
|
+
function effectivenessOf(s) {
|
|
84
|
+
const e = s?.effectiveness;
|
|
85
|
+
return e && typeof e === 'object' && !Array.isArray(e) ? e : {};
|
|
86
|
+
}
|
|
87
|
+
|
|
72
88
|
/**
|
|
73
89
|
* Extract a completion ratio from a session record. Sessions encode this in a
|
|
74
90
|
* few historically-evolved shapes; we tolerate all of them and fall back to
|
|
75
91
|
* `null` (excluded from the average) when nothing usable is present.
|
|
76
92
|
*
|
|
93
|
+
* Shape precedence (#835): the NESTED `effectiveness.completion_rate` is the
|
|
94
|
+
* shape the session-end writer actually emits and is therefore read FIRST. The
|
|
95
|
+
* top-level read is retained as a fallback for legacy/hand-written records —
|
|
96
|
+
* dropping it would silently zero out any record predating the nesting.
|
|
97
|
+
*
|
|
77
98
|
* @param {object} s
|
|
78
99
|
* @returns {number|null} ratio in [0, 1], or null when unknown
|
|
79
100
|
*/
|
|
80
101
|
function completionOf(s) {
|
|
81
102
|
if (!s || typeof s !== 'object') return null;
|
|
82
|
-
|
|
83
|
-
|
|
103
|
+
const eff = effectivenessOf(s);
|
|
104
|
+
// Direct ratio fields — nested (canonical) first, then legacy top-level.
|
|
105
|
+
const direct = num(
|
|
106
|
+
eff.completion_rate ?? eff.completion_ratio ?? s.completion_rate ?? s.completion_ratio,
|
|
107
|
+
);
|
|
84
108
|
if (direct !== null && direct >= 0 && direct <= 1) return direct;
|
|
85
109
|
// Planned vs completed counts
|
|
86
110
|
const planned = num(s.planned_count ?? s.tasks_planned);
|
|
@@ -93,15 +117,18 @@ function completionOf(s) {
|
|
|
93
117
|
|
|
94
118
|
/**
|
|
95
119
|
* Extract a carryover ratio from a session record. Carryover = work not closed
|
|
96
|
-
* within the session that flowed to a follow-up. Same tolerance pattern
|
|
97
|
-
* `completionOf`.
|
|
120
|
+
* within the session that flowed to a follow-up. Same tolerance pattern —
|
|
121
|
+
* and the same nested-first precedence (#835) — as `completionOf`.
|
|
98
122
|
*
|
|
99
123
|
* @param {object} s
|
|
100
124
|
* @returns {number|null} ratio in [0, 1], or null when unknown
|
|
101
125
|
*/
|
|
102
126
|
function carryoverOf(s) {
|
|
103
127
|
if (!s || typeof s !== 'object') return null;
|
|
104
|
-
const
|
|
128
|
+
const eff = effectivenessOf(s);
|
|
129
|
+
const direct = num(
|
|
130
|
+
eff.carryover_ratio ?? eff.carryover_rate ?? s.carryover_ratio ?? s.carryover_rate,
|
|
131
|
+
);
|
|
105
132
|
if (direct !== null && direct >= 0 && direct <= 1) return direct;
|
|
106
133
|
const planned = num(s.planned_count ?? s.tasks_planned);
|
|
107
134
|
const carried = num(s.carryover_count ?? s.tasks_carried_over);
|
|
@@ -132,6 +159,18 @@ function mean(values) {
|
|
|
132
159
|
* whose autopilot_run_id matches a known run). For the skeleton it is used
|
|
133
160
|
* only as a non-emptiness signal.
|
|
134
161
|
*
|
|
162
|
+
* Abandoned-session filtering (#834): `sessions` is filtered to REAL (non-
|
|
163
|
+
* phantom) records via `filterRealSessions()` before bucketing. Phantom
|
|
164
|
+
* `status: 'abandoned'` stubs are legitimate DATA but not legitimate SIGNAL —
|
|
165
|
+
* they must not inflate `n_manual`/`n_autopilot` or dilute the
|
|
166
|
+
* completion/carryover averages. See `scripts/lib/session-schema/filters.mjs`.
|
|
167
|
+
*
|
|
168
|
+
* Mode resolution (#834): `mode` is a LEGACY alias for the canonical
|
|
169
|
+
* `session_type` field (`session-schema/constants.mjs` `SESSION_KEY_ALIASES`).
|
|
170
|
+
* `session_type` is read first; `mode` is a fallback for legacy records that
|
|
171
|
+
* predate the rename (production ledgers overwhelmingly carry `session_type`,
|
|
172
|
+
* not `mode` — reading `mode` alone left this analyzer largely inert).
|
|
173
|
+
*
|
|
135
174
|
* @param {Array} autopilotRuns
|
|
136
175
|
* @param {Array} sessions
|
|
137
176
|
* @returns {Map<string, {n_manual:number, n_autopilot:number,
|
|
@@ -142,6 +181,9 @@ export function groupByMode(autopilotRuns, sessions) {
|
|
|
142
181
|
const out = new Map();
|
|
143
182
|
if (!Array.isArray(sessions) || sessions.length === 0) return out;
|
|
144
183
|
|
|
184
|
+
const realSessions = filterRealSessions(sessions);
|
|
185
|
+
if (realSessions.length === 0) return out;
|
|
186
|
+
|
|
145
187
|
// Optional: known autopilot_run_id set for stricter pairing. Empty set means
|
|
146
188
|
// accept any session with a non-empty autopilot_run_id field.
|
|
147
189
|
const knownRunIds = new Set();
|
|
@@ -156,9 +198,14 @@ export function groupByMode(autopilotRuns, sessions) {
|
|
|
156
198
|
|
|
157
199
|
// Bucket: mode → {manual: [], autopilot: []}
|
|
158
200
|
const buckets = new Map();
|
|
159
|
-
for (const s of
|
|
201
|
+
for (const s of realSessions) {
|
|
160
202
|
if (!s || typeof s !== 'object') continue;
|
|
161
|
-
const mode =
|
|
203
|
+
const mode =
|
|
204
|
+
typeof s.session_type === 'string'
|
|
205
|
+
? s.session_type
|
|
206
|
+
: typeof s.mode === 'string'
|
|
207
|
+
? s.mode
|
|
208
|
+
: null;
|
|
162
209
|
if (!mode) continue;
|
|
163
210
|
const apId = typeof s.autopilot_run_id === 'string' ? s.autopilot_run_id : null;
|
|
164
211
|
const isAutopilot =
|
|
@@ -11,6 +11,7 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { join, relative, sep } from 'node:path';
|
|
12
12
|
|
|
13
13
|
import { parseFrontmatter, safeRead, parseJsonl, pass, fail } from './helpers.mjs';
|
|
14
|
+
import { isRealSession } from '../../session-schema/filters.mjs';
|
|
14
15
|
|
|
15
16
|
// Default session-lock TTL in hours — mirrors DEFAULT_TTL_HOURS in
|
|
16
17
|
// scripts/lib/session-lock.mjs. Inlined to keep this category stdlib-only
|
|
@@ -93,9 +94,19 @@ export function runCategory4(root) {
|
|
|
93
94
|
{ latestCompletedAt: null, ageInDays: null },
|
|
94
95
|
'sessions.jsonl is empty'));
|
|
95
96
|
} else {
|
|
96
|
-
|
|
97
|
+
// Scan backward past any trailing `status: 'abandoned'` phantom stubs
|
|
98
|
+
// (#834, session-close-backfill) to the last REAL session — otherwise a
|
|
99
|
+
// recent phantom lets a dormant repo pass a check meant to certify
|
|
100
|
+
// recent REAL engagement.
|
|
97
101
|
let lastObj = null;
|
|
98
|
-
|
|
102
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
103
|
+
let parsed = null;
|
|
104
|
+
try { parsed = JSON.parse(lines[i]); } catch { /* ignore */ }
|
|
105
|
+
if (parsed && isRealSession(parsed)) {
|
|
106
|
+
lastObj = parsed;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
99
110
|
const completedAt = lastObj ? lastObj.completed_at : null;
|
|
100
111
|
if (!completedAt) {
|
|
101
112
|
checks.push(fail('sessions-jsonl-recent', 3, relPath,
|