@vibecompass/vibecompass 0.16.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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.17.0 - 2026-10-03
4
+
5
+ Session brief retrieval and framing fixes (recall plan A7). The lane brief stays
6
+ opt-in (D-368). The re-evaluation meant to decide default-on stopped at a protocol
7
+ deviation before any run was graded, so it gives no evidence either way. These
8
+ fixes target misses from the earlier evaluation and were checked on its tuning
9
+ tasks with briefs alone, not in an agent evaluation.
10
+
11
+ **Breaking for `vibecompass brief --json` consumers:** contract version 2 removes
12
+ `selection.narrowed` (see below). Other commands and the read model are unchanged.
13
+
14
+ - Selection: a decision is also matched on the sentences in architecture docs
15
+ that cite it (the overview included; Review metadata and Involved files
16
+ excluded), so a doc's own words reach a decision whose entry uses different
17
+ ones. A doc shares the keyword relevance of the decisions it cites, so a doc
18
+ whose Description misses the task's words is still reached through the
19
+ decisions it describes. Common abbreviations meet their long forms
20
+ (organization/org, repository/repo, configuration/config, database/db,
21
+ environment/env, documentation/doc, authentication/auth).
22
+ - No-match: evaluative words ("clearer", "flaky", "noticeably") and
23
+ kind-of-work words ("investigate", "failure", "bug") never count as a task's
24
+ distinctive topic words. When the distinctive topic words are mostly unknown
25
+ to memory and only keywords matched, the status is `no-match` with up to four
26
+ nearby reads (paths that share other task words, never presented as
27
+ relevant). This replaces the narrow `partial` match.
28
+ - Framing: a brief with units says it is a starting point of cut excerpts that
29
+ can miss the doc a task depends on, and names up to six of the task's words
30
+ to search for beyond it (`selection.search_terms` in `--json`).
31
+ - Lane `brief.md` format 2: `exclude` and `repo_aliases` show at most four
32
+ entries, bound by `exclude_digest` and `repo_aliases_digest`, so staleness
33
+ checks stay complete as settings and repos grow; a header comment says the
34
+ token budget covers the body only. Format 1 briefs read as stale and are
35
+ regenerated on resume.
36
+ - `--json` contract version 2: `selection.narrowed` is replaced by
37
+ `selection.topic_absent` and `selection.search_terms`; follow-ups gain the
38
+ `nearby` priority.
39
+
3
40
  ## 0.16.0 - 2026-09-30
4
41
 
5
42
  The session brief ships as an opt-in experiment (D-368). In a baseline-versus-brief
package/README.md CHANGED
@@ -266,7 +266,9 @@ follow-up reads. It runs locally and deterministically: no network, no model,
266
266
  no account. A decision always appears together with any later decision whose
267
267
  own text explicitly declares that it replaces or amends it, and the brief never
268
268
  claims that a decision governs your task. It cannot spot a replacement that is
269
- only implied, so read the follow-up list too.
269
+ only implied, so read the follow-up list too. When the task's distinctive words
270
+ match nothing in memory, the status is `no-match`, with at most four nearby
271
+ reads that share the task's other words.
270
272
 
271
273
  The brief in a session lane is an experiment you turn on (D-368):
272
274
 
@@ -286,8 +288,10 @@ The brief is opt-in because of the project's own evaluation, which did not
286
288
  fully follow its protocol. Agents with a brief found about as much of the
287
289
  relevant memory as agents without one, and made none of the specific false
288
290
  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.
291
+ others found, in one case after the brief left out the key document. A second
292
+ evaluation, after retrieval fixes, stopped at a protocol deviation before any
293
+ grading, so turning the brief on by default still waits for a conforming one.
294
+ Treat a brief as a starting point, not the whole answer.
291
295
 
292
296
  ## Hosted Sync
293
297
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecompass/vibecompass",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
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,6 +1,6 @@
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';
@@ -28,7 +28,7 @@ import { parseSimpleYaml } from './simple-yaml.js';
28
28
  * doc, file, or task, or that one is currently valid.
29
29
  */
30
30
 
31
- export const BRIEF_CONTRACT_VERSION = 1;
31
+ export const BRIEF_CONTRACT_VERSION = 2;
32
32
  export const BRIEF_DEFAULT_BUDGET = 6000;
