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
@@ -0,0 +1,312 @@
1
+ /**
2
+ * telemetry/sync.mjs — batch build + offline-tolerant sync for anonymous usage
3
+ * telemetry (Epic #841, Issue #844 / S3 FA3; PRD
4
+ * docs/prd/2026-07-20-anonymous-usage-telemetry.md §3-FA3).
5
+ *
6
+ * This module is the SEND path. It ties together the W2 primitives:
7
+ * - consent.mjs — resolveConsent (the outermost gate), telemetry.json read/write
8
+ * - schema.mjs — buildUsagePing + projectUsagePing (whitelist projection)
9
+ * - anon-id.mjs — ensureAnonId (lazy mint + 90-day rotation)
10
+ * - queue.mjs — the bounded NDJSON offline queue
11
+ *
12
+ * ── Outermost-seam gating (load-bearing privacy invariant) ───────────────────
13
+ * `resolveConsent()` is the FIRST statement of `flush()`. When it returns
14
+ * `send !== true` the function returns immediately — nothing below the gate is
15
+ * reachable: no fetch, no queue write, and NO anon-ID minting. The anon-ID is
16
+ * minted lazily inside `buildBatch()`, which `flush()` calls ONLY after the gate
17
+ * has passed. This makes "no ID exists until an affirmative-consent send is
18
+ * actually attempted" a structural guarantee, not a discipline.
19
+ *
20
+ * ── Fire-and-forget, never-throw ─────────────────────────────────────────────
21
+ * A flush never throws and never blocks a session beyond the POST timeout. On
22
+ * any send failure (network, timeout, non-2xx) the batch lands in the host-local
23
+ * queue (bounded, oldest-dropped) and the session closes with zero user-facing
24
+ * error.
25
+ *
26
+ * Node ESM. The only network dependency is the global `fetch`.
27
+ */
28
+
29
+ import path from 'node:path';
30
+
31
+ import {
32
+ resolveConsent,
33
+ readTelemetryState,
34
+ writeTelemetryState,
35
+ TELEMETRY_JSON_PATH,
36
+ } from './consent.mjs';
37
+ import { buildUsagePing, projectUsagePing } from './schema.mjs';
38
+ import { ensureAnonId } from './anon-id.mjs';
39
+ import { peekAll, enqueue, clear, queueStats } from './queue.mjs';
40
+ import { loadOwnerConfig } from '../owner-yaml.mjs';
41
+ import { readJsonlFile } from '../io.mjs';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Constants
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Public ingest endpoint. Overridable per-process via SO_TELEMETRY_ENDPOINT (tests/staging). */
48
+ export const TELEMETRY_ENDPOINT = 'https://telemetry.session-orchestrator.com/v1/records';
49
+
50
+ /** Fire-and-forget POST timeout (ms). */
51
+ export const POST_TIMEOUT_MS = 3000;
52
+
53
+ /** Daily-fallback horizon: only flush a backlog older than this. */
54
+ const DAILY_FLUSH_MS = 24 * 60 * 60 * 1000;
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Internal helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ /** Resolve the effective ingest endpoint (env override wins). */
61
+ function resolveEndpoint(env) {
62
+ const override = (env?.SO_TELEMETRY_ENDPOINT || '').trim();
63
+ return override || TELEMETRY_ENDPOINT;
64
+ }
65
+
66
+ /**
67
+ * The default network sender: one POST carrying the whole batch array as the
68
+ * JSON body (the server accepts arrays), with an AbortSignal timeout. Resolves
69
+ * on a 2xx status, rejects on anything else (which routes the caller into the
70
+ * offline queue).
71
+ *
72
+ * @param {object} opts
73
+ * @param {NodeJS.ProcessEnv} opts.env
74
+ * @param {number} opts.timeoutMs
75
+ * @returns {(batches: object[]) => Promise<void>}
76
+ */
77
+ function defaultSender({ env, timeoutMs }) {
78
+ const endpoint = resolveEndpoint(env);
79
+ return async (batches) => {
80
+ const res = await fetch(endpoint, {
81
+ method: 'POST',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify(batches),
84
+ signal: AbortSignal.timeout(timeoutMs),
85
+ });
86
+ if (!res.ok) {
87
+ throw new Error(`telemetry endpoint returned HTTP ${res.status}`);
88
+ }
89
+ };
90
+ }
91
+
92
+ // ---------------------------------------------------------------------------
93
+ // Batch build
94
+ // ---------------------------------------------------------------------------
95
+
96
+ /**
97
+ * Build ONE whitelist-projected usage-ping record from the local JSONL streams.
98
+ *
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.
105
+ *
106
+ * anon-ID handling (persist=true, the send path): `ensureAnonId` runs on the
107
+ * telemetry.json record; a created/rotated ID is persisted via
108
+ * `writeTelemetryState`. With `persist=false` (the CLI `show` preview) NOTHING is
109
+ * minted or written — an existing ID is echoed, otherwise a placeholder string is
110
+ * shown. This preserves the lazy-ID invariant even for `show`.
111
+ *
112
+ * INVARIANT: `flush()` calls this ONLY after the consent gate has passed, so the
113
+ * (persisting) anon-ID mint is never reachable under `send !== true`.
114
+ *
115
+ * Never throws — an internal failure returns `{ record: null, reason }`.
116
+ *
117
+ * @param {object} [opts]
118
+ * @param {string} [opts.metricsDir] Metrics dir (default `<cwd>/.orchestrator/metrics`).
119
+ * @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
120
+ * @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here).
121
+ * @param {{skills: Set<string>, commands: Set<string>}} [opts.roster] Roster (default: loaded by schema).
122
+ * @param {string} [opts.now] ISO timestamp for sent_at + rotation clock.
123
+ * @param {string} [opts.statePath] telemetry.json path override (test injection).
124
+ * @param {boolean} [opts.persist=true] Mint+persist the anon-ID (false ⇒ preview only).
125
+ * @returns {{ record: object|null, reason?: string }}
126
+ */
127
+ export function buildBatch({
128
+ metricsDir,
129
+ env = process.env,
130
+ ownerConfig,
131
+ roster,
132
+ now,
133
+ statePath,
134
+ persist = true,
135
+ } = {}) {
136
+ try {
137
+ const dir = metricsDir || path.join(process.cwd(), '.orchestrator', 'metrics');
138
+ const nowIso = now || new Date().toISOString();
139
+
140
+ const sessions = readJsonlFile(path.join(dir, 'sessions.jsonl'), { skipInvalid: true });
141
+ const invocations = readJsonlFile(path.join(dir, 'skill-invocations.jsonl'), { skipInvalid: true });
142
+
143
+ const sessionRecord = sessions.length > 0 ? sessions[sessions.length - 1] : null;
144
+
145
+ let windowInvocations;
146
+ let sessionForPing;
147
+ if (sessionRecord && typeof sessionRecord.started_at === 'string' && !Number.isNaN(Date.parse(sessionRecord.started_at))) {
148
+ const startMs = Date.parse(sessionRecord.started_at);
149
+ windowInvocations = invocations.filter((rec) => {
150
+ const t = Date.parse(rec?.timestamp);
151
+ return !Number.isNaN(t) && t >= startMs;
152
+ });
153
+ sessionForPing = sessionRecord;
154
+ } else {
155
+ // No usable session record → 24h window + synthetic session (schema
156
+ // fallbacks yield session_type 'other' / duration_bucket '<15m').
157
+ const cutoff = (Number.isNaN(Date.parse(nowIso)) ? Date.now() : Date.parse(nowIso)) - DAILY_FLUSH_MS;
158
+ windowInvocations = invocations.filter((rec) => {
159
+ const t = Date.parse(rec?.timestamp);
160
+ return !Number.isNaN(t) && t >= cutoff;
161
+ });
162
+ sessionForPing = {};
163
+ }
164
+
165
+ const cfg = ownerConfig ?? loadOwnerConfig().config;
166
+
167
+ const ping = buildUsagePing({
168
+ sessionRecord: sessionForPing,
169
+ skillInvocations: windowInvocations,
170
+ ownerConfig: cfg,
171
+ env,
172
+ now: nowIso,
173
+ roster,
174
+ });
175
+
176
+ const target = statePath || TELEMETRY_JSON_PATH;
177
+ const { record: stateRecord } = readTelemetryState({ path: target });
178
+
179
+ if (persist) {
180
+ const { record: nextState, anon_id, created, rotated } = ensureAnonId(stateRecord, { now: nowIso });
181
+ if (created || rotated) {
182
+ writeTelemetryState(nextState, { path: target });
183
+ }
184
+ ping.anon_id = anon_id;
185
+ } else {
186
+ ping.anon_id =
187
+ typeof stateRecord.anon_id === 'string' && stateRecord.anon_id.trim() !== ''
188
+ ? stateRecord.anon_id
189
+ : '(generated on first send)';
190
+ }
191
+
192
+ return { record: projectUsagePing(ping) };
193
+ } catch (err) {
194
+ return { record: null, reason: `build-error: ${err?.message ?? String(err)}` };
195
+ }
196
+ }
197
+
198
+ // ---------------------------------------------------------------------------
199
+ // Flush
200
+ // ---------------------------------------------------------------------------
201
+
202
+ /**
203
+ * Attempt to flush telemetry: gate on consent, build the batch, drain the
204
+ * offline queue together with the new record in ONE send, and empty the queue on
205
+ * success. Never throws, never blocks beyond `timeoutMs`.
206
+ *
207
+ * @param {object} [opts]
208
+ * @param {NodeJS.ProcessEnv} [opts.env] Env source (default process.env).
209
+ * @param {number} [opts.timeoutMs] POST timeout (default POST_TIMEOUT_MS).
210
+ * @param {(batches: object[]) => Promise<void>} [opts.sender] Injected sender (default: network POST).
211
+ * @param {string} [opts.metricsDir] Metrics dir override.
212
+ * @param {string} [opts.statePath] telemetry.json path override.
213
+ * @param {string} [opts.queuePath] queue path override.
214
+ * @param {string} [opts.now] ISO timestamp (sent_at, last_flush_at, rotation clock).
215
+ * @param {object} [opts.ownerConfig] Parsed owner.yaml (default: loaded here). Inject to
216
+ * isolate a test from the host's real owner.yaml fleet flag.
217
+ * @returns {Promise<{ sent: boolean, queued: boolean, state: string, reason: string }>}
218
+ */
219
+ export async function flush({
220
+ env = process.env,
221
+ timeoutMs = POST_TIMEOUT_MS,
222
+ sender,
223
+ metricsDir,
224
+ statePath,
225
+ queuePath,
226
+ now,
227
+ ownerConfig,
228
+ } = {}) {
229
+ // Resolve owner.yaml once (injectable for hermetic tests). loadOwnerConfig reads the host's
230
+ // real owner.yaml — a test asserting "consent absent" MUST inject {} or a real fleet flag
231
+ // (telemetry.enabled: true) legitimately flips send=true.
232
+ const cfg = ownerConfig ?? loadOwnerConfig().config;
233
+
234
+ // OUTERMOST SEAM — the consent gate is the FIRST statement. When send !== true
235
+ // nothing below (no fetch, no queue write, no anon-ID mint) is reachable.
236
+ const consent = resolveConsent({
237
+ env,
238
+ ownerConfig: cfg,
239
+ state: readTelemetryState({ path: statePath }).record,
240
+ interactive: false,
241
+ });
242
+ if (consent.send !== true) {
243
+ return { sent: false, queued: false, state: consent.state, reason: 'gated' };
244
+ }
245
+
246
+ const nowIso = now || new Date().toISOString();
247
+
248
+ // Build the batch (this lazily mints + persists the anon-ID — only reachable
249
+ // here, i.e. strictly after the gate).
250
+ const { record, reason } = buildBatch({ metricsDir, env, ownerConfig: cfg, statePath, now: nowIso });
251
+ if (!record) {
252
+ return { sent: false, queued: false, state: consent.state, reason: reason || 'no-record' };
253
+ }
254
+
255
+ // Debug seam: print the exact payload, send nothing.
256
+ if (env?.SO_TELEMETRY_DEBUG === '1') {
257
+ process.stderr.write(`${JSON.stringify(record)}\n`);
258
+ return { sent: false, queued: false, state: consent.state, reason: 'debug' };
259
+ }
260
+
261
+ // Drain the existing queue together with the new record in ONE send.
262
+ const queuedBatches = peekAll({ path: queuePath }).map((entry) => entry.batch);
263
+ const batches = [...queuedBatches, record];
264
+
265
+ const send = typeof sender === 'function' ? sender : defaultSender({ env, timeoutMs });
266
+
267
+ try {
268
+ await send(batches);
269
+ } catch {
270
+ // Send failed → only the NEW record joins the queue (queued batches remain
271
+ // in place since the queue was not cleared).
272
+ enqueue(record, { path: queuePath, now: nowIso });
273
+ return { sent: false, queued: true, state: consent.state, reason: 'queued' };
274
+ }
275
+
276
+ // 2xx → empty the queue and stamp last_flush_at (preserving the anon-ID that
277
+ // buildBatch may have just persisted).
278
+ clear({ path: queuePath });
279
+ const { record: freshState } = readTelemetryState({ path: statePath });
280
+ writeTelemetryState({ ...freshState, last_flush_at: nowIso }, { path: statePath });
281
+
282
+ return { sent: true, queued: false, state: consent.state, reason: 'sent' };
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Daily-fallback predicate
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /**
290
+ * Whether a daily-fallback flush is due: the queue is non-empty AND more than 24h
291
+ * have passed since the last successful flush (a never-flushed queue with items
292
+ * counts as due). Cheap — a single telemetry.json read + a queue stat. Never throws.
293
+ *
294
+ * @param {object} [opts]
295
+ * @param {string} [opts.statePath] telemetry.json path override.
296
+ * @param {string} [opts.queuePath] queue path override.
297
+ * @param {number} [opts.now] Reference time in epoch-ms (default Date.now()).
298
+ * @returns {boolean}
299
+ */
300
+ export function shouldDailyFlush({ statePath, queuePath, now = Date.now() } = {}) {
301
+ try {
302
+ const { count } = queueStats({ path: queuePath });
303
+ if (count <= 0) return false;
304
+
305
+ const { record } = readTelemetryState({ path: statePath });
306
+ const raw = record?.last_flush_at;
307
+ const lastMs = typeof raw === 'string' && !Number.isNaN(Date.parse(raw)) ? Date.parse(raw) : 0;
308
+ return now - lastMs > DAILY_FLUSH_MS;
309
+ } catch {
310
+ return false;
311
+ }
312
+ }
@@ -479,7 +479,13 @@ export function writeBoard(opts) {
479
479
  * @param {object} opts
480
480
  * @param {string} opts.repoRoot — the repo whose row is being updated.
481
481
  * @param {Array<{ repoRoot: string, repoName?: string, status?: string }>} [opts.repos]
482
- * Full repo list; defaults to `[{ repoRoot, status: explicitStatus }]`.
482
+ * Full repo list; defaults to a single-repo descriptor
483
+ * `[{ repoRoot, status: explicitStatus }]`. Whichever entry resolves to
484
+ * `repoRoot` — in the caller-supplied list as well as in the default — gets
485
+ * its `repoName` filled from Session Config `vault-integration.vault-name`
486
+ * (#660) unless it already carries one; entries for other repos are never
487
+ * touched. With no `vault-name` configured, {@link collectRows} falls back to
488
+ * `path.basename(repoRoot)` as before.
483
489
  * @param {string} [opts.explicitStatus] — per-repo status override ('closed' from session-end).
484
490
  * @param {Date} [opts.now]
485
491
  * @param {boolean} [opts.dryRun]
@@ -524,10 +530,51 @@ export async function mirrorBoard({ repoRoot, repos, explicitStatus, now = new D
524
530
  return { action: 'skipped-vault-disabled' };
525
531
  }
526
532
 
527
- const repoList = Array.isArray(repos) && repos.length > 0
533
+ // `vault-name` (#660) overrides the git-derived repo slug for per-project
534
+ // vault namespacing. Without it `collectRows` falls back to
535
+ // `path.basename(repoRoot)`, so a repo whose directory name differs from its
536
+ // configured vault name renders under the wrong board row.
537
+ //
538
+ // The override is applied during descriptor NORMALISATION, not descriptor
539
+ // CONSTRUCTION (#835). Applying it only to the fallback single-repo
540
+ // descriptor made it inert on the primary production path: `sweepBoard`
541
+ // (session-start Phase 1.7) ALWAYS passes a non-empty `repos` — see
542
+ // {@link buildSweepRepos}, which unconditionally appends `thisRepoRoot` and
543
+ // emits bare `{ repoRoot }` entries with NO `repoName`. So session-start
544
+ // keyed the row `foldKey(path.basename(repoRoot))` while session-end (which
545
+ // calls this function WITHOUT `repos`) keyed it `foldKey(vault-name)`. The
546
+ // merge key is `repoName`, so the close never updated the in-progress row:
547
+ // a duplicate row plus a permanently stale `in-progress` status.
548
+ //
549
+ // Scope: ONLY the entry whose `repoRoot` resolves to THIS repo's root is
550
+ // touched. Entries for FOREIGN repos are left alone — a foreign repo's
551
+ // `vault-name` lives in ITS own Session Config, which this function does not
552
+ // read; stamping our override onto it would mislabel someone else's row. An
553
+ // entry that already carries an explicit non-empty `repoName` also wins, so
554
+ // a caller can still name its own row deliberately (#832 contract).
555
+ const vaultName = vault['vault-name'];
556
+ const repoNameOverride = typeof vaultName === 'string' && vaultName.length > 0
557
+ ? vaultName
558
+ : undefined;
559
+
560
+ const baseRepoList = Array.isArray(repos) && repos.length > 0
528
561
  ? repos
529
562
  : [{ repoRoot, status: explicitStatus }];
530
563
 
564
+ const repoList = repoNameOverride === undefined
565
+ ? baseRepoList
566
+ : baseRepoList.map((entry) => {
567
+ if (!entry || typeof entry.repoRoot !== 'string' || entry.repoRoot.length === 0) return entry;
568
+ if (typeof entry.repoName === 'string' && entry.repoName.length > 0) return entry;
569
+ let isSelf;
570
+ try {
571
+ isSelf = path.resolve(entry.repoRoot) === path.resolve(repoRoot);
572
+ } catch {
573
+ isSelf = false;
574
+ }
575
+ return isSelf ? { ...entry, repoName: repoNameOverride } : entry;
576
+ });
577
+
531
578
  const outputPath = resolveBoardPath(vaultDir);
532
579
 
533
580
  // Read the EXISTING generator-owned board (if any) to:
@@ -696,9 +743,11 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
696
743
  * {@link mirrorBoard}'s idempotent merge, never dropped.
697
744
  * (c) The enumerate + collectRows path is synchronous fs (readdirSync /
698
745
  * existsSync / readLock per candidate) — O(repos) small reads, single-digit
699
- * ms at host scale (~31 repos observed). No timeout is applied: a sync
700
- * call cannot be preempted in-process, so a timeout would only convert a
701
- * slow sweep into a thrown error, not a faster one.
746
+ * ms at host scale (45 repos, ~0.9-1.9ms warm measured 2026-07-19 at the
747
+ * default walk depth of 2; pre-#832's depth-1 scan saw only 1 of 47). No
748
+ * timeout is applied: a sync call cannot be preempted in-process, so a
749
+ * timeout would only convert a slow sweep into a thrown error, not a
750
+ * faster one.
702
751
  * (d) Merge key is `repoName` (`path.basename`), case-insensitively folded via
703
752
  * {@link foldKey} (issue #719) — two rows differing only by case (e.g.
704
753
  * `some-repo` vs `Some-Repo`, the same physical directory on a
@@ -713,6 +762,15 @@ export function buildSweepRepos(candidates, { thisRepoRoot } = {}) {
713
762
  * remains a known limitation, inherited from {@link collectRows}/
714
763
  * {@link mirrorBoard}; not addressed here.
715
764
  *
765
+ * This limitation got materially WORSE with the depth-2 walk (#832):
766
+ * under the old depth-1 scan, `<org-a>/<name>` and `<org-b>/<name>` were
767
+ * both un-enumerable, so they could not collide. Both are now enumerated
768
+ * and fold to a single row. Two such basename collisions were measured on
769
+ * the reference host immediately after the change (same repo name under
770
+ * two different org directories). Fixing this requires re-keying rows on
771
+ * something path-derived rather than `path.basename` — deliberately out
772
+ * of scope for #832 and tracked as a follow-up.
773
+ *
716
774
  * Best-effort contract: `sweepBoard` itself never throws for an enumeration
717
775
  * failure — `enumerateCandidates` is wrapped in try/catch; on ANY failure the
718
776
  * sweep degrades to the pre-#716 single-repo write
@@ -489,13 +489,6 @@ export async function mirrorNarrative(opts) {
489
489
  return { action: 'skipped-vault-disabled' };
490
490
  }
491
491
 
492
- // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
493
- // it from the repoRoot basename rather than mis-filing the narrative under the
494
- // 'unknown' slug. A missing repo name must never silently mis-file (#675 review).
495
- const repoName = (typeof repo === 'string' && repo.trim().length > 0)
496
- ? repo
497
- : path.basename(path.resolve(repoRoot));
498
-
499
492
  // Read Session Config (CLAUDE.md / AGENTS.md) and resolve vault settings.
500
493
  let config;
501
494
  try {
@@ -509,6 +502,19 @@ export async function mirrorNarrative(opts) {
509
502
  if (!vaultIntegration || vaultIntegration.enabled !== true) {
510
503
  return { action: 'skipped-vault-disabled' };
511
504
  }
505
+
506
+ // Defense-in-depth: when the caller omits (or passes an empty) `repo`, derive
507
+ // it from the operator-configured `vault-name` override (#660/#832) when set,
508
+ // else the repoRoot basename — never silently mis-file under 'unknown' (#675
509
+ // review). Precedence: explicit `repo` opt > `vault-name` > basename.
510
+ const vaultNameOverride =
511
+ typeof vaultIntegration['vault-name'] === 'string' && vaultIntegration['vault-name'].trim()
512
+ ? vaultIntegration['vault-name'].trim()
513
+ : null;
514
+ const repoName = (typeof repo === 'string' && repo.trim().length > 0)
515
+ ? repo
516
+ : vaultNameOverride ?? path.basename(path.resolve(repoRoot));
517
+
512
518
  const rawVaultDir = vaultIntegration['vault-dir'];
513
519
  if (!rawVaultDir || typeof rawVaultDir !== 'string') {
514
520
  return { action: 'skipped-vault-disabled' };
@@ -8,7 +8,8 @@ set -euo pipefail
8
8
  # - session_config — reads Session Config from the project instruction file
9
9
  # (CLAUDE.md, or AGENTS.md alias on Codex CLI — see
10
10
  # skills/_shared/instruction-file-resolution.md)
11
- # - session_metrics — reads last 5 session metrics entries
11
+ # - session_metrics — reads last 5 REAL session metrics entries (#834:
12
+ # abandoned phantom stubs are filtered out first)
12
13
 
13
14
  # ---------------------------------------------------------------------------
14
15
  # Helpers
@@ -75,7 +76,7 @@ handle_tools_list() {
75
76
  },
76
77
  {
77
78
  "name": "session_metrics",
78
- "description": "Reads the last 5 session metrics entries from .orchestrator/metrics/sessions.jsonl",
79
+ "description": "Reads the last 5 REAL session metrics entries from .orchestrator/metrics/sessions.jsonl (abandoned phantom stubs excluded)",
79
80
  "inputSchema": {"type": "object", "properties": {}, "required": []}
80
81
  }
81
82
  ]
@@ -156,8 +157,19 @@ tool_session_metrics() {
156
157
  return
157
158
  fi
158
159
 
160
+ # Filter out phantom `status: 'abandoned'` stubs (#834, session-close-backfill
161
+ # — 0 waves, seconds of runtime) BEFORE taking the tail, so a recent phantom
162
+ # cannot displace real session records out of the last-5 window.
163
+ #
164
+ # `-R` (raw-input) + `fromjson?` parses each line individually and SKIPS
165
+ # unparseable ones instead of aborting the whole stream — plain
166
+ # `jq -c 'select(...)'` aborts at the FIRST malformed line (jq: parse error,
167
+ # exit 5), which is fatal here because sessions.jsonl is append-only from
168
+ # multiple writers and a torn write is exactly the case that matters. This
169
+ # mirrors the per-line try/catch behaviour of the .mjs path
170
+ # (scripts/lib/session-schema/filters.mjs).
159
171
  local entries
160
- entries=$(tail -n 5 "$metrics_file" 2>/dev/null) || true
172
+ entries=$(jq -R -c 'fromjson? | select(.status != "abandoned")' "$metrics_file" 2>/dev/null | tail -n 5) || true
161
173
 
162
174
  if [[ -z "$entries" ]]; then
163
175
  respond "$id" "$(text_content "No metrics found (file is empty)")"