session-orchestrator 3.23.0 → 3.24.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 (102) 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/.cursor/skills/remote-offload/SKILL.md +13 -0
  5. package/CHANGELOG.md +278 -0
  6. package/README.md +16 -14
  7. package/agents/db-specialist.md +0 -1
  8. package/docs/ci-setup.md +180 -25
  9. package/docs/codex-setup.md +1 -1
  10. package/docs/components.md +3 -3
  11. package/docs/events-schema.md +46 -8
  12. package/docs/scope-collision-guard.md +4 -4
  13. package/docs/session-config-reference.md +64 -6
  14. package/docs/session-config-template.md +34 -4
  15. package/docs/telemetry/telemetry-claims.md +11 -10
  16. package/docs/telemetry.md +30 -1
  17. package/hooks/_lib/atomic-json.mjs +111 -0
  18. package/hooks/_lib/subagent-paths.mjs +143 -0
  19. package/hooks/cwd-change-restore.mjs +9 -29
  20. package/hooks/enforce-scope.mjs +35 -6
  21. package/hooks/hooks-codex.json +1 -1
  22. package/hooks/hooks.json +1 -1
  23. package/hooks/on-session-end.mjs +278 -12
  24. package/hooks/on-session-start.mjs +50 -2
  25. package/hooks/on-stop.mjs +349 -20
  26. package/hooks/post-bash-write-verify.mjs +104 -4
  27. package/hooks/post-subagent-discovery-validator.mjs +148 -18
  28. package/hooks/post-tool-batch-wave-signal.mjs +154 -40
  29. package/hooks/post-tool-failure-corrective-context.mjs +9 -32
  30. package/hooks/pre-bash-memory-propose-audit.mjs +13 -7
  31. package/hooks/subagent-telemetry.mjs +11 -26
  32. package/package.json +1 -1
  33. package/scripts/autopilot.mjs +26 -12
  34. package/scripts/backfill-abandoned-sessions.mjs +80 -11
  35. package/scripts/emit-event.mjs +10 -2
  36. package/scripts/lib/auq/parse.mjs +5 -29
  37. package/scripts/lib/auto-dialectic.mjs +68 -0
  38. package/scripts/lib/autopilot/worktree-pipeline.mjs +82 -6
  39. package/scripts/lib/build-live-signals.mjs +25 -22
  40. package/scripts/lib/cold-start-detector.mjs +23 -14
  41. package/scripts/lib/config/block-header.mjs +55 -0
  42. package/scripts/lib/config/discovery-validator.mjs +7 -2
  43. package/scripts/lib/config/health-endpoints.mjs +383 -0
  44. package/scripts/lib/config/remote-hosts.mjs +233 -0
  45. package/scripts/lib/config.mjs +31 -3
  46. package/scripts/lib/dispatcher/enumerate.mjs +2 -17
  47. package/scripts/lib/events-schema.mjs +48 -0
  48. package/scripts/lib/events.mjs +238 -5
  49. package/scripts/lib/evolve/autonomy-verdict.mjs +9 -4
  50. package/scripts/lib/evolve/autopilot-effectiveness.mjs +18 -1
  51. package/scripts/lib/gitlab-portfolio/cli.mjs +3 -15
  52. package/scripts/lib/harness-audit/categories/category1.mjs +17 -6
  53. package/scripts/lib/memory-banner.mjs +20 -8
  54. package/scripts/lib/peer-discovery.mjs +20 -2
  55. package/scripts/lib/reconcile/engine.mjs +236 -5
  56. package/scripts/lib/scope-gate.mjs +36 -0
  57. package/scripts/lib/session-close-backfill.mjs +59 -10
  58. package/scripts/lib/session-discovery.mjs +57 -3
  59. package/scripts/lib/session-end/phase-skip.mjs +2 -2
  60. package/scripts/lib/session-identity/own-session.mjs +62 -1
  61. package/scripts/lib/session-transition.mjs +1 -1
  62. package/scripts/lib/sessions-canonical.mjs +446 -0
  63. package/scripts/lib/telemetry/schema.mjs +74 -8
  64. package/scripts/lib/telemetry/sync.mjs +49 -12
  65. package/scripts/lib/tmux-layout/telemetry.mjs +14 -2
  66. package/scripts/lib/validate/check-doc-cli-commands.mjs +9 -33
  67. package/scripts/lib/validate/check-hooks-emit-event-guard.mjs +370 -0
  68. package/scripts/lib/validate/check-skill-script-paths.mjs +436 -0
  69. package/scripts/lib/validate/check-untracked-test-deps.mjs +10 -0
  70. package/scripts/lib/validate/check-unwired-features.mjs +0 -7
  71. package/scripts/lib/validate/check-validator-registration.mjs +248 -0
  72. package/scripts/lib/validate/check-vcs-repo-flag.mjs +6 -28
  73. package/scripts/lib/validate/markdown-fences.mjs +196 -0
  74. package/scripts/lib/vault-status/board-lock.mjs +185 -0
  75. package/scripts/lib/vault-status/board-writer.mjs +174 -135
  76. package/scripts/lib/vault-status/narrative-mirror.mjs +2 -19
  77. package/scripts/lib/wave-executor/foreign-dispatch.mjs +2 -2
  78. package/scripts/lib/wave-executor/remote-dispatch.mjs +504 -0
  79. package/scripts/lib/wave-resource-gate.mjs +127 -7
  80. package/scripts/lib/wave-transcript-tail.mjs +24 -4
  81. package/scripts/materialize-wave-scope.mjs +20 -4
  82. package/scripts/memory-propose.mjs +132 -8
  83. package/scripts/promote-vault-strict.mjs +4 -15
  84. package/scripts/site-numbers.mjs +36 -4
  85. package/scripts/validate-plugin.mjs +26 -0
  86. package/scripts/vault-consolidate.mjs +3 -11
  87. package/scripts/vault-integration-watcher.mjs +2 -4
  88. package/scripts/vault-mirror.mjs +111 -26
  89. package/skills/_shared/parallel-aware-auq.md +31 -2
  90. package/skills/_shared/parallel-aware-preamble.md +17 -4
  91. package/skills/_shared/state-ownership.md +1 -1
  92. package/skills/contract-version-bump/SKILL.md +1 -1
  93. package/skills/ecosystem-health/SKILL.md +4 -1
  94. package/skills/ecosystem-health/wizard.md +5 -0
  95. package/skills/evolve/SKILL.md +38 -1
  96. package/skills/journey-audit/SKILL.md +6 -5
  97. package/skills/reconcile/SKILL.md +5 -2
  98. package/skills/remote-offload/SKILL.md +89 -0
  99. package/skills/session-end/phase-3-6-tail.md +9 -6
  100. package/skills/session-start/SKILL.md +26 -3
  101. package/skills/wave-executor/SKILL.md +1 -1
  102. package/skills/wave-executor/wave-loop.md +43 -5