33
33
  export const BRIEF_MIN_BUDGET = 800;
34
34
  export const BRIEF_RESERVE_TOKENS = 600;
@@ -41,7 +41,12 @@ const DOC_FIELDS = {
41
41
  title: { weight: 3, b: 0.3 },
42
42
  body: { weight: 1, b: 0.75 },
43
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 },
44
48
  };
49
+ const REVERSE_CITATION_WEIGHT = 4;
45
50
  const MAX_DOC_CANDIDATES = 10;
46
51
  const MAX_DECISION_CANDIDATES = 14;
47
52
  // A doc whose mandatory companions would take more than a quarter of the
@@ -60,8 +65,8 @@ const CITATION_WEIGHT = 6;
60
65
  // Evidence lists and file inventories cite decisions without saying anything about them.
61
66
  const NON_PROPAGATING_SECTIONS = new Set(['Review metadata', 'Involved files']);
62
67
  const DISTINCTIVE_TERM_LIMIT = 3;
63
- const STRONG_TOPIC_TERMS = 3;
64
- const NARROW_PACKED_UNITS = 4;
68
+ const NEARBY_READS = 4;
69
+ const SEARCH_TERM_LIMIT = 6;
65
70
  const DISTINCTIVE_MAX_DOCUMENT_SHARE = 0.02;
66
71
 
