@vibecompass/vibecompass 0.16.0 → 0.17.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.1 - 2026-10-07
4
+
5
+ Hardening release: decision lineage fails soft, the read model reads sections
6
+ fence-aware, and close-session no longer leaves half-deleted worktrees. The lane
7
+ brief stays opt-in (D-368).
8
+
9
+ - Lineage fails soft (D-371): when the lineage parser throws on a file, the read
10
+ model still loads and `decision_lineage.status` is `unavailable`, listing the
11
+ file in `unavailable_sources` with a `lineage-extraction-failed` diagnostic
12
+ (the error's class and first line, at most 200 characters). Relations, counts,
13
+ and `getDecisionLineage`'s lists are null, never empty. `getProjectContext`
14
+ gains a `decision_lineage` status, so Local MCP's project context shows it.
15
+ Before, one parser exception failed the whole read model: the brief, every
16
+ Local MCP read, and agent-file sync. Available lineage is unchanged, with the
17
+ same relation records and contract version 1.
18
+ - Brief: with lineage unavailable, the brief abstains. It is `incomplete`, shows
19
+ the lane only, and lists the file lineage could not be read from, the
20
+ overview, and `decisions/INDEX.md` as required reads. A lane `brief.md`
21
+ written then records a failed generation, so `continue-session` regenerates
22
+ it.
23
+ - Read model (D-372): architecture component sections (Description, Details,
24
+ Next steps, Retrieval guidance, Review metadata, Involved files) are read
25
+ fence-aware, and the first section of a title wins. A heading inside a fenced
26
+ example no longer replaces the real section (for example a sample
27
+ `## Description` in a schema doc). It is the brief's reader, now shared.
28
+ - Lineage: section headings in architecture docs and session notes now follow
29
+ the closing-fence rule that 0.17.0 gave masking. A fence closes only on a
30
+ line of its opener's own character, so a mixed line such as ```` ```~~~ ````
31
+ no longer ends an example early. In 0.17.0 a sample `## Decisions made`
32
+ heading after such a line could be taken as the note's listing, making a
33
+ false `made` relation and missing the real ones, and later real headings
34
+ could be read as fenced. A shared golden fixture pair
35
+ (`decisions-fence-closing`, `note-fence-closing`) pins the rule for the app
36
+ port.
37
+ - close-session: git removes a lane's worktrees with no time limit. A removal
38
+ killed by the 120-second limit while deleting a full `node_modules` left a
39
+ half-deleted checkout that the next close counted as modified. Git still
40
+ refuses locked worktrees, worktrees with submodules, and modified ones before
41
+ it deletes anything.
42
+ - Tests: the lane-runtime settings test passes inside a `lane-env` shell.
43
+
44
+ Not listed in 0.17.0's notes, though it shipped there: masking and decision
45
+ entry structure closed a fence only on a line of its opener's own character,
46
+ so a sample `**Supersedes:**` line after a mixed ```` ```~~~ ```` line no longer
47
+ declared false lineage.
48
+
49
+ ## 0.17.0 - 2026-10-03
50
+
51
+ Session brief retrieval and framing fixes (recall plan A7). The lane brief stays
52
+ opt-in (D-368). The re-evaluation meant to decide default-on stopped at a protocol
53
+ deviation before any run was graded, so it gives no evidence either way. These
54
+ fixes target misses from the earlier evaluation and were checked on its tuning
55
+ tasks with briefs alone, not in an agent evaluation.
56
+
57
+ **Breaking for `vibecompass brief --json` consumers:** contract version 2 removes
58
+ `selection.narrowed` (see below). Other commands and the read model are unchanged.
59
+
60
+ - Selection: a decision is also matched on the sentences in architecture docs
61
+ that cite it (the overview included; Review metadata and Involved files
62
+ excluded), so a doc's own words reach a decision whose entry uses different
63
+ ones. A doc shares the keyword relevance of the decisions it cites, so a doc
64
+ whose Description misses the task's words is still reached through the
65
+ decisions it describes. Common abbreviations meet their long forms
66
+ (organization/org, repository/repo, configuration/config, database/db,
67
+ environment/env, documentation/doc, authentication/auth).
68
+ - No-match: evaluative words ("clearer", "flaky", "noticeably") and
69
+ kind-of-work words ("investigate", "failure", "bug") never count as a task's
70
+ distinctive topic words. When the distinctive topic words are mostly unknown
71
+ to memory and only keywords matched, the status is `no-match` with up to four
72
+ nearby reads (paths that share other task words, never presented as
73
+ relevant). This replaces the narrow `partial` match.
74
+ - Framing: a brief with units says it is a starting point of cut excerpts that
75
+ can miss the doc a task depends on, and names up to six of the task's words
76
+ to search for beyond it (`selection.search_terms` in `--json`).
77
+ - Lane `brief.md` format 2: `exclude` and `repo_aliases` show at most four
78
+ entries, bound by `exclude_digest` and `repo_aliases_digest`, so staleness
79
+ checks stay complete as settings and repos grow; a header comment says the
80
+ token budget covers the body only. Format 1 briefs read as stale and are
81
+ regenerated on resume.
82
+ - `--json` contract version 2: `selection.narrowed` is replaced by
83
+ `selection.topic_absent` and `selection.search_terms`; follow-ups gain the
84
+ `nearby` priority.
85
+
3
86
  ## 0.16.0 - 2026-09-30
4
87
 
5
88
  The session brief ships as an opt-in experiment (D-368). In a baseline-versus-brief
package/README.md CHANGED
@@ -162,7 +162,9 @@ npx -y @vibecompass/vibecompass@latest brief --root .compass --session auth-flow
162
162
  # (guarded, never forced; branches are never deleted); dirty or in-use
163
163
  # worktrees survive with guidance and the lane marker stays as a breadcrumb.
164
164
  # Gitignored files (e.g. .env) do not count as dirty and are deleted with a
165
- # clean worktree — copy ignored local files out before closing.
165
+ # clean worktree — copy ignored local files out before closing. Git does the
166
+ # removal with no time limit, so a large node_modules can take a while; it
167
+ # keeps locked worktrees and worktrees with submodules.
166
168
  # close-session and docs-update also print a pre-close staleness set: new
167
169
  # decisions since lane start, stale base revisions, newer finalized notes
168
170
  # that mention the lane's scope, and claim overlap with other active lanes.
@@ -266,7 +268,11 @@ follow-up reads. It runs locally and deterministically: no network, no model,
266
268
  no account. A decision always appears together with any later decision whose
267
269
  own text explicitly declares that it replaces or amends it, and the brief never
268
270
  claims that a decision governs your task. It cannot spot a replacement that is
269
- only implied, so read the follow-up list too.
271
+ only implied, so read the follow-up list too. When the task's distinctive words
272
+ match nothing in memory, the status is `no-match`, with at most four nearby
273
+ reads that share the task's other words. If decision lineage cannot be read
274
+ from a file, the brief shows no decisions or docs: it is `incomplete` and lists
275
+ that file, the overview, and the decision index to read first.
270
276
 
271
277
  The brief in a session lane is an experiment you turn on (D-368):
272
278
 
@@ -286,8 +292,10 @@ The brief is opt-in because of the project's own evaluation, which did not
286
292
  fully follow its protocol. Agents with a brief found about as much of the
287
293
  relevant memory as agents without one, and made none of the specific false
288
294
  authority claims the evaluation checked for. But they missed nine facts the
289
- others found, in one case after the brief left out the key document. Treat a
290
- brief as a starting point, not the whole answer.
295
+ others found, in one case after the brief left out the key document. A second
296
+ evaluation, after retrieval fixes, stopped at a protocol deviation before any
297
+ grading, so turning the brief on by default still waits for a conforming one.
298
+ Treat a brief as a starting point, not the whole answer.
291
299
 
292
300
  ## Hosted Sync
293
301
 
@@ -412,8 +420,13 @@ the parser cannot certify is `unknown`. Every relation carries its source
412
420
  path, section, line, and content hashes. `getDecisionLineage(readModel, id)`
413
421
  returns one decision's incoming and outgoing lineage and its declared
414
422
  successors, followed transitively. Nothing is ever reported as "governs" or
415
- "currently valid". Architecture components also carry `retrieval_guidance`
416
- and `retrieval_scope` when their docs define them.
423
+ "currently valid". If the parser throws on any file, `decision_lineage.status`
424
+ is `unavailable`, with the file in `unavailable_sources` and null relations
425
+ and counts, never empty ones; the rest of the read model still loads, and
426
+ `getProjectContext` reports the lineage status. Architecture components also
427
+ carry `retrieval_guidance` and `retrieval_scope` when their docs define them;
428
+ their sections are read fence-aware, so a heading inside a code example never
429
+ replaces a real section.
417
430
 
418
431
  `buildSessionBrief({ rootDir, task, files, featureSlugs, claims, laneId, budget })`
419
432
  is the read-only engine behind `vibecompass brief`: it returns the selection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,7 +66,24 @@ const SUFFIX_RULES = [
66
66
  ['s', '', 3],
67
67
  ];
68
68
 
69
+ // Common abbreviations meet their long forms after stemming, at index and
70
+ // query time alike: "organizations" and "org" both become "org".
71
+ const CANONICAL_STEMS = new Map([
72
+ ['organiz', 'org'],
73
+ ['repository', 'repo'],
74
+ ['configurat', 'config'],
75
+ ['databas', 'db'],
76
+ ['environ', 'env'],
77
+ ['documentat', 'doc'],
78
+ ['authenticat', 'auth'],
79
+ ]);
80
+
69
81
  export function stemWord(word) {
82
+ const stem = stemRaw(word);
83
+ return CANONICAL_STEMS.get(stem) ?? stem;
84
+ }
85
+
86
+ function stemRaw(word) {
70
87
  const lower = word.toLowerCase();
71
88
  if (IRREGULAR.has(lower)) return IRREGULAR.get(lower);
72
89
  if (lower.length <= 3 || lower.endsWith('ss') || lower.endsWith('us') || lower.endsWith('is')) {
@@ -91,6 +108,47 @@ export function stemWord(word) {
91
108
  return stem;
92
109
  }
93
110
 
111
+ // Words that say how a user feels about a topic ("clearer", "flaky",
112
+ // "noticeably", "honestly") or what kind of work they want ("investigate",
113
+ // "failure", "bug") rather than what the topic is. They still count for
114
+ // keyword scoring; they are never a task's distinctive topic words, so they
115
+ // alone cannot make a brief abstain.
116
+ const NON_TOPICAL_WORDS = new Set(
117
+ (
118
+ 'actual annoying awful awkward bad basic beautiful better best big brittle broken buggy careful clean clear clever clumsy clunky ' +
119
+ 'complete confus confusing constant curious decent difficult dumb easy elegant entire ever extreme fast feel fine flaky fragile frank ' +
120
+ 'frequent friendly frustrating full general glitchy good great happy hard heavy helpful honest hopeful huge ideal important ' +
121
+ 'intermittent intuitive janky laggy large light little lovely main maybe messy minor modern mostly much nice noisy noticeable obvious ' +
122
+ 'occasional odd often overall painful perhaps pleasant polished poor possible pretty probable quick quiet quite random rare real ' +
123
+ 'rather reliable responsive rough seem serious simple skeptical slight slow sluggish small smart smooth snappy solid somehow ' +
124
+ 'sometime sometimes somewhat sporadic strange stupid sturdy subtle super terrible thorough tidy tiny total ugly unclear unfriendly ' +
125
+ 'unhappy unpleasant unreliable unstable usable useful useless usual verbose very weird worried worse worst ' +
126
+ // Kinds of work and trouble.
127
+ 'behave behavior behaviour bug check crash debug diagnose error explain fail failure figure fix handle help idea improve ' +
128
+ 'improvement incorrect investigate issue missing problem question refactor regression repair tweak understand verify wrong'
129
+ ).split(' '),
130
+ );
131
+
132
+ /**
133
+ * True for a word that only evaluates, hedges, intensifies, or names a kind of
134
+ * work (comparatives, -ly adverbs, and -s/-ed/-ing forms included).
135
+ */
136
+ export function isNonTopicalWord(word) {
137
+ const lower = String(word).toLowerCase();
138
+ const forms = new Set([lower]);
139
+ for (const [suffix, replacement] of [
140
+ ['iest', 'y'], ['ier', 'y'], ['est', ''], ['er', ''], ['ily', 'y'], ['ably', 'able'], ['ibly', 'ible'], ['ly', ''],
141
+ ['ing', ''], ['ing', 'e'], ['ed', ''], ['ed', 'e'], ['es', ''], ['s', ''],
142
+ ]) {
143
+ if (lower.endsWith(suffix) && lower.length - suffix.length >= 3) forms.add(lower.slice(0, lower.length - suffix.length) + replacement);
144
+ }
145
+ for (const form of [...forms]) {
146
+ if (/(.)\1$/.test(form)) forms.add(form.slice(0, -1)); // "bigger" → "bigg" → "big"
147
+ if (form.endsWith('i')) forms.add(`${form.slice(0, -1)}y`);
148
+ }
149
+ return [...forms].some((form) => NON_TOPICAL_WORDS.has(form));
150
+ }
151
+
94
152
  /** Raw lowercase word tokens: letters and digits, split on everything else. */
95
153
  export function splitWords(text) {
96
154
  return String(text ?? '')
@@ -4,6 +4,7 @@ import {
4
4
  BRIEF_CONTRACT_VERSION,
5
5
  BRIEF_DEFAULT_BUDGET,
6
6
  BRIEF_OVERVIEW_PATH,
7
+ briefShortHash,
7
8
  buildSessionBrief,
8
9
  computeBriefCorpusDigest,
9
10
  describeBriefInputs,
@@ -30,8 +31,11 @@ import { PACKAGE_VERSION } from './version.js';
30
31
  */
31
32
 
32
33
  export const LANE_BRIEF_FILENAME = 'brief.md';
33
- export const LANE_BRIEF_FORMAT = 1;
34
+ export const LANE_BRIEF_FORMAT = 2;
34
35
  const MAX_HEADER_INPUTS = 24;
36
+ // Lists that grow with settings and repos show at most this many entries; a
37
+ // digest of the whole list is the binding, so staleness checks stay complete.
38
+ const MAX_HEADER_LIST = 4;
35
39
  const MAX_REASON_LENGTH = 400;
36
40
 
37
41
  export function laneBriefPath(rootDir, laneId) {
@@ -210,8 +214,7 @@ async function compareBindings({ header, destinationRoot, laneId, sourceRootDir,
210
214
  const reasons = [];
211
215
  // The alias map comes from both roots' project.yaml; the destination's is
212
216
  // not part of the source corpus, so it is compared directly.
213
- const recordedAliases = Array.isArray(header.repo_aliases) ? header.repo_aliases.map(String) : [];
214
- if (JSON.stringify(recordedAliases) !== JSON.stringify(source.aliasPairs.map(formatAliasPair))) {
217
+ if (header.repo_aliases_digest !== headerListDigest(source.aliasPairs.map(formatAliasPair))) {
215
218
  reasons.push('the repo alias map changed (a destination or source repo id or remote)');
216
219
  }
217
220
 
@@ -222,6 +225,9 @@ async function compareBindings({ header, destinationRoot, laneId, sourceRootDir,
222
225
  return [...reasons, `brief settings are invalid, so freshness cannot be checked without reading excluded paths: ${problems.join('; ')}`];
223
226
  }
224
227
  const exclusions = compileBriefExclusions(uniqueStrings([...sourceSettings.exclude, ...destinationSettings.exclude]));
228
+ if (header.exclude_digest !== headerListDigest(exclusions.patterns)) {
229
+ reasons.push('the brief exclusions changed (project.yaml brief.exclude)');
230
+ }
225
231
 
226
232
  const lane = await readBriefLaneBindings(source.rootDir, source.laneId);
227
233
  const identity = lane.sessionDate && Number.isInteger(lane.sessionNumber) ? `${lane.sessionDate}-${lane.sessionNumber}` : null;
@@ -392,12 +398,14 @@ function renderLaneBriefDocument({ destinationRoot, laneId, generatedAt, budget,
392
398
  `destination_root: ${quote(destinationRoot)}`,
393
399
  `destination_lane: ${quote(laneId)}`,
394
400
  `corpus_digest: ${quote(result.source.corpus_digest ?? 'none')}`,
395
- ...renderList('exclude', result.source.exclude ?? []),
401
+ `exclude_digest: ${quote(headerListDigest(result.source.exclude ?? []))}`,
402
+ ...renderBoundedList('exclude', result.source.exclude ?? [], 'exclude_digest'),
396
403
  'lane_bindings:',
397
404
  ` session: ${quote(lane?.session ?? 'none')}`,
398
405
  ` handoff: ${quote(encodeOptionalHash(lane?.handoff ?? null))}`,
399
406
  ` other_lanes: ${quote(lane?.other_lanes ?? 'none')}`,
400
- ...renderList('repo_aliases', source.aliasPairs.map(formatAliasPair)),
407
+ `repo_aliases_digest: ${quote(headerListDigest(source.aliasPairs.map(formatAliasPair)))}`,
408
+ ...renderBoundedList('repo_aliases', source.aliasPairs.map(formatAliasPair), 'repo_aliases_digest'),
401
409
  ...renderList('inputs', [
402
410
  ...shown.map((input) => `${input.hash} ${input.key}`),
403
411
  ...(folded.length > 0 ? [`+${folded.length} more inputs (covered by corpus_digest)`] : []),
@@ -444,6 +452,7 @@ function assembleBriefDocument(commands, generatedAt, fields, body) {
444
452
  const lines = [
445
453
  '---',
446
454
  '# Generated session brief (D-357, D-359, D-364); do not edit. It is stale once any binding below changes.',
455
+ '# The token budget covers the brief body below this header; the header is metadata outside it.',
447
456
  `# Check: ${commands.check}`,
448
457
  `# Refresh: ${commands.write}`,
449
458
  `brief_format: ${LANE_BRIEF_FORMAT}`,
@@ -466,6 +475,18 @@ function renderList(key, values) {
466
475
  return [`${key}:`, ...values.map((value) => ` - ${quote(value)}`)];
467
476
  }
468
477
 
478
+ /** At most MAX_HEADER_LIST entries, then "+N more"; the named digest binds the whole list. */
479
+ function renderBoundedList(key, values, digestKey) {
480
+ if (values.length <= MAX_HEADER_LIST) return renderList(key, values);
481
+ const shown = values.slice(0, MAX_HEADER_LIST - 1);
482
+ return renderList(key, [...shown, `+${values.length - shown.length} more (bound by ${digestKey})`]);
483
+ }
484
+
485
+ /** Order-insensitive short digest of a header list (sorted values). */
486
+ function headerListDigest(values) {
487
+ return briefShortHash([...values].map(String).sort());
488
+ }
489
+
469
490
  function parseInputLines(values) {
470
491
  if (!Array.isArray(values)) return [];
471
492
  return values
@@ -27,6 +27,13 @@ const EVIDENCE_NOTE =
27
27
  '_Evidence: "covers" and "cites" are mechanical (the doc lists the file or mentions the decision); "supersedes" and "amends" come only from the later decision\'s own text. The brief infers no other relation and does not decide which decision applies to this task._';
28
28
  const EVIDENCE_NOTE_SHORT = '_Evidence: covers/cites are mechanical; supersedes/amends are declared by the later decision. Nothing else is inferred._';
29
29
 
30
+ // Framing (A7): A5 found agents that stopped searching where the brief
31
+ // stopped. The brief says what it is — a keyword- and citation-selected
32
+ // starting point of cut excerpts — and names the task's key terms to search.
33
+ const USE_NOTE =
34
+ "**Use this brief as a starting point, not the whole context.** It shows cut excerpts chosen by keyword and citation matching, so it can miss the doc this task depends on. Read the full source behind any fact you rely on, and search memory beyond this list";
35
+ const USE_NOTE_SHORT = '**Starting point only:** excerpts are cut and selection can miss key docs; read full sources and search beyond this list';
36
+
30
37
  /**
31
38
  * Frame limits (header, status, follow-ups) per compaction level. Level 0 is
32
39
  * normal; the engine raises the level only when the frame itself would not
@@ -34,10 +41,10 @@ const EVIDENCE_NOTE_SHORT = '_Evidence: covers/cites are mechanical; supersedes/
34
41
  * field is bounded at every level; units render the same at every level.
35
42
  */
36
43
  const FRAME_LIMITS = [
37
- { task: 300, file: 120, files: 4, root: 160, reason: 300, gap: 240, followUpPath: 120, followUpHeading: 60, followUpReason: 170, followUps: Infinity, evidence: EVIDENCE_NOTE },
38
- { task: 160, file: 60, files: 2, root: 60, reason: 200, gap: 160, followUpPath: 100, followUpHeading: 40, followUpReason: 80, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT },
39
- { task: 120, file: 50, files: 1, root: 40, reason: 160, gap: 110, followUpPath: 80, followUpHeading: 30, followUpReason: 40, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT },
40
- { task: 100, file: 40, files: 1, root: 30, reason: 120, gap: 90, followUpPath: 70, followUpHeading: 24, followUpReason: 30, followUps: 6, evidence: EVIDENCE_NOTE_SHORT },
44
+ { task: 300, file: 120, files: 4, root: 160, reason: 300, gap: 240, followUpPath: 120, followUpHeading: 60, followUpReason: 170, followUps: Infinity, evidence: EVIDENCE_NOTE, use: USE_NOTE, searchTerms: 6, searchTerm: 40 },
45
+ { task: 160, file: 60, files: 2, root: 60, reason: 200, gap: 160, followUpPath: 100, followUpHeading: 40, followUpReason: 80, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT, use: USE_NOTE_SHORT, searchTerms: 4, searchTerm: 30 },
46
+ { task: 120, file: 50, files: 1, root: 40, reason: 160, gap: 110, followUpPath: 80, followUpHeading: 30, followUpReason: 40, followUps: Infinity, evidence: EVIDENCE_NOTE_SHORT, use: USE_NOTE_SHORT, searchTerms: 3, searchTerm: 24 },
47
+ { task: 100, file: 40, files: 1, root: 30, reason: 120, gap: 90, followUpPath: 70, followUpHeading: 24, followUpReason: 30, followUps: 6, evidence: EVIDENCE_NOTE_SHORT, use: USE_NOTE_SHORT, searchTerms: 2, searchTerm: 20 },
41
48
  ];
42
49
  export const BRIEF_MAX_COMPACTION = FRAME_LIMITS.length - 1;
43
50
 
@@ -53,7 +60,7 @@ export function renderBrief(result) {
53
60
  lines.push(`**Status: ${result.status}** — ${statusSentence(result, frame)}`, '');
54
61
  lines.push(...renderHeader(result, frame), '');
55
62
 
56
- if (result.status !== 'no-match') lines.push(frame.evidence, '');
63
+ if (result.status !== 'no-match') lines.push(`${frame.use}${searchSuffix(result, frame)}`, '', frame.evidence, '');
57
64
 
58
65
  const byKind = (kind) => result.units.filter((unit) => unit.kind === kind);
59
66
  const emitted = new Set();
@@ -89,7 +96,7 @@ export function renderBrief(result) {
89
96
  }
90
97
 
91
98
  if (result.status !== 'incomplete' && followUps.length > 0) {
92
- lines.push('## Follow-up reads', '', ...followUps, '');
99
+ lines.push(result.status === 'no-match' ? '## Nearby memory (shares task words only)' : '## Follow-up reads', '', ...followUps, '');
93
100
  }
94
101
 
95
102
  return `${lines.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd()}\n`;
@@ -114,27 +121,31 @@ export function renderBriefUnit(unit, options = {}) {
114
121
  }
115
122
 
116
123
  function statusSentence(result, frame) {
117
- const withheld = result.omitted.filter((unit) => unit.omitted_reason === 'narrow-match').length;
118
- const overflow = result.omitted.length - withheld;
119
124
  switch (result.status) {
120
125
  case 'complete':
121
126
  return 'every selected unit fits the budget.';
122
127
  case 'partial': {
123
- const parts = [];
124
- if (overflow > 0) parts.push(`${overflow} lower-tier unit${overflow === 1 ? '' : 's'} did not fit`);
125
- if (withheld > 0) {
126
- // Every unit in a narrow match is low-confidence, the packed ones included.
127
- parts.push(`most of the task's distinctive words had no keyword match, so every match is low-confidence: the top ${result.units.filter((unit) => unit.tier === 'ranked').length} are shown for orientation and ${withheld} more ${withheld === 1 ? 'is' : 'are'} listed as reads only`);
128
- }
129
- return `${parts.join('; ')}; see Follow-up reads.`;
128
+ const overflow = result.omitted.length;
129
+ return `${overflow} lower-tier unit${overflow === 1 ? '' : 's'} did not fit; see Follow-up reads.`;
130
130
  }
131
131
  case 'no-match':
132
- return 'no architecture doc or decision matched this task. Nothing in memory is presented as relevant; say so in your plan, and read the orientation overview if the task needs broader context.';
132
+ if (result.follow_ups.length > 0) {
133
+ // Topic words unknown to the index: nearby reads share other task words.
134
+ return `no architecture doc or decision matched this task's distinctive words. Nothing in memory is presented as relevant. The nearby reads below share only other task words: check them${searchSuffix(result, frame, ' and search memory')} before concluding that nothing applies, and say in your plan what, if anything, applies.`;
135
+ }
136
+ return `no architecture doc or decision matched this task. Nothing in memory is presented as relevant${searchSuffix(result, frame, '; search memory')} before concluding that nothing applies, say so in your plan, and read the orientation overview if the task needs broader context.`;
133
137
  default:
134
138
  return `${excerpt(result.status_reason, frame.reason)}.`;
135
139
  }
136
140
  }
137
141
 
142
+ /** " for: `a`, `b`" — the task's key terms to search (rarest first), bounded by the frame. */
143
+ function searchSuffix(result, frame, lead = '') {
144
+ const terms = (result.selection?.search_terms ?? []).slice(0, frame.searchTerms).map((term) => `\`${excerpt(term, frame.searchTerm)}\``);
145
+ if (terms.length === 0) return lead ? `${lead}` : '.';
146
+ return `${lead} for ${terms.join(', ')}${lead ? '' : '.'}`;
147
+ }
148
+
138
149
  function renderHeader(result, frame) {
139
150
  const files = result.inputs.files;
140
151
  const fileText =
package/src/brief.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { readdir, readFile, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { createKeywordIndex, queryTermOrigins, queryTerms } from './brief-keywords.js';
3
+ import { createKeywordIndex, isNonTopicalWord, queryTermOrigins, queryTerms } from './brief-keywords.js';
4
4
  import { BRIEF_MAX_COMPACTION, renderBrief, renderBriefUnit, renderedFollowUpCount } from './brief-render.js';
5
5
  import { compileBriefExclusions, readBriefSettingsForRoot, validateExcludePattern } from './brief-settings.js';
6
6
  import { collectDeclaredSuccessors, parseDecisionEntries, scanDecisionReferences } from './decision-lineage.js';
7
7
  import { sha256Text, stableStringify } from './hash.js';
8
8
  import { resolveLaneMarkerContext, resolveLaneSelection, validateLaneId } from './lane-marker.js';
9
+ import { extractSection, splitSections, stepFence } from './markdown-sections.js';
9
10
  import { loadProjectReadModelWithDocuments } from './read-model.js';
10
11
  import { parseSimpleYaml } from './simple-yaml.js';
11
12
 
@@ -28,7 +29,7 @@ import { parseSimpleYaml } from './simple-yaml.js';
28
29
  * doc, file, or task, or that one is currently valid.
29
30
  */
30
31
 
31
- export const BRIEF_CONTRACT_VERSION = 1;
32
+ export const BRIEF_CONTRACT_VERSION = 2;
32
33
  export const BRIEF_DEFAULT_BUDGET = 6000;
33
34
  export const BRIEF_MIN_BUDGET = 800;
34
35
  export const BRIEF_RESERVE_TOKENS = 600;
@@ -41,7 +42,12 @@ const DOC_FIELDS = {
41
42
  title: { weight: 3, b: 0.3 },
42
43
  body: { weight: 1, b: 0.75 },
43
44
  guidance: { weight: 1.5, b: 0.75 },
45
+ // Decisions only: the sentences around each mention of the decision in an
46
+ // architecture doc (anchor text). Docs describe a decision in their own
47
+ // words, which are often the words a task uses.
48
+ anchor: { weight: 1, b: 0.5 },
44
49
  };
50
+ const REVERSE_CITATION_WEIGHT = 4;
45
51
  const MAX_DOC_CANDIDATES = 10;
46
52
  const MAX_DECISION_CANDIDATES = 14;
47
53
  // A doc whose mandatory companions would take more than a quarter of the
@@ -60,8 +66,8 @@ const CITATION_WEIGHT = 6;
60
66
  // Evidence lists and file inventories cite decisions without saying anything about them.
61
67
  const NON_PROPAGATING_SECTIONS = new Set(['Review metadata', 'Involved files']);
62
68
  const DISTINCTIVE_TERM_LIMIT = 3;
63
- const STRONG_TOPIC_TERMS = 3;
64
- const NARROW_PACKED_UNITS = 4;
69
+ const NEARBY_READS = 4;
70
+ const SEARCH_TERM_LIMIT = 6;
65
71
  const DISTINCTIVE_MAX_DOCUMENT_SHARE = 0.02;
66
72
 
67
73
  /**
@@ -114,6 +120,15 @@ export async function buildSessionBrief(options = {}) {
114
120
  }
115
121
  }
116
122
 
123
+ // D-371: with lineage unavailable no decision can be shown with its
124
+ // declared successors (D-359), so the brief abstains: the lane unit only,
125
+ // and the sources lineage could not be read from as required reads.
126
+ let lineageUnavailable = [];
127
+ if (!retrievalError && loaded.readModel.decision_lineage.status === 'unavailable') {
128
+ lineageUnavailable = loaded.readModel.decision_lineage.unavailable_sources;
129
+ retrievalError = `decision lineage is unavailable (D-371): it could not be extracted from ${describePaths(lineageUnavailable.map((source) => source.path))}, so no decision can be shown with its declared successors`;
130
+ }
131
+
117
132
  const overviewExcluded = exclusions?.matches(BRIEF_OVERVIEW_PATH) ?? false;
118
133
  const overviewExists = !overviewExcluded && (await pathExists(path.join(input.rootDir, BRIEF_OVERVIEW_PATH)));
119
134
  if (!overviewExists) {
@@ -144,7 +159,7 @@ export async function buildSessionBrief(options = {}) {
144
159
  };
145
160
 
146
161
  if (retrievalError) {
147
- return finalizeRetrievalFailure(context, retrievalError);
162
+ return finalizeRetrievalFailure(context, retrievalError, lineageUnavailable);
148
163
  }
149
164
 
150
165
  const selection = selectCandidates(context, loaded, task);
@@ -454,6 +469,7 @@ function selectCandidates(context, loaded, task) {
454
469
  entry: decisionEntries.get(decision.decision_id),
455
470
  }));
456
471
 
472
+ const anchors = buildDecisionAnchors(documents, new Set(decisions.map((decision) => decision.decisionId)));
457
473
  const index = createKeywordIndex(DOC_FIELDS, [
458
474
  ...docs.map((doc) => ({
459
475
  id: doc.id,
@@ -461,11 +477,12 @@ function selectCandidates(context, loaded, task) {
461
477
  title: `${doc.component} ${doc.feature} ${doc.domain} ${path.basename(doc.path, '.md')}`,
462
478
  body: doc.description ?? '',
463
479
  guidance: [doc.retrievalGuidance, doc.retrievalScope].filter(Boolean).join('\n'),
480
+ anchor: '',
464
481
  },
465
482
  })),
466
483
  ...decisions.map((decision) => ({
467
484
  id: decision.id,
468
- fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '' },
485
+ fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '', anchor: anchors.get(decision.decisionId) ?? '' },
469
486
  })),
470
487
  ]);
471
488
 
@@ -475,7 +492,18 @@ function selectCandidates(context, loaded, task) {
475
492
  const pathWords = [...input.files, ...input.claims, ...(lane?.claims ?? [])].map(pathTopicText).join(' ');
476
493
  const terms = uniqueStrings([...queryTerms(task), ...queryTerms(pathWords)]).filter((term) => !projectTerms.has(term));
477
494
  const keywordScores = new Map(index.score(terms).map((result) => [result.id, result]));
478
- const distinctive = pickDistinctiveTerms(terms, index);
495
+ const origins = queryTermOrigins(`${task} ${pathWords}`);
496
+ // Evaluative and kind-of-work words ("clearer", "flaky", "investigate",
497
+ // "failure") say how the user feels or what they want done, not what the
498
+ // topic is, so they are never distinctive topic words.
499
+ const topicTerms = terms.filter((term) => !isNonTopicalWord(origins.get(term) ?? term));
500
+ const distinctive = pickDistinctiveTerms(topicTerms, index);
501
+ // The task's key words to search beyond the brief, rarest in the index first.
502
+ const searchTerms = uniqueStrings(
503
+ [...topicTerms]
504
+ .sort((left, right) => index.documentFrequency(left) - index.documentFrequency(right) || left.localeCompare(right))
505
+ .map((term) => (origins.get(term) ?? term).toLowerCase()),
506
+ ).slice(0, SEARCH_TERM_LIMIT);
479
507
 
480
508
  // Mechanical evidence from the inputs: files and claims through the
481
509
  // file-owner index, lane-declared architecture docs, feature slugs.
@@ -535,6 +563,34 @@ function selectCandidates(context, loaded, task) {
535
563
  doc.score = evidence + doc.keywordScore;
536
564
  }
537
565
 
566
+ // Reverse propagation: a doc shares the keyword relevance of the decisions
567
+ // it cites (outside evidence sections), diluted by how many it cites. A doc
568
+ // whose Description misses the task's words is still reached through the
569
+ // decisions it describes.
570
+ const keywordDecisions = decisions
571
+ .map((decision) => ({ decision, score: keywordScores.get(decision.id)?.score ?? 0 }))
572
+ .filter((entry) => entry.score >= MIN_KEYWORD_SCORE);
573
+ const topDecisionKeyword = Math.max(0, ...keywordDecisions.map((entry) => entry.score));
574
+ const decisionKeyword = new Map(
575
+ keywordDecisions
576
+ .filter((entry) => entry.score >= topDecisionKeyword * RELATIVE_SCORE_FLOOR)
577
+ .map((entry) => [entry.decision.decisionId, entry.score / topDecisionKeyword]),
578
+ );
579
+ const citesByDoc = new Map();
580
+ for (const relation of relations) {
581
+ if (relation.relation !== 'cites' || !relation.target_exists) continue;
582
+ if (NON_PROPAGATING_SECTIONS.has(String(relation.source_section ?? '').split(' > ')[0])) continue;
583
+ if (!citesByDoc.has(relation.source_path)) citesByDoc.set(relation.source_path, new Set());
584
+ citesByDoc.get(relation.source_path).add(relation.target_decision_id);
585
+ }
586
+ for (const doc of docs) {
587
+ const cited = [...(citesByDoc.get(doc.path) ?? [])];
588
+ const matched = cited.filter((id) => decisionKeyword.has(id)).sort((left, right) => decisionKeyword.get(right) - decisionKeyword.get(left) || left - right);
589
+ doc.reverseBoost = matched.length > 0 ? (REVERSE_CITATION_WEIGHT * matched.reduce((sum, id) => sum + decisionKeyword.get(id), 0)) / Math.sqrt(cited.length) : 0;
590
+ doc.reverseCited = matched;
591
+ doc.score += doc.reverseBoost;
592
+ }
593
+
538
594
  const decisionById = new Map(decisions.map((decision) => [decision.decisionId, decision]));
539
595
  const namedDecisionIds = new Set(
540
596
  scanDecisionReferences(task)
@@ -543,22 +599,19 @@ function selectCandidates(context, loaded, task) {
543
599
  );
544
600
 
545
601
  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));
602
+ // No-match when only keywords selected memory and either nothing clears the
603
+ // keyword floor or the task's distinctive topic words are mostly unknown to
604
+ // the index (evaluative and kind-of-work words never count, so "clearer" or
605
+ // "investigate" alone cannot abstain). With an unknown topic the best keyword matches become
606
+ // nearby reads, never units: a misjudged topic still points at the memory
607
+ // that shares the task's other words, and nothing is presented as relevant.
608
+ const keywordsOnly = docs.every((doc) => doc.evidenceScore === 0) && namedDecisionIds.size === 0;
609
+ const topicAbsent = distinctiveTermsMostlyAbsent(distinctive, index);
610
+ const noMatch = keywordsOnly && (terms.length === 0 || topKeyword < MIN_KEYWORD_SCORE || topicAbsent);
557
611
 
558
612
  // Task words with no keyword match in the searched fields are disclosed, never guessed at.
559
613
  const absent = terms.filter((term) => index.documentFrequency(term) === 0);
560
614
  if (absent.length > 0) {
561
- const origins = queryTermOrigins(`${task} ${pathWords}`);
562
615
  const words = absent.map((term) => origins.get(term) ?? term);
563
616
  context.gaps.push({
564
617
  code: 'unmatched-terms',
@@ -571,24 +624,17 @@ function selectCandidates(context, loaded, task) {
571
624
  }
572
625
 
573
626
  if (noMatch) {
574
- return { noMatch: true, terms, distinctive, docs: [], namedDecisions: [], rankedDecisions: [], notes: [], relations, decisionById, decisionEntries, readModel };
627
+ const nearby = (topicAbsent && topKeyword >= MIN_KEYWORD_SCORE ? pickNearby(docs, decisions, keywordScores) : []).map((entry) => ({
628
+ ...entry,
629
+ matched: entry.matched.map((term) => (origins.get(term) ?? term).toLowerCase()),
630
+ }));
631
+ return { noMatch: true, topicAbsent, nearby, terms, distinctive, searchTerms, docs: [], namedDecisions: [], rankedDecisions: [], notes: [], relations, decisionById, decisionEntries, readModel };
575
632
  }
576
633
 
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
634
  // Docs with mechanical evidence (they cover an input file or claim, the lane
589
635
  // declares them, or they belong to a named feature) skip the relative floor.
590
636
  const selectedDocs = pickTop(
591
- docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore >= MIN_KEYWORD_SCORE),
637
+ docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore + doc.reverseBoost >= MIN_KEYWORD_SCORE),
592
638
  MAX_DOC_CANDIDATES,
593
639
  (doc) => doc.evidenceScore > 0,
594
640
  );
@@ -647,12 +693,15 @@ function selectCandidates(context, loaded, task) {
647
693
 
648
694
  for (const doc of selectedDocs) {
649
695
  if (doc.matchedTerms.length > 0) doc.reasons.push(`keywords: ${doc.matchedTerms.join(', ')}`);
696
+ 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)` : ''}`);
650
697
  }
651
698
 
652
699
  return {
653
700
  noMatch: false,
654
- narrow,
701
+ topicAbsent: false,
702
+ nearby: [],
655
703
  terms,
704
+ searchTerms,
656
705
  distinctive,
657
706
  docs: selectedDocs,
658
707
  namedDecisions,
@@ -683,6 +732,29 @@ function pickDistinctiveTerms(terms, index) {
683
732
  .slice(0, limit);
684
733
  }
685
734
 
735
+ /**
736
+ * Nearby reads for a no-match brief whose topic words are unknown: the best
737
+ * keyword matches among docs and decisions, interleaved by score relative to
738
+ * the top of their kind. Paths and matched words only — never units.
739
+ */
740
+ function pickNearby(docs, decisions, keywordScores) {
741
+ const docEntries = docs
742
+ .filter((doc) => doc.keywordScore + doc.reverseBoost >= MIN_KEYWORD_SCORE)
743
+ .map((doc) => ({ id: doc.id, score: doc.keywordScore + doc.reverseBoost, path: doc.path, heading: null, matched: doc.matchedTerms }));
744
+ const decisionEntries = decisions
745
+ .map((decision) => ({ decision, keyword: keywordScores.get(decision.id) }))
746
+ .filter((entry) => (entry.keyword?.score ?? 0) >= MIN_KEYWORD_SCORE)
747
+ .map(({ decision, keyword }) => ({ id: decision.id, score: keyword.score, path: decision.path, heading: formatId(decision.decisionId), matched: keyword.matched }));
748
+ const relative = (entries) => {
749
+ const top = Math.max(0, ...entries.map((entry) => entry.score));
750
+ return entries.map((entry) => ({ ...entry, relative: top > 0 ? entry.score / top : 0 }));
751
+ };
752
+ return [...relative(docEntries), ...relative(decisionEntries)]
753
+ .sort((left, right) => right.relative - left.relative || right.score - left.score || left.id.localeCompare(right.id))
754
+ .slice(0, NEARBY_READS)
755
+ .map(({ path: entryPath, heading, matched }) => ({ path: entryPath, heading, matched }));
756
+ }
757
+
686
758
  function pickTop(candidates, limit, bypassFloor = () => false) {
687
759
  const sorted = [...candidates].sort(compareByScore);
688
760
  const top = sorted[0]?.score ?? 0;
@@ -760,8 +832,7 @@ function buildUnits(context, selection) {
760
832
  })),
761
833
  ].sort((left, right) => right.relative - left.relative || left.unit.id.localeCompare(right.unit.id));
762
834
  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 } : {}) });
