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
@@ -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 one level below a confinement root and resolves each as free or busy
6
- * via its per-repo `session.lock` v2 lease (heartbeat-based liveness).
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 — scan immediate children of a startDir, resolve each
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. Scan the IMMEDIATE children (one level deep) of `startDir` that are git
130
- * repos (a child is a repo iff `<child>/.git` exists — dir or file).
131
- * 2. Drop any child failing the confinement guard
132
- * (`validatePathInsideProject(childAbs, startDir)`).
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 scan of immediate children, confinement-guarded. ──
183
- let entries;
184
- try {
185
- entries = readdirSyncFn(root, { withFileTypes: true });
186
- } catch {
187
- // Unreadable/absent startDir no FS-scanned repos. The config-declared
188
- // secondary source below may still contribute.
189
- entries = [];
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
- for (const entry of entries) {
193
- // Only directories can be repos. Dirent.isDirectory() guards against files,
194
- // sockets, etc. A stubbed entry may not implement isDirectory — fall back
195
- // to treating it as a directory candidate (existsSync('.git') gates anyway).
196
- const isDir = typeof entry?.isDirectory === 'function' ? entry.isDirectory() : true;
197
- if (!isDir) continue;
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
- const childAbs = path.join(root, entry.name);
200
- if (!isGitRepo(childAbs, existsSyncFn)) continue;
304
+ const childAbs = path.join(dirAbs, entry.name);
201
305
 
202
- // Confinement guard: drop anything not strictly inside startDir.
203
- const guard = validatePathInsideProjectFn(childAbs, root);
204
- if (!guard || guard.ok !== true) continue;
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
- addRepo(childAbs);
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
- * take the LAST record, and compute days since `completed_at` (fallback
160
- * `started_at`). No file / no parsable record / no timestamp ⇒
161
- * `STALENESS_CAP_DAYS` (treat as maximally stale = most worthwhile).
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
- // Last non-empty line = most recent session record.
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
- last = JSON.parse(lines[i]);
179
- break;
189
+ parsed = JSON.parse(lines[i]);
180
190
  } catch {
181
- // Skip a corrupt trailing line and try the previous one.
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
- // Direct ratio fields
83
- const direct = num(s.completion_rate ?? s.completion_ratio);
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 as
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 direct = num(s.carryover_ratio ?? s.carryover_rate);
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 sessions) {
201
+ for (const s of realSessions) {
160
202
  if (!s || typeof s !== 'object') continue;
161
- const mode = typeof s.mode === 'string' ? s.mode : null;
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
- const lastLine = lines[lines.length - 1];
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
- try { lastObj = JSON.parse(lastLine); } catch { /* ignore */ }
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,