@vibecompass/vibecompass 0.14.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.
package/src/brief.js ADDED
@@ -0,0 +1,1557 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createKeywordIndex, queryTermOrigins, queryTerms } from './brief-keywords.js';
4
+ import { BRIEF_MAX_COMPACTION, renderBrief, renderBriefUnit, renderedFollowUpCount } from './brief-render.js';
5
+ import { compileBriefExclusions, readBriefSettingsForRoot, validateExcludePattern } from './brief-settings.js';
6
+ import { collectDeclaredSuccessors, parseDecisionEntries, scanDecisionReferences } from './decision-lineage.js';
7
+ import { sha256Text, stableStringify } from './hash.js';
8
+ import { resolveLaneMarkerContext, resolveLaneSelection, validateLaneId } from './lane-marker.js';
9
+ import { loadProjectReadModelWithDocuments } from './read-model.js';
10
+ import { parseSimpleYaml } from './simple-yaml.js';
11
+
12
+ /**
13
+ * Session brief engine (plan task A3; D-357, D-359). A read-only function from
14
+ * a memory root plus task inputs to a selection result: which memory units a
15
+ * session should read first, packed whole into an estimated token budget,
16
+ * with an honest status and a prioritized follow-up list. It never writes;
17
+ * rendering lives in `brief-render.js` and persistence (lane `brief.md`) in
18
+ * `brief-lifecycle.js`. Contract: `architecture/platform/project-memory/session-brief.md`.
19
+ *
20
+ * Paths the root's `project.yaml` `brief.exclude` names are never read
21
+ * (D-364): the settings are read first, invalid settings fail closed, and the
22
+ * scan drops excluded files before opening them.
23
+ *
24
+ * Relations stay evidence-typed: a doc *covers* a file it lists under
25
+ * Involved files and *cites* a decision it mentions (mechanical); a decision
26
+ * *supersedes* or *amends* another only where its own text declares it
27
+ * (`decision-lineage.md`). Nothing here certifies that a decision governs a
28
+ * doc, file, or task, or that one is currently valid.
29
+ */
30
+
31
+ export const BRIEF_CONTRACT_VERSION = 1;
32
+ export const BRIEF_DEFAULT_BUDGET = 6000;
33
+ export const BRIEF_MIN_BUDGET = 800;
34
+ export const BRIEF_RESERVE_TOKENS = 600;
35
+ export const BRIEF_FOLLOW_UP_CAP = 12;
36
+ export const BRIEF_STATUSES = Object.freeze(['complete', 'partial', 'incomplete', 'no-match']);
37
+ export const BRIEF_OVERVIEW_PATH = 'architecture/overview/project-shape.md';
38
+
39
+ // Selection tuning (plan Track A: tunable without amending D-359).
40
+ const DOC_FIELDS = {
41
+ title: { weight: 3, b: 0.3 },
42
+ body: { weight: 1, b: 0.75 },
43
+ guidance: { weight: 1.5, b: 0.75 },
44
+ };
45
+ const MAX_DOC_CANDIDATES = 10;
46
+ const MAX_DECISION_CANDIDATES = 14;
47
+ // A doc whose mandatory companions would take more than a quarter of the
48
+ // unit capacity (and never less than a quarter of the default capacity)
49
+ // becomes a follow-up read, so one citation-heavy doc cannot crowd out the
50
+ // rest of the brief.
51
+ const MAX_COMPANION_SHARE = 0.25;
52
+ const MIN_COMPANION_ALLOWANCE = Math.round(MAX_COMPANION_SHARE * (BRIEF_DEFAULT_BUDGET - BRIEF_RESERVE_TOKENS));
53
+ const MAX_NOTE_CANDIDATES = 2;
54
+ const RELATIVE_SCORE_FLOOR = 0.35;
55
+ const MIN_KEYWORD_SCORE = 1.5;
56
+ const FILE_MATCH_WEIGHT = 12;
57
+ const LANE_DOC_WEIGHT = 14;
58
+ const FEATURE_WEIGHT = 4;
59
+ const CITATION_WEIGHT = 6;
60
+ // Evidence lists and file inventories cite decisions without saying anything about them.
61
+ const NON_PROPAGATING_SECTIONS = new Set(['Review metadata', 'Involved files']);
62
+ const DISTINCTIVE_TERM_LIMIT = 3;
63
+ const STRONG_TOPIC_TERMS = 3;
64
+ const NARROW_PACKED_UNITS = 4;
65
+ const DISTINCTIVE_MAX_DOCUMENT_SHARE = 0.02;
66
+
67
+ /**
68
+ * Builds a session brief selection result. Options: `rootDir` (required),
69
+ * `task`, `files` (`repo:path`), `featureSlugs`, `claims`, `laneId`, and
70
+ * `budget` (estimated tokens, default 6,000, minimum 800). `exclude` adds
71
+ * patterns to the root's own `brief.exclude`, and `repoAliases` maps other
72
+ * repo ids (`{ 'vibecompass-app': 'app' }`) onto this root's; the dual-root
73
+ * adapter passes both (D-364). Invalid options throw; retrieval failures,
74
+ * invalid brief settings included, return status `incomplete` with a reason.
75
+ */
76
+ export async function buildSessionBrief(options = {}) {
77
+ const input = normalizeBriefOptions(options);
78
+ const gaps = [];
79
+ const warnings = [];
80
+ let retrievalError = null;
81
+
82
+ let lane = null;
83
+ if (input.laneId) {
84
+ try {
85
+ lane = await readLane(input.rootDir, input.laneId);
86
+ } catch (error) {
87
+ retrievalError = `lane "${input.laneId}" could not be read: ${errorMessage(error)}`;
88
+ }
89
+ }
90
+
91
+ const task = input.task || lane?.workingOn || '';
92
+ if (!task && !retrievalError) {
93
+ throw new Error('A brief needs task text: pass a task, or a lane with a recorded working-on summary.');
94
+ }
95
+
96
+ // D-364: exclusions are known before any canonical document is read, and
97
+ // settings that cannot be trusted stop the read entirely.
98
+ let exclusions = null;
99
+ if (!retrievalError) {
100
+ const settings = await readBriefSettingsForRoot(input.rootDir);
101
+ if (settings.problems.length > 0) {
102
+ retrievalError = `project.yaml brief settings are invalid, so no canonical document was read (D-364): ${settings.problems.join('; ')}`;
103
+ } else {
104
+ exclusions = compileBriefExclusions(uniqueStrings([...settings.exclude, ...input.exclude]));
105
+ }
106
+ }
107
+
108
+ let loaded = null;
109
+ if (!retrievalError) {
110
+ try {
111
+ loaded = await loadProjectReadModelWithDocuments(input.rootDir, { exclude: (relativePath) => exclusions.matches(relativePath) });
112
+ } catch (error) {
113
+ retrievalError = `the read model could not be built: ${errorMessage(error)}`;
114
+ }
115
+ }
116
+
117
+ const overviewExcluded = exclusions?.matches(BRIEF_OVERVIEW_PATH) ?? false;
118
+ const overviewExists = !overviewExcluded && (await pathExists(path.join(input.rootDir, BRIEF_OVERVIEW_PATH)));
119
+ if (!overviewExists) {
120
+ gaps.push({
121
+ code: 'missing-overview',
122
+ path: BRIEF_OVERVIEW_PATH,
123
+ message: overviewExcluded
124
+ ? `The orientation overview \`${BRIEF_OVERVIEW_PATH}\` is excluded from briefs (project.yaml \`brief.exclude\`); whole-project orientation is not available from the brief.`
125
+ : `No orientation overview at \`${BRIEF_OVERVIEW_PATH}\`; whole-project orientation is not available from memory.`,
126
+ });
127
+ }
128
+ if (lane && !lane.handoffExists) {
129
+ gaps.push({
130
+ code: 'missing-handoff',
131
+ path: lane.handoffPath,
132
+ message: `Lane \`${lane.id}\` has no \`handoff.md\`; its baton-pass state is not recorded.`,
133
+ });
134
+ }
135
+
136
+ const context = {
137
+ input: { ...input, task },
138
+ lane,
139
+ gaps,
140
+ warnings,
141
+ overview: { path: BRIEF_OVERVIEW_PATH, exists: overviewExists },
142
+ exclude: exclusions?.patterns ?? null,
143
+ corpusDigest: loaded ? computeBriefCorpusDigest(loaded.documents, exclusions.patterns) : null,
144
+ };
145
+
146
+ if (retrievalError) {
147
+ return finalizeRetrievalFailure(context, retrievalError);
148
+ }
149
+
150
+ const selection = selectCandidates(context, loaded, task);
151
+ return packBrief(context, selection);
152
+ }
153
+
154
+ /**
155
+ * Sets `budget.estimated_tokens` to the size of the rendered brief, which
156
+ * itself prints that number: iterate to the fixed point.
157
+ */
158
+ function measureBrief(result) {
159
+ result.render.follow_ups_shown = renderedFollowUpCount(result);
160
+ for (let attempt = 0; attempt < 5; attempt += 1) {
161
+ const estimate = estimateBriefTokens(renderBrief(result));
162
+ if (estimate === result.budget.estimated_tokens) return;
163
+ result.budget.estimated_tokens = estimate;
164
+ }
165
+ }
166
+
167
+ /** Estimated tokens: Unicode code points ÷ 4, rounded up. */
168
+ export function estimateBriefTokens(text) {
169
+ let count = 0;
170
+ for (const _ of String(text ?? '')) count += 1;
171
+ return Math.ceil(count / 4);
172
+ }
173
+
174
+ /**
175
+ * Root and lane resolution for `vibecompass brief`, shared with the other
176
+ * lane-scoped commands and read-only: the root is `--root`, else the nearest
177
+ * worktree lane marker's root, else `cwd/.compass` (D-280); the lane is
178
+ * `--session`, else the marker's lane, else the single active lane, and two
179
+ * or more active lanes without a selection fail closed (D-277, D-353).
180
+ */
181
+ export async function resolveBriefRequest(options = {}) {
182
+ const markerContext = await resolveLaneMarkerContext({
183
+ cwd: options.cwd,
184
+ explicitRootDir: options.rootDir ?? null,
185
+ explicitSessionId: options.sessionId ?? null,
186
+ });
187
+ const selection = resolveLaneSelection({
188
+ explicitSessionId: options.sessionId ?? null,
189
+ marker: markerContext.marker,
190
+ laneIds: await listLaneDirs(path.join(markerContext.rootDir, 'sessions', 'active')),
191
+ rootDir: markerContext.rootDir,
192
+ purpose: 'brief',
193
+ });
194
+ return {
195
+ rootDir: markerContext.rootDir,
196
+ laneId: selection.sessionId,
197
+ laneSource: selection.source,
198
+ warnings: [...markerContext.warnings, ...selection.warnings],
199
+ };
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Inputs and retrieval
204
+ // ---------------------------------------------------------------------------
205
+
206
+ function normalizeBriefOptions(options) {
207
+ if (typeof options.rootDir !== 'string' || options.rootDir.trim() === '') {
208
+ throw new Error('A brief needs a project-memory root (rootDir).');
209
+ }
210
+
211
+ const budget = options.budget ?? BRIEF_DEFAULT_BUDGET;
212
+ if (!Number.isInteger(budget) || budget < BRIEF_MIN_BUDGET) {
213
+ throw new RangeError(`Brief budget must be a whole number of at least ${BRIEF_MIN_BUDGET} estimated tokens (got ${budget}).`);
214
+ }
215
+
216
+ const exclude = uniqueStrings(options.exclude);
217
+ for (const pattern of exclude) {
218
+ const problem = validateExcludePattern(pattern);
219
+ if (problem) throw new RangeError(`Brief exclude pattern ${problem}.`);
220
+ }
221
+
222
+ const repoAliases = {};
223
+ for (const [alias, repoId] of Object.entries(options.repoAliases ?? {})) {
224
+ if (typeof alias === 'string' && alias.trim() && typeof repoId === 'string' && repoId.trim()) {
225
+ repoAliases[alias.trim()] = repoId.trim();
226
+ }
227
+ }
228
+
229
+ return {
230
+ rootDir: path.resolve(options.rootDir),
231
+ task: typeof options.task === 'string' ? options.task.trim() : '',
232
+ files: uniqueStrings(options.files),
233
+ featureSlugs: uniqueStrings(options.featureSlugs).map((slug) => slug.toLowerCase()),
234
+ claims: uniqueStrings(options.claims),
235
+ laneId: options.laneId ? validateLaneId(String(options.laneId)) : null,
236
+ budget,
237
+ exclude,
238
+ repoAliases,
239
+ };
240
+ }
241
+
242
+ async function readLane(rootDir, laneId) {
243
+ const activeRoot = path.join(rootDir, 'sessions', 'active');
244
+ const laneDir = path.join(activeRoot, laneId);
245
+ const sessionPath = path.join(laneDir, 'session.yaml');
246
+ const data = parseSimpleYaml(await readFile(sessionPath, 'utf8'), { sourceName: sessionPath });
247
+ if (!data || typeof data !== 'object') {
248
+ throw new Error(`${relativeTo(rootDir, sessionPath)} is empty.`);
249
+ }
250
+
251
+ const handoffPath = path.join(laneDir, 'handoff.md');
252
+ const handoff = await readOptional(handoffPath);
253
+ const otherLanes = [];
254
+ for (const otherId of await listLaneDirs(activeRoot)) {
255
+ if (otherId === laneId) continue;
256
+ const otherPath = path.join(activeRoot, otherId, 'session.yaml');
257
+ try {
258
+ const other = parseSimpleYaml(await readFile(otherPath, 'utf8'), { sourceName: otherPath });
259
+ otherLanes.push({
260
+ id: otherId,
261
+ workingOn: optionalString(other?.working_on),
262
+ claims: stringArray(other?.claimed_paths),
263
+ repos: stringArray(other?.repos),
264
+ });
265
+ } catch {
266
+ otherLanes.push({ id: otherId, workingOn: null, claims: [], repos: [], unreadable: true });
267
+ }
268
+ }
269
+
270
+ const worktrees = data.worktrees && typeof data.worktrees === 'object' && !Array.isArray(data.worktrees) ? data.worktrees : {};
271
+ const missingWorktrees = [];
272
+ for (const [repoId, worktreePath] of Object.entries(worktrees).sort(([left], [right]) => left.localeCompare(right))) {
273
+ if (typeof worktreePath === 'string' && !(await pathExists(worktreePath))) {
274
+ missingWorktrees.push({ repo: repoId, path: worktreePath });
275
+ }
276
+ }
277
+
278
+ const lane = {
279
+ id: laneId,
280
+ sessionPath: relativeTo(rootDir, sessionPath),
281
+ handoffPath: relativeTo(rootDir, handoffPath),
282
+ handoffExists: handoff !== null,
283
+ handoff,
284
+ workingOn: optionalString(data.working_on),
285
+ claims: stringArray(data.claimed_paths),
286
+ featureSlugs: stringArray(data.feature_slugs).map((slug) => slug.toLowerCase()),
287
+ architectureDocs: stringArray(data.architecture_docs),
288
+ repos: stringArray(data.repos),
289
+ decisionSnapshot: Number.isInteger(data.decision_snapshot?.highest_decision_id)
290
+ ? data.decision_snapshot.highest_decision_id
291
+ : null,
292
+ sessionDate: data.session_date == null ? null : optionalString(String(data.session_date)),
293
+ sessionNumber: Number.isInteger(data.session_number) ? data.session_number : null,
294
+ otherLanes,
295
+ missingWorktrees,
296
+ };
297
+
298
+ // D-364 bindings: exactly the lane scratch this brief read. The session
299
+ // hash covers only the fields above, so resume bookkeeping (resumed_at,
300
+ // resume_count) does not make a brief stale.
301
+ lane.bindings = {
302
+ session: briefShortHash({
303
+ id: lane.id,
304
+ working_on: lane.workingOn,
305
+ claimed_paths: lane.claims,
306
+ feature_slugs: lane.featureSlugs,
307
+ architecture_docs: lane.architectureDocs,
308
+ repos: lane.repos,
309
+ decision_snapshot: lane.decisionSnapshot,
310
+ missing_worktrees: lane.missingWorktrees,
311
+ }),
312
+ handoff: handoff === null ? null : briefShortHash(handoff),
313
+ other_lanes: briefShortHash(otherLanes),
314
+ };
315
+ return lane;
316
+ }
317
+
318
+ /**
319
+ * The lane identity and scratch bindings a brief for this lane would record
320
+ * now (D-364), read exactly as `buildSessionBrief` reads them.
321
+ */
322
+ export async function readBriefLaneBindings(rootDir, laneId) {
323
+ const lane = await readLane(path.resolve(rootDir), validateLaneId(String(laneId)));
324
+ return {
325
+ laneId: lane.id,
326
+ sessionDate: lane.sessionDate,
327
+ sessionNumber: lane.sessionNumber,
328
+ bindings: lane.bindings,
329
+ };
330
+ }
331
+
332
+ /**
333
+ * Digest of the canonical corpus a brief read: every scanned document's path
334
+ * and content hash (project.yaml included) plus the exclusion patterns in
335
+ * effect. Any change can change selection, so the lane brief binds it.
336
+ */
337
+ export function computeBriefCorpusDigest(documents, excludePatterns = []) {
338
+ return sha256Text(
339
+ stableStringify({
340
+ exclude: [...excludePatterns].sort(),
341
+ documents: [...documents]
342
+ .map((document) => [document.path, sha256Text(document.content ?? '')])
343
+ .sort(([left], [right]) => left.localeCompare(right)),
344
+ }),
345
+ );
346
+ }
347
+
348
+ /** First 16 hex digits of a SHA-256: enough to detect a changed input. */
349
+ export function briefShortHash(value) {
350
+ const hash = typeof value === 'string' && value.startsWith('sha256:') ? value : sha256Text(typeof value === 'string' ? value : stableStringify(value));
351
+ return hash.slice('sha256:'.length, 'sha256:'.length + 16);
352
+ }
353
+
354
+ /**
355
+ * The canonical files an emitted brief drew text from, each with a short
356
+ * content hash: docs and notes by file, decisions by the entries shown
357
+ * (`decisions/x.md#D-001,D-002`, hashed over those entries only).
358
+ */
359
+ export function describeBriefInputs(result) {
360
+ const inputs = new Map();
361
+ const decisionsByFile = new Map();
362
+ for (const unit of result.units ?? []) {
363
+ if ((unit.kind === 'doc' || unit.kind === 'note') && unit.path && unit.source_hash) {
364
+ inputs.set(unit.path, briefShortHash(unit.source_hash));
365
+ } else if (unit.kind === 'lineage') {
366
+ for (const member of unit.members) {
367
+ if (!member.path || !member.section_hash) continue;
368
+ if (!decisionsByFile.has(member.path)) decisionsByFile.set(member.path, new Map());
369
+ decisionsByFile.get(member.path).set(member.decision_id, member.section_hash);
370
+ }
371
+ }
372
+ }
373
+ for (const [filePath, entries] of decisionsByFile) {
374
+ const ids = [...entries.keys()].sort((left, right) => left - right);
375
+ inputs.set(`${filePath}#${ids.map(formatId).join(',')}`, briefShortHash(ids.map((id) => [id, entries.get(id)])));
376
+ }
377
+ return [...inputs.entries()].map(([key, hash]) => ({ key, hash })).sort((left, right) => left.key.localeCompare(right.key));
378
+ }
379
+
380
+ /**
381
+ * Current short hashes for input keys from `describeBriefInputs`, computed
382
+ * from scanned documents the same way; a key whose file or entry is gone (or
383
+ * excluded) maps to null.
384
+ */
385
+ export function hashBriefInputs(documents, keys) {
386
+ const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
387
+ const hashes = new Map();
388
+ for (const key of keys) {
389
+ const hashIndex = key.indexOf('#');
390
+ const filePath = hashIndex === -1 ? key : key.slice(0, hashIndex);
391
+ const content = contentByPath.get(filePath);
392
+ if (typeof content !== 'string') {
393
+ hashes.set(key, null);
394
+ continue;
395
+ }
396
+ if (hashIndex === -1) {
397
+ hashes.set(key, briefShortHash(sha256Text(content)));
398
+ continue;
399
+ }
400
+ const ids = key
401
+ .slice(hashIndex + 1)
402
+ .split(',')
403
+ .map((value) => Number(value.replace(/^D-/, '')))
404
+ .filter(Number.isInteger)
405
+ .sort((left, right) => left - right);
406
+ const entries = new Map(parseDecisionEntries(content).map((entry) => [entry.decision_id, sha256Text(entry.text)]));
407
+ hashes.set(key, ids.every((id) => entries.has(id)) ? briefShortHash(ids.map((id) => [id, entries.get(id)])) : null);
408
+ }
409
+ return hashes;
410
+ }
411
+
412
+ // ---------------------------------------------------------------------------
413
+ // Selection
414
+ // ---------------------------------------------------------------------------
415
+
416
+ function selectCandidates(context, loaded, task) {
417
+ const { readModel, documents } = loaded;
418
+ const { input, lane } = context;
419
+ const aliases = buildAliasMap(readModel, input.repoAliases);
420
+ const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
421
+ const relations = readModel.decision_lineage.relations;
422
+ const decisionEntries = buildDecisionEntryMap(documents);
423
+
424
+ const docs = readModel.features
425
+ .flatMap((feature) => feature.components.map((component) => ({ feature, component })))
426
+ .filter(({ component }) => component.path !== BRIEF_OVERVIEW_PATH)
427
+ .map(({ feature, component }) => ({
428
+ kind: 'doc',
429
+ id: `doc:${component.path}`,
430
+ path: component.path,
431
+ domain: feature.domain,
432
+ feature: feature.feature,
433
+ featureSlug: feature.feature_slug,
434
+ featureKey: feature.feature_key,
435
+ component: component.component,
436
+ status: component.status,
437
+ // Section text is re-read fence-aware from the canonical file; the read
438
+ // model's own section map lets fenced examples overwrite real sections.
439
+ ...docSectionFields(contentByPath.get(component.path), component),
440
+ involvedFiles: component.involved_files,
441
+ warnings: component.warnings,
442
+ sourceHash: contentByPath.has(component.path) ? sha256Text(contentByPath.get(component.path)) : null,
443
+ }));
444
+
445
+ const decisions = readModel.decisions
446
+ .filter((decision) => decisionEntries.has(decision.decision_id))
447
+ .map((decision) => ({
448
+ kind: 'decision',
449
+ id: `decision:${decision.decision_id}`,
450
+ decisionId: decision.decision_id,
451
+ title: decision.title,
452
+ path: decision.path,
453
+ domainFile: decision.domain_file,
454
+ entry: decisionEntries.get(decision.decision_id),
455
+ }));
456
+
457
+ const index = createKeywordIndex(DOC_FIELDS, [
458
+ ...docs.map((doc) => ({
459
+ id: doc.id,
460
+ fields: {
461
+ title: `${doc.component} ${doc.feature} ${doc.domain} ${path.basename(doc.path, '.md')}`,
462
+ body: doc.description ?? '',
463
+ guidance: [doc.retrievalGuidance, doc.retrievalScope].filter(Boolean).join('\n'),
464
+ },
465
+ })),
466
+ ...decisions.map((decision) => ({
467
+ id: decision.id,
468
+ fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '' },
469
+ })),
470
+ ]);
471
+
472
+ // The project's own name carries no topic inside its own memory.
473
+ const projectTerms = new Set(queryTerms(`${readModel.project.name ?? ''} ${readModel.project.slug ?? ''}`));
474
+ // Input file names add topic words: `app:src/lib/entitlements.ts` → "entitlements", "lib".
475
+ const pathWords = [...input.files, ...input.claims, ...(lane?.claims ?? [])].map(pathTopicText).join(' ');
476
+ const terms = uniqueStrings([...queryTerms(task), ...queryTerms(pathWords)]).filter((term) => !projectTerms.has(term));
477
+ const keywordScores = new Map(index.score(terms).map((result) => [result.id, result]));
478
+ const distinctive = pickDistinctiveTerms(terms, index);
479
+
480
+ // Mechanical evidence from the inputs: files and claims through the
481
+ // file-owner index, lane-declared architecture docs, feature slugs.
482
+ const pathInputs = [
483
+ ...input.files.map((value) => ({ value, source: 'file' })),
484
+ ...uniqueStrings([...input.claims, ...(lane?.claims ?? [])])
485
+ .filter((value) => !input.files.includes(value))
486
+ .map((value) => ({ value, source: 'claim' })),
487
+ ];
488
+ const ownerCounts = countOwners(docs, pathInputs, aliases);
489
+ const featureSlugs = uniqueStrings([...input.featureSlugs, ...(lane?.featureSlugs ?? [])]);
490
+ const laneDocs = new Set((lane?.architectureDocs ?? []).map((docPath) => docPath.replace(/^docs:/, '')));
491
+
492
+ for (const doc of docs) {
493
+ doc.reasons = [];
494
+ doc.relations = [];
495
+ let evidence = 0;
496
+
497
+ for (const pathInput of pathInputs) {
498
+ const match = matchPathInput(doc, pathInput.value, aliases);
499
+ if (!match) continue;
500
+ const owners = ownerCounts.get(pathInput.value) ?? 1;
501
+ evidence += (FILE_MATCH_WEIGHT * match.strength) / Math.sqrt(owners);
502
+ const label = pathInput.source === 'claim' ? 'lane claim' : 'file';
503
+ doc.reasons.push(`covers ${label} \`${pathInput.value}\` (Involved files: \`${match.involved}\`)`);
504
+ doc.relations.push({
505
+ relation: 'covers',
506
+ source_kind: 'architecture',
507
+ source_path: doc.path,
508
+ source_section: 'Involved files',
509
+ source_hash: doc.sourceHash,
510
+ evidence: 'explicit',
511
+ basis: match.strength === 1 ? 'involved-file' : 'involved-directory',
512
+ involved_file: match.involved,
513
+ input: pathInput.value,
514
+ input_kind: pathInput.source,
515
+ });
516
+ }
517
+
518
+ if (laneDocs.has(doc.path)) {
519
+ evidence += LANE_DOC_WEIGHT;
520
+ doc.reasons.push('declared by the lane (`architecture_docs`)');
521
+ }
522
+
523
+ const matchedFeature = featureSlugs.find(
524
+ (slug) => slug === doc.featureSlug || slug === doc.featureKey || slug === `${slugify(doc.domain)}/${doc.featureSlug}`,
525
+ );
526
+ if (matchedFeature) {
527
+ evidence += FEATURE_WEIGHT;
528
+ doc.reasons.push(`feature \`${matchedFeature}\``);
529
+ }
530
+
531
+ const keyword = keywordScores.get(doc.id);
532
+ doc.matchedTerms = keyword?.matched ?? [];
533
+ doc.keywordScore = keyword?.score ?? 0;
534
+ doc.evidenceScore = evidence;
535
+ doc.score = evidence + doc.keywordScore;
536
+ }
537
+
538
+ const decisionById = new Map(decisions.map((decision) => [decision.decisionId, decision]));
539
+ const namedDecisionIds = new Set(
540
+ scanDecisionReferences(task)
541
+ .refs.map((ref) => ref.id)
542
+ .filter((id) => decisionById.has(id)),
543
+ );
544
+
545
+ const topKeyword = Math.max(0, ...[...keywordScores.values()].map((result) => result.score));
546
+ // Absent distinctive words alone never abstain: unfamiliar modifiers
547
+ // ("clearer", "sporadic") are common in real tasks, and a false no-match
548
+ // hides memory that exists. Abstain on them only when no single unit
549
+ // shares at least STRONG_TOPIC_TERMS of the task's words.
550
+ const strongestOverlap = Math.max(0, ...[...keywordScores.values()].map((result) => result.matched.length));
551
+ const noMatch =
552
+ docs.every((doc) => doc.evidenceScore === 0) &&
553
+ namedDecisionIds.size === 0 &&
554
+ (terms.length === 0 ||
555
+ topKeyword < MIN_KEYWORD_SCORE ||
556
+ (distinctiveTermsMostlyAbsent(distinctive, index) && strongestOverlap < STRONG_TOPIC_TERMS));
557
+
558
+ // Task words with no keyword match in the searched fields are disclosed, never guessed at.
559
+ const absent = terms.filter((term) => index.documentFrequency(term) === 0);
560
+ if (absent.length > 0) {
561
+ const origins = queryTermOrigins(`${task} ${pathWords}`);
562
+ const words = absent.map((term) => origins.get(term) ?? term);
563
+ context.gaps.push({
564
+ code: 'unmatched-terms',
565
+ terms: absent,
566
+ // Reports the search, not absence from memory: the index covers titles,
567
+ // Description, Retrieval guidance, and Decision text only, and the
568
+ // units shown matched other task words.
569
+ message: `No keyword match for ${words.slice(0, 6).map((word) => `"${word}"`).join(', ')}${words.length > 6 ? ` (+${words.length - 6} more)` : ''} in the fields the brief searches (titles, Description, Retrieval guidance, Decision text); other text may mention them.`,
570
+ });
571
+ }
572
+
573
+ if (noMatch) {
574
+ return { noMatch: true, terms, distinctive, docs: [], namedDecisions: [], rankedDecisions: [], notes: [], relations, decisionById, decisionEntries, readModel };
575
+ }
576
+
577
+ // When the task's distinctive words are mostly unknown and nothing but
578
+ // keywords selected the memory, confidence is low: the match is kept
579
+ // narrow. Candidates are chosen and ranked as usual; only the top
580
+ // NARROW_PACKED_UNITS ranked units are packed and the rest are listed as
581
+ // follow-up reads, so the top-scoring topic is never dropped for a raw
582
+ // overlap count and a weak match does not fill the budget.
583
+ const narrow =
584
+ distinctiveTermsMostlyAbsent(distinctive, index) &&
585
+ docs.every((doc) => doc.evidenceScore === 0) &&
586
+ namedDecisionIds.size === 0;
587
+
588
+ // Docs with mechanical evidence (they cover an input file or claim, the lane
589
+ // declares them, or they belong to a named feature) skip the relative floor.
590
+ const selectedDocs = pickTop(
591
+ docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore >= MIN_KEYWORD_SCORE),
592
+ MAX_DOC_CANDIDATES,
593
+ (doc) => doc.evidenceScore > 0,
594
+ );
595
+ const topDocScore = Math.max(selectedDocs[0]?.score ?? 0, 1e-9);
596
+ const selectedByPath = new Map(selectedDocs.map((doc) => [doc.path, doc]));
597
+
598
+ // Mechanical propagation: each selected doc shares a boost among the
599
+ // decisions it cites, weighted by the doc's relative score and diluted by
600
+ // how many decisions it cites.
601
+ const citedTargets = new Map();
602
+ for (const relation of relations) {
603
+ if (relation.relation !== 'cites' || !relation.target_exists || !selectedByPath.has(relation.source_path)) continue;
604
+ if (NON_PROPAGATING_SECTIONS.has(String(relation.source_section ?? '').split(' > ')[0])) continue;
605
+ if (!citedTargets.has(relation.source_path)) citedTargets.set(relation.source_path, new Map());
606
+ const targets = citedTargets.get(relation.source_path);
607
+ if (!targets.has(relation.target_decision_id)) targets.set(relation.target_decision_id, relation);
608
+ }
609
+
610
+ for (const decision of decisions) {
611
+ const keyword = keywordScores.get(decision.id);
612
+ decision.matchedTerms = keyword?.matched ?? [];
613
+ decision.keywordScore = keyword?.score ?? 0;
614
+ decision.citedBy = [];
615
+ decision.citationBoost = 0;
616
+ }
617
+ for (const [docPath, targets] of citedTargets) {
618
+ const doc = selectedByPath.get(docPath);
619
+ const share = (CITATION_WEIGHT * (doc.score / topDocScore)) / Math.sqrt(targets.size);
620
+ for (const [decisionId, relation] of targets) {
621
+ const decision = decisionById.get(decisionId);
622
+ if (!decision) continue;
623
+ decision.citationBoost += share;
624
+ decision.citedBy.push({ relation, docScore: doc.score });
625
+ }
626
+ }
627
+ for (const decision of decisions) {
628
+ decision.score = decision.keywordScore + decision.citationBoost;
629
+ decision.citedBy.sort((left, right) => right.docScore - left.docScore || left.relation.source_path.localeCompare(right.relation.source_path));
630
+ decision.reasons = [];
631
+ if (namedDecisionIds.has(decision.decisionId)) decision.reasons.push('named in the task');
632
+ if (decision.matchedTerms.length > 0) decision.reasons.push(`keywords: ${decision.matchedTerms.join(', ')}`);
633
+ for (const citation of decision.citedBy.slice(0, 2)) {
634
+ decision.reasons.push(`cited by \`${citation.relation.source_path}\` › ${citation.relation.source_section ?? 'preamble'}`);
635
+ }
636
+ }
637
+
638
+ const namedDecisions = [...namedDecisionIds].map((id) => decisionById.get(id)).sort((left, right) => right.decisionId - left.decisionId);
639
+ const rankedDecisions = pickTop(
640
+ decisions.filter(
641
+ (decision) =>
642
+ !namedDecisionIds.has(decision.decisionId) &&
643
+ (decision.keywordScore >= MIN_KEYWORD_SCORE || decision.citationBoost > 0),
644
+ ),
645
+ MAX_DECISION_CANDIDATES,
646
+ );
647
+
648
+ for (const doc of selectedDocs) {
649
+ if (doc.matchedTerms.length > 0) doc.reasons.push(`keywords: ${doc.matchedTerms.join(', ')}`);
650
+ }
651
+
652
+ return {
653
+ noMatch: false,
654
+ narrow,
655
+ terms,
656
+ distinctive,
657
+ docs: selectedDocs,
658
+ namedDecisions,
659
+ rankedDecisions,
660
+ notes: selectNotes(readModel, relations, [...namedDecisions, ...rankedDecisions], documents),
661
+ relations,
662
+ decisionById,
663
+ decisionEntries,
664
+ readModel,
665
+ };
666
+ }
667
+
668
+ /**
669
+ * No-match signal: at least two of the task's most distinctive terms (the
670
+ * rarest in the index, up to three) occur nowhere in the indexed memory, and
671
+ * they are at least two thirds of that set.
672
+ */
673
+ function distinctiveTermsMostlyAbsent(distinctive, index) {
674
+ const absent = distinctive.filter((term) => index.documentFrequency(term) === 0).length;
675
+ return absent >= 2 && absent * 3 >= distinctive.length * 2;
676
+ }
677
+
678
+ function pickDistinctiveTerms(terms, index) {
679
+ const limit = Math.min(DISTINCTIVE_TERM_LIMIT, Math.ceil(terms.length / 3));
680
+ return [...terms]
681
+ .filter((term) => index.documentFrequency(term) <= Math.max(1, index.count * DISTINCTIVE_MAX_DOCUMENT_SHARE))
682
+ .sort((left, right) => index.documentFrequency(left) - index.documentFrequency(right) || left.localeCompare(right))
683
+ .slice(0, limit);
684
+ }
685
+
686
+ function pickTop(candidates, limit, bypassFloor = () => false) {
687
+ const sorted = [...candidates].sort(compareByScore);
688
+ const top = sorted[0]?.score ?? 0;
689
+ return sorted.filter((candidate) => bypassFloor(candidate) || candidate.score >= top * RELATIVE_SCORE_FLOOR).slice(0, limit);
690
+ }
691
+
692
+ function compareByScore(left, right) {
693
+ return right.score - left.score || left.id.localeCompare(right.id);
694
+ }
695
+
696
+ function selectNotes(readModel, relations, decisions, documents) {
697
+ const rankById = new Map(decisions.map((decision, rank) => [decision.decisionId, rank]));
698
+ const made = new Map();
699
+ for (const relation of relations) {
700
+ if (relation.relation !== 'made' || !rankById.has(relation.target_decision_id)) continue;
701
+ const rank = rankById.get(relation.target_decision_id);
702
+ const existing = made.get(relation.source_path);
703
+ if (!existing || rank < existing.rank) {
704
+ made.set(relation.source_path, { rank, decisionIds: [...(existing?.decisionIds ?? []), relation.target_decision_id] });
705
+ } else {
706
+ existing.decisionIds.push(relation.target_decision_id);
707
+ }
708
+ }
709
+
710
+ const sessions = new Map(readModel.sessions.map((session) => [session.path, session]));
711
+ const contentByPath = new Map(documents.map((document) => [document.path, document.content]));
712
+ return [...made.entries()]
713
+ .filter(([notePath]) => sessions.has(notePath))
714
+ .sort(([leftPath, left], [rightPath, right]) => left.rank - right.rank || rightPath.localeCompare(leftPath))
715
+ .slice(0, MAX_NOTE_CANDIDATES)
716
+ .map(([notePath, info]) => {
717
+ const session = sessions.get(notePath);
718
+ const content = contentByPath.get(notePath) ?? '';
719
+ return {
720
+ kind: 'note',
721
+ id: `note:${notePath}`,
722
+ path: notePath,
723
+ title: session.title,
724
+ date: session.session_date,
725
+ number: session.session_number,
726
+ madeDecisionIds: [...new Set(info.decisionIds)].sort((left, right) => left - right),
727
+ nextSession: extractSection(content, 'Next session should start with'),
728
+ sourceHash: sha256Text(content),
729
+ };
730
+ });
731
+ }
732
+
733
+ // ---------------------------------------------------------------------------
734
+ // Units (serialized shape: what `--json` prints and the renderer reads)
735
+ // ---------------------------------------------------------------------------
736
+
737
+ function buildUnits(context, selection) {
738
+ const units = [];
739
+ const { lane, input } = context;
740
+
741
+ if (lane) units.push(buildLaneUnit(lane));
742
+ if (selection.noMatch || selection.retrievalError) return units;
743
+
744
+ // Mandatory: decisions the task names, with their declared successors.
745
+ for (const decision of selection.namedDecisions) {
746
+ units.push(buildLineageUnit(decision, 'mandatory', selection));
747
+ }
748
+
749
+ // Ranked: docs and lineage units interleave by score relative to the top of
750
+ // their kind. A doc carries its mandatory companions — lineage units for the
751
+ // decisions/cross-cutting.md entries its emitted text cites — and is packed
752
+ // only together with them.
753
+ const topDoc = Math.max(selection.docs[0]?.score ?? 0, 1e-9);
754
+ const topDecision = Math.max(selection.rankedDecisions[0]?.score ?? 0, 1e-9);
755
+ const ranked = [
756
+ ...selection.docs.map((doc) => ({ unit: withCompanions(buildDocUnit(doc), selection), relative: doc.score / topDoc })),
757
+ ...selection.rankedDecisions.map((decision) => ({
758
+ unit: buildLineageUnit(decision, 'ranked', selection),
759
+ relative: decision.score / topDecision,
760
+ })),
761
+ ].sort((left, right) => right.relative - left.relative || left.unit.id.localeCompare(right.unit.id));
762
+ for (const [index, entry] of ranked.entries()) {
763
+ const withheld = selection.narrow && index >= NARROW_PACKED_UNITS;
764
+ units.push({ ...entry.unit, rank: index + 1, ...(withheld ? { withheld: true } : {}) });
765
+ }
766
+
767
+ for (const note of selection.notes) units.push(buildNoteUnit(note));
768
+ if (lane) units.push(...buildWatchOutUnits(lane, selection, input));
769
+ return units;
770
+ }
771
+
772
+ function withCompanions(docUnit, selection) {
773
+ const shown = scanDecisionReferences(renderBriefUnit(docUnit)).refs.map((ref) => ref.id);
774
+ const companions = [];
775
+ for (const id of [...new Set(shown)].sort((left, right) => right - left)) {
776
+ const decision = selection.decisionById.get(id);
777
+ if (!decision || decision.domainFile !== 'cross-cutting') continue;
778
+ const citation = selection.relations.find(
779
+ (relation) => relation.relation === 'cites' && relation.source_path === docUnit.path && relation.target_decision_id === id,
780
+ );
781
+ companions.push({
782
+ ...buildLineageUnit(decision, 'mandatory', selection),
783
+ id: `lineage:${formatId(id)}`,
784
+ companion_of: docUnit.id,
785
+ reasons: [`cross-cutting; cited by \`${docUnit.path}\`${citation?.source_section ? ` › ${citation.source_section}` : ''}`],
786
+ cited_by: citation ? [citation] : [],
787
+ });
788
+ }
789
+ return { ...docUnit, companions };
790
+ }
791
+
792
+ function buildLaneUnit(lane) {
793
+ const openReview = [
794
+ extractSubsection(lane.handoff, 'Reviewer → Builder', 'Findings summary'),
795
+ extractSubsection(lane.handoff, 'Reviewer → Builder', 'Recommended next step'),
796
+ ].filter(Boolean);
797
+ return {
798
+ id: `lane:${lane.id}`,
799
+ kind: 'lane',
800
+ tier: 'mandatory',
801
+ reasons: ['the brief is for this lane'],
802
+ lane_id: lane.id,
803
+ working_on: lane.workingOn,
804
+ handoff_path: lane.handoffPath,
805
+ handoff_exists: lane.handoffExists,
806
+ handoff_next: extractSubsection(lane.handoff, 'Builder → Reviewer', "What's next"),
807
+ open_review: openReview.length > 0 ? openReview.join('\n') : null,
808
+ };
809
+ }
810
+
811
+ function buildDocUnit(doc) {
812
+ const missingSections = doc.warnings
813
+ .filter((warning) => warning.code === 'architecture-missing-section')
814
+ .map((warning) => warning.message.match(/"## ([^"]+)"/)?.[1])
815
+ .filter(Boolean);
816
+ return {
817
+ id: doc.id,
818
+ kind: 'doc',
819
+ tier: 'ranked',
820
+ reasons: doc.reasons,
821
+ path: doc.path,
822
+ domain: doc.domain,
823
+ feature: doc.feature,
824
+ component: doc.component,
825
+ status: doc.status,
826
+ description: doc.description,
827
+ retrieval_guidance: doc.retrievalGuidance,
828
+ retrieval_scope: doc.retrievalScope,
829
+ missing_sections: missingSections,
830
+ matched_terms: doc.matchedTerms,
831
+ score: round(doc.score),
832
+ source_hash: doc.sourceHash,
833
+ relations: doc.relations,
834
+ };
835
+ }
836
+
837
+ function buildLineageUnit(decision, tier, selection) {
838
+ const successors = collectDeclaredSuccessors(selection.relations, decision.decisionId);
839
+ const chain = [
840
+ { role: 'root', decisionId: decision.decisionId, via: [], successorRelations: [] },
841
+ ...successors.map((successor) => ({
842
+ role: 'successor',
843
+ decisionId: successor.decision_id,
844
+ via: successor.via,
845
+ successorRelations: successor.relations,
846
+ })),
847
+ ];
848
+ const members = chain.map((member) => {
849
+ const entry = selection.decisionEntries.get(member.decisionId);
850
+ const record = selection.decisionById.get(member.decisionId);
851
+ // Show every declared relation from this successor to another member of
852
+ // the unit, not only the one along its `via` path.
853
+ const earlier = new Set(chain.map((entry) => entry.decisionId).filter((id) => id !== member.decisionId));
854
+ const declared =
855
+ member.role === 'successor'
856
+ ? selection.relations.filter(
857
+ (relation) =>
858
+ relation.source_kind === 'decision' &&
859
+ relation.source_decision_id === member.decisionId &&
860
+ (relation.relation === 'supersedes' || relation.relation === 'amends') &&
861
+ earlier.has(relation.target_decision_id),
862
+ )
863
+ : [];
864
+ return {
865
+ role: member.role,
866
+ decision_id: member.decisionId,
867
+ title: record?.title ?? entry?.title ?? null,
868
+ path: entry?.path ?? record?.path ?? null,
869
+ date: entry?.timestamp ? entry.timestamp.slice(0, 10) : null,
870
+ decision: entry?.fields.Decision ?? null,
871
+ impact: entry?.fields['Impact on prior decisions'] ?? entry?.fields.Impact ?? null,
872
+ via: member.via,
873
+ successor_relations: member.successorRelations,
874
+ declared_relations: declared,
875
+ section_hash: entry?.sectionHash ?? null,
876
+ };
877
+ });
878
+
879
+ const memberIds = new Set(members.map((member) => member.decision_id));
880
+ // Uncertified wording aimed at any member stays a follow-up read, even when
881
+ // its source is itself a declared successor in this unit: the unit shows
882
+ // bounded Decision and Impact excerpts, not the field the wording is in.
883
+ const unknownIncoming = selection.relations.filter(
884
+ (relation) =>
885
+ relation.relation === 'unknown' &&
886
+ relation.source_kind === 'decision' &&
887
+ memberIds.has(relation.target_decision_id) &&
888
+ relation.source_decision_id !== relation.target_decision_id,
889
+ );
890
+
891
+ return {
892
+ id: `lineage:${formatId(decision.decisionId)}`,
893
+ kind: 'lineage',
894
+ tier,
895
+ reasons: decision.reasons,
896
+ root_decision_id: decision.decisionId,
897
+ members,
898
+ unknown_incoming: unknownIncoming,
899
+ cited_by: (decision.citedBy ?? []).map((citation) => citation.relation),
900
+ matched_terms: decision.matchedTerms,
901
+ score: round(decision.score),
902
+ };
903
+ }
904
+
905
+ function buildNoteUnit(note) {
906
+ return {
907
+ id: note.id,
908
+ kind: 'note',
909
+ tier: 'optional',
910
+ reasons: [`made ${note.madeDecisionIds.map(formatId).join(', ')} (Decisions made)`],
911
+ path: note.path,
912
+ title: note.title,
913
+ date: note.date,
914
+ number: note.number,
915
+ made_decision_ids: note.madeDecisionIds,
916
+ next_session: note.nextSession,
917
+ source_hash: note.sourceHash,
918
+ };
919
+ }
920
+
921
+ function buildWatchOutUnits(lane, selection, input) {
922
+ const units = [];
923
+ const laneClaims = uniqueStrings([...input.claims, ...lane.claims]);
924
+
925
+ if (lane.otherLanes.length > 0) {
926
+ units.push({
927
+ id: 'watch-out:other-lanes',
928
+ kind: 'watch-out',
929
+ tier: 'optional',
930
+ reasons: ['other active lanes'],
931
+ category: 'other-lanes',
932
+ snapshot: null,
933
+ items: lane.otherLanes.map((other) => ({
934
+ lane_id: other.id,
935
+ working_on: other.workingOn,
936
+ claims: other.claims,
937
+ overlapping_claims: other.claims.filter((claim) => laneClaims.some((own) => claimsOverlap(own, claim))),
938
+ unreadable: Boolean(other.unreadable),
939
+ })),
940
+ });
941
+ }
942
+
943
+ if (lane.decisionSnapshot !== null) {
944
+ const newer = [...selection.decisionById.values()]
945
+ .filter((decision) => decision.decisionId > lane.decisionSnapshot)
946
+ .sort((left, right) => right.decisionId - left.decisionId)
947
+ .map((decision) => ({ decision_id: decision.decisionId, title: decision.title, path: decision.path }));
948
+ if (newer.length > 0) {
949
+ units.push({
950
+ id: 'watch-out:newer-decisions',
951
+ kind: 'watch-out',
952
+ tier: 'optional',
953
+ reasons: [`decisions appended after the lane snapshot ${formatId(lane.decisionSnapshot)}`],
954
+ category: 'newer-decisions',
955
+ snapshot: lane.decisionSnapshot,
956
+ items: newer,
957
+ });
958
+ }
959
+ }
960
+
961
+ if (lane.missingWorktrees.length > 0) {
962
+ units.push({
963
+ id: 'watch-out:stale-bindings',
964
+ kind: 'watch-out',
965
+ tier: 'optional',
966
+ reasons: ['recorded lane worktree paths are missing'],
967
+ category: 'stale-bindings',
968
+ snapshot: null,
969
+ items: lane.missingWorktrees,
970
+ });
971
+ }
972
+
973
+ return units;
974
+ }
975
+
976
+ // ---------------------------------------------------------------------------
977
+ // Packing, status, follow-ups
978
+ // ---------------------------------------------------------------------------
979
+
980
+ function packBrief(context, selection) {
981
+ const units = buildUnits(context, selection);
982
+ const budget = context.input.budget;
983
+ const fits = (candidate) => candidate.budget.estimated_tokens <= budget;
984
+ const mandatoryOmitted = (candidate) => candidate.omitted.filter((unit) => unit.tier === 'mandatory').length;
985
+
986
+ // Pack whole units in tier order against a unit capacity. The header,
987
+ // status, and follow-up list take about BRIEF_RESERVE_TOKENS; rather than
988
+ // guess, binary-search the capacity against the whole rendered brief,
989
+ // repacking from scratch each time so no emitted unit ever loses a
990
+ // successor it was rendered against. Skip-and-continue packing is not
991
+ // monotone in capacity, so the result always fits but is not guaranteed
992
+ // to be the largest fitting capacity. The frame is compacted (bounded
993
+ // header, status, and follow-up fields) only when the normal frame leaves
994
+ // no room for every mandatory unit; the most compact frame with no units
995
+ // fits the minimum budget for any accepted input.
996
+ let chosen = null;
997
+ for (let compaction = 0; compaction <= BRIEF_MAX_COMPACTION; compaction += 1) {
998
+ const candidate = packAtCompaction(context, selection, units, compaction, fits);
999
+ if (!candidate) continue;
1000
+ if (!chosen || mandatoryOmitted(candidate) < mandatoryOmitted(chosen)) chosen = candidate;
1001
+ if (mandatoryOmitted(chosen) === 0) break;
1002
+ }
1003
+
1004
+ if (!chosen || !fits(chosen)) {
1005
+ throw new Error(`Brief packing could not fit the ${budget}-token budget.`);
1006
+ }
1007
+ assertLineageSafety(chosen, selection.relations);
1008
+ return chosen;
1009
+ }
1010
+
1011
+ function packAtCompaction(context, selection, units, compaction, fits) {
1012
+ const budget = context.input.budget;
1013
+ const full = assemble(context, selection, units, budget, compaction);
1014
+ if (fits(full)) return full;
1015
+ let best = null;
1016
+ let low = 0;
1017
+ let high = budget - 1;
1018
+ while (low <= high) {
1019
+ const middle = Math.floor((low + high) / 2);
1020
+ const candidate = assemble(context, selection, units, middle, compaction);
1021
+ if (fits(candidate)) {
1022
+ best = candidate;
1023
+ low = middle + 1;
1024
+ } else {
1025
+ high = middle - 1;
1026
+ }
1027
+ }
1028
+ if (best) return best;
1029
+ const empty = assemble(context, selection, units, 0, compaction);
1030
+ return fits(empty) ? empty : null;
1031
+ }
1032
+
1033
+ function assemble(context, selection, units, capacity, compaction = 0) {
1034
+ const emitted = new Set();
1035
+ const included = [];
1036
+ const omitted = [];
1037
+ const covered = [];
1038
+ let used = 0;
1039
+
1040
+ for (const { companions = [], withheld = false, ...unit } of units) {
1041
+ if (unit.kind === 'lineage' && unit.members.every((member) => emitted.has(member.decision_id))) {
1042
+ covered.push(unit.id);
1043
+ continue;
1044
+ }
1045
+ if (withheld) {
1046
+ const tokens = estimateBriefTokens(renderBriefUnit(unit, { emittedDecisions: emitted }));
1047
+ omitted.push({ ...unit, estimated_tokens: tokens, omitted_reason: 'narrow-match' });
1048
+ continue;
1049
+ }
1050
+
1051
+ // A doc and its mandatory companions pack atomically; companions already
1052
+ // shown by an earlier unit cost nothing.
1053
+ const group = [unit];
1054
+ const groupEmitted = new Set(emitted);
1055
+ let tokens = estimateBriefTokens(renderBriefUnit(unit, { emittedDecisions: groupEmitted }));
1056
+ if (unit.kind === 'lineage') for (const member of unit.members) groupEmitted.add(member.decision_id);
1057
+ const sizes = [tokens];
1058
+ for (const companion of companions) {
1059
+ if (companion.members.every((member) => groupEmitted.has(member.decision_id))) continue;
1060
+ const size = estimateBriefTokens(renderBriefUnit(companion, { emittedDecisions: groupEmitted }));
1061
+ for (const member of companion.members) groupEmitted.add(member.decision_id);
1062
+ group.push(companion);
1063
+ sizes.push(size);
1064
+ tokens += size;
1065
+ }
1066
+
1067
+ const companionTokens = tokens - sizes[0];
1068
+ const companionAllowance = Math.max(MIN_COMPANION_ALLOWANCE, MAX_COMPANION_SHARE * (context.input.budget - BRIEF_RESERVE_TOKENS));
1069
+ const companionsTooHeavy = companionTokens > companionAllowance;
1070
+ if (!companionsTooHeavy && used + tokens <= capacity) {
1071
+ used += tokens;
1072
+ group.forEach((member, index) => included.push({ ...member, estimated_tokens: sizes[index] }));
1073
+ for (const id of groupEmitted) emitted.add(id);
1074
+ } else {
1075
+ omitted.push({
1076
+ ...unit,
1077
+ estimated_tokens: tokens,
1078
+ ...(group.length > 1 ? { companion_ids: group.slice(1).map((member) => member.id) } : {}),
1079
+ omitted_reason: companionsTooHeavy ? 'companions-exceed-allowance' : 'budget',
1080
+ });
1081
+ }
1082
+ }
1083
+
1084
+ const status = decideStatus(selection, omitted);
1085
+ const result = baseResult(context, selection, {
1086
+ status,
1087
+ units: included,
1088
+ omitted,
1089
+ covered,
1090
+ followUps: buildFollowUps(selection, context, included, omitted),
1091
+ compaction,
1092
+ });
1093
+ measureBrief(result);
1094
+ return result;
1095
+ }
1096
+
1097
+ function baseResult(context, selection, { status, units, omitted, covered, followUps, compaction = 0 }) {
1098
+ return {
1099
+ contract_version: BRIEF_CONTRACT_VERSION,
1100
+ status: status.status,
1101
+ status_reason: status.reason,
1102
+ // Set when memory could not be read (the brief is `incomplete` for that
1103
+ // reason); null for every brief built from memory, overflow included.
1104
+ retrieval_error: selection?.retrievalError ?? null,
1105
+ task: context.input.task,
1106
+ inputs: {
1107
+ files: context.input.files,
1108
+ feature_slugs: context.input.featureSlugs,
1109
+ claims: context.input.claims,
1110
+ lane_id: context.input.laneId,
1111
+ },
1112
+ source: {
1113
+ root_dir: context.input.rootDir,
1114
+ lane_id: context.lane?.id ?? null,
1115
+ manifest_hash: selection?.readModel?.manifest_state?.manifest_hash ?? null,
1116
+ // D-364 bindings for a persisted lane brief.
1117
+ corpus_digest: context.corpusDigest ?? null,
1118
+ exclude: context.exclude,
1119
+ lane_session: context.lane ? { date: context.lane.sessionDate, number: context.lane.sessionNumber } : null,
1120
+ lane_bindings: context.lane?.bindings ?? null,
1121
+ repo_aliases: context.input.repoAliases,
1122
+ },
1123
+ orientation: context.overview,
1124
+ budget: {
1125
+ limit: context.input.budget,
1126
+ reserve: BRIEF_RESERVE_TOKENS,
1127
+ method: 'unicode-code-points/4',
1128
+ estimated_tokens: 0,
1129
+ },
1130
+ selection: {
1131
+ query_terms: selection?.terms ?? [],
1132
+ distinctive_terms: selection?.distinctive ?? [],
1133
+ narrowed: Boolean(selection?.narrow),
1134
+ },
1135
+ units,
1136
+ omitted,
1137
+ covered_by_other_units: covered,
1138
+ follow_ups: followUps.map((entry, index) => ({ rank: index + 1, ...entry })),
1139
+ follow_up_cap: BRIEF_FOLLOW_UP_CAP,
1140
+ gaps: context.gaps,
1141
+ warnings: context.warnings,
1142
+ render: { compaction, follow_ups_shown: 0 },
1143
+ };
1144
+ }
1145
+
1146
+ function decideStatus(selection, omitted) {
1147
+ if (selection.retrievalError) {
1148
+ return { status: 'incomplete', reason: `retrieval failed — ${selection.retrievalError}` };
1149
+ }
1150
+ if (selection.noMatch) {
1151
+ return omitted.some((unit) => unit.kind === 'lane')
1152
+ ? { status: 'incomplete', reason: 'no memory matched the task, and the lane unit did not fit the budget' }
1153
+ : { status: 'no-match', reason: 'no architecture doc or decision matched the task' };
1154
+ }
1155
+
1156
+ const mandatoryOmitted = omitted.filter((unit) => unit.tier === 'mandatory');
1157
+ if (mandatoryOmitted.length > 0) {
1158
+ return {
1159
+ status: 'incomplete',
1160
+ reason: `${plural(mandatoryOmitted.length, 'mandatory unit')} did not fit the budget (${mandatoryOmitted.map((unit) => unit.id).join(', ')})`,
1161
+ };
1162
+ }
1163
+ if (omitted.length > 0) {
1164
+ const withheld = omitted.filter((unit) => unit.omitted_reason === 'narrow-match').length;
1165
+ const parts = [];
1166
+ if (omitted.length > withheld) parts.push(`${plural(omitted.length - withheld, 'ranked or optional unit')} did not fit the budget`);
1167
+ if (withheld > 0) parts.push(`${plural(withheld, 'lower-confidence match', 'lower-confidence matches')} listed as reads only (narrow match)`);
1168
+ return { status: 'partial', reason: parts.join('; ') };
1169
+ }
1170
+ return { status: 'complete', reason: 'every selected unit fits the budget' };
1171
+ }
1172
+
1173
+ function buildFollowUps(selection, context, included, omitted) {
1174
+ const entries = [];
1175
+
1176
+ // Required reads: omitted mandatory units — the lane handoff, then every
1177
+ // decision of the omitted lineage units, newest declared successor first.
1178
+ const requiredDecisions = [];
1179
+ for (const unit of omitted.filter((candidate) => candidate.tier === 'mandatory')) {
1180
+ if (unit.kind === 'lane') {
1181
+ entries.push({ priority: 'required', path: unit.handoff_path, heading: null, reason: `lane \`${unit.lane_id}\` handoff (the lane unit did not fit)` });
1182
+ } else {
1183
+ requiredDecisions.push(...lineageReads(unit, 'required'));
1184
+ }
1185
+ }
1186
+ entries.push(...requiredDecisions.sort((left, right) => right.decision_id - left.decision_id));
1187
+
1188
+ // A failed retrieval can still point at the fixed entry points.
1189
+ if (selection.retrievalError) {
1190
+ if (context.overview.exists) {
1191
+ entries.push({ priority: 'required', path: BRIEF_OVERVIEW_PATH, heading: null, reason: 'whole-project orientation' });
1192
+ }
1193
+ entries.push({
1194
+ priority: 'required',
1195
+ path: 'decisions/INDEX.md',
1196
+ heading: null,
1197
+ reason: "decision index; read each entry's Impact on prior decisions before relying on it",
1198
+ });
1199
+ }
1200
+
1201
+ for (const unit of omitted.filter((candidate) => candidate.tier === 'ranked')) {
1202
+ const prefix = unit.omitted_reason === 'narrow-match' ? 'narrow match, read only: ' : '';
1203
+ if (unit.kind === 'doc') {
1204
+ entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(`${prefix}${unit.reasons[0] ?? 'ranked doc'}`) });
1205
+ } else {
1206
+ entries.push(...lineageReads(unit, 'ranked', prefix));
1207
+ }
1208
+ }
1209
+
1210
+ // Uncertified lineage wording aimed at an emitted decision: a read, never
1211
+ // a status or a relation. Grouped per source file and target decision.
1212
+ const unknownGroups = new Map();
1213
+ for (const unit of included.filter((candidate) => candidate.kind === 'lineage')) {
1214
+ for (const relation of unit.unknown_incoming) {
1215
+ const key = `${relation.source_path}\u0000${relation.target_decision_id}`;
1216
+ if (!unknownGroups.has(key)) {
1217
+ unknownGroups.set(key, { path: relation.source_path, target: relation.target_decision_id, sources: new Set(), cues: new Set() });
1218
+ }
1219
+ const group = unknownGroups.get(key);
1220
+ group.sources.add(relation.source_decision_id);
1221
+ group.cues.add(relation.cue);
1222
+ }
1223
+ }
1224
+ for (const group of [...unknownGroups.values()].sort((left, right) => right.target - left.target || left.path.localeCompare(right.path))) {
1225
+ const target = formatId(group.target);
1226
+ entries.push({
1227
+ priority: 'lineage',
1228
+ path: group.path,
1229
+ heading: [...group.sources].sort((left, right) => right - left).map(formatId).join(', '),
1230
+ reason: `uncertified lineage wording (${[...group.cues].sort().join(', ')}) about ${target}; judge it before relying on ${target}`,
1231
+ });
1232
+ }
1233
+
1234
+ for (const unit of omitted.filter((candidate) => candidate.tier === 'optional')) {
1235
+ if (unit.kind === 'note') {
1236
+ entries.push({ priority: 'optional', path: unit.path, heading: 'Next session should start with', reason: shortReason(unit.reasons[0]) });
1237
+ } else if (unit.kind === 'watch-out') {
1238
+ entries.push({ priority: 'optional', path: 'sessions/active/', heading: null, reason: `watch-out: ${unit.reasons[0]}` });
1239
+ }
1240
+ }
1241
+
1242
+ const seen = new Set();
1243
+ return entries.filter((entry) => {
1244
+ const key = `${entry.path}\u0000${entry.heading ?? ''}`;
1245
+ if (seen.has(key)) return false;
1246
+ seen.add(key);
1247
+ return true;
1248
+ });
1249
+ }
1250
+
1251
+ function lineageReads(unit, priority, prefix = '') {
1252
+ return [...unit.members]
1253
+ .sort((left, right) => right.decision_id - left.decision_id)
1254
+ .map((member) => ({
1255
+ priority,
1256
+ decision_id: member.decision_id,
1257
+ path: member.path,
1258
+ heading: formatId(member.decision_id),
1259
+ reason:
1260
+ member.role === 'root'
1261
+ ? shortReason(`${prefix}${unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`}`)
1262
+ : `declared successor of ${formatId(unit.root_decision_id)} (${member.successor_relations.join(', ')})`,
1263
+ }));
1264
+ }
1265
+
1266
+ function finalizeRetrievalFailure(context, reason) {
1267
+ return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [] });
1268
+ }
1269
+
1270
+ /**
1271
+ * D-359 guard, checked independently of how units were built: every emitted
1272
+ * decision appears with every declared superseding or amending successor,
1273
+ * transitively.
1274
+ */
1275
+ function assertLineageSafety(result, relations) {
1276
+ const emitted = new Set();
1277
+ for (const unit of result.units) {
1278
+ if (unit.kind === 'lineage') for (const member of unit.members) emitted.add(member.decision_id);
1279
+ }
1280
+ for (const id of emitted) {
1281
+ for (const successor of collectDeclaredSuccessors(relations, id)) {
1282
+ if (!emitted.has(successor.decision_id)) {
1283
+ throw new Error(`Brief lineage guard: ${formatId(id)} would be emitted without its declared successor ${formatId(successor.decision_id)}.`);
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ // ---------------------------------------------------------------------------
1290
+ // Helpers
1291
+ // ---------------------------------------------------------------------------
1292
+
1293
+ function buildDecisionEntryMap(documents) {
1294
+ const map = new Map();
1295
+ for (const document of documents) {
1296
+ if (document.kind !== 'decision') continue;
1297
+ for (const entry of parseDecisionEntries(document.content)) {
1298
+ const fields = {};
1299
+ for (const field of entry.fields) {
1300
+ if (!(field.name in fields)) fields[field.name] = collapse(field.text);
1301
+ }
1302
+ map.set(entry.decision_id, {
1303
+ decision_id: entry.decision_id,
1304
+ title: entry.title,
1305
+ path: document.path,
1306
+ timestamp: fields.Timestamp ?? null,
1307
+ fields,
1308
+ sectionHash: sha256Text(entry.text),
1309
+ });
1310
+ }
1311
+ }
1312
+ return map;
1313
+ }
1314
+
1315
+ function buildAliasMap(readModel, extraAliases = {}) {
1316
+ const aliases = new Map(readModel.repo_aliases);
1317
+ // Another root's repo ids (dual-root adapter, D-364): `vibecompass-app` → `app`.
1318
+ for (const [alias, repoId] of Object.entries(extraAliases)) {
1319
+ aliases.set(alias, repoId);
1320
+ aliases.set(alias.toLowerCase(), repoId);
1321
+ }
1322
+ for (const repo of readModel.project.repos ?? []) {
1323
+ if (typeof repo.path === 'string' && repo.path.trim()) {
1324
+ const normalized = repo.path.trim().replace(/^\.\//, '').replace(/\/+$/, '');
1325
+ aliases.set(normalized, repo.id);
1326
+ aliases.set(normalized.toLowerCase(), repo.id);
1327
+ }
1328
+ }
1329
+ return aliases;
1330
+ }
1331
+
1332
+ /**
1333
+ * `{ repo, paths }` for a `repo:path`, `<repo-dir>/path`, or bare path.
1334
+ * Involved files often use workspace paths (`vibecompass-app/src/x.ts`), and
1335
+ * the read model prefixes a single-repo doc's entries with its repo
1336
+ * (`app:vibecompass-app/src/x.ts`), so a leading repo-directory segment is
1337
+ * also tried stripped.
1338
+ */
1339
+ function normalizeRepoPath(value, aliases) {
1340
+ const trimmed = String(value).trim().replace(/^\.\//, '');
1341
+ const prefixed = trimmed.match(/^([^:/\s]+):(.+)$/);
1342
+ let repo = null;
1343
+ let rest = trimmed;
1344
+ if (prefixed) {
1345
+ repo = aliases.get(prefixed[1]) ?? aliases.get(prefixed[1].toLowerCase()) ?? prefixed[1];
1346
+ rest = prefixed[2];
1347
+ }
1348
+ const cleaned = rest.replace(/^\.?\/+/, '').replace(/\/+$/, '');
1349
+ const paths = [cleaned];
1350
+ const slash = cleaned.indexOf('/');
1351
+ if (slash > 0) {
1352
+ const head = cleaned.slice(0, slash);
1353
+ const aliasRepo = aliases.get(head) ?? aliases.get(head.toLowerCase());
1354
+ if (aliasRepo && (!repo || repo === aliasRepo)) {
1355
+ repo = aliasRepo;
1356
+ paths.push(cleaned.slice(slash + 1));
1357
+ }
1358
+ }
1359
+ return { repo, paths: paths.filter(Boolean) };
1360
+ }
1361
+
1362
+ function matchPathInput(doc, value, aliases) {
1363
+ const wanted = normalizeRepoPath(value, aliases);
1364
+ if (wanted.paths.length === 0) return null;
1365
+ let best = null;
1366
+ for (const involved of doc.involvedFiles) {
1367
+ if (/\s/.test(involved)) continue;
1368
+ const owned = normalizeRepoPath(involved, aliases);
1369
+ if (wanted.repo && owned.repo && wanted.repo !== owned.repo) continue;
1370
+ let strength = 0;
1371
+ for (const ownedPath of owned.paths) {
1372
+ for (const wantedPath of wanted.paths) {
1373
+ if (ownedPath === wantedPath) strength = Math.max(strength, 1);
1374
+ else if (wantedPath.startsWith(`${ownedPath}/`) || ownedPath.startsWith(`${wantedPath}/`)) strength = Math.max(strength, 0.5);
1375
+ }
1376
+ }
1377
+ if (strength > (best?.strength ?? 0)) best = { strength, involved };
1378
+ }
1379
+ return best;
1380
+ }
1381
+
1382
+ function countOwners(docs, pathInputs, aliases) {
1383
+ const counts = new Map();
1384
+ for (const { value } of pathInputs) {
1385
+ counts.set(value, docs.filter((doc) => matchPathInput(doc, value, aliases)).length || 1);
1386
+ }
1387
+ return counts;
1388
+ }
1389
+
1390
+ /** Basename (without extension) and parent directory of a `repo:path`. */
1391
+ function pathTopicText(value) {
1392
+ const segments = String(value).replace(/^[^:/\s]+:/, '').replace(/\/+$/, '').split('/').filter(Boolean);
1393
+ const base = (segments.at(-1) ?? '').replace(/\.[a-z0-9]+$/i, '');
1394
+ const parent = segments.at(-2) ?? '';
1395
+ return [STRUCTURAL_DIRECTORIES.has(parent.toLowerCase()) ? '' : parent, base].join(' ');
1396
+ }
1397
+
1398
+ const STRUCTURAL_DIRECTORIES = new Set(['src', 'lib', 'app', 'apps', 'packages', 'components', 'utils', 'tests', 'test', 'scripts', 'dist', 'public']);
1399
+
1400
+ function claimsOverlap(left, right) {
1401
+ const a = stripRepo(left);
1402
+ const b = stripRepo(right);
1403
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
1404
+ }
1405
+
1406
+ function stripRepo(value) {
1407
+ return String(value).replace(/^[^:/\s]+:/, '').replace(/\/+$/, '');
1408
+ }
1409
+
1410
+ function docSectionFields(content, component) {
1411
+ if (typeof content !== 'string') {
1412
+ return {
1413
+ description: component.description,
1414
+ retrievalGuidance: component.retrieval_guidance,
1415
+ retrievalScope: component.retrieval_scope,
1416
+ };
1417
+ }
1418
+ const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, '');
1419
+ const sections = splitSections(body, 2);
1420
+ return {
1421
+ description: sections.get('Description') ?? null,
1422
+ retrievalGuidance: sections.get('Retrieval guidance') ?? null,
1423
+ retrievalScope: reviewMetadataField(sections.get('Review metadata'), 'Retrieval scope'),
1424
+ };
1425
+ }
1426
+
1427
+ /**
1428
+ * Level-2 sections of a markdown body, fence-aware: headings inside fenced
1429
+ * code are content, and the first section with a given title wins (docs such
1430
+ * as `file-schema.md` show example `## Description` blocks in fences).
1431
+ */
1432
+ function splitSections(content, level = 2) {
1433
+ const sections = new Map();
1434
+ if (!content) return sections;
1435
+ const lines = content.split(/\r?\n/);
1436
+ const heading = new RegExp(`^#{1,${level}}\\s`);
1437
+ const exact = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`);
1438
+ let fence = null;
1439
+ let current = null;
1440
+ for (const line of lines) {
1441
+ const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
1442
+ if (marker) {
1443
+ if (!fence) fence = marker[1][0];
1444
+ else if (marker[1][0] === fence) fence = null;
1445
+ }
1446
+ if (!fence && !marker && heading.test(line)) {
1447
+ const match = line.match(exact);
1448
+ current = match && !sections.has(match[1]) ? { title: match[1], lines: [] } : null;
1449
+ if (current) sections.set(current.title, current);
1450
+ continue;
1451
+ }
1452
+ if (current) current.lines.push(line);
1453
+ }
1454
+ return new Map([...sections].map(([title, section]) => [title, section.lines.join('\n').trim() || null]));
1455
+ }
1456
+
1457
+ /** Body of a level-2 section (`## Title`), trimmed; null when absent. */
1458
+ export function extractSection(content, title) {
1459
+ return splitSections(content, 2).get(title) ?? null;
1460
+ }
1461
+
1462
+ /** Body of `### sub` inside `## section`; null when absent. */
1463
+ function extractSubsection(content, section, sub) {
1464
+ return splitSections(extractSection(content, section), 3).get(sub) ?? null;
1465
+ }
1466
+
1467
+ /** One `- Label: value` bullet from a Review metadata section, continuation lines included. */
1468
+ function reviewMetadataField(section, label) {
1469
+ if (!section) return null;
1470
+ const lines = section.split(/\r?\n/);
1471
+ const prefix = new RegExp(`^\\s*[-*]\\s+${label}:\\s*`, 'i');
1472
+ const start = lines.findIndex((line) => prefix.test(line));
1473
+ if (start === -1) return null;
1474
+ const parts = [lines[start].replace(prefix, '')];
1475
+ for (const line of lines.slice(start + 1)) {
1476
+ if (!/^\s+\S/.test(line) || /^\s*[-*]\s+/.test(line)) break;
1477
+ parts.push(line.trim());
1478
+ }
1479
+ return parts.join(' ').trim() || null;
1480
+ }
1481
+
1482
+ async function listLaneDirs(activeRoot) {
1483
+ try {
1484
+ const entries = await readdir(activeRoot, { withFileTypes: true });
1485
+ return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
1486
+ } catch {
1487
+ return [];
1488
+ }
1489
+ }
1490
+
1491
+ async function readOptional(filePath) {
1492
+ try {
1493
+ return await readFile(filePath, 'utf8');
1494
+ } catch (error) {
1495
+ if (error && error.code === 'ENOENT') return null;
1496
+ throw error;
1497
+ }
1498
+ }
1499
+
1500
+ async function pathExists(filePath) {
1501
+ try {
1502
+ await stat(filePath);
1503
+ return true;
1504
+ } catch {
1505
+ return false;
1506
+ }
1507
+ }
1508
+
1509
+ function relativeTo(rootDir, filePath) {
1510
+ return path.relative(rootDir, filePath).split(path.sep).join('/');
1511
+ }
1512
+
1513
+ function uniqueStrings(values) {
1514
+ if (!Array.isArray(values)) return [];
1515
+ return [...new Set(values.filter((value) => typeof value === 'string').map((value) => value.trim()).filter(Boolean))];
1516
+ }
1517
+
1518
+ function stringArray(value) {
1519
+ return Array.isArray(value) ? uniqueStrings(value) : [];
1520
+ }
1521
+
1522
+ function optionalString(value) {
1523
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
1524
+ }
1525
+
1526
+ function slugify(value) {
1527
+ return String(value ?? '')
1528
+ .toLowerCase()
1529
+ .replace(/[^a-z0-9]+/g, '-')
1530
+ .replace(/^-+|-+$/g, '');
1531
+ }
1532
+
1533
+ function collapse(value) {
1534
+ return String(value ?? '').replace(/\s+/g, ' ').trim();
1535
+ }
1536
+
1537
+ function shortReason(value) {
1538
+ const text = collapse(value);
1539
+ const points = [...text];
1540
+ return points.length <= 110 ? text : `${points.slice(0, 109).join('')}…`;
1541
+ }
1542
+
1543
+ function plural(count, noun, pluralNoun = `${noun}s`) {
1544
+ return `${count} ${count === 1 ? noun : pluralNoun}`;
1545
+ }
1546
+
1547
+ function round(value) {
1548
+ return Math.round(value * 1000) / 1000;
1549
+ }
1550
+
1551
+ function errorMessage(error) {
1552
+ return collapse(error instanceof Error ? error.message : String(error)).slice(0, 400);
1553
+ }
1554
+
1555
+ export function formatId(id) {
1556
+ return `D-${String(id).padStart(3, '0')}`;
1557
+ }