835
+ units.push({ ...entry.unit, rank: index + 1 });
765
836
  }
766
837
 
767
838
  for (const note of selection.notes) units.push(buildNoteUnit(note));
@@ -1037,16 +1108,11 @@ function assemble(context, selection, units, capacity, compaction = 0) {
1037
1108
  const covered = [];
1038
1109
  let used = 0;
1039
1110
 
1040
- for (const { companions = [], withheld = false, ...unit } of units) {
1111
+ for (const { companions = [], ...unit } of units) {
1041
1112
  if (unit.kind === 'lineage' && unit.members.every((member) => emitted.has(member.decision_id))) {
1042
1113
  covered.push(unit.id);
1043
1114
  continue;
1044
1115
  }
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
1116
 
1051
1117
  // A doc and its mandatory companions pack atomically; companions already
1052
1118
  // shown by an earlier unit cost nothing.
@@ -1130,7 +1196,8 @@ function baseResult(context, selection, { status, units, omitted, covered, follo
1130
1196
  selection: {
1131
1197
  query_terms: selection?.terms ?? [],
1132
1198
  distinctive_terms: selection?.distinctive ?? [],
1133
- narrowed: Boolean(selection?.narrow),
1199
+ topic_absent: Boolean(selection?.topicAbsent),
1200
+ search_terms: selection?.searchTerms ?? [],
1134
1201
  },
1135
1202
  units,
1136
1203
  omitted,
@@ -1148,8 +1215,11 @@ function decideStatus(selection, omitted) {
1148
1215
  return { status: 'incomplete', reason: `retrieval failed — ${selection.retrievalError}` };
1149
1216
  }
1150
1217
  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' }
1218
+ if (omitted.some((unit) => unit.kind === 'lane')) {
1219
+ return { status: 'incomplete', reason: 'no memory matched the task, and the lane unit did not fit the budget' };
1220
+ }
1221
+ return selection.topicAbsent
1222
+ ? { status: 'no-match', reason: "no architecture doc or decision matched the task's distinctive words" }
1153
1223
  : { status: 'no-match', reason: 'no architecture doc or decision matched the task' };
1154
1224
  }
1155
1225
 
@@ -1161,11 +1231,7 @@ function decideStatus(selection, omitted) {
1161
1231
  };
1162
1232
  }
1163
1233
  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('; ') };
1234
+ return { status: 'partial', reason: `${plural(omitted.length, 'ranked or optional unit')} did not fit the budget` };
1169
1235
  }
1170
1236
  return { status: 'complete', reason: 'every selected unit fits the budget' };
1171
1237
  }
