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
@@ -7,12 +7,52 @@
7
7
  * Part of v3.0.0 migration (Epic #124, issue #133).
8
8
  * Issue #228: removed hardcoded personal-domain default URL. Clank Event Bus URL
9
9
  * must now be supplied explicitly via CLANK_EVENT_URL when CLANK_EVENT_SECRET is set.
10
+ *
11
+ * ## Correlation envelope (#1177 FA3)
12
+ *
13
+ * Measured 2026-09-02 @ c3ab480 over 33,608 ledger records: only 22.1% carry
14
+ * `session_id` and 4.8% carry `wave`, because filling them was every call
15
+ * site's own job and 32 of 34 call sites pass no options at all. `emitEvent()`
16
+ * now fills those keys itself — under three hard rules:
17
+ *
18
+ * 1. **Additive, never overriding.** The correlation keys are spread BEFORE
19
+ * `payload`, so any caller-supplied `session_id` / `semantic_session_id` /
20
+ * `wave` wins byte-for-byte. A payload that supplies EITHER session key
21
+ * suppresses the session fill entirely (both keys), so a caller that
22
+ * deliberately pins attribution elsewhere — `vault-mirror/telemetry.mjs`
23
+ * pins to `SO_PROJECT_DIR` and passes both keys — is untouched.
24
+ * 2. **Omit, never fabricate.** When attribution cannot be PROVEN, both keys
25
+ * are left ABSENT — never `null`, never `''`. An absent key is the only
26
+ * honest encoding of "not attributable" (see `sessionAttribution()`).
27
+ * 3. **Never a peer's id (#1123).** A shared working copy means
28
+ * `session.lock` can name a PEER session that won the acquire race. The
29
+ * lock alone therefore does not prove ownership; the fill happens only
30
+ * when a PROCESS-LOCAL id (`CLAUDE_CODE_SESSION_ID`, or a hook payload's
31
+ * `session_id`) equals the lock's raw `session_id`. STATE.md is NOT a
32
+ * witness here (#1177 FX1): it is a shared working-copy file written by
33
+ * the lock holder, so under a peer-owned lock both agreed about the peer
34
+ * and the union stamped the peer's ids. See {@link attributionForRecord}.
35
+ *
36
+ * The attribution root is the SAME root the ledger line is pinned to
37
+ * (`opts.repoRoot ?? SO_PROJECT_DIR`), never `process.cwd()`. Measured cost of
38
+ * the whole envelope (lock + wave manifest, 100 calls, this repo):
39
+ * 0.0961 ms/call.
10
40
  */
11
41
 
12
- import { promises as fs } from 'node:fs';
42
+ import { promises as fs, existsSync, readFileSync } from 'node:fs';
13
43
  import path from 'node:path';
14
44
  import { SO_PROJECT_DIR, SO_SHARED_DIR } from './platform.mjs';
15
45
  import { readLock } from './session-lock.mjs';
46
+ import { resolveStateMdPath } from './state-md/frontmatter-mutators.mjs';
47
+ import {
48
+ classifyManifestSession,
49
+ readProcessLocalSessionIds,
50
+ } from './session-identity/own-session.mjs';
51
+ import {
52
+ EventValidationError,
53
+ stampEventSchemaVersion,
54
+ validateEventRecord,
55
+ } from './events-schema.mjs';
16
56
 
17
57
  // ---------------------------------------------------------------------------
18
58
  // Public API
@@ -69,16 +109,173 @@ export function sessionAttribution(repoRoot) {
69
109
  }
70
110
  }
71
111
 
112
+ // ---------------------------------------------------------------------------
113
+ // Correlation envelope (#1177 FA3)
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /** State-dir candidates, in the same order `state-md` resolves them. */
117
+ const STATE_DIR_CANDIDATES = ['.claude', '.codex', '.cursor', '.pi'];
118
+
119
+ /**
120
+ * Session-correlation keys for a record pinned to `root` — `{}` when ownership
121
+ * is not provable.
122
+ *
123
+ * Decision, in one line: **only a PROCESS-LOCAL id may confirm the lock, and
124
+ * when one exists it decides alone.**
125
+ *
126
+ * - No lock (CI, a bare script) → `{}`. Nothing to attribute to.
127
+ * - No process-local id (`CLAUDE_CODE_SESSION_ID` absent) → `{}`. Ownership is
128
+ * UNPROVEN, and an unproven attribution is exactly the peer-id write #1123
129
+ * forbids; an absent key costs a correlation, a wrong key costs a false one.
130
+ * - A process-local id that equals the lock's raw `session_id` → fill BOTH
131
+ * keys, verbatim from the lock.
132
+ * - A process-local id that DISAGREES → `{}` (the lock names a peer that won
133
+ * the acquire race).
134
+ *
135
+ * **Why STATE.md is not a witness (#1177 FX1).** It used to be one, unioned
136
+ * with the env id — and the union was the bug: `.claude/STATE.md` is a SHARED
137
+ * working-copy artefact written by the session that OWNS the working copy,
138
+ * i.e. normally the lock holder. When a peer holds the lock, the peer also
139
+ * wrote STATE.md, so both "independent" witnesses name the PEER and a
140
+ * disagreeing process-local id could not veto them. Measured: lock=peer,
141
+ * STATE.md=peer, `CLAUDE_CODE_SESSION_ID`=me → the peer's ids were stamped on
142
+ * this session's records. A shared file cannot prove which PROCESS is emitting;
143
+ * see `readProcessLocalSessionIds()` for the tiering rationale (HR-102: a
144
+ * better signal replaces a worse one, it does not merely get outvoted by it).
145
+ *
146
+ * CEILING (BV-004): the comparison is against the lock's RAW `session_id`, so a
147
+ * harness that ROTATES its session id mid-session (see
148
+ * `tests/hooks/on-session-end.test.mjs` `new-rotated-uuid`) has an env id that
149
+ * no longer equals the lock's raw id, and BOTH keys are then omitted — honest
150
+ * absence, never misattribution. REVISIT when the rotation rate is measured in
151
+ * `events.jsonl` (count `orchestrator.session.started` against lock rewrites):
152
+ * if rotation is common, the lock must be refreshed on rotation rather than
153
+ * this comparison widened.
154
+ *
155
+ * @param {string} [root=SO_PROJECT_DIR] — the repo the record is pinned to.
156
+ * @returns {{session_id?: string, semantic_session_id?: string}}
157
+ */
158
+ export function attributionForRecord(root = SO_PROJECT_DIR) {
159
+ const attribution = sessionAttribution(root);
160
+ const lockRawId =
161
+ typeof attribution.session_id === 'string' ? attribution.session_id.trim() : '';
162
+ if (!lockRawId) return {};
163
+ const processLocal = readProcessLocalSessionIds();
164
+ if (processLocal.length === 0) return {};
165
+ return processLocal.includes(lockRawId) ? { ...attribution } : {};
166
+ }
167
+
168
+ /**
169
+ * Absolute path of the active `wave-scope.json`, or `null` when none exists.
170
+ *
171
+ * The active platform's state dir is tried first (via `resolveStateMdPath()`,
172
+ * the repo's existing resolver), then the remaining candidates — so a Codex or
173
+ * Cursor run finds its own manifest rather than a stale `.claude/` one.
174
+ *
175
+ * @param {string} root
176
+ * @returns {string|null}
177
+ */
178
+ function waveScopePath(root) {
179
+ const dirs = [];
180
+ try {
181
+ dirs.push(path.dirname(resolveStateMdPath(root)));
182
+ } catch {
183
+ /* fall through to the fixed candidate list */
184
+ }
185
+ for (const dir of STATE_DIR_CANDIDATES) {
186
+ const abs = path.join(root, dir);
187
+ if (!dirs.includes(abs)) dirs.push(abs);
188
+ }
189
+ for (const dir of dirs) {
190
+ const candidate = path.join(dir, 'wave-scope.json');
191
+ try {
192
+ if (existsSync(candidate)) return candidate;
193
+ } catch {
194
+ /* unreadable candidate — try the next one */
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /**
201
+ * `{ wave }` from the live wave-scope manifest — `{}` when the manifest is
202
+ * missing, waveless, or belongs to another session.
203
+ *
204
+ * The manifest is a SHARED working-copy artefact (`.claude/wave-scope.json`),
205
+ * so a peer session's manifest is readable here and would stamp this session's
206
+ * events with a foreign wave number. Ownership is classified with
207
+ * `classifyManifestSession()` against the PROCESS-LOCAL id set plus whatever
208
+ * `attributionForRecord()` actually filled — the same tiering as the session
209
+ * keys, for the same reason (a shared file cannot prove which process emits):
210
+ *
211
+ * - manifest classified `own` → fill (as a NUMBER, see below).
212
+ * - anything else → omit. That includes an UNBOUND manifest (no `session` /
213
+ * `semantic_session`): since #1123 BOTH writers stamp the binding, so a
214
+ * manifest without one is a peer's or a stale artefact, never a legacy own
215
+ * one. It also includes `unknown` because we cannot resolve our own
216
+ * identity — stricter than `classifyManifestSession()`'s own `unknown`
217
+ * doctrine on purpose: a wave number is data on the record, not a feature
218
+ * gate, so "cannot tell" must not become "stamp it anyway".
219
+ *
220
+ * @param {string} root
221
+ * @param {{session_id?: string, semantic_session_id?: string}} attribution
222
+ * @returns {{wave?: number}}
223
+ */
224
+ function waveForRecord(root, attribution) {
225
+ let scope;
226
+ try {
227
+ const file = waveScopePath(root);
228
+ if (!file) return {};
229
+ scope = JSON.parse(readFileSync(file, 'utf8'));
230
+ } catch {
231
+ return {};
232
+ }
233
+ // The ledger's `wave` is numeric in 1876 of 1876 live records; a manifest
234
+ // carrying `"wave": "3"` used to write the STRING through verbatim and split
235
+ // every downstream group-by. Coerce, and omit anything that is not an integer
236
+ // (`"abc"`, `2.5`) rather than writing a NaN or a fraction.
237
+ const waveNum = Number(scope?.wave);
238
+ if (scope?.wave === null || scope?.wave === '' || !Number.isInteger(waveNum)) return {};
239
+
240
+ const ownIds = new Set([
241
+ ...readProcessLocalSessionIds(),
242
+ ...[attribution.session_id, attribution.semantic_session_id].filter(Boolean),
243
+ ]);
244
+ const { verdict } = classifyManifestSession(scope, ownIds);
245
+ return verdict === 'own' ? { wave: waveNum } : {};
246
+ }
247
+
72
248
  /**
73
249
  * Append a JSONL event record and optionally POST to the Clank Event Bus webhook.
74
250
  *
75
- * Writes `{ts, event, ...payload}` as a single JSON line to
76
- * `.orchestrator/metrics/events.jsonl` (creates parent directory if needed).
251
+ * Writes `{timestamp, event, schema_version, ...payload}` as a single JSON line
252
+ * to `.orchestrator/metrics/events.jsonl` (creates parent directory if needed).
77
253
  * If both `CLANK_EVENT_SECRET` and `CLANK_EVENT_URL` are set, fires an async
78
254
  * fire-and-forget POST to `CLANK_EVENT_URL` with a 3-second timeout. Network
79
255
  * errors are swallowed. Write errors propagate to the caller. No personal-domain
80
256
  * default URL exists — both vars must be set explicitly (#228).
81
257
  *
258
+ * Validation + versioning (#1177). Every record is stamped via
259
+ * `stampEventSchemaVersion()` (the schema module's own stamper — it fills the
260
+ * field only when absent, so a caller keeps authority over it) and run through
261
+ * `validateEventRecord()` BEFORE any side effect. An invalid record throws
262
+ * `EventValidationError` and produces NO ledger line and NO webhook POST —
263
+ * a malformed event is dropped at the producer rather than written and
264
+ * discovered by a downstream reader. The stamp is applied AFTER the payload
265
+ * spread, and still yields to it: the helper fills the field only when it is
266
+ * absent or null, so a caller supplying its own `schema_version` wins.
267
+ *
268
+ * Correlation envelope (#1177 FA3). When the payload carries neither session
269
+ * key, `session_id`/`semantic_session_id` are filled from
270
+ * {@link attributionForRecord}; when it carries no `wave`, `wave` is filled
271
+ * from the OWN wave-scope manifest. Both are additive and omitted whenever
272
+ * ownership is unproven — see the module header for the three rules.
273
+ *
274
+ * The webhook body deliberately stays `{ event_type, source, payload }` with the
275
+ * RAW payload — the wire format is a published contract with an external
276
+ * consumer; `schema_version` describes the JSONL record, not the webhook
277
+ * envelope, and is not added to it.
278
+ *
82
279
  * @param {string} type — event type (e.g. "orchestrator.session.started")
83
280
  * @param {object} [payload={}] — additional fields shallow-merged into the record
84
281
  * @param {object} [opts={}] — emission options.
@@ -95,8 +292,44 @@ export function sessionAttribution(repoRoot) {
95
292
  * @returns {Promise<void>}
96
293
  */
97
294
  export async function emitEvent(type, payload = {}, opts = {}) {
98
- // Build the JSONL record: ts + event come first, payload spreads last.
99
- const record = { timestamp: new Date().toISOString(), event: type, ...payload };
295
+ // Correlation envelope (#1177 FA3) computed against the SAME root the line
296
+ // is pinned to. Both fills are gated on the payload NOT already carrying the
297
+ // key, and both spread BEFORE `payload`, so a caller always wins twice over.
298
+ // A payload that supplies EITHER session key suppresses BOTH: mixing a
299
+ // caller's `session_id` with a lock-derived `semantic_session_id` would
300
+ // silently produce a record whose two id fields name different sessions.
301
+ const attributionRoot = opts.repoRoot ?? SO_PROJECT_DIR;
302
+ const correlation = {};
303
+ if (payload.session_id === undefined && payload.semantic_session_id === undefined) {
304
+ Object.assign(correlation, attributionForRecord(attributionRoot));
305
+ }
306
+ if (payload.wave === undefined) {
307
+ Object.assign(correlation, waveForRecord(attributionRoot, correlation));
308
+ }
309
+
310
+ // Build the JSONL record: timestamp + event first, payload spreads last, and
311
+ // `stampEventSchemaVersion()` — the schema module's own stamper, which only
312
+ // fills an ABSENT/null field — adds the version. Routing through the helper
313
+ // instead of inlining `schema_version: CURRENT_SCHEMA_VERSION` keeps the
314
+ // stamp rule in ONE place: a caller-supplied version still wins, because the
315
+ // helper never overwrites a value the spread already put there.
316
+ const record = stampEventSchemaVersion({
317
+ timestamp: new Date().toISOString(),
318
+ event: type,
319
+ ...correlation,
320
+ ...payload,
321
+ });
322
+
323
+ // Validate BEFORE any side effect — no line, no directory, no webhook (#1177).
324
+ const verdict = validateEventRecord(record);
325
+ if (!verdict.valid) {
326
+ throw new EventValidationError(
327
+ `invalid event record for "${String(type)}": ${verdict.errors.join('; ')}`,
328
+ verdict.errors,
329
+ typeof type === 'string' ? type : undefined,
330
+ );
331
+ }
332
+
100
333
  const line = JSON.stringify(record) + '\n';
101
334
 
102
335
  // Ensure the destination directory exists before appending. Resolution order:
@@ -223,10 +223,15 @@ 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()`.
226
+ * Abandoned-session filtering (#834) AND duplicate-identity collapse (#1167):
227
+ * `sessions` is passed straight through to `groupByMode()`, which canonicalizes
228
+ * the array (one record per physical session) and then filters phantom
229
+ * `status: 'abandoned'` stubs before bucketing this function inherits BOTH
230
+ * guarantees transitively and duplicates neither. The abandoned filter alone
231
+ * was not enough: a `supersedes` pair and an exact same-id duplicate are two
232
+ * records of one session that both survive it. See
233
+ * `autopilot-effectiveness.mjs` `groupByMode()` (which canonicalizes via
234
+ * `canonicalizeSessions(sessions, { keepUnidentified: true })` before filtering).
230
235
  *
231
236
  * @param {Array} autopilotRuns
232
237
  * @param {Array} sessions
@@ -27,6 +27,7 @@
27
27
  import { randomUUID } from 'node:crypto';
28
28
 
29
29
  import { filterRealSessions } from '../session-schema.mjs';
30
+ import { canonicalizeSessions } from '../sessions-canonical.mjs';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Constants
@@ -181,7 +182,23 @@ export function groupByMode(autopilotRuns, sessions) {
181
182
  const out = new Map();
182
183
  if (!Array.isArray(sessions) || sessions.length === 0) return out;
183
184
 
184
- const realSessions = filterRealSessions(sessions);
185
+ // Canonicalize BEFORE filtering (#1167). `filterRealSessions` drops phantom
186
+ // `abandoned` stubs, but two records of ONE physical session that are both
187
+ // real survive it — a `supersedes` pair (the backfilled stub plus the
188
+ // authoritative `completed` record that refutes it) and an exact same-id
189
+ // duplicate line both do. Each of those inflates `n_manual` / `n_autopilot`
190
+ // and skews every mean computed from the bucket, so the identity collapse
191
+ // has to happen first; the two compose in exactly this order (see
192
+ // `sessions-canonical.mjs` § "What this module does not do").
193
+ //
194
+ // `keepUnidentified: true` because effectiveness analysis must not LOSE the
195
+ // id-less rows `canonicalizeSessions` drops by default: legacy ledger rows
196
+ // and every in-repo fixture of the pre-id era carry `session_type` and
197
+ // metrics but no id, and dropping them would silently shrink `n_manual`
198
+ // instead of de-duplicating it.
199
+ const realSessions = filterRealSessions(
200
+ canonicalizeSessions(sessions, { keepUnidentified: true }),
201
+ );
185
202
  if (realSessions.length === 0) return out;
186
203
 
187
204
  // Optional: known autopilot_run_id set for stricter pairing. Empty set means
@@ -31,6 +31,7 @@ import { readFile } from 'node:fs/promises';
31
31
  import { readConfigFile, parseSessionConfig } from '../config.mjs';
32
32
  import { parseFrontmatter } from '../vault-mirror/utils.mjs';
33
33
  import { validatePathInsideProject } from '../path-utils.mjs';
34
+ import { expandTilde } from '../common.mjs';
34
35
 
35
36
  import { discoverVaultRepos } from './vcs-detect.mjs';
36
37
  import { fetchIssuesMultiRepo, summarizeRepo } from './aggregator.mjs';
@@ -38,19 +39,6 @@ import { renderPortfolio, writePortfolio } from './markdown-writer.mjs';
38
39
 
39
40
  // ── Helpers ───────────────────────────────────────────────────────────────────
40
41
 
41
- /**
42
- * Expand a leading `~` to os.homedir().
43
- *
44
- * @param {string} p
45
- * @returns {string}
46
- */
47
- function expandHome(p) {
48
- if (typeof p === 'string' && p.startsWith('~')) {
49
- return path.join(os.homedir(), p.slice(1));
50
- }
51
- return p;
52
- }
53
-
54
42
  /**
55
43
  * Print a human-readable summary line to stdout.
56
44
  *
@@ -214,9 +202,9 @@ EXIT CODES
214
202
  const vaultIntegration = config['vault-integration'] ?? {};
215
203
  const configVaultDir = vaultIntegration['vault-dir'];
216
204
  const resolvedVaultDir = optVaultDir
217
- ? expandHome(optVaultDir)
205
+ ? expandTilde(optVaultDir)
218
206
  : configVaultDir
219
- ? expandHome(configVaultDir)
207
+ ? expandTilde(configVaultDir)
220
208
  : null;
221
209
 
222
210
  if (!resolvedVaultDir) {
@@ -4,13 +4,15 @@
4
4
  * Checks: state-md-present, sessions-jsonl-growth, learnings-jsonl-nonempty,
5
5
  * orchestrator-layout
6
6
  *
7
- * Stdlib only: node:fs, node:path.
7
+ * Stdlib + intra-repo helpers only (node:fs, node:path, ./helpers.mjs,
8
+ * ../../sessions-canonical.mjs).
8
9
  */
9
10
 
10
11
  import { existsSync } from 'node:fs';
11
12
  import { join, relative, sep } from 'node:path';
12
13
 
13
14
  import { parseFrontmatter, safeRead, parseJsonl, pass, fail } from './helpers.mjs';
15
+ import { canonicalizeSessions } from '../../sessions-canonical.mjs';
14
16
 
15
17
  export function runCategory1(root) {
16
18
  const checks = [];
@@ -62,14 +64,23 @@ export function runCategory1(root) {
62
64
  const { lines, validLines } = parseJsonl(text);
63
65
  const requiredKeys = ['session_id', 'session_type', 'started_at'];
64
66
  const wellFormed = validLines.filter((l) => requiredKeys.every((k) => k in l));
65
- if (lines >= 2 && wellFormed.length === lines) {
67
+ // "Growth" means IDENTITIES, not records (#1167): sessions.jsonl is
68
+ // append-only, so one physical session can occupy two lines (an abandoned
69
+ // stub plus the record that supersedes it, or the systemic double-stub
70
+ // pair). A repo whose only two lines are one duplicated session has not
71
+ // grown, and the ≥2 threshold exists to prove the ledger is actually
72
+ // accumulating sessions. The WELL-FORMEDNESS half deliberately stays on
73
+ // raw lines — a malformed or key-missing line is a defect of that line,
74
+ // and collapsing duplicates first would hide it.
75
+ const sessionCount = canonicalizeSessions(validLines).length;
76
+ if (sessionCount >= 2 && wellFormed.length === lines) {
66
77
  checks.push(pass('sessions-jsonl-growth', 3, 3, relPath,
67
- { lineCount: lines, validLines: wellFormed.length },
68
- `sessions.jsonl has ${lines} valid lines`));
78
+ { lineCount: lines, validLines: wellFormed.length, sessionCount },
79
+ `sessions.jsonl has ${sessionCount} distinct sessions in ${lines} valid lines`));
69
80
  } else {
70
81
  checks.push(fail('sessions-jsonl-growth', 3, relPath,
71
- { lineCount: lines, validLines: wellFormed.length },
72
- `sessions.jsonl: lines=${lines} (need ≥2), wellFormed=${wellFormed.length}/${lines}`));
82
+ { lineCount: lines, validLines: wellFormed.length, sessionCount },
83
+ `sessions.jsonl: sessions=${sessionCount} (need ≥2), lines=${lines}, wellFormed=${wellFormed.length}/${lines}`));
73
84
  }
74
85
  }
75
86
  }
@@ -27,6 +27,7 @@ import { resolveMemoryDir } from './memory-paths.mjs';
27
27
  import { readDreamSignals } from './auto-dream.mjs';
28
28
  import { readPeerCards } from './peer-cards/reader.mjs';
29
29
  import { surfaceTopN, decayOptsFromConfig } from './learnings/surface.mjs';
30
+ import { countSessionsInJsonl } from './sessions-canonical.mjs';
30
31
 
31
32
  // ---------------------------------------------------------------------------
32
33
  // Constants
@@ -259,19 +260,30 @@ export function _formatBanner(inputs) {
259
260
  // ---------------------------------------------------------------------------
260
261
 
261
262
  /**
262
- * Count lines (newline-terminated entries) in a JSONL file. Missing files
263
- * resolve to 0; read errors resolve to 0. Empty trailing lines are not
264
- * counted, matching the JSONL convention.
263
+ * Count DISTINCT physical sessions in `sessions.jsonl` the "sessions ever"
264
+ * stat. Missing files resolve to 0; read errors resolve to 0.
265
+ *
266
+ * Identities, not lines (#1167). `sessions.jsonl` is append-only, so one
267
+ * physical session can occupy two lines (an abandoned stub plus the
268
+ * authoritative record that supersedes it, or the systemic double-stub pair
269
+ * from the two backfill writers). Counting lines inflated this stat by exactly
270
+ * the number of duplicates — 286 lines for 275 sessions in this repo, measured
271
+ * 2026-09-02.
272
+ *
273
+ * The abandoned-session semantic is DELIBERATELY unchanged: an abandoned
274
+ * session is a real session and still counts. Only DUPLICATE records of ONE
275
+ * physical session collapse — see `sessions-canonical.mjs`, which is not a
276
+ * phantom filter.
265
277
  *
266
278
  * @param {string} filePath
267
279
  * @returns {Promise<number>}
268
280
  */
269
- async function countJsonlLines(filePath) {
281
+ async function countSessionIdentities(filePath) {
270
282
  if (!existsSync(filePath)) return 0;
271
283
  try {
272
- const raw = await readFile(filePath, 'utf8');
273
- if (raw.length === 0) return 0;
274
- return raw.split('\n').filter((l) => l.length > 0).length;
284
+ // Counting rule (blank/malformed lines, id-less records) lives in
285
+ // `countSessionsInJsonl`; only the async read is local.
286
+ return countSessionsInJsonl(await readFile(filePath, 'utf8'));
275
287
  } catch {
276
288
  return 0;
277
289
  }
@@ -378,7 +390,7 @@ export async function readBannerInputs({ repoRoot, memoryDir, learningsPath, now
378
390
  // computed the same way, so we re-derive locally to keep this dependency
379
391
  // surface lean and explicit.
380
392
  const sessionsPath = path.join(repoRoot, '.orchestrator', 'metrics', 'sessions.jsonl');
381
- const sessionsEver = await countJsonlLines(sessionsPath);
393
+ const sessionsEver = await countSessionIdentities(sessionsPath);
382
394
 
383
395
  let daysSinceCleanup = null;
384
396
  if (typeof signals?.lastCleanupAt === 'string' && signals.lastCleanupAt.length > 0) {
@@ -100,7 +100,16 @@ function _ageHoursFrom(startedAt, nowMs) {
100
100
  /**
101
101
  * Map one discoverActiveSessions entry into a provenance-tagged peer.
102
102
  *
103
- * @param {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string}} s
103
+ * GH#67 pass-through: `registryOnly` / `lockSuperseded` / `lockOwnerId` are
104
+ * threaded through UNCHANGED when the upstream entry carries them (registry-
105
+ * sourced sessions only). Absent stays absent, so a lock-sourced peer object is
106
+ * byte-identical to the pre-GH#67 shape. `lockSuperseded: true` is a HINT that
107
+ * a LIVE lock at that repoRoot is owned by a different raw session_id — not a
108
+ * verdict that the peer is dead (the lock is advisory, #1085). Consumers
109
+ * deciding a worktree PROMOTION_OFFER downgrade such a peer to an advisory
110
+ * line; consumers counting or displaying peers keep it.
111
+ *
112
+ * @param {{worktreePath:string,sessionId:string,mode:string,startedAt:string,pid:number,host:string,branch:string,registryOnly?:boolean,lockSuperseded?:boolean,lockOwnerId?:string|null}} s
104
113
  * @param {number} nowMs
105
114
  * @returns {object}
106
115
  */
@@ -118,6 +127,10 @@ function _peerFromDiscovered(s, nowMs) {
118
127
  worktreePath: s.worktreePath,
119
128
  };
120
129
  if (ageHours !== undefined) peer.ageHours = ageHours;
130
+ // GH#67 — additive, absent-stays-absent.
131
+ if (s.registryOnly !== undefined) peer.registryOnly = s.registryOnly;
132
+ if (s.lockSuperseded !== undefined) peer.lockSuperseded = s.lockSuperseded;
133
+ if (s.lockOwnerId !== undefined) peer.lockOwnerId = s.lockOwnerId;
121
134
  return peer;
122
135
  }
123
136
 
@@ -189,7 +202,12 @@ function _discoveredSelfSessionId(mySessionId, repoRoot) {
189
202
  * per-source — only the fields the originating surface can supply are emitted
190
203
  * (no field is advertised that the implementation does not set):
191
204
  * - source 'discovered' (from discoverActiveSessions — lock + registry unified):
192
- * { source, sessionId, mode|null, host, pid, worktreePath, ageHours? }
205
+ * { source, sessionId, mode|null, host, pid, worktreePath, ageHours?,
206
+ * registryOnly?, lockSuperseded?, lockOwnerId? }
207
+ * The last three appear ONLY on registry-sourced entries (GH#67
208
+ * annotation, passed through verbatim — see `_peerFromDiscovered`).
209
+ * `lockSuperseded` is a HINT, never a liveness verdict: no peer is
210
+ * dropped on account of it, because the session lock is advisory.
193
211
  * - source 'state-md' (from checkPeerStateMd):
194
212
  * { source, sessionId, mode|null, currentWave, reason, ageHours? }
195
213
  */