67
72
  /**
@@ -454,6 +459,7 @@ function selectCandidates(context, loaded, task) {
454
459
  entry: decisionEntries.get(decision.decision_id),
455
460
  }));
456
461
 
462
+ const anchors = buildDecisionAnchors(documents, new Set(decisions.map((decision) => decision.decisionId)));
457
463
  const index = createKeywordIndex(DOC_FIELDS, [
458
464
  ...docs.map((doc) => ({
459
465
  id: doc.id,
@@ -461,11 +467,12 @@ function selectCandidates(context, loaded, task) {
461
467
  title: `${doc.component} ${doc.feature} ${doc.domain} ${path.basename(doc.path, '.md')}`,
462
468
  body: doc.description ?? '',
463
469
  guidance: [doc.retrievalGuidance, doc.retrievalScope].filter(Boolean).join('\n'),
470
+ anchor: '',
464
471
  },
465
472
  })),
466
473
  ...decisions.map((decision) => ({
467
474
  id: decision.id,
468
- fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '' },
475
+ fields: { title: decision.title, body: decision.entry.fields.Decision ?? '', guidance: '', anchor: anchors.get(decision.decisionId) ?? '' },
469
476
  })),
470
477
  ]);
471
478
 
@@ -475,7 +482,18 @@ function selectCandidates(context, loaded, task) {
475
482
  const pathWords = [...input.files, ...input.claims, ...(lane?.claims ?? [])].map(pathTopicText).join(' ');
476
483
  const terms = uniqueStrings([...queryTerms(task), ...queryTerms(pathWords)]).filter((term) => !projectTerms.has(term));
477
484
  const keywordScores = new Map(index.score(terms).map((result) => [result.id, result]));
478
- const distinctive = pickDistinctiveTerms(terms, index);
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);
479
497
 
480
498
  // Mechanical evidence from the inputs: files and claims through the
481
499
  // file-owner index, lane-declared architecture docs, feature slugs.
@@ -535,6 +553,34 @@ function selectCandidates(context, loaded, task) {
535
553
  doc.score = evidence + doc.keywordScore;
536
554
  }
537
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
+
538
584
  const decisionById = new Map(decisions.map((decision) => [decision.decisionId, decision]));
539
585
  const namedDecisionIds = new Set(
540
586
  scanDecisionReferences(task)
@@ -543,22 +589,19 @@ function selectCandidates(context, loaded, task) {
543
589
  );
544
590
 
545
591
  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));
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);
557
601
 
558
602
  // Task words with no keyword match in the searched fields are disclosed, never guessed at.
559
603
  const absent = terms.filter((term) => index.documentFrequency(term) === 0);
560
604
  if (absent.length > 0) {
561
- const origins = queryTermOrigins(`${task} ${pathWords}`);
562
605
  const words = absent.map((term) => origins.get(term) ?? term);
563
606
  context.gaps.push({
564
607
  code: 'unmatched-terms',
@@ -571,24 +614,17 @@ function selectCandidates(context, loaded, task) {
571
614
  }
572
615
 
573
616
  if (noMatch) {
574
- return { noMatch: true, terms, distinctive, docs: [], namedDecisions: [], rankedDecisions: [], notes: [], relations, decisionById, decisionEntries, readModel };
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 };
575
622
  }
576
623
 
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
624
  // Docs with mechanical evidence (they cover an input file or claim, the lane
589
625
  // declares them, or they belong to a named feature) skip the relative floor.
590
626
  const selectedDocs = pickTop(
591
- docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore >= MIN_KEYWORD_SCORE),
627
+ docs.filter((doc) => doc.evidenceScore > 0 || doc.keywordScore + doc.reverseBoost >= MIN_KEYWORD_SCORE),
592
628
  MAX_DOC_CANDIDATES,
593
629
  (doc) => doc.evidenceScore > 0,
594
630
  );
@@ -647,12 +683,15 @@ function selectCandidates(context, loaded, task) {
647
683
 
648
684
  for (const doc of selectedDocs) {
649
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)` : ''}`);
650
687
  }
651
688
 
652
689
  return {
653
690
  noMatch: false,
654
- narrow,
691
+ topicAbsent: false,
692
+ nearby: [],
655
693
  terms,
694
+ searchTerms,
656
695
  distinctive,
657
696
  docs: selectedDocs,
658
697
  namedDecisions,
@@ -683,6 +722,29 @@ function pickDistinctiveTerms(terms, index) {
683
722
  .slice(0, limit);
684
723
  }
685
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
+
686
748
  function pickTop(candidates, limit, bypassFloor = () => false) {
687
749
  const sorted = [...candidates].sort(compareByScore);
688
750
  const top = sorted[0]?.score ?? 0;
@@ -760,8 +822,7 @@ function buildUnits(context, selection) {
760
822
  })),
761
823
  ].sort((left, right) => right.relative - left.relative || left.unit.id.localeCompare(right.unit.id));
762
824
  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 } : {}) });
825
+ units.push({ ...entry.unit, rank: index + 1 });
765
826
  }
766
827
 
767
828
  for (const note of selection.notes) units.push(buildNoteUnit(note));
@@ -1037,16 +1098,11 @@ function assemble(context, selection, units, capacity, compaction = 0) {
1037
1098
  const covered = [];
1038
1099
  let used = 0;
1039
1100
 
1040
- for (const { companions = [], withheld = false, ...unit } of units) {
1101
+ for (const { companions = [], ...unit } of units) {
1041
1102
  if (unit.kind === 'lineage' && unit.members.every((member) => emitted.has(member.decision_id))) {
1042
1103
  covered.push(unit.id);
1043
1104
  continue;
1044
1105
  }
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
1106
 
1051
1107
  // A doc and its mandatory companions pack atomically; companions already
1052
1108
  // shown by an earlier unit cost nothing.
@@ -1130,7 +1186,8 @@ function baseResult(context, selection, { status, units, omitted, covered, follo
1130
1186
  selection: {
1131
1187
  query_terms: selection?.terms ?? [],
1132
1188
  distinctive_terms: selection?.distinctive ?? [],
1133
- narrowed: Boolean(selection?.narrow),
1189
+ topic_absent: Boolean(selection?.topicAbsent),
1190
+ search_terms: selection?.searchTerms ?? [],
1134
1191
  },
1135
1192
  units,
1136
1193
  omitted,
@@ -1148,8 +1205,11 @@ function decideStatus(selection, omitted) {
1148
1205
  return { status: 'incomplete', reason: `retrieval failed — ${selection.retrievalError}` };
1149
1206
  }
1150
1207
  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' }
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" }
1153
1213
  : { status: 'no-match', reason: 'no architecture doc or decision matched the task' };
1154
1214
  }
1155
1215
 
@@ -1161,11 +1221,7 @@ function decideStatus(selection, omitted) {
1161
1221
  };
1162
1222
  }
1163
1223
  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('; ') };
1224
+ return { status: 'partial', reason: `${plural(omitted.length, 'ranked or optional unit')} did not fit the budget` };
1169
1225
  }
1170
1226
  return { status: 'complete', reason: 'every selected unit fits the budget' };
1171
1227
  }
@@ -1199,14 +1255,23 @@ function buildFollowUps(selection, context, included, omitted) {
1199
1255
  }
1200
1256
 
1201
1257
  for (const unit of omitted.filter((candidate) => candidate.tier === 'ranked')) {
1202
- const prefix = unit.omitted_reason === 'narrow-match' ? 'narrow match, read only: ' : '';
1203
1258
  if (unit.kind === 'doc') {
1204
- entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(`${prefix}${unit.reasons[0] ?? 'ranked doc'}`) });
1259
+ entries.push({ priority: 'ranked', path: unit.path, heading: null, reason: shortReason(unit.reasons[0] ?? 'ranked doc') });
1205
1260
  } else {
1206
- entries.push(...lineageReads(unit, 'ranked', prefix));
1261
+ entries.push(...lineageReads(unit, 'ranked'));
1207
1262
  }
1208
1263
  }
1209
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
+
1210
1275
  // Uncertified lineage wording aimed at an emitted decision: a read, never
1211
1276
  // a status or a relation. Grouped per source file and target decision.
1212
1277
  const unknownGroups = new Map();
@@ -1248,7 +1313,7 @@ function buildFollowUps(selection, context, included, omitted) {
1248
1313
  });
1249
1314
  }
1250
1315
 
1251
- function lineageReads(unit, priority, prefix = '') {
1316
+ function lineageReads(unit, priority) {
1252
1317
  return [...unit.members]
1253
1318
  .sort((left, right) => right.decision_id - left.decision_id)
1254
1319
  .map((member) => ({
@@ -1258,13 +1323,13 @@ function lineageReads(unit, priority, prefix = '') {
1258
1323
  heading: formatId(member.decision_id),
1259
1324
  reason:
1260
1325
  member.role === 'root'
1261
- ? shortReason(`${prefix}${unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`}`)
1326
+ ? shortReason(unit.reasons[0] ? `${unit.tier}: ${unit.reasons[0]}` : `${unit.tier} decision`)
1262
1327
  : `declared successor of ${formatId(unit.root_decision_id)} (${member.successor_relations.join(', ')})`,
1263
1328
  }));
1264
1329
  }
1265
1330
 
1266
1331
  function finalizeRetrievalFailure(context, reason) {
1267
- return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [] });
1332
+ return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [], nearby: [] });
1268
1333
  }
1269
1334
 
1270
1335
  /**
@@ -1290,6 +1355,100 @@ function assertLineageSafety(result, relations) {
1290
1355
  // Helpers
1291
1356
  // ---------------------------------------------------------------------------
1292
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
+
1293
1452
  function buildDecisionEntryMap(documents) {
1294
1453
  const map = new Map();
1295
1454
  for (const document of documents) {
@@ -1438,12 +1597,10 @@ function splitSections(content, level = 2) {
1438
1597
  let fence = null;
1439
1598
  let current = null;
1440
1599
  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)) {
1600
+ const step = stepFence(fence, line);
1601
+ const fenced = fence !== null || step.delimiter;
1602
+ fence = step.fence;
1603
+ if (!fenced && heading.test(line)) {
1447
1604
  const match = line.match(exact);
1448
1605
  current = match && !sections.has(match[1]) ? { title: match[1], lines: [] } : null;
1449
1606
  if (current) sections.set(current.title, current);
@@ -1476,7 +1476,8 @@ export function findFencedRanges(text) {
1476
1476
  offset += line.length + 1;
1477
1477
  const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
1478
1478
  if (fence) {
1479
- if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
1479
+ // A closing fence holds only the opener's character, so ```~~~ never closes.
1480
+ if (marker && marker[1][0] === fence.char && marker[1].length >= fence.length && (fence.char === '`' ? /^ {0,3}`+\s*$/ : /^ {0,3}~+\s*$/).test(line)) {
1480
1481
  ranges.push([fence.start, Math.min(offset - 1, text.length)]);
1481
1482
  fence = null;
1482
1483
  }