@@ -1185,8 +1251,17 @@ function buildFollowUps(selection, context, included, omitted) {
1185
1251
  }
1186
1252
  entries.push(...requiredDecisions.sort((left, right) => right.decision_id - left.decision_id));
1187
1253
 
1188
- // A failed retrieval can still point at the fixed entry points.
1254
+ // A failed retrieval can still point at the fixed entry points, after the
1255
+ // sources lineage could not be read from (D-371).
1189
1256
  if (selection.retrievalError) {
1257
+ for (const source of selection.lineageUnavailable ?? []) {
1258
+ entries.push({
1259
+ priority: 'required',
1260
+ path: source.path,
1261
+ heading: null,
1262
+ reason: 'decision lineage could not be extracted from this file (D-371); read it directly',
1263
+ });
1264
+ }
1190
1265
  if (context.overview.exists) {
1191
1266
  entries.push({ priority: 'required', path: BRIEF_OVERVIEW_PATH, heading: null, reason: 'whole-project orientation' });
1192
1267
  }
@@ -1199,14 +1274,23 @@ function buildFollowUps(selection, context, included, omitted) {
1199
1274
  }
1200
1275
 
1201
1276
  for (const unit of omitted.filter((candidate) => candidate.tier === 'ranked')) {
1202
- const prefix = unit.omitted_reason === 'narrow-match' ? 'narrow match, read only: ' : '';
1203
1277
  if (unit.kind === 'doc') {
1204
- entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(`${prefix}${unit.reasons[0] ?? 'ranked doc'}`) });
1278
+ entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(unit.reasons[0] ?? 'ranked doc') });
1205
1279
  } else {
1206
- entries.push(...lineageReads(unit, 'ranked', prefix));
1280
+ entries.push(...lineageReads(unit, 'ranked'));
1207
1281
  }