@@ -0,0 +1,446 @@
1
+ /**
2
+ * sessions-canonical.mjs — one record per physical session (#1167).
3
+ *
4
+ * `.orchestrator/metrics/sessions.jsonl` is APPEND-ONLY by design: nothing is
5
+ * ever rewritten in place, so the same physical session can appear more than
6
+ * once. A consumer that treats "one line = one session" therefore over-counts,
7
+ * and every duration/effectiveness aggregate computed from the raw file is
8
+ * silently wrong by however many duplicates happen to sit in its window.
9
+ *
10
+ * This module is the READ-side collapse. It never repairs the file (see § 3).
11
+ *
12
+ * ── THE THREE RULES, AND WHAT MEASURED THEM ─────────────────────────────────
13
+ *
14
+ * (1) NEWEST-WINS PER `session_id`.
15
+ * File order is chronological, so the LAST record carrying an id is the
16
+ * current one. This is the same reading rule
17
+ * `session-close-backfill.mjs::classifyExisting()` already applies (it
18
+ * takes `matches[matches.length - 1]`); this module generalises it to the
19
+ * whole file. Measured 2026-09-02 @ c3ab480 over 286 records: one id
20
+ * (2026-05-10) carries a byte-identical duplicate LINE — an older
21
+ * collision class than (3), and rule (1) alone resolves it.
22
+ *
23
+ * (2) NARROW COLLAPSE OF THE SYSTEMIC DOUBLE-STUB CLASS.
24
+ * Two `abandoned` records with an EXACT `started_at` + `completed_at`
25
+ * tuple match are one physical session recorded twice by the two backfill
26
+ * writers: `hooks/on-session-end.mjs` resolves the semantic id from
27
+ * `current-session.json` and writes `main-YYYY-MM-DD-session-N`, while
28
+ * `scripts/backfill-abandoned-sessions.mjs` could resolve a semantic id
29
+ * ONLY via `orchestrator.session.lock.acquired` — a session that lost the
30
+ * lock-acquire race has no such event, so it fell through to the synthetic
31
+ * mint (`<branch>-<date>-abandoned-<sha8>`, `_synthetic_session_id: true`)
32
+ * and wrote a SECOND stub for the same session. The join-back was
33
+ * impossible because `raw_session_id` is null on 286/286 records
34
+ * (`jq -s '[.[]|select(.raw_session_id != null)]|length'` → 0, measured
35
+ * 2026-09-02 @ c3ab480), so the two records share no key at all — only
36
+ * their millisecond-identical timestamps.
37
+ * Measured population: 8 such pairs over 6 weeks (16 records), via
38
+ * `jq -r '[.started_at,.completed_at,.status]|@tsv' … | sort | uniq -d`.
39
+ * The NON-synthetic record survives; the synthetic mint is the artefact.
40
+ *
41
+ * Deliberately narrow. The collapse requires BOTH records to be
42
+ * `status: 'abandoned'` and BOTH timestamps to be present and equal.
43
+ * `started_at` alone is NOT enough (two real sessions can start in the
44
+ * same millisecond of a re-fire), and `completed` records are never
45
+ * collapsed (an authoritative record is a truth claim about itself, never
46
+ * an artefact of a second writer).
47
+ *
48
+ * (3) AN ATTESTABLE `supersedes: X` REMOVES record X.
49
+ * The #1068 AC3/AC4 supersede path appends an authoritative `completed`
50
+ * record carrying a forward pointer to the backfilled `abandoned` stub it
51
+ * refutes. The stub is kept on disk verbatim (AC4 — forensic provenance);
52
+ * a canonical READER must drop it, or the same session is counted as both
53
+ * abandoned and completed.
54
+ *
55
+ * Two constraints, both measured defects of the first implementation:
56
+ * - ORDER-INDEPENDENT. A record is dropped iff some SURVIVING record
57
+ * supersedes it (a fixpoint over the supersede graph; cycles broken by
58
+ * keeping the newest member). Deleting in file order made a chain
59
+ * `C → B → A` resolve to `{C, A}` or `{C}` depending on the
60
+ * permutation the appends happened to land in, and a mutual pair
61
+ * resolved by insertion order.
62
+ * - ATTESTABLE ONLY. The marker is honoured only when the target is not
63
+ * authoritative (`status` `abandoned`, or absent on a legacy stub —
64
+ * never `completed`) AND the two records share a join key (equal
65
+ * `raw_session_id`, or byte-equal `started_at` — the shape
66
+ * `session-close-backfill.mjs::synthesizeRecord()` emits, since stub
67
+ * and superseder are synthesized from the same gathered events).
68
+ * Without that constraint ONE appended line could delete ANY id from
69
+ * EVERY reader of this module, the armed autonomy verdict included. A
70
+ * refused marker keeps both records and is reported (never logged)
71
+ * via `canonicalizeSessionsDetailed().ignoredSupersedes`.
72
+ *
73
+ * RULE ORDER: (1) → (2) → (3). The double-stub collapse must run BEFORE
74
+ * supersede removal: with the reverse order a `supersedes` append deleted the
75
+ * authentic stub first, shrank the tuple group to a single member, and the
76
+ * synthetic phantom then survived the very session that refuted it.
77
+ *
78
+ * ── WHAT THIS MODULE DOES NOT DO ────────────────────────────────────────────
79
+ * - It never writes. The 8 historical pairs stay on disk; the ledger is
80
+ * append-only and the duplicates are their own provenance.
81
+ * - It is not a phantom filter. `status: 'abandoned'` records SURVIVE here —
82
+ * dropping them is `session-schema/filters.mjs`'s job
83
+ * (`isRealSession` / `filterRealSessions` / `tailRealSessions`), and the
84
+ * two compose: canonicalize first, then filter.
85
+ *
86
+ * Plain Node ESM. Named exports. `canonicalizeSessions` is pure; only
87
+ * `readCanonicalSessions` touches the filesystem (sync, `readFileSync`).
88
+ */
89
+
90
+ import fs from 'node:fs';
91
+ import path from 'node:path';
92
+
93
+ const SESSIONS_REL = ['.orchestrator', 'metrics', 'sessions.jsonl'];
94
+
95
+ /**
96
+ * True when the value is a usable record object (not null, not an array).
97
+ * @param {unknown} v
98
+ * @returns {boolean}
99
+ */
100
+ function isRecordObject(v) {
101
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
102
+ }
103
+
104
+ /** Non-empty string guard — `''` is never a usable id or timestamp. */
105
+ function isNonEmptyString(v) {
106
+ return typeof v === 'string' && v.length > 0;
107
+ }
108
+
109
+ /**
110
+ * The timestamp a record is ordered by when a supersede CYCLE has to be broken:
111
+ * `completed_at` when present, else `started_at`, else `''` (sorts last).
112
+ * ISO-8601 strings compare lexicographically, so no Date parsing is needed.
113
+ * @param {object} rec
114
+ * @returns {string}
115
+ */
116
+ function cycleOrderTimestamp(rec) {
117
+ if (isNonEmptyString(rec.completed_at)) return rec.completed_at;
118
+ if (isNonEmptyString(rec.started_at)) return rec.started_at;
119
+ return '';
120
+ }
121
+
122
+ /**
123
+ * True when `superseder`'s `supersedes` marker is ATTESTABLE against `target`.
124
+ *
125
+ * `supersedes` is a forward pointer inside an append-only file that anyone (or
126
+ * any buggy writer) can append a line to, and a reader that obeys it blindly
127
+ * lets a single appended line delete ANY id from EVERY consumer — including the
128
+ * armed autonomy verdict. So the marker is honoured only for the shape the
129
+ * #1068 writer actually produces: the stub it refutes is never an AUTHORITATIVE
130
+ * record — its `status` is `abandoned`, or absent/null on a legacy stub, but
131
+ * never any other declared status (a `completed` record is a truth claim about
132
+ * itself and can never be deleted by an appended pointer) — and both records
133
+ * were synthesized from the SAME gathered events, hence share an attestable
134
+ * join key —
135
+ * - equal non-empty `raw_session_id` (the #1167 harness-uuid join), or
136
+ * - byte-equal non-empty `started_at` (`session-close-backfill.mjs`
137
+ * `synthesizeRecord()` derives `startedIso` from the same event set for the
138
+ * stub and for the record that supersedes it).
139
+ * Anything else is a data-integrity anomaly: BOTH records are kept and the
140
+ * marker is reported via `canonicalizeSessionsDetailed().ignoredSupersedes`.
141
+ *
142
+ * @param {object} superseder
143
+ * @param {object} target
144
+ * @returns {string|null} null when the marker is valid, else the reject reason
145
+ */
146
+ function supersedeRejectReason(superseder, target) {
147
+ // Absent/null `status` is a legacy stub, not an authoritative record; any
148
+ // OTHER declared status (`completed` above all) is untouchable.
149
+ if (isNonEmptyString(target.status) && target.status !== 'abandoned') {
150
+ return 'target-not-abandoned';
151
+ }
152
+ const a = superseder.raw_session_id;
153
+ const b = target.raw_session_id;
154
+ if (isNonEmptyString(a) && isNonEmptyString(b) && a === b) return null;
155
+ if (isNonEmptyString(superseder.started_at) && superseder.started_at === target.started_at) {
156
+ return null;
157
+ }
158
+ return 'no-shared-join-key';
159
+ }
160
+
161
+ /**
162
+ * Rule (3) — collapse the systemic two-writer double stub. Mutates `byId` by
163
+ * deleting the synthetic twin of each qualifying pair.
164
+ * @param {Map<string, object>} byId
165
+ * @returns {void}
166
+ */
167
+ function collapseAbandonedTuples(byId) {
168
+ // Group ONLY the records eligible for the systemic double-stub class; every
169
+ // other record bypasses this pass entirely and can never be dropped by it.
170
+ const byTuple = new Map();
171
+ for (const rec of byId.values()) {
172
+ if (rec.status !== 'abandoned') continue;
173
+ if (!isNonEmptyString(rec.started_at) || !isNonEmptyString(rec.completed_at)) continue;
174
+ const key = `${rec.started_at} ${rec.completed_at}`;
175
+ const group = byTuple.get(key);
176
+ if (group) group.push(rec);
177
+ else byTuple.set(key, [rec]);
178
+ }
179
+ for (const group of byTuple.values()) {
180
+ if (group.length < 2) continue;
181
+ const authentic = group.filter((r) => r._synthetic_session_id !== true);
182
+ // All-synthetic (or all-authentic) groups are left intact: with no
183
+ // non-synthetic record to prefer there is no evidence about WHICH one is
184
+ // the artefact, and guessing would delete a session nobody can recover.
185
+ if (authentic.length === 0 || authentic.length === group.length) continue;
186
+ for (const rec of group) {
187
+ if (rec._synthetic_session_id === true) byId.delete(rec.session_id);
188
+ }
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Rule (2) — order-independent supersede resolution. Mutates `byId` by deleting
194
+ * every record that a SURVIVING record supersedes, and appends every rejected
195
+ * marker to `ignored`.
196
+ *
197
+ * A record is dropped iff some record that itself survives supersedes it; the
198
+ * marking is a fixpoint over the supersede graph, so it depends on the EDGES
199
+ * only, never on the order the records appear in the file. A chain
200
+ * `C → B → A` therefore always resolves to `{C, A}` (B is dropped by the
201
+ * surviving C, so B's own marker no longer removes A).
202
+ *
203
+ * A cycle (`X → Y`, `Y → X`) has no fixpoint; it is broken deterministically by
204
+ * keeping the NEWEST member (`completed_at ?? started_at`, ties by ascending
205
+ * `session_id`) and re-running the propagation.
206
+ *
207
+ * @param {Map<string, object>} byId
208
+ * @param {Array<{by: string, target: string, reason: string}>} ignored
209
+ * @returns {void}
210
+ */
211
+ function resolveSupersedes(byId, ignored) {
212
+ /** targetId → Set of ids of records that validly supersede it. */
213
+ const supersededBy = new Map();
214
+ for (const rec of byId.values()) {
215
+ const target = rec.supersedes;
216
+ if (!isNonEmptyString(target) || target === rec.session_id) continue;
217
+ const targetRec = byId.get(target);
218
+ // A marker pointing at an id that is not present removes nothing; it is not
219
+ // an anomaly either (the target may legitimately have been collapsed by
220
+ // rule 3 first, or simply predate this window of the ledger).
221
+ if (!targetRec) continue;
222
+ const reason = supersedeRejectReason(rec, targetRec);
223
+ if (reason !== null) {
224
+ ignored.push({ by: rec.session_id, target, reason });
225
+ continue;
226
+ }
227
+ const set = supersededBy.get(target);
228
+ if (set) set.add(rec.session_id);
229
+ else supersededBy.set(target, new Set([rec.session_id]));
230
+ }
231
+ if (supersededBy.size === 0) return;
232
+
233
+ const ids = [...byId.keys()];
234
+ /** id → 'alive' | 'dead'; absent = not yet decided. */
235
+ const state = new Map();
236
+ for (;;) {
237
+ let changed = true;
238
+ while (changed) {
239
+ changed = false;
240
+ for (const id of ids) {
241
+ if (state.has(id)) continue;
242
+ const sup = supersededBy.get(id);
243
+ if (!sup || sup.size === 0) {
244
+ state.set(id, 'alive');
245
+ changed = true;
246
+ continue;
247
+ }
248
+ let anyAlive = false;
249
+ let allDead = true;
250
+ for (const s of sup) {
251
+ const st = state.get(s);
252
+ if (st === 'alive') anyAlive = true;
253
+ if (st !== 'dead') allDead = false;
254
+ }
255
+ if (anyAlive) {
256
+ state.set(id, 'dead');
257
+ changed = true;
258
+ } else if (allDead) {
259
+ state.set(id, 'alive');
260
+ changed = true;
261
+ }
262
+ }
263
+ }
264
+ const undecided = ids.filter((id) => !state.has(id));
265
+ if (undecided.length === 0) break;
266
+ // Cycle: keep the newest member, then let propagation settle the rest.
267
+ undecided.sort((a, b) => {
268
+ const ta = cycleOrderTimestamp(byId.get(a));
269
+ const tb = cycleOrderTimestamp(byId.get(b));
270
+ if (ta !== tb) return ta < tb ? 1 : -1;
271
+ return a < b ? -1 : 1;
272
+ });
273
+ state.set(undecided[0], 'alive');
274
+ }
275
+
276
+ for (const [id, st] of state) {
277
+ if (st === 'dead') byId.delete(id);
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Collapse a raw sessions.jsonl record array to one record per physical
283
+ * session AND report the supersede markers that were refused. Pure — the input
284
+ * array is never mutated.
285
+ *
286
+ * Rules, applied in this order (see the module header for the measured
287
+ * justification of each):
288
+ * 1. newest-wins per `session_id` (file order is chronological);
289
+ * 2. two `abandoned` records with an exact, both-present
290
+ * `started_at` + `completed_at` tuple collapse to the non-synthetic one;
291
+ * 3. a surviving record's ATTESTABLE `supersedes: X` removes record `X`.
292
+ *
293
+ * The double-stub collapse runs BEFORE supersede removal so that a stub which
294
+ * is itself about to be superseded still shadows its synthetic twin — with the
295
+ * old order the twin outlived the record it duplicated (a `supersedes` append
296
+ * shrank the tuple group to one member, and the phantom survived the session
297
+ * that refuted it).
298
+ *
299
+ * Records without a usable `session_id` are dropped, unless
300
+ * `keepUnidentified: true` (they cannot be deduplicated; a COUNT-style or
301
+ * effectiveness-style consumer would rather keep them than shrink its `n`).
302
+ *
303
+ * Output order follows FIRST appearance of each surviving id in the input; kept
304
+ * unidentified records are appended after them, in their original order.
305
+ *
306
+ * @param {Array<unknown>} records
307
+ * @param {object} [opts]
308
+ * @param {boolean} [opts.keepUnidentified=false] pass id-less record objects
309
+ * through untouched instead of dropping them.
310
+ * @returns {{records: Array<object>, ignoredSupersedes: Array<{by: string,
311
+ * target: string, reason: string}>}}
312
+ */
313
+ export function canonicalizeSessionsDetailed(records, { keepUnidentified = false } = {}) {
314
+ if (!Array.isArray(records)) return { records: [], ignoredSupersedes: [] };
315
+
316
+ // -- (1) newest-wins per id ------------------------------------------------
317
+ // Map insertion order = FIRST appearance of the id; the stored value is the
318
+ // LAST record carrying it, so a superseding append wins without reordering
319
+ // the ledger's chronology.
320
+ const byId = new Map();
321
+ const unidentified = [];
322
+ for (const rec of records) {
323
+ if (!isRecordObject(rec)) continue;
324
+ if (!isNonEmptyString(rec.session_id)) {
325
+ if (keepUnidentified) unidentified.push(rec);
326
+ continue;
327
+ }
328
+ byId.set(rec.session_id, rec);
329
+ }
330
+
331
+ // -- (2) narrow abandoned-tuple collapse -----------------------------------
332
+ collapseAbandonedTuples(byId);
333
+
334
+ // -- (3) supersede removal (order-independent, join-key constrained) -------
335
+ const ignoredSupersedes = [];
336
+ resolveSupersedes(byId, ignoredSupersedes);
337
+
338
+ return { records: [...byId.values(), ...unidentified], ignoredSupersedes };
339
+ }
340
+
341
+ /**
342
+ * Collapse a raw sessions.jsonl record array to one record per physical
343
+ * session. Pure — the input array is never mutated. Thin wrapper over
344
+ * `canonicalizeSessionsDetailed`, returning only the records (the array shape
345
+ * every consumer reads).
346
+ *
347
+ * @param {Array<unknown>} records
348
+ * @param {object} [opts] — see `canonicalizeSessionsDetailed`.
349
+ * @param {boolean} [opts.keepUnidentified=false]
350
+ * @returns {Array<object>} canonical records
351
+ */
352
+ export function canonicalizeSessions(records, opts) {
353
+ return canonicalizeSessionsDetailed(records, opts).records;
354
+ }
355
+
356
+ /**
357
+ * Count DISTINCT physical sessions in RAW `sessions.jsonl` text. Pure — no fs,
358
+ * so an async reader keeps its own `readFile` and only the counting rule is
359
+ * shared (the two async consumers, `memory-banner.mjs` and
360
+ * `cold-start-detector.mjs`, carried byte-identical copies of this body).
361
+ *
362
+ * Blank lines (incl. the trailing newline) are skipped. A line that does not
363
+ * PARSE is not counted at all — it cannot be attributed to any session (this
364
+ * replaces the pre-#1167 "count every non-empty line, never parse" rule).
365
+ *
366
+ * `canonicalizeSessions` DROPS records without a `session_id` (they cannot be
367
+ * deduplicated). For a COUNT that would under-report rather than de-duplicate,
368
+ * so id-less records are counted as-is and only the id-bearing ones go through
369
+ * the identity collapse.
370
+ *
371
+ * @param {string} raw — full file contents.
372
+ * @returns {number}
373
+ */
374
+ export function countSessionsInJsonl(raw) {
375
+ if (typeof raw !== 'string' || raw.length === 0) return 0;
376
+ const parsed = [];
377
+ for (const line of raw.split('\n')) {
378
+ const trimmed = line.trim();
379
+ if (!trimmed) continue;
380
+ try {
381
+ parsed.push(JSON.parse(trimmed));
382
+ } catch {
383
+ /* skip malformed line */
384
+ }
385
+ }
386
+ const identified = parsed.filter((r) => isRecordObject(r) && isNonEmptyString(r.session_id));
387
+ const anonymous = parsed.length - identified.length;
388
+ return canonicalizeSessions(identified).length + anonymous;
389
+ }
390
+
391
+ /**
392
+ * Read `sessions.jsonl` and return its canonical records (see
393
+ * `canonicalizeSessions` for the three collapse rules).
394
+ *
395
+ * Synchronous by design — every consumer of the ledger in this repo reads it
396
+ * with `readFileSync`, and the file is small (286 records / ~0.5 MB at the
397
+ * time of writing). A MISSING file (ENOENT) yields `[]` silently; an UNREADABLE
398
+ * one (EACCES/EISDIR/…) yields `[]` with a stderr WARN (#1188); each malformed line
399
+ * is skipped rather than aborting the whole read (same posture as the readers
400
+ * in `session-close-backfill.mjs` and `backfill-abandoned-sessions.mjs`).
401
+ *
402
+ * @param {object} [args]
403
+ * @param {string} [args.repoRoot] project root; the ledger is resolved as
404
+ * `<repoRoot>/.orchestrator/metrics/sessions.jsonl`. Defaults to
405
+ * `process.cwd()` when neither this nor `filePath` is given.
406
+ * @param {string} [args.filePath] explicit ledger path (wins over `repoRoot`).
407
+ * @returns {Array<object>} canonical records
408
+ */
409
+ export function readCanonicalSessions({ repoRoot, filePath } = {}) {
410
+ const resolved = isNonEmptyString(filePath)
411
+ ? filePath
412
+ : path.join(isNonEmptyString(repoRoot) ? repoRoot : process.cwd(), ...SESSIONS_REL);
413
+
414
+ let raw;
415
+ try {
416
+ raw = fs.readFileSync(resolved, 'utf8');
417
+ } catch (err) {
418
+ // #1188 — ENOENT and EACCES/EISDIR are different facts: a missing ledger is
419
+ // the ordinary fresh-repo case; an UNREADABLE one previously read as "no
420
+ // sessions" and made every downstream count silently wrong. Same split as
421
+ // readLockDetailed (session-lock.mjs § absent vs unreadable).
422
+ if (!err || err.code !== 'ENOENT') {
423
+ process.stderr.write(
424
+ `⚠ readCanonicalSessions: cannot read ${resolved} ` +
425
+ `(${err?.code ?? '?'}: ${err?.message ?? String(err)}) — ` +
426
+ 'treating as EMPTY, counts below are floors\n',
427
+ );
428
+ }
429
+ // CEILING (BV-004): still [] rather than throw — SessionStart callers must
430
+ // not crash on a transient permissions fault. REVISIT if the warn rate in
431
+ // events.jsonl shows masked corruption.
432
+ return [];
433
+ }
434
+
435
+ const parsed = [];
436
+ for (const line of raw.split('\n')) {
437
+ const trimmed = line.trim();
438
+ if (!trimmed) continue;
439
+ try {
440
+ parsed.push(JSON.parse(trimmed));
441
+ } catch {
442
+ /* skip malformed line */
443
+ }
444
+ }
445
+ return canonicalizeSessions(parsed);
446
+ }
@@ -220,6 +220,54 @@ export function filterRosterNames(names, rosterSet) {
220
220
  return deduped.slice(0, MAX_NAMES);
221
221
  }
222
222
 
223
+ /**
224
+ * Classify a single raw invocation name into the skills or commands bucket.
225
+ *
226
+ * The Skill tool surfaces slash-commands that have NO backing `skills/` directory
227
+ * under the same plugin-prefixed name form as real skills (e.g.
228
+ * `session-orchestrator:session` for `commands/session.md`). `roster.skills` holds
229
+ * PREFIXED names, `roster.commands` holds BARE ones — so a prefixed command name
230
+ * matches neither set and used to be bucketed to 'other' (GitLab #1189: commands
231
+ * was `[]` in 345/345 usage pings).
232
+ *
233
+ * Decision order (skill roster wins on a spelling collision such as
234
+ * `memory-cleanup`, which is both a skill dir and a command file):
235
+ * 1. name in rosterSkills → {kind:'skill', name} (verbatim)
236
+ * 2. name is PREFIXED and its bare form is in rosterCommands
237
+ * → {kind:'command', bare}
238
+ * 3. otherwise → {kind:'skill', name} (verbatim)
239
+ *
240
+ * Case 2 REQUIRES the plugin prefix (session-reviewer W2 finding, 2026-09-02).
241
+ * Without that requirement, a BARE third-party or personal skill name that
242
+ * happens to collide with one of our shipped command names (`test`, `close`,
243
+ * `go`, `release`, `portfolio` are all plausible foreign skill names) would be
244
+ * recorded as OUR command — a data-integrity defect, and the same rule made
245
+ * `memory-cleanup` reach BOTH buckets in one ping (bare → commands, prefixed →
246
+ * skills), double-counting a single surface. Requiring the prefix removes both:
247
+ * a bare arrival can only ever be a skill-path name, so an unknown one becomes
248
+ * 'other'. Cost: the one genuine bare `memory-cleanup` record is recorded as
249
+ * 'other' rather than as the command.
250
+ *
251
+ * Case 3 keeps foreign/third-party names on the SKILLS path only, where
252
+ * filterRosterNames projects them to the opaque token 'other' — an unclassified
253
+ * name is never duplicated into `commands`, preserving the privacy invariant.
254
+ *
255
+ * @param {unknown} name raw invocation name
256
+ * @param {Set<string>} rosterSkills prefixed shipped-skill names
257
+ * @param {Set<string>} rosterCommands bare shipped-command names
258
+ * @returns {{kind: 'skill'|'command', name: unknown}}
259
+ */
260
+ export function classifyInvocationName(name, rosterSkills, rosterCommands) {
261
+ const skills = rosterSkills instanceof Set ? rosterSkills : new Set();
262
+ const commands = rosterCommands instanceof Set ? rosterCommands : new Set();
263
+ if (typeof name !== 'string') return { kind: 'skill', name };
264
+ if (skills.has(name)) return { kind: 'skill', name };
265
+ if (!name.startsWith(SKILL_PREFIX)) return { kind: 'skill', name };
266
+ const bare = name.slice(SKILL_PREFIX.length);
267
+ if (commands.has(bare)) return { kind: 'command', name: bare };
268
+ return { kind: 'skill', name };
269
+ }
270
+
223
271
  // ---------------------------------------------------------------------------
224
272
  // Duration bucketing
225
273
  // ---------------------------------------------------------------------------
@@ -331,12 +379,12 @@ function distinctField(records, field) {
331
379
  * whitelist-clean but does NOT carry `anon_id` — the caller sets it via
332
380
  * ensureAnonId (anon-id.mjs), keeping ID rotation isolated there.
333
381
  *
334
- * skills are the distinct `.skill` values of `skillInvocations`; commands are the
335
- * distinct `.command` values of the same records (none in v1 skill-invocations
336
- * telemetry []; the field is honored so a future command-telemetry stream feeds
337
- * in without a signature change). Both are roster-filtered — off-roster names
338
- * become "other" — deduped, sorted, and capped. No frequencies are recorded (v1
339
- * decision).
382
+ * The distinct `.skill` and `.command` values of `skillInvocations` go through a
383
+ * single classifyInvocationName pass that routes each name to skills or commands
384
+ * (the `.command` field carries nothing today; it is honored so a future direct
385
+ * command-telemetry stream feeds in without a signature change). Both buckets are
386
+ * then roster-filtered — off-roster names become "other" — deduped, sorted, and
387
+ * capped. No frequencies are recorded (v1 decision).
340
388
  *
341
389
  * @param {{
342
390
  * sessionRecord: object,
@@ -362,8 +410,26 @@ export function buildUsagePing({
362
410
  const rosterSkills = rst?.skills instanceof Set ? rst.skills : new Set();
363
411
  const rosterCommands = rst?.commands instanceof Set ? rst.commands : new Set();
364
412
 
365
- const skillNames = distinctField(invocations, 'skill');
366
- const commandNames = distinctField(invocations, 'command');
413
+ // ONE classification pass over the union of both producer fields: today only
414
+ // `.skill` is written by hooks/skill-invocation-telemetry.mjs, `.command` is
415
+ // kept for the forward-compat producer documented above.
416
+ //
417
+ // `.command` records carry the BARE name, and classifyInvocationName requires
418
+ // the plugin prefix before it will route anything to the commands bucket (see
419
+ // its doc comment). So a `.command` name is normalized to the prefixed form
420
+ // first — the field itself is the "this is one of ours" signal that a bare
421
+ // `.skill` arrival lacks. Without this the forward-compat producer would be
422
+ // wired but dead: every record it writes would silently become 'other'.
423
+ const skillNames = [];
424
+ const commandNames = [];
425
+ const rawNames = [
426
+ ...distinctField(invocations, 'skill'),
427
+ ...distinctField(invocations, 'command').map((n) => (n.startsWith(SKILL_PREFIX) ? n : `${SKILL_PREFIX}${n}`)),
428
+ ];
429
+ for (const raw of rawNames) {
430
+ const { kind, name } = classifyInvocationName(raw, rosterSkills, rosterCommands);
431
+ (kind === 'command' ? commandNames : skillNames).push(name);
432
+ }
367
433
 
368
434
  return {
369
435
  record_kind: 'usage-ping',
@@ -39,6 +39,7 @@ import { ensureAnonId } from './anon-id.mjs';
39
39
  import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
40
40
  import { loadOwnerConfig } from '../owner-yaml.mjs';
41
41
  import { readJsonlFile } from '../io.mjs';
42
+ import { readCanonicalSessions } from '../sessions-canonical.mjs';
42
43
 
43
44
  // ---------------------------------------------------------------------------
44
45
  // Constants
@@ -93,15 +94,51 @@ function defaultSender({ env, timeoutMs }) {
93
94
  // Batch build
94
95
  // ---------------------------------------------------------------------------
95
96
 
97
+ /**
98
+ * The canonical (#1167-deduplicated) session record most recently WRITTEN to
99
+ * the ledger — ranked by `completed_at` (falling back to `started_at` when
100
+ * absent), the closest analogue to "the last line of the file" once the reader
101
+ * no longer trusts append order.
102
+ *
103
+ * `readCanonicalSessions` reorders its output to "first appearance of each
104
+ * surviving id" (see sessions-canonical.mjs's own docstring) — it is NOT
105
+ * append order — so a raw `records[records.length - 1]` (the pre-#1186 read)
106
+ * silently picks the WRONG session once a `session_id` duplicate or a
107
+ * `supersedes` collapse reshuffles the array. A record with neither timestamp
108
+ * sorts last and is never chosen over a dated one.
109
+ *
110
+ * @param {Array<object>} records — canonical session records.
111
+ * @returns {object|null}
112
+ */
113
+ function mostRecentSession(records) {
114
+ let best = null;
115
+ let bestTs = '';
116
+ for (const rec of records) {
117
+ if (!rec || typeof rec !== 'object') continue;
118
+ const ts =
119
+ typeof rec.completed_at === 'string' && rec.completed_at
120
+ ? rec.completed_at
121
+ : typeof rec.started_at === 'string'
122
+ ? rec.started_at
123
+ : '';
124
+ if (ts && ts > bestTs) {
125
+ best = rec;
126
+ bestTs = ts;
127
+ }
128
+ }
129
+ return best;
130
+ }
131
+
96
132
  /**
97
133
  * Build ONE whitelist-projected usage-ping record from the local JSONL streams.
98
134
  *
99
- * Reads `<metricsDir>/sessions.jsonl` + `<metricsDir>/skill-invocations.jsonl`
100
- * (metricsDir defaults to `<cwd>/.orchestrator/metrics`). The LAST sessions.jsonl
101
- * record defines the session window: skill-invocations whose `timestamp >=` its
102
- * `started_at` are included. When no session record exists, the ping falls back
103
- * to `session_type: 'other'`, `duration_bucket: '<15m'`, and the invocations of
104
- * the last 24 hours.
135
+ * Reads `<metricsDir>/sessions.jsonl` (via `readCanonicalSessions`, #1186 — the
136
+ * #1167 newest-wins-per-`session_id` / `supersedes` collapse) +
137
+ * `<metricsDir>/skill-invocations.jsonl`. The most-recently-written CANONICAL
138
+ * session record (`mostRecentSession`, above) defines the session window:
139
+ * skill-invocations whose `timestamp >=` its `started_at` are included. When no
140
+ * session record exists, the ping falls back to `session_type: 'other'`,
141
+ * `duration_bucket: '<15m'`, and the invocations of the last 24 hours.
105
142
  *
106
143
  * anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
107
144
  * telemetry.json record; a created/rotated ID is persisted via
@@ -137,10 +174,10 @@ export function buildBatch({
137
174
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
138
175
  const nowIso = now || new Date().toISOString();
139
176
 
140
- const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
177
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
141
178
  const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
142
179
 
143
- const sessionRecord = sessions.length > 0 ? sessions[sessions.length - 1] : null;
180
+ const sessionRecord = mostRecentSession(sessions);
144
181
 
145
182
  let windowInvocations;
146
183
  let sessionForPing;
@@ -331,8 +368,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
331
368
  if (count > 0) return true;
332
369
 
333
370
  // (b) Catch-up: a session completed after the last successful flush.
334
- // Reuses buildBatch's reader same file, same skipInvalid tolerance,
335
- // no second parser.
371
+ // Reuses buildBatch's reader (#1186: readCanonicalSessions +
372
+ // mostRecentSession) — same file, same #1167 dedupe, no second parser.
336
373
  //
337
374
  // Deliberate simplification (named ceiling): this parses the WHOLE
338
375
  // sessions.jsonl to look at its last record. At the observed ledger size
@@ -340,8 +377,8 @@ export function shouldDailyFlush({ statePath, queuePath, metricsDir, now = Date.
340
377
  // and it only runs once the 24h gate above has already passed. Revisit
341
378
  // with a tail-read if any repo's sessions.jsonl passes ~10 MB.
342
379
  const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
343
- const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
344
- const last = sessions.length > 0 ? sessions[sessions.length - 1] : null;
380
+ const sessions = readCanonicalSessions({ filePath: path.join(dir, 'sessions.jsonl') });
381
+ const last = mostRecentSession(sessions);
345
382
  const completedMs = Date.parse(last?.completed_at);
346
383
  return !Number.isNaN(completedMs) && completedMs > lastMs;
347
384
  } catch {