@vibecompass/vibecompass 0.15.0 → 0.17.0

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