1208
1282
  }
1209
1283
 
1284
+ // No-match with an unknown topic: the closest keyword matches, as reads.
1285
+ for (const entry of selection.nearby ?? []) {
1286
+ entries.push({
1287
+ priority: 'nearby',
1288
+ path: entry.path,
1289
+ heading: entry.heading,
1290
+ reason: shortReason(`shares other task words (${entry.matched.join(', ')}); not presented as relevant`),
1291
+ });
1292
+ }
1293
+
1210
1294
  // Uncertified lineage wording aimed at an emitted decision: a read, never
1211
1295
  // a status or a relation. Grouped per source file and target decision.
1212
1296
  const unknownGroups = new Map();
@@ -1248,7 +1332,7 @@ function buildFollowUps(selection, context, included, omitted) {
1248
1332
  });
1249
1333
  }
1250
1334
 
1251
- function lineageReads(unit, priority, prefix = '') {
1335
+ function lineageReads(unit, priority) {
1252
1336
  return [...unit.members]
1253
1337
  .sort((left, right) => right.decision_id - left.decision_id)
1254
1338
  .map((member) => ({
@@ -1258,13 +1342,19 @@ function lineageReads(unit, priority, prefix = '') {
1258
1342
  heading: formatId(member.decision_id),
1259
1343
  reason:
1260
1344
  member.role === 'root'
1261
- ? shortReason(`${prefix}${unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`}`)
1345
+ ? shortReason(unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`)
1262
1346
  : `declared successor of ${formatId(unit.root_decision_id)} (${member.successor_relations.join(', ')})`,
1263
1347
  }));
1264
1348
  }
1265
1349
 
1266
- function finalizeRetrievalFailure(context, reason) {
1267
- return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [] });
1350
+ function finalizeRetrievalFailure(context, reason, lineageUnavailable = []) {
1351
+ return packBrief(context, { retrievalError: reason, lineageUnavailable, terms: [], distinctive: [], relations: [], nearby: [] });
1352
+ }
1353
+
1354
+ /** Up to three paths, then "+N more". */
1355
+ function describePaths(paths) {
1356
+ const shown = paths.slice(0, 3).map((value) => `\`${value}\``);
1357
+ return paths.length > 3 ? `${shown.join(', ')} and ${paths.length - 3} more` : shown.join(', ');
1268
1358
  }
