@vibecompass/vibecompass 0.15.0 → 0.16.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.
@@ -0,0 +1,638 @@
1
+ import { readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {
4
+ BRIEF_CONTRACT_VERSION,
5
+ BRIEF_DEFAULT_BUDGET,
6
+ BRIEF_OVERVIEW_PATH,
7
+ buildSessionBrief,
8
+ computeBriefCorpusDigest,
9
+ describeBriefInputs,
10
+ hashBriefInputs,
11
+ readBriefLaneBindings,
12
+ } from './brief.js';
13
+ import { renderBrief } from './brief-render.js';
14
+ import { compileBriefExclusions, readBriefSettingsForRoot } from './brief-settings.js';
15
+ import { parseFrontmatter } from './frontmatter.js';
16
+ import { validateLaneId } from './lane-marker.js';
17
+ import { scanProjectMemory } from './project-memory.js';
18
+ import { withMemoryRootLock } from './serialization.js';
19
+ import { parseSimpleYaml } from './simple-yaml.js';
20
+ import { PACKAGE_VERSION } from './version.js';
21
+
22
+ /**
23
+ * The session brief in the lane lifecycle (plan task A4; D-359, D-364).
24
+ * `start-session` and `continue-session` write `sessions/active/<lane-id>/
25
+ * brief.md` — local-only lane scratch (D-278) and a generated view (D-357) —
26
+ * whose YAML header binds the inputs the brief read, so staleness can be
27
+ * computed later without trusting a stored flag. During D-255 dogfood an
28
+ * explicit source root can be read instead of the lane's own root; it is only
29
+ * ever read. Contract: `architecture/platform/project-memory/session-brief.md`.
30
+ */
31
+
32
+ export const LANE_BRIEF_FILENAME = 'brief.md';
33
+ export const LANE_BRIEF_FORMAT = 1;
34
+ const MAX_HEADER_INPUTS = 24;
35
+ const MAX_REASON_LENGTH = 400;
36
+
37
+ export function laneBriefPath(rootDir, laneId) {
38
+ return path.join(path.resolve(rootDir), 'sessions', 'active', laneId, LANE_BRIEF_FILENAME);
39
+ }
40
+
41
+ /**
42
+ * Generates and writes the lane's `brief.md` under the destination root's
43
+ * memory-root lock. The brief reads `sourceRootDir` when given (the dual-root
44
+ * adapter, after the lane-correspondence check), otherwise the lane's own
45
+ * root. A refused or unresolvable source, a build error, and memory that
46
+ * could not be read still write an `incomplete` brief whose header says
47
+ * `generation: "failed"` with the reason, returned as `refused` or `failed`.
48
+ * A brief that is incomplete only because mandatory units overflowed the
49
+ * budget is a successful generation (`written`). Only errors that prevent
50
+ * writing the file throw.
51
+ */
52
+ export async function generateLaneBrief(options = {}) {
53
+ const destinationRoot = path.resolve(requireString(options.rootDir, 'generateLaneBrief requires rootDir.'));
54
+ const laneId = validateLaneId(requireString(options.laneId, 'generateLaneBrief requires laneId.'));
55
+ const budget = options.budget ?? BRIEF_DEFAULT_BUDGET;
56
+ const build = options.buildBrief ?? buildSessionBrief;
57
+ if (options.sourceSessionId && !options.sourceRootDir) {
58
+ throw new Error('--source-session requires --source-root; without a source root the brief reads the lane\'s own root.');
59
+ }
60
+ const requestedSource = options.sourceRootDir ? path.resolve(options.sourceRootDir) : null;
61
+
62
+ return withMemoryRootLock(destinationRoot, 'brief', async () => {
63
+ const briefPath = laneBriefPath(destinationRoot, laneId);
64
+ if (!(await isFile(path.join(path.dirname(briefPath), 'session.yaml')))) {
65
+ throw new Error(`Session lane "${laneId}" is not an active lane in ${destinationRoot}; a brief is written only into an open lane.`);
66
+ }
67
+
68
+ const generatedAt = formatLocalDateTime(options.now ?? new Date());
69
+ const frame = { destinationRoot, laneId, generatedAt, budget, requestedSource };
70
+ let source;
71
+ try {
72
+ source = await resolveBriefSource({
73
+ destinationRoot,
74
+ laneId,
75
+ sourceRootDir: requestedSource ?? undefined,
76
+ sourceSessionId: options.sourceSessionId,
77
+ });
78
+ } catch (error) {
79
+ // A source that cannot even be read (for example an unreadable lane
80
+ // session.yaml) is refused like a non-corresponding one.
81
+ source = {
82
+ adapter: Boolean(requestedSource),
83
+ rootDir: requestedSource ?? destinationRoot,
84
+ laneId,
85
+ repoAliases: {},
86
+ aliasPairs: [],
87
+ refusal: `the source could not be read: ${errorMessage(error)}`,
88
+ };
89
+ }
90
+
91
+ const failed = async (state, reason, warning) => {
92
+ await writeLaneBriefFile(briefPath, renderFailedLaneBrief({ ...frame, source, reason }));
93
+ return { state, status: 'incomplete', reason, path: briefPath, source: describeSource(source), warnings: [warning] };
94
+ };
95
+
96
+ if (source.refusal) {
97
+ return failed('refused', `the dual-root source was refused: ${source.refusal}`, `Session brief refused (D-364): ${source.refusal}. Fix it, then run: ${briefCommand(frame, 'write')}`);
98
+ }
99
+
100
+ // The adapter reads another root, so the destination's exclusions apply
101
+ // too (their union); a destination whose settings cannot be trusted
102
+ // fails closed before the source is read.
103
+ let extraExclude = [];
104
+ if (source.adapter) {
105
+ const destinationSettings = await readBriefSettingsForRoot(destinationRoot);
106
+ if (destinationSettings.problems.length > 0) {
107
+ const reason = `project.yaml brief settings in the destination root are invalid, so no canonical document was read (D-364): ${destinationSettings.problems.join('; ')}`;
108
+ return failed('failed', reason, `Session brief incomplete: ${reason}`);
109
+ }
110
+ extraExclude = destinationSettings.exclude;
111
+ }
112
+
113
+ let result;
114
+ try {
115
+ result = await build({
116
+ rootDir: source.rootDir,
117
+ laneId: source.laneId,
118
+ budget,
119
+ exclude: extraExclude,
120
+ repoAliases: source.repoAliases,
121
+ });
122
+ } catch (error) {
123
+ const reason = `the brief engine failed: ${errorMessage(error)}`;
124
+ return failed('failed', reason, `Session brief incomplete: ${reason}. Retry with: ${briefCommand(frame, 'write')}`);
125
+ }
126
+
127
+ // The engine reports unreadable memory (invalid settings included) as a
128
+ // returned result, not a throw: that is a failed generation, although
129
+ // its body still lists the reads to make.
130
+ if (result.retrieval_error) {
131
+ const reason = `memory could not be read: ${result.retrieval_error}`;
132
+ await writeLaneBriefFile(briefPath, renderLaneBriefDocument({ ...frame, source, result, failure: reason }));
133
+ return {
134
+ state: 'failed',
135
+ status: result.status,
136
+ reason,
137
+ path: briefPath,
138
+ source: describeSource(source),
139
+ warnings: [`Session brief incomplete: ${reason}. Retry with: ${briefCommand(frame, 'write')}`],
140
+ };
141
+ }
142
+
143
+ await writeLaneBriefFile(briefPath, renderLaneBriefDocument({ ...frame, source, result }));
144
+ return {
145
+ state: 'written',
146
+ status: result.status,
147
+ reason: result.status_reason,
148
+ path: briefPath,
149
+ source: describeSource(source),
150
+ warnings: result.status === 'incomplete' ? [`Session brief is incomplete: ${result.status_reason}`] : [],
151
+ };
152
+ });
153
+ }
154
+
155
+ /**
156
+ * Read-only freshness check of a lane's `brief.md` against its header
157
+ * bindings (D-364). The expected source is the one this call names
158
+ * (`sourceRootDir`, else the lane's own root); a brief recorded against any
159
+ * other root is stale, and that recorded root is never read. A check that
160
+ * cannot read what it needs reports the brief stale, never current.
161
+ */
162
+ export async function inspectLaneBrief(options = {}) {
163
+ const destinationRoot = path.resolve(requireString(options.rootDir, 'inspectLaneBrief requires rootDir.'));
164
+ const laneId = validateLaneId(requireString(options.laneId, 'inspectLaneBrief requires laneId.'));
165
+ const briefPath = laneBriefPath(destinationRoot, laneId);
166
+ const content = await readOptional(briefPath);
167
+ if (content === null) {
168
+ return { exists: false, path: briefPath, stale: true, reasons: ['no brief.md exists for this lane'], status: null, generatedAt: null, header: null };
169
+ }
170
+
171
+ let header;
172
+ try {
173
+ header = parseFrontmatter(content, { sourceName: briefPath }).data;
174
+ } catch (error) {
175
+ header = null;
176
+ }
177
+ const base = { exists: true, path: briefPath, status: optionalString(header?.status), generatedAt: optionalString(header?.generated_at), header };
178
+ const stale = (reasons) => ({ ...base, stale: reasons.length > 0, reasons });
179
+
180
+ if (!header || typeof header !== 'object') {
181
+ return stale(['the brief header is missing or unreadable']);
182
+ }
183
+ if (header.brief_format !== LANE_BRIEF_FORMAT || header.contract_version !== BRIEF_CONTRACT_VERSION) {
184
+ return stale([`the brief was written in another format (brief_format ${header.brief_format ?? 'none'}, contract ${header.contract_version ?? 'none'})`]);
185
+ }
186
+ if (header.generation !== 'ok') {
187
+ return stale([`the last generation did not complete${header.reason ? `: ${header.reason}` : ''}`]);
188
+ }
189
+
190
+ try {
191
+ return stale(await compareBindings({ header, destinationRoot, laneId, sourceRootDir: options.sourceRootDir, sourceSessionId: options.sourceSessionId }));
192
+ } catch (error) {
193
+ return stale([`freshness could not be checked: ${errorMessage(error)}`]);
194
+ }
195
+ }
196
+
197
+ async function compareBindings({ header, destinationRoot, laneId, sourceRootDir, sourceSessionId }) {
198
+ const source = await resolveBriefSource({ destinationRoot, laneId, sourceRootDir, sourceSessionId });
199
+ if (source.refusal) {
200
+ return [`the dual-root source is refused: ${source.refusal}`];
201
+ }
202
+ if (!(await sameDirectory(header.source_root, source.rootDir))) {
203
+ // Never read the recorded root: only the root this call names is read.
204
+ return [`the brief was built from ${header.source_root ?? 'an unrecorded root'}, and this run reads ${source.rootDir}`];
205
+ }
206
+ if (header.source_lane !== source.laneId || header.destination_lane !== laneId || !(await sameDirectory(header.destination_root, destinationRoot))) {
207
+ return ['the brief is bound to a different lane or destination root'];
208
+ }
209
+
210
+ const reasons = [];
211
+ // The alias map comes from both roots' project.yaml; the destination's is
212
+ // not part of the source corpus, so it is compared directly.
213
+ const recordedAliases = Array.isArray(header.repo_aliases) ? header.repo_aliases.map(String) : [];
214
+ if (JSON.stringify(recordedAliases) !== JSON.stringify(source.aliasPairs.map(formatAliasPair))) {
215
+ reasons.push('the repo alias map changed (a destination or source repo id or remote)');
216
+ }
217
+
218
+ const sourceSettings = await readBriefSettingsForRoot(source.rootDir);
219
+ const destinationSettings = source.adapter ? await readBriefSettingsForRoot(destinationRoot) : { exclude: [], problems: [] };
220
+ const problems = [...sourceSettings.problems, ...destinationSettings.problems];
221
+ if (problems.length > 0) {
222
+ return [...reasons, `brief settings are invalid, so freshness cannot be checked without reading excluded paths: ${problems.join('; ')}`];
223
+ }
224
+ const exclusions = compileBriefExclusions(uniqueStrings([...sourceSettings.exclude, ...destinationSettings.exclude]));
225
+
226
+ const lane = await readBriefLaneBindings(source.rootDir, source.laneId);
227
+ const identity = lane.sessionDate && Number.isInteger(lane.sessionNumber) ? `${lane.sessionDate}-${lane.sessionNumber}` : null;
228
+ if (!identity) {
229
+ reasons.push('the lane has no usable session identity (session_date and session_number)');
230
+ } else if (header.source_session !== identity) {
231
+ reasons.push(`the lane session identity changed (${header.source_session ?? 'none'} → ${identity})`);
232
+ }
233
+ const recordedLane = header.lane_bindings && typeof header.lane_bindings === 'object' ? header.lane_bindings : {};
234
+ if (recordedLane.session !== lane.bindings.session) reasons.push('session.yaml changed (working-on, claims, features, architecture docs, repos, snapshot, or worktrees)');
235
+ if (recordedLane.handoff !== encodeOptionalHash(lane.bindings.handoff)) reasons.push('handoff.md changed');
236
+ if (recordedLane.other_lanes !== lane.bindings.other_lanes) reasons.push('the other active lanes changed');
237
+
238
+ const scan = await scanProjectMemory(source.rootDir, { exclude: (relativePath) => exclusions.matches(relativePath) });
239
+ const recordedInputs = parseInputLines(header.inputs);
240
+ const current = hashBriefInputs(scan.documents, recordedInputs.map((input) => input.key));
241
+ const changed = recordedInputs.filter((input) => current.get(input.key) !== input.hash).map((input) => input.key);
242
+ if (changed.length > 0) {
243
+ reasons.push(`changed or removed input${changed.length === 1 ? '' : 's'}: ${changed.slice(0, 6).join(', ')}${changed.length > 6 ? ` (+${changed.length - 6} more)` : ''}`);
244
+ }
245
+ if (header.corpus_digest === 'none') {
246
+ reasons.push('the brief could not read canonical memory when it was generated');
247
+ } else if (header.corpus_digest !== computeBriefCorpusDigest(scan.documents, exclusions.patterns) && changed.length === 0) {
248
+ reasons.push('other canonical memory changed (docs, decisions, notes, or project.yaml), so selection may differ');
249
+ }
250
+ return reasons;
251
+ }
252
+
253
+ /**
254
+ * Lifecycle entry point for `start-session` and `continue-session`. Never
255
+ * throws: a brief failure becomes a warning (and, where possible, an
256
+ * `incomplete` brief), so it cannot overturn an otherwise successful start or
257
+ * resume (D-359). `continue-session` keeps a current brief and regenerates a
258
+ * missing or stale one; a freshness check that fails counts as stale, so the
259
+ * old brief is replaced by one that discloses the failure.
260
+ */
261
+ export async function refreshLaneBriefForLifecycle(options = {}) {
262
+ const trigger = options.trigger === 'continue' ? 'continue' : 'start';
263
+ const commandFrame = {
264
+ destinationRoot: typeof options.rootDir === 'string' ? path.resolve(options.rootDir) : '<root>',
265
+ laneId: options.laneId ?? '<lane-id>',
266
+ requestedSource: options.sourceRootDir ? path.resolve(options.sourceRootDir) : null,
267
+ };
268
+ let briefPath = null;
269
+ try {
270
+ const rootDir = path.resolve(requireString(options.rootDir, 'The lifecycle brief requires rootDir.'));
271
+ const laneId = validateLaneId(requireString(options.laneId, 'The lifecycle brief requires laneId.'));
272
+ briefPath = laneBriefPath(rootDir, laneId);
273
+
274
+ if (options.disabled === true) {
275
+ return { state: 'off', reason: '--no-brief', path: briefPath, warnings: [] };
276
+ }
277
+ // Opt-in (D-368): only an explicit `brief.enabled: true` generates. Invalid
278
+ // settings still go on to fail closed as a disclosed incomplete brief.
279
+ const settings = await readBriefSettingsForRoot(rootDir);
280
+ if (settings.problems.length === 0 && settings.enabled !== true) {
281
+ const reason = settings.enabledDeclared ? 'project.yaml brief.enabled: false' : 'opt-in; project.yaml does not set brief.enabled: true';
282
+ return { state: 'off', reason, path: briefPath, warnings: [] };
283
+ }
284
+
285
+ let previous = null;
286
+ if (trigger === 'continue') {
287
+ try {
288
+ previous = await inspectLaneBrief({ rootDir, laneId, sourceRootDir: options.sourceRootDir, sourceSessionId: options.sourceSessionId });
289
+ } catch (error) {
290
+ previous = { exists: true, stale: true, reasons: [`freshness could not be checked: ${errorMessage(error)}`] };
291
+ }
292
+ if (previous.exists && !previous.stale) {
293
+ return { state: 'kept', status: previous.status, generatedAt: previous.generatedAt, path: briefPath, warnings: [] };
294
+ }
295
+ }
296
+
297
+ const generated = await generateLaneBrief({
298
+ rootDir,
299
+ laneId,
300
+ sourceRootDir: options.sourceRootDir,
301
+ sourceSessionId: options.sourceSessionId,
302
+ buildBrief: options.buildBrief,
303
+ });
304
+ return {
305
+ ...generated,
306
+ state: generated.state === 'written' && previous?.exists ? 'regenerated' : generated.state,
307
+ staleReasons: previous?.exists ? previous.reasons : [],
308
+ };
309
+ } catch (error) {
310
+ const message = errorMessage(error);
311
+ return {
312
+ state: 'failed',
313
+ status: null,
314
+ reason: message,
315
+ path: briefPath,
316
+ warnings: [
317
+ `Session brief not written: ${message}. The ${trigger}-session result is unaffected; retry with: ${briefCommand(commandFrame, 'write')} (D-364).`,
318
+ ],
319
+ };
320
+ }
321
+ }
322
+
323
+ /**
324
+ * Resolves where a lane brief reads from. Without `sourceRootDir` it is the
325
+ * lane's own root. With it (D-255 dual-root dogfood, D-364), the source lane
326
+ * must have the destination lane's id, session date, and session number, or
327
+ * the source is refused; repo ids translate from destination to source by
328
+ * matching normalized remotes, then identical ids.
329
+ */
330
+ export async function resolveBriefSource({ destinationRoot, laneId, sourceRootDir, sourceSessionId }) {
331
+ const destination = path.resolve(destinationRoot);
332
+ const own = { adapter: false, rootDir: destination, laneId, repoAliases: {}, aliasPairs: [], identity: null, refusal: null };
333
+ if (sourceSessionId && !sourceRootDir) {
334
+ throw new Error('--source-session requires --source-root; without a source root the brief reads the lane\'s own root.');
335
+ }
336
+ if (!sourceRootDir || (await sameDirectory(sourceRootDir, destination))) {
337
+ const { repos } = await readBriefSettingsForRoot(destination);
338
+ const ids = repos.filter((repo) => repo && typeof repo.id === 'string').map((repo) => repo.id).sort();
339
+ return { ...own, aliasPairs: ids.map((id) => ({ destination: id, source: id })) };
340
+ }
341
+
342
+ const sourceRoot = path.resolve(sourceRootDir);
343
+ const refuse = (refusal) => ({ ...own, adapter: true, rootDir: sourceRoot, refusal });
344
+ if (sourceSessionId && sourceSessionId !== laneId) {
345
+ return refuse(`--source-session "${sourceSessionId}" does not match the destination lane "${laneId}"; the source lane must have the same id`);
346
+ }
347
+ if (!(await isFile(path.join(sourceRoot, 'project.yaml')))) {
348
+ return refuse(`${sourceRoot} is not a project-memory root (no project.yaml)`);
349
+ }
350
+
351
+ const destinationIdentity = await readLaneIdentity(destination, laneId);
352
+ const sourceIdentity = await readLaneIdentity(sourceRoot, laneId);
353
+ if (!destinationIdentity) {
354
+ return refuse(`lane "${laneId}" has no readable session.yaml in the destination root ${destination}`);
355
+ }
356
+ if (!sourceIdentity) {
357
+ return refuse(`the source root ${sourceRoot} has no active lane "${laneId}"; mirror the lane there first (D-255)`);
358
+ }
359
+ if (sourceIdentity.id !== laneId || sourceIdentity.date !== destinationIdentity.date || sourceIdentity.number !== destinationIdentity.number) {
360
+ return refuse(
361
+ `the source lane is session ${formatIdentity(sourceIdentity)} and the destination lane is session ${formatIdentity(destinationIdentity)}; they must be the same lane id, session date, and session number`,
362
+ );
363
+ }
364
+
365
+ const [destinationSettings, sourceSettings] = await Promise.all([readBriefSettingsForRoot(destination), readBriefSettingsForRoot(sourceRoot)]);
366
+ const aliasPairs = buildRepoAliasPairs(destinationSettings.repos, sourceSettings.repos);
367
+ const repoAliases = Object.fromEntries(
368
+ aliasPairs.filter((pair) => pair.source && pair.source !== pair.destination).map((pair) => [pair.destination, pair.source]),
369
+ );
370
+ return { adapter: true, rootDir: sourceRoot, laneId, repoAliases, aliasPairs, identity: sourceIdentity, refusal: null };
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // brief.md rendering
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function renderLaneBriefDocument({ destinationRoot, laneId, generatedAt, budget, source, result, failure = null }) {
378
+ const inputs = describeBriefInputs(result);
379
+ const shown = inputs.slice(0, inputs.length > MAX_HEADER_INPUTS ? MAX_HEADER_INPUTS - 1 : MAX_HEADER_INPUTS);
380
+ const folded = inputs.slice(shown.length);
381
+ const lane = result.source.lane_bindings;
382
+ const session = result.source.lane_session;
383
+
384
+ const fields = [
385
+ `generation: ${quote(failure ? 'failed' : 'ok')}`,
386
+ `status: ${quote(result.status)}`,
387
+ ...(failure ? [`reason: ${quote(boundReason(failure))}`] : []),
388
+ `budget: ${budget}`,
389
+ `source_root: ${quote(source.rootDir)}`,
390
+ `source_lane: ${quote(source.laneId)}`,
391
+ `source_session: ${quote(session?.date && Number.isInteger(session?.number) ? `${session.date}-${session.number}` : 'unknown')}`,
392
+ `destination_root: ${quote(destinationRoot)}`,
393
+ `destination_lane: ${quote(laneId)}`,
394
+ `corpus_digest: ${quote(result.source.corpus_digest ?? 'none')}`,
395
+ ...renderList('exclude', result.source.exclude ?? []),
396
+ 'lane_bindings:',
397
+ ` session: ${quote(lane?.session ?? 'none')}`,
398
+ ` handoff: ${quote(encodeOptionalHash(lane?.handoff ?? null))}`,
399
+ ` other_lanes: ${quote(lane?.other_lanes ?? 'none')}`,
400
+ ...renderList('repo_aliases', source.aliasPairs.map(formatAliasPair)),
401
+ ...renderList('inputs', [
402
+ ...shown.map((input) => `${input.hash} ${input.key}`),
403
+ ...(folded.length > 0 ? [`+${folded.length} more inputs (covered by corpus_digest)`] : []),
404
+ ]),
405
+ ];
406
+ return assembleBriefDocument(commandsFor(destinationRoot, laneId, source), generatedAt, fields, renderBrief(result));
407
+ }
408
+
409
+ function renderFailedLaneBrief({ destinationRoot, laneId, generatedAt, source, reason }) {
410
+ const rootNote = source.rootDir === destinationRoot ? '' : ` (paths are relative to \`${source.rootDir}\`)`;
411
+ const commands = commandsFor(destinationRoot, laneId, source);
412
+ const fields = [
413
+ 'generation: "failed"',
414
+ 'status: "incomplete"',
415
+ `reason: ${quote(boundReason(reason))}`,
416
+ `source_root: ${quote(source.rootDir)}`,
417
+ `source_lane: ${quote(source.laneId)}`,
418
+ `destination_root: ${quote(destinationRoot)}`,
419
+ `destination_lane: ${quote(laneId)}`,
420
+ ];
421
+ const body = [
422
+ '# Session brief',
423
+ '',
424
+ `**INCOMPLETE — the session brief could not be generated:** ${boundReason(reason)}`,
425
+ '',
426
+ `Read these before planning${rootNote}:`,
427
+ `- \`sessions/active/${laneId}/handoff.md\` — the lane's baton-pass state`,
428
+ `- \`${BRIEF_OVERVIEW_PATH}\` — whole-project orientation`,
429
+ "- `decisions/INDEX.md` — decision index; read each entry's Impact on prior decisions before relying on it",
430
+ '',
431
+ `Retry once the cause is fixed: \`${commands.write}\``,
432
+ '',
433
+ ].join('\n');
434
+ return assembleBriefDocument(commands, generatedAt, fields, body);
435
+ }
436
+
437
+ /** Check and refresh commands that keep this brief's root, lane, and source explicit. */
438
+ function commandsFor(destinationRoot, laneId, source) {
439
+ const frame = { destinationRoot, laneId, requestedSource: source.adapter ? source.rootDir : null };
440
+ return { check: briefCommand(frame, 'check'), write: briefCommand(frame, 'write') };
441
+ }
442
+
443
+ function assembleBriefDocument(commands, generatedAt, fields, body) {
444
+ const lines = [
445
+ '---',
446
+ '# Generated session brief (D-357, D-359, D-364); do not edit. It is stale once any binding below changes.',
447
+ `# Check: ${commands.check}`,
448
+ `# Refresh: ${commands.write}`,
449
+ `brief_format: ${LANE_BRIEF_FORMAT}`,
450
+ `contract_version: ${BRIEF_CONTRACT_VERSION}`,
451
+ `generated_at: ${quote(generatedAt)}`,
452
+ `generated_by: ${quote(PACKAGE_VERSION)}`,
453
+ ...fields,
454
+ '---',
455
+ '',
456
+ ];
457
+ const document = `${lines.join('\n')}${body}`;
458
+ // The header must round-trip through the package's YAML subset, or later
459
+ // freshness checks could not read it.
460
+ parseSimpleYaml(lines.slice(1, -2).join('\n'), { sourceName: 'brief.md header' });
461
+ return document;
462
+ }
463
+
464
+ function renderList(key, values) {
465
+ if (values.length === 0) return [`${key}: []`];
466
+ return [`${key}:`, ...values.map((value) => ` - ${quote(value)}`)];
467
+ }
468
+
469
+ function parseInputLines(values) {
470
+ if (!Array.isArray(values)) return [];
471
+ return values
472
+ .map((value) => String(value).match(/^([0-9a-f]{16}) (.+)$/))
473
+ .filter(Boolean)
474
+ .map((match) => ({ hash: match[1], key: match[2] }));
475
+ }
476
+
477
+ // ---------------------------------------------------------------------------
478
+ // Helpers
479
+ // ---------------------------------------------------------------------------
480
+
481
+ async function writeLaneBriefFile(briefPath, content) {
482
+ const temporary = `${briefPath}.${process.pid}.${Date.now()}.tmp`;
483
+ try {
484
+ await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
485
+ await rename(temporary, briefPath);
486
+ } catch (error) {
487
+ await rm(temporary, { force: true }).catch(() => {});
488
+ throw error;
489
+ }
490
+ }
491
+
492
+ async function readLaneIdentity(rootDir, laneId) {
493
+ const sessionPath = path.join(rootDir, 'sessions', 'active', laneId, 'session.yaml');
494
+ const content = await readOptional(sessionPath);
495
+ if (content === null) return null;
496
+ try {
497
+ const data = parseSimpleYaml(content, { sourceName: sessionPath });
498
+ return {
499
+ id: typeof data?.id === 'string' ? data.id : laneId,
500
+ date: data?.session_date === undefined || data?.session_date === null ? null : String(data.session_date),
501
+ number: Number.isInteger(data?.session_number) ? data.session_number : null,
502
+ };
503
+ } catch {
504
+ return null;
505
+ }
506
+ }
507
+
508
+ function buildRepoAliasPairs(destinationRepos, sourceRepos) {
509
+ const sources = (Array.isArray(sourceRepos) ? sourceRepos : []).filter((repo) => repo && typeof repo.id === 'string');
510
+ return (Array.isArray(destinationRepos) ? destinationRepos : [])
511
+ .filter((repo) => repo && typeof repo.id === 'string')
512
+ .map((repo) => {
513
+ const remote = normalizeRemote(repo.remote);
514
+ const match = (remote && sources.find((candidate) => normalizeRemote(candidate.remote) === remote)) ?? sources.find((candidate) => candidate.id === repo.id) ?? null;
515
+ return { destination: repo.id, source: match?.id ?? null };
516
+ })
517
+ .sort((left, right) => left.destination.localeCompare(right.destination));
518
+ }
519
+
520
+ /** `https://github.com/o/r.git`, `git@github.com:o/r`, and `ssh://git@github.com/o/r` compare equal. */
521
+ function normalizeRemote(remote) {
522
+ if (typeof remote !== 'string' || remote.trim() === '') return null;
523
+ return remote
524
+ .trim()
525
+ .toLowerCase()
526
+ .replace(/^[a-z+]+:\/\//, '')
527
+ .replace(/^[^@/]+@/, '')
528
+ .replace(/^([^/:]+):(?!\d)/, '$1/')
529
+ .replace(/\.git\/?$/, '')
530
+ .replace(/\/+$/, '');
531
+ }
532
+
533
+ /**
534
+ * A `vibecompass brief` command for this lane with its root, lane, and (when
535
+ * one was named) source root spelled out, so following the advice never
536
+ * falls back to another root or fails lane selection.
537
+ */
538
+ function briefCommand({ destinationRoot, laneId, requestedSource }, action) {
539
+ return [
540
+ 'vibecompass brief',
541
+ '--root',
542
+ shellQuote(destinationRoot),
543
+ '--session',
544
+ shellQuote(laneId),
545
+ `--${action}`,
546
+ ...(requestedSource ? ['--source-root', shellQuote(requestedSource)] : []),
547
+ ].join(' ');
548
+ }
549
+
550
+ function shellQuote(value) {
551
+ const text = String(value);
552
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`;
553
+ }
554
+
555
+ function formatAliasPair(pair) {
556
+ return `${pair.destination} -> ${pair.source ?? '(no match)'}`;
557
+ }
558
+
559
+ function describeSource(source) {
560
+ return {
561
+ adapter: source.adapter,
562
+ root_dir: source.rootDir,
563
+ lane_id: source.laneId,
564
+ repo_aliases: source.aliasPairs,
565
+ };
566
+ }
567
+
568
+ function encodeOptionalHash(value) {
569
+ return value ?? 'missing';
570
+ }
571
+
572
+ function formatIdentity(identity) {
573
+ return `${identity.id} ${identity.date ?? '?'}-${identity.number ?? '?'}`;
574
+ }
575
+
576
+ async function sameDirectory(left, right) {
577
+ if (typeof left !== 'string' || typeof right !== 'string') return false;
578
+ const canonical = async (value) => {
579
+ const resolved = path.resolve(value);
580
+ try {
581
+ return await realpath(resolved);
582
+ } catch {
583
+ return resolved;
584
+ }
585
+ };
586
+ return (await canonical(left)) === (await canonical(right));
587
+ }
588
+
589
+ async function isFile(filePath) {
590
+ try {
591
+ return (await stat(filePath)).isFile();
592
+ } catch {
593
+ return false;
594
+ }
595
+ }
596
+
597
+ async function readOptional(filePath) {
598
+ try {
599
+ return await readFile(filePath, 'utf8');
600
+ } catch (error) {
601
+ if (error && error.code === 'ENOENT') return null;
602
+ throw error;
603
+ }
604
+ }
605
+
606
+ function formatLocalDateTime(date) {
607
+ const pad = (value) => String(value).padStart(2, '0');
608
+ const offsetMinutes = -date.getTimezoneOffset();
609
+ const sign = offsetMinutes >= 0 ? '+' : '-';
610
+ const absolute = Math.abs(offsetMinutes);
611
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}${sign}${pad(Math.floor(absolute / 60))}:${pad(absolute % 60)}`;
612
+ }
613
+
614
+ function boundReason(value) {
615
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
616
+ return text.length <= MAX_REASON_LENGTH ? text : `${text.slice(0, MAX_REASON_LENGTH - 1)}…`;
617
+ }
618
+
619
+ function quote(value) {
620
+ return JSON.stringify(String(value));
621
+ }
622
+
623
+ function uniqueStrings(values) {
624
+ return [...new Set((values ?? []).filter((value) => typeof value === 'string' && value.trim()).map((value) => value.trim()))];
625
+ }
626
+
627
+ function optionalString(value) {
628
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
629
+ }
630
+
631
+ function requireString(value, message) {
632
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(message);
633
+ return value.trim();
634
+ }
635
+
636
+ function errorMessage(error) {
637
+ return String(error instanceof Error ? error.message : error).replace(/\s+/g, ' ').trim().slice(0, MAX_REASON_LENGTH);
638
+ }