@vibecompass/vibecompass 0.17.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,51 @@
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
+
3
49
  ## 0.17.0 - 2026-10-03
4
50
 
5
51
  Session brief retrieval and framing fixes (recall plan A7). The lane brief stays
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.
@@ -268,7 +270,9 @@ 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
271
  only implied, so read the follow-up list too. When the task's distinctive words
270
272
  match nothing in memory, the status is `no-match`, with at most four nearby
271
- reads that share the task's other words.
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.
272
276
 
273
277
  The brief in a session lane is an experiment you turn on (D-368):
274
278
 
@@ -416,8 +420,13 @@ the parser cannot certify is `unknown`. Every relation carries its source
416
420
  path, section, line, and content hashes. `getDecisionLineage(readModel, id)`
417
421
  returns one decision's incoming and outgoing lineage and its declared
418
422
  successors, followed transitively. Nothing is ever reported as "governs" or
419
- "currently valid". Architecture components also carry `retrieval_guidance`
420
- 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.
421
430
 
422
431
  `buildSessionBrief({ rootDir, task, files, featureSlugs, claims, laneId, budget })`
423
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.17.0",
3
+ "version": "0.17.1",
4
4
  "description": "Local-first project-memory core for VibeCompass",
5
5
  "type": "module",
6
6
  "bin": {
package/src/brief.js CHANGED
@@ -6,6 +6,7 @@ import { compileBriefExclusions, readBriefSettingsForRoot, validateExcludePatter
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
 
@@ -119,6 +120,15 @@ export async function buildSessionBrief(options = {}) {
119
120
  }
120
121
  }
121
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
+
122
132
  const overviewExcluded = exclusions?.matches(BRIEF_OVERVIEW_PATH) ?? false;
123
133
  const overviewExists = !overviewExcluded && (await pathExists(path.join(input.rootDir, BRIEF_OVERVIEW_PATH)));
124
134
  if (!overviewExists) {
@@ -149,7 +159,7 @@ export async function buildSessionBrief(options = {}) {
149
159
  };
150
160
 
151
161
  if (retrievalError) {
152
- return finalizeRetrievalFailure(context, retrievalError);
162
+ return finalizeRetrievalFailure(context, retrievalError, lineageUnavailable);
153
163
  }
154
164
 
155
165
  const selection = selectCandidates(context, loaded, task);
@@ -1241,8 +1251,17 @@ function buildFollowUps(selection, context, included, omitted) {
1241
1251
  }
1242
1252
  entries.push(...requiredDecisions.sort((left, right) => right.decision_id - left.decision_id));
1243
1253
 
1244
- // 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).
1245
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
+ }
1246
1265
  if (context.overview.exists) {
1247
1266
  entries.push({ priority: 'required', path: BRIEF_OVERVIEW_PATH, heading: null, reason: 'whole-project orientation' });
1248
1267
  }
@@ -1328,8 +1347,14 @@ function lineageReads(unit, priority) {
1328
1347
  }));
1329
1348
  }
1330
1349
 
1331
- function finalizeRetrievalFailure(context, reason) {
1332
- return packBrief(context, { retrievalError: reason, terms: [], distinctive: [], relations: [], nearby: [] });
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(', ');
1333
1358
  }
1334
1359
 
1335
1360
  /**
@@ -1379,31 +1404,6 @@ function buildDecisionAnchors(documents, decisionIds) {
1379
1404
  return new Map([...anchors].map(([id, sentences]) => [id, [...sentences].join('\n')]));
1380
1405
  }
1381
1406
 
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
1407
  /** Paragraphs, list items, and table rows of a markdown body with their level-2 section, fence-aware. */
1408
1408
  function markdownBlocks(content) {
1409
1409
  const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---[ \t]*(?:\r?\n|$)/, '');
@@ -1583,39 +1583,6 @@ function docSectionFields(content, component) {
1583
1583
  };
1584
1584
  }
1585
1585
 
1586
- /**
1587
- * Level-2 sections of a markdown body, fence-aware: headings inside fenced
1588
- * code are content, and the first section with a given title wins (docs such
1589
- * as `file-schema.md` show example `## Description` blocks in fences).
1590
- */
1591
- function splitSections(content, level = 2) {
1592
- const sections = new Map();
1593
- if (!content) return sections;
1594
- const lines = content.split(/\r?\n/);
1595
- const heading = new RegExp(`^#{1,${level}}\\s`);
1596
- const exact = new RegExp(`^#{${level}}\\s+(.+?)\\s*$`);
1597
- let fence = null;
1598
- let current = null;
1599
- for (const line of lines) {
1600
- const step = stepFence(fence, line);
1601
- const fenced = fence !== null || step.delimiter;
1602
- fence = step.fence;
1603
- if (!fenced && heading.test(line)) {
1604
- const match = line.match(exact);
1605
- current = match && !sections.has(match[1]) ? { title: match[1], lines: [] } : null;
1606
- if (current) sections.set(current.title, current);
1607
- continue;
1608
- }
1609
- if (current) current.lines.push(line);
1610
- }
1611
- return new Map([...sections].map(([title, section]) => [title, section.lines.join('\n').trim() || null]));
1612
- }
1613
-
1614
- /** Body of a level-2 section (`## Title`), trimmed; null when absent. */
1615
- export function extractSection(content, title) {
1616
- return splitSections(content, 2).get(title) ?? null;
1617
- }
1618
-
1619
1586
  /** Body of `### sub` inside `## section`; null when absent. */
1620
1587
  function extractSubsection(content, section, sub) {
1621
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,8 +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
- // 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)) {
1574
+ if (closesFence(fence, marker, line)) {
1481
1575
  ranges.push([fence.start, Math.min(offset - 1, text.length)]);
1482
1576
  fence = null;
1483
1577
  }
@@ -1491,6 +1585,13 @@ export function findFencedRanges(text) {
1491
1585
  return ranges;
1492
1586
  }
1493
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
+
1494
1595
  function insideRanges(offset, ranges) {
1495
1596
  return ranges.some(([start, end]) => offset >= start && offset < end);
1496
1597
  }
@@ -1580,7 +1681,7 @@ function buildSectionIndex(content, bodyStart) {
1580
1681
 
1581
1682
  const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
1582
1683
  if (fence) {
1583
- if (fenceMatch && fenceMatch[1][0] === fence.char && fenceMatch[1].length >= fence.length && /^ {0,3}[`~]+\s*$/.test(line)) {
1684
+ if (closesFence(fence, fenceMatch, line)) {
1584
1685
  fence = null;
1585
1686
  }
1586
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 [];