1269
1359
 
1270
1360
  /**
@@ -1290,6 +1380,75 @@ function assertLineageSafety(result, relations) {
1290
1380
  // Helpers
1291
1381
  // ---------------------------------------------------------------------------
1292
1382
 
1383
+ /**
1384
+ * Anchor text per decision: each sentence of an architecture doc (the
1385
+ * overview included) that mentions the decision, outside fenced code and the
1386
+ * evidence sections (Review metadata, Involved files). Identical sentences
1387
+ * count once per decision.
1388
+ */
1389
+ function buildDecisionAnchors(documents, decisionIds) {
1390
+ const anchors = new Map();
1391
+ for (const document of documents) {
1392
+ if (document.kind !== 'architecture' || typeof document.content !== 'string') continue;
1393
+ for (const block of markdownBlocks(document.content)) {
1394
+ if (NON_PROPAGATING_SECTIONS.has(block.section)) continue;
1395
+ const { refs } = scanDecisionReferences(block.text);
1396
+ for (const ref of refs) {
1397
+ if (!decisionIds.has(ref.id)) continue;
1398
+ const sentence = sentenceAround(block.text, ref.start, ref.end);
1399
+ if (!anchors.has(ref.id)) anchors.set(ref.id, new Set());
1400
+ anchors.get(ref.id).add(sentence);
1401
+ }
1402
+ }
1403
+ }
1404
+ return new Map([...anchors].map(([id, sentences]) => [id, [...sentences].join('\n')]));
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
+
1293
1452
  function buildDecisionEntryMap(documents) {
1294
1453
  const map = new Map();
1295
1454
  for (const document of documents) {
@@ -1424,41 +1583,6 @@ function docSectionFields(content, component) {
1424
1583
  };
1425
1584
  }
1426
1585
 
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
1586
  /** Body of `### sub` inside `## section`; null when absent. */
1463
1587
  function extractSubsection(content, section, sub) {
1464
1588
  return splitSections(extractSection(content, section), 3).get(sub) ?? null;
@@ -309,30 +309,37 @@ export function extractDecisionFileRelations({ path, content }) {
309
309
  /**
310
310
  * Builds the corpus-wide lineage view over scanned canonical documents
311
311
  * (`{ path, kind, content }`): every relation plus `target_exists`, the
312
- * diagnostics, and per-relation counts.
312
+ * diagnostics, and per-relation counts, with `status: 'available'`.
313
+ *
314
+ * D-371: each lineage source is extracted inside its own guard, which holds
315
+ * only that source's pure extraction (entry parse and relation extraction;
316
+ * no I/O). When any source throws, lineage is unavailable as a whole — a
317
+ * missing decision file can hide a declared successor of any decision — so
318
+ * the model carries `status: 'unavailable'`, the failed sources, and their
319
+ * `lineage-extraction-failed` diagnostics, with null relations and counts.
320
+ * Unavailable lineage is never returned as empty lineage.
313
321
  */
314
322
  export function buildDecisionLineageModel(documents) {
315
323
  const knownIds = new Set();
316
- for (const document of documents) {
317
- if (document.kind !== 'decision') continue;
318
- for (const entry of parseDecisionEntries(document.content)) knownIds.add(entry.decision_id);
319
- }
320
-
321
324
  const relations = [];
322
325
  const diagnostics = [];
326
+ const failures = [];
323
327
  for (const document of documents) {
324
- const extractor =
325
- document.kind === 'architecture'
326
- ? extractArchitectureDocCitations
327
- : document.kind === 'session'
328
- ? extractSessionNoteRelations
329
- : document.kind === 'decision'
330
- ? extractDecisionFileRelations
331
- : null;
332
- if (!extractor) continue;
333
- const result = extractor({ path: document.path, content: document.content });
334
- relations.push(...result.relations);
335
- diagnostics.push(...result.diagnostics);
328
+ if (!LINEAGE_SOURCE_KINDS.has(document.kind)) continue;
329
+ let extracted;
330
+ try {
331
+ extracted = extractLineageSource(document);
332
+ } catch (error) {
333
+ failures.push({ path: document.path, kind: document.kind, error: describeExtractionError(error) });
334
+ continue;
335
+ }
336
+ for (const id of extracted.decisionIds) knownIds.add(id);
337
+ relations.push(...extracted.relations);
338
+ diagnostics.push(...extracted.diagnostics);
339
+ }
340
+
341
+ if (failures.length > 0) {
342
+ return buildUnavailableLineageModel(failures);
336
343
  }
337
344
 
338
345
  const unresolved = new Set();
@@ -356,13 +363,101 @@ export function buildDecisionLineageModel(documents) {
356
363
 
357
364
  return {
358
365
  contract_version: DECISION_LINEAGE_CONTRACT_VERSION,
366
+ status: 'available',
359
367
  decision_count: knownIds.size,
360
368
  relation_counts: relationCounts,
361
369
  relations: sortRelations(withExistence),
370
+ unavailable_sources: [],
362
371
  diagnostics: sortDiagnostics(diagnostics),
363
372
  };
364
373
  }
365
374
 
375
+ function buildUnavailableLineageModel(failures) {
376
+ const sources = [...failures].sort((left, right) => left.path.localeCompare(right.path));
377
+ return {
378
+ contract_version: DECISION_LINEAGE_CONTRACT_VERSION,
379
+ status: 'unavailable',
380
+ decision_count: null,
381
+ relation_counts: null,
382
+ relations: null,
383
+ unavailable_sources: sources.map(({ path, kind, error }) => ({ path, kind, error })),
384
+ diagnostics: sources.map(({ path, error }) => ({
385
+ code: 'lineage-extraction-failed',
386
+ path,
387
+ line: null,
388
+ message: `Decision lineage could not be extracted from this file (${error}), so lineage is unavailable for the whole root until the file changes or the extractor is fixed.`,
389
+ })),
390
+ };
391
+ }
392
+
393
+ // ---------------------------------------------------------------------------
394
+ // Per-source extraction (D-371)
395
+ // ---------------------------------------------------------------------------
396
+
397
+ const LINEAGE_SOURCE_KINDS = new Set(['architecture', 'session', 'decision']);
398
+ const EXTRACTION_ERROR_LIMIT = 200;
399
+
400
+ /** The relations of one canonical document, by kind. */
401
+ function extractDocumentRelations({ path, kind, content }) {
402
+ if (kind === 'architecture') return extractArchitectureDocCitations({ path, content });
403
+ if (kind === 'session') return extractSessionNoteRelations({ path, content });
404
+ return extractDecisionFileRelations({ path, content });
405
+ }
406
+
407
+ const DEFAULT_LINEAGE_PHASES = Object.freeze({
408
+ parseEntries: parseDecisionEntries,
409
+ extractRelations: extractDocumentRelations,
410
+ });
411
+ let lineagePhases = DEFAULT_LINEAGE_PHASES;
412
+
413
+ /**
414
+ * Internal fault-injection seam for degraded-lineage tests (D-371): replaces
415
+ * the entry-parse and/or relation-extraction phase of per-source extraction
416
+ * and returns a function that restores the previous phases. It is not
417
+ * exported from `index.js`, so the package's `exports` map keeps it out of
418
+ * reach of consumers. Tests restore it in `finally`.
419
+ */
420
+ export function setLineagePhasesForTesting(overrides = {}) {
421
+ const previous = lineagePhases;
422
+ lineagePhases = Object.freeze({ ...DEFAULT_LINEAGE_PHASES, ...overrides });
423
+ return () => {
424
+ lineagePhases = previous;
425
+ };
426
+ }
427
+
428
+ // One source's extraction: pure, synchronous, and the only code inside the
429
+ // D-371 guard. A decision file contributes its entry IDs (known targets) and
430
+ // its relations; a doc or note contributes relations.
431
+ function extractLineageSource(document) {
432
+ const decisionIds =
433
+ document.kind === 'decision' ? lineagePhases.parseEntries(document.content).map((entry) => entry.decision_id) : [];
434
+ const result = lineagePhases.extractRelations({ path: document.path, kind: document.kind, content: document.content });
435
+ return { decisionIds, relations: result.relations, diagnostics: result.diagnostics };
436
+ }
437
+
438
+ /**
439
+ * The error class (or a thrown non-Error value's type) and the first line of
440
+ * its message, at most 200 code points; never a stack trace. The source path
441
+ * is recorded separately, so cutting this text never hides which source failed.
442
+ */
443
+ function describeExtractionError(error) {
444
+ let text;
445
+ try {
446
+ if (error instanceof Error) {
447
+ const name = typeof error.name === 'string' && error.name ? error.name : 'Error';
448
+ const message = typeof error.message === 'string' ? error.message.split(/\r?\n/, 1)[0].trim() : '';
449
+ text = message ? `${name}: ${message}` : name;
450
+ } else {
451
+ const type = error === null ? 'null' : typeof error;
452
+ const value = typeof error === 'string' ? error.split(/\r?\n/, 1)[0].trim() : '';
453
+ text = value ? `thrown ${type}: ${value}` : `thrown ${type}`;
454
+ }
455
+ } catch {
456
+ text = 'unreadable thrown value';
457
+ }
458
+ return boundText(collapse(text), EXTRACTION_ERROR_LIMIT);
459
+ }
460
+
366
461
  /**
367
462
  * Declared successors of a decision, transitively: later decisions whose own
368
463
  * text (or structured fields) declares `supersedes` or `amends` for it, and
@@ -1476,7 +1571,7 @@ export function findFencedRanges(text) {
1476
1571
  offset += line.length + 1;
1477
1572
  const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
1478
1573
  if (fence) {
1479
- if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
1574
+ if (closesFence(fence, marker, line)) {
1480
1575
  ranges.push([fence.start, Math.min(offset - 1, text.length)]);
1481
1576
  fence = null;
1482
1577
  }
@@ -1490,6 +1585,13 @@ export function findFencedRanges(text) {
1490
1585
  return ranges;
1491
1586
  }
1492
1587
 
1588
+ // A closing fence holds only the opener's character, at least as many times,
1589
+ // so ```~~~ never closes. Shared by masking and the section index.
1590
+ function closesFence(fence, marker, line) {
1591
+ return Boolean(marker) && marker[1][0] === fence.char && marker[1].length >= fence.length &&
1592
+ (fence.char === '`' ? /^ {0,3}`+\s*$/ : /^ {0,3}~+\s*$/).test(line);
1593
+ }
1594
+
1493
1595
  function insideRanges(offset, ranges) {
1494
1596
  return ranges.some(([start, end]) => offset >= start && offset < end);
1495
1597
  }
@@ -1579,7 +1681,7 @@ function buildSectionIndex(content, bodyStart) {
1579
1681
 
1580
1682
  const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
1581
1683
  if (fence) {
1582
- if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
1684
+ if (closesFence(fence, fenceMatch, line)) {
1583
1685
  fence = null;
1584
1686
  }
1585
1687
  continue;
@@ -15,6 +15,14 @@ const QUICK_GIT_TIMEOUT_MS = 5000;
15
15
  const WORKTREE_GIT_TIMEOUT_MS = 120000;
16
16
  const GIT_OUTPUT_MAX_BUFFER = 1024 * 1024;
17
17
 
18
+ // H3: close-time removal has no kill timeout. Git is the only deleter, and it
19
+ // makes every refusal (locked, submodules, modified or untracked files, not a
20
+ // worktree of this repo) before it deletes anything; killing it while it
21
+ // deletes a large tree (a full node_modules) left a half-deleted checkout that
22
+ // the next close saw as modified. A filesystem that stalls makes close wait
23
+ // until git finishes or the user interrupts it.
24
+ export const WORKTREE_REMOVE_AT_CLOSE_EXEC_OPTIONS = Object.freeze({ maxBuffer: GIT_OUTPUT_MAX_BUFFER });
25
+
18
26
  /**
19
27
  * Repo working copies live as siblings of the memory root: the workspace is
20
28
  * `dirname(rootDir)` and a declared `repo.path` (else the repo id) resolves
@@ -396,6 +404,9 @@ export async function rollbackGitBinding(progress, branch) {
396
404
  * container AND covered by a token-matched container marker are removable
397
405
  * (D-279/D-280 guards — arbitrary path removal is refused)
398
406
  * - removal is never forced; a status failure counts as unknown, not clean
407
+ * - git is the only deleter and runs without a kill timeout (H3), so its own
408
+ * refusals (lock, submodules, registration) come before any deletion and a
409
+ * large ignored tree is never left half-deleted by a timer
399
410
  * - removal is skipped with guidance when the process cwd sits inside the
400
411
  * target
401
412
  * - branches are never deleted at close
@@ -487,10 +498,16 @@ export async function removeLaneWorktreesAtClose(options) {
487
498
  await execFileAsync(
488
499
  'git',
489
500
  ['-C', sourceDir, 'worktree', 'remove', entry.worktreePath],
490
- { timeout: WORKTREE_GIT_TIMEOUT_MS, maxBuffer: GIT_OUTPUT_MAX_BUFFER },
501
+ WORKTREE_REMOVE_AT_CLOSE_EXEC_OPTIONS,
491
502
  );
492
503
  removed.push({ repoId: entry.repoId, worktreePath: entry.worktreePath, sourceDir });
493
504
  } catch (error) {
505
+ if (error && typeof error === 'object' && error.signal) {
506
+ // No timer kills this call (H3), so a signal is an outside
507
+ // interruption, possibly mid-delete.
508
+ keep(entry, 'git-interrupted', `git was interrupted (${error.signal}) while removing it, so the checkout may be partly deleted. Inspect it before removing it.`);
509
+ continue;
510
+ }
494
511
  keep(entry, 'git-refused', `git refused the removal: ${describeGitError(error)}. Resolve that first.`);
495
512
  }
496
513
  }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The one markdown section reader shared by the read model's component fields
3
+ * (D-372) and the session brief (D-359): fence-aware, first section of a title
4
+ * wins. The lineage extractor keeps its own section index
5
+ * (`decision-lineage.js`), whose fence opener differs (`decision-lineage.md`).
6
+ */
7
+
8
+ /**
9
+ * One line of fenced-code tracking, with the rules of `findFencedRanges` in
10
+ * `decision-lineage.js`. A fence opens on three or more backticks or tildes
11
+ * indented at most three spaces; a backtick fence's info string has no
12
+ * backtick. It closes only on a line holding the same character at least as
13
+ * many times and nothing else (no other character, so ```~~~ never closes), so
14
+ * a ``` line inside a ```` example stays code.
15
+ */
16
+ export function stepFence(fence, line) {
17
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
18
+ if (fence) {
19
+ const closes = marker && marker[1][0] === fence.char && marker[1].length >= fence.length && closingFence(fence.char).test(line);
20
+ return { fence: closes ? null : fence, delimiter: Boolean(closes) };
21
+ }
22
+ if (marker && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'))) {
23
+ return { fence: { char: marker[1][0], length: marker[1].length }, delimiter: true };
24
+ }
25
+ return { fence: null, delimiter: false };
26
+ }
27
+
28
+ /** A closing fence line: only the opener's character (three or more), then whitespace — never mixed delimiters. */
29
+ function closingFence(char) {
30
+ return char === '`' ? /^ {0,3}`{3,}\s*$/ : /^ {0,3}~{3,}\s*$/;
31
+ }
32
+
33
+ /**
34
+ * Sections of a markdown body at `level` (default 2), fence-aware: headings
35
+ * inside fenced code are content, the first section with a given title wins
36
+ * (docs such as `file-schema.md` show example `## Description` blocks in
37
+ * fences), and a heading at that level or above ends the current section.
38
+ */
39
+ export function splitSections(content, level = 2) {
40
+ const sections = new Map();
41
+ if (!content) return sections;
42
+ const lines = content.split(/\r?\n/);
43
+ const heading = new RegExp(`^#{1,${level}}\\s`);
44
+ const exact = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`);
45
+ let fence = null;
46
+ let current = null;
47
+ for (const line of lines) {
48
+ const step = stepFence(fence, line);
49
+ const fenced = fence !== null || step.delimiter;
50
+ fence = step.fence;
51
+ if (!fenced && heading.test(line)) {
52
+ const match = line.match(exact);
53
+ current = match && !sections.has(match[1]) ? { title: match[1], lines: [] } : null;
54
+ if (current) sections.set(current.title, current);
55
+ continue;
56
+ }
57
+ if (current) current.lines.push(line);
58
+ }
59
+ return new Map([...sections].map(([title, section]) => [title, section.lines.join('\n').trim() || null]));
60
+ }
61
+
62
+ /** Body of a level-2 section (`## Title`), trimmed; null when absent. */
63
+ export function extractSection(content, title) {
64
+ return splitSections(content, 2).get(title) ?? null;
65
+ }
package/src/read-model.js CHANGED
@@ -3,9 +3,9 @@ import path from 'node:path';
3
3
  import { buildDecisionLineageModel, collectDeclaredSuccessors } from './decision-lineage.js';
4
4
  import { parseFrontmatter } from './frontmatter.js';
5
5
  import { generateStateManifest } from './manifest.js';
6
+ import { splitSections } from './markdown-sections.js';
6
7
  import { scanProjectMemory } from './project-memory.js';
7
8
 
8
- const SECTION_PATTERN = /^##\s+(.+)$/gm;
9
9
  const DECISION_HEADING_PATTERN = /^###\s+D-(\d{3,})\s+—\s+(.+)$/gm;
10
10
 
11
11
  export async function loadProjectReadModel(rootDir) {
@@ -83,6 +83,12 @@ export function getProjectContext(readModel, options = {}) {
83
83
  domains: readModel.domains,
84
84
  recent_decisions: readModel.decisions.slice(0, decisionLimit),
85
85
  recent_sessions: readModel.sessions.slice(0, sessionLimit),
86
+ // D-371: whether decision lineage could be read, in every response, so a
87
+ // reader (Local MCP's project context) can tell unavailable from empty.
88
+ decision_lineage: {
89
+ status: readModel.decision_lineage.status,
90
+ unavailable_sources: readModel.decision_lineage.unavailable_sources,
91
+ },
86
92
  };
87
93
  }
88
94
 
@@ -113,7 +119,8 @@ export function getDecisionLog(readModel, options = {}) {
113
119
  * what later decisions declare about it, and `declared_successors` follows
114
120
  * declared `supersedes`/`amends` transitively. Mentions stay mentions: no
115
121
  * relation here certifies that a decision governs anything or is currently
116
- * valid.
122
+ * valid. When lineage is unavailable (D-371), `lineage_status` says so and
123
+ * every relation-derived value is null, never an empty list.
117
124
  */
118
125
  export function getDecisionLineage(readModel, decisionId, options = {}) {
119
126
  const id = typeof decisionId === 'number' ? decisionId : Number(String(decisionId ?? '').replace(/^D-/i, ''));
@@ -122,8 +129,27 @@ export function getDecisionLineage(readModel, decisionId, options = {}) {
122
129
  }
123
130
 
124
131
  const citationLimit = Math.max(1, Math.min(options.citationLimit ?? 50, 500));
125
- const relations = readModel.decision_lineage.relations;
132
+ const lineage = readModel.decision_lineage;
126
133
  const decision = readModel.decisions.find((entry) => entry.decision_id === id) ?? null;
134
+ if (lineage.status === 'unavailable') {
135
+ return {
136
+ freshness: readModel.freshness,
137
+ decision_id: id,
138
+ exists: Boolean(decision),
139
+ title: decision?.title ?? null,
140
+ path: decision?.path ?? null,
141
+ lineage_status: 'unavailable',
142
+ unavailable_sources: lineage.unavailable_sources,
143
+ outgoing: null,
144
+ incoming: null,
145
+ declared_successors: null,
146
+ made_in: null,
147
+ cited_by: null,
148
+ cited_by_total: null,
149
+ };
150
+ }
151
+
152
+ const relations = lineage.relations;
127
153
  const lineageRelations = new Set(['supersedes', 'amends', 'preserves', 'unknown']);
128
154
  const outgoing = relations.filter(
129
155
  (relation) => relation.source_decision_id === id && lineageRelations.has(relation.relation),
@@ -142,6 +168,8 @@ export function getDecisionLineage(readModel, decisionId, options = {}) {
142
168
  exists: Boolean(decision),
143
169
  title: decision?.title ?? null,
144
170
  path: decision?.path ?? null,
171
+ lineage_status: 'available',
172
+ unavailable_sources: [],
145
173
  outgoing,
146
174
  incoming,
147
175
  declared_successors: collectDeclaredSuccessors(relations, id),
@@ -237,7 +265,9 @@ function buildFeatureMap(scanResult, repoAliases, projectRepos) {
237
265
 
238
266
  function buildComponentRecord(document, repoAliases, projectRepos) {
239
267
  const frontmatter = parseFrontmatter(document.content, { sourceName: document.path });
240
- const sections = splitMarkdownSections(frontmatter.body);
268
+ // D-372: the brief's fence-aware reader, so a fenced example heading or a
269
+ // repeated title never replaces a real section.
270
+ const sections = splitSections(frontmatter.body, 2);
241
271
  const repoIds = uniqueSorted(document.extracted.repo_ids ?? []);
242
272
  const involvedFiles = parseInvolvedFiles({
243
273
  rawSection: sections.get('Involved files') ?? '',
@@ -418,19 +448,6 @@ function extractDecisionEntries(content, sourcePath) {
418
448
  });
419
449
  }
420
450
 
421
- function splitMarkdownSections(body) {
422
- const sections = new Map();
423
- const matches = [...body.matchAll(SECTION_PATTERN)];
424
-
425
- for (const [index, match] of matches.entries()) {
426
- const start = (match.index ?? 0) + match[0].length;
427
- const end = matches[index + 1]?.index ?? body.length;
428
- sections.set(match[1].trim(), body.slice(start, end).trim());
429
- }
430
-
431
- return sections;
432
- }
433
-
434
451
  function parseInvolvedFiles({ rawSection, defaultRepoIds, repoAliases, projectRepos }) {
435
452
  if (!rawSection) {
436
453
  return [];