session-orchestrator 3.16.0 → 3.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +25 -0
- package/README.md +13 -11
- package/docs/README.md +2 -1
- package/docs/components.md +2 -2
- package/docs/pi-setup.md +1 -1
- package/docs/session-config-reference.md +65 -0
- package/docs/session-config-template.md +27 -0
- package/docs/telemetry/telemetry-claims.md +204 -0
- package/docs/telemetry.md +158 -0
- package/hooks/hooks-codex.json +1 -1
- package/hooks/hooks.json +1 -1
- package/hooks/skill-invocation-telemetry.mjs +109 -10
- package/package.json +12 -2
- package/scripts/compute-grounding-injection.sh +18 -3
- package/scripts/dialectic-deriver.mjs +7 -2
- package/scripts/lib/auto-dialectic.mjs +11 -2
- package/scripts/lib/auto-dream.mjs +16 -5
- package/scripts/lib/build-live-signals.mjs +7 -4
- package/scripts/lib/config/context-coverage.mjs +82 -0
- package/scripts/lib/config/moc-staleness.mjs +98 -0
- package/scripts/lib/config/worktree-orphans.mjs +138 -0
- package/scripts/lib/config.mjs +15 -0
- package/scripts/lib/context-coverage-banner.mjs +223 -0
- package/scripts/lib/dispatcher/enumerate.mjs +151 -31
- package/scripts/lib/dispatcher/rank.mjs +22 -8
- package/scripts/lib/evolve/autonomy-verdict.mjs +5 -0
- package/scripts/lib/evolve/autopilot-effectiveness.mjs +54 -7
- package/scripts/lib/harness-audit/categories/category4.mjs +13 -2
- package/scripts/lib/moc-staleness-banner.mjs +267 -0
- package/scripts/lib/session-end/worktree-orphan-sweep.mjs +252 -0
- package/scripts/lib/session-schema/filters.mjs +88 -0
- package/scripts/lib/session-schema.mjs +1 -0
- package/scripts/lib/skill-health/join.mjs +35 -9
- package/scripts/lib/telemetry/anon-id.mjs +141 -0
- package/scripts/lib/telemetry/consent.mjs +299 -0
- package/scripts/lib/telemetry/paths.mjs +27 -0
- package/scripts/lib/telemetry/queue.mjs +287 -0
- package/scripts/lib/telemetry/schema.mjs +384 -0
- package/scripts/lib/telemetry/sync.mjs +312 -0
- package/scripts/lib/vault-status/board-writer.mjs +63 -5
- package/scripts/lib/vault-status/narrative-mirror.mjs +13 -7
- package/scripts/mcp-server.sh +15 -3
- package/scripts/telemetry.mjs +250 -0
- package/skills/npm-publish/SKILL.md +81 -0
- package/skills/session-end/SKILL.md +74 -1
- package/skills/session-start/SKILL.md +77 -1
- package/skills/vault-sync/SKILL.md +1 -1
- package/skills/vault-sync/package-lock.json +3 -3
- package/skills/vault-sync/validator.mjs +121 -34
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
700
|
-
*
|
|
701
|
-
*
|
|
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' };
|
package/scripts/mcp-server.sh
CHANGED
|
@@ -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=$(
|
|
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)")"
|