@vibecompass/vibecompass 0.17.0 → 0.17.2
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 +84 -0
- package/README.md +16 -4
- package/package.json +1 -1
- package/src/brief.js +29 -62
- package/src/cli.js +2 -2
- package/src/decision-lineage.js +131 -23
- package/src/generators/agent-files/template.js +4 -2
- package/src/git-binding.js +18 -1
- package/src/init.js +9 -6
- package/src/markdown-sections.js +65 -0
- package/src/project-memory.js +5 -5
- package/src/read-model.js +34 -17
- package/src/scaffold.js +8 -2
- package/src/serialization.js +88 -9
- package/src/session.js +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.17.2 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
Lockstep release with the app's lineage port: section headings open a fence
|
|
6
|
+
only where masking does, commands leave nothing behind on an uninitialized root,
|
|
7
|
+
agent files show a repo's remote beside its local path, `init` ignores lane
|
|
8
|
+
scratch, and the scanner's section warnings read headings fence-aware. The
|
|
9
|
+
lane brief stays opt-in (D-368).
|
|
10
|
+
|
|
11
|
+
- Lineage: section headings in architecture docs and session notes now open a
|
|
12
|
+
fence only where masking does. A backtick fence's info string holds no
|
|
13
|
+
backtick, so a prose line that starts with backticks, such as
|
|
14
|
+
```` ```a`b ````, no longer hides every later heading of the file. Before,
|
|
15
|
+
the decisions listed under a later `## Decisions made` heading were missed as
|
|
16
|
+
`made` relations, and later citations were filed under the wrong section.
|
|
17
|
+
Tilde fences may still hold backticks in their info string. A shared golden
|
|
18
|
+
fixture pair (`note-fence-opener`, `doc-fence-opener`) pins the rule for the
|
|
19
|
+
app port.
|
|
20
|
+
- Commands no longer leave directories behind on an uninitialized root. A
|
|
21
|
+
write command run against a missing root (or a folder holding no project
|
|
22
|
+
memory) left an empty `state/` directory, and the root folder itself, behind
|
|
23
|
+
before it failed. The cross-process lock still serializes every command
|
|
24
|
+
there, since `bootstrap` and `apply-export` from an external bundle can
|
|
25
|
+
create a root, but at release it removes the directories it made while they
|
|
26
|
+
are still empty. Session commands report the missing root instead of a
|
|
27
|
+
missing `project.yaml`.
|
|
28
|
+
- Agent files and `context.md` show a Git repo's remote when `project.yaml` also
|
|
29
|
+
records its local `path:`, as `<remote> (local path <path>)`. Before, any repo
|
|
30
|
+
with a path read as a local folder. Local-folder sources and repos without a
|
|
31
|
+
path read as before; run `vibecompass sync-agents` to refresh managed blocks.
|
|
32
|
+
- `init` adds `sessions/active/` to the root's `.gitignore` beside `state/`, so
|
|
33
|
+
a versioned memory root never commits local-only lane scratch (D-278). It
|
|
34
|
+
appends only the entries a `.gitignore` lacks. An existing root's
|
|
35
|
+
`.gitignore` is not changed; add the line yourself.
|
|
36
|
+
- Scanner: the recommended-section warnings for architecture docs and session
|
|
37
|
+
notes ignore headings inside fenced code, with the read model's fence-aware
|
|
38
|
+
reader. A fenced example `## Description` no longer hides a missing real
|
|
39
|
+
section.
|
|
40
|
+
|
|
41
|
+
## 0.17.1 - 2026-10-07
|
|
42
|
+
|
|
43
|
+
Hardening release: decision lineage fails soft, the read model reads sections
|
|
44
|
+
fence-aware, and close-session no longer leaves half-deleted worktrees. The lane
|
|
45
|
+
brief stays opt-in (D-368).
|
|
46
|
+
|
|
47
|
+
- Lineage fails soft (D-371): when the lineage parser throws on a file, the read
|
|
48
|
+
model still loads and `decision_lineage.status` is `unavailable`, listing the
|
|
49
|
+
file in `unavailable_sources` with a `lineage-extraction-failed` diagnostic
|
|
50
|
+
(the error's class and first line, at most 200 characters). Relations, counts,
|
|
51
|
+
and `getDecisionLineage`'s lists are null, never empty. `getProjectContext`
|
|
52
|
+
gains a `decision_lineage` status, so Local MCP's project context shows it.
|
|
53
|
+
Before, one parser exception failed the whole read model: the brief, every
|
|
54
|
+
Local MCP read, and agent-file sync. Available lineage is unchanged, with the
|
|
55
|
+
same relation records and contract version 1.
|
|
56
|
+
- Brief: with lineage unavailable, the brief abstains. It is `incomplete`, shows
|
|
57
|
+
the lane only, and lists the file lineage could not be read from, the
|
|
58
|
+
overview, and `decisions/INDEX.md` as required reads. A lane `brief.md`
|
|
59
|
+
written then records a failed generation, so `continue-session` regenerates
|
|
60
|
+
it.
|
|
61
|
+
- Read model (D-372): architecture component sections (Description, Details,
|
|
62
|
+
Next steps, Retrieval guidance, Review metadata, Involved files) are read
|
|
63
|
+
fence-aware, and the first section of a title wins. A heading inside a fenced
|
|
64
|
+
example no longer replaces the real section (for example a sample
|
|
65
|
+
`## Description` in a schema doc). It is the brief's reader, now shared.
|
|
66
|
+
- Lineage: section headings in architecture docs and session notes now follow
|
|
67
|
+
the closing-fence rule that 0.17.0 gave masking. A fence closes only on a
|
|
68
|
+
line of its opener's own character, so a mixed line such as ```` ```~~~ ````
|
|
69
|
+
no longer ends an example early. In 0.17.0 a sample `## Decisions made`
|
|
70
|
+
heading after such a line could be taken as the note's listing, making a
|
|
71
|
+
false `made` relation and missing the real ones, and later real headings
|
|
72
|
+
could be read as fenced. A shared golden fixture pair
|
|
73
|
+
(`decisions-fence-closing`, `note-fence-closing`) pins the rule for the app
|
|
74
|
+
port.
|
|
75
|
+
- close-session: git removes a lane's worktrees with no time limit. A removal
|
|
76
|
+
killed by the 120-second limit while deleting a full `node_modules` left a
|
|
77
|
+
half-deleted checkout that the next close counted as modified. Git still
|
|
78
|
+
refuses locked worktrees, worktrees with submodules, and modified ones before
|
|
79
|
+
it deletes anything.
|
|
80
|
+
- Tests: the lane-runtime settings test passes inside a `lane-env` shell.
|
|
81
|
+
|
|
82
|
+
Not listed in 0.17.0's notes, though it shipped there: masking and decision
|
|
83
|
+
entry structure closed a fence only on a line of its opener's own character,
|
|
84
|
+
so a sample `**Supersedes:**` line after a mixed ```` ```~~~ ```` line no longer
|
|
85
|
+
declared false lineage.
|
|
86
|
+
|
|
3
87
|
## 0.17.0 - 2026-10-03
|
|
4
88
|
|
|
5
89
|
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.
|
|
@@ -228,6 +230,9 @@ npx -y @vibecompass/vibecompass@latest docs-review --root .compass --rebuild --a
|
|
|
228
230
|
|
|
229
231
|
Docs review writes derived state under `state/`, including scanned source
|
|
230
232
|
inventory, accepted coverage, and the accepted documentation-plan projection.
|
|
233
|
+
`init` adds `state/` and `sessions/active/` (local-only lane scratch) to the
|
|
234
|
+
root's `.gitignore`. A command that fails in a folder that is not a project
|
|
235
|
+
memory root yet leaves nothing behind there.
|
|
231
236
|
|
|
232
237
|
Generated agent files can also include managed VibeCompass blocks:
|
|
233
238
|
|
|
@@ -268,7 +273,9 @@ own text explicitly declares that it replaces or amends it, and the brief never
|
|
|
268
273
|
claims that a decision governs your task. It cannot spot a replacement that is
|
|
269
274
|
only implied, so read the follow-up list too. When the task's distinctive words
|
|
270
275
|
match nothing in memory, the status is `no-match`, with at most four nearby
|
|
271
|
-
reads that share the task's other words.
|
|
276
|
+
reads that share the task's other words. If decision lineage cannot be read
|
|
277
|
+
from a file, the brief shows no decisions or docs: it is `incomplete` and lists
|
|
278
|
+
that file, the overview, and the decision index to read first.
|
|
272
279
|
|
|
273
280
|
The brief in a session lane is an experiment you turn on (D-368):
|
|
274
281
|
|
|
@@ -416,8 +423,13 @@ the parser cannot certify is `unknown`. Every relation carries its source
|
|
|
416
423
|
path, section, line, and content hashes. `getDecisionLineage(readModel, id)`
|
|
417
424
|
returns one decision's incoming and outgoing lineage and its declared
|
|
418
425
|
successors, followed transitively. Nothing is ever reported as "governs" or
|
|
419
|
-
"currently valid".
|
|
420
|
-
|
|
426
|
+
"currently valid". If the parser throws on any file, `decision_lineage.status`
|
|
427
|
+
is `unavailable`, with the file in `unavailable_sources` and null relations
|
|
428
|
+
and counts, never empty ones; the rest of the read model still loads, and
|
|
429
|
+
`getProjectContext` reports the lineage status. Architecture components also
|
|
430
|
+
carry `retrieval_guidance` and `retrieval_scope` when their docs define them;
|
|
431
|
+
their sections are read fence-aware, so a heading inside a code example never
|
|
432
|
+
replaces a real section.
|
|
421
433
|
|
|
422
434
|
`buildSessionBrief({ rootDir, task, files, featureSlugs, claims, laneId, budget })`
|
|
423
435
|
is the read-only engine behind `vibecompass brief`: it returns the selection
|
package/package.json
CHANGED
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;
|
package/src/cli.js
CHANGED
|
@@ -74,9 +74,9 @@ export async function runCli(argv, io = createDefaultIo(), runtime = {}) {
|
|
|
74
74
|
}
|
|
75
75
|
io.stdout.write(`Wrote ${result.projectFilePath}\n`);
|
|
76
76
|
if (result.gitignoreUpdated) {
|
|
77
|
-
io.stdout.write(`Updated ${result.gitignorePath} to ignore state/\n`);
|
|
77
|
+
io.stdout.write(`Updated ${result.gitignorePath} to ignore state/ and sessions/active/\n`);
|
|
78
78
|
} else {
|
|
79
|
-
io.stdout.write(`${result.gitignorePath} already ignored state/\n`);
|
|
79
|
+
io.stdout.write(`${result.gitignorePath} already ignored state/ and sessions/active/\n`);
|
|
80
80
|
}
|
|
81
81
|
io.stdout.write(
|
|
82
82
|
`Generated ${result.manifestPath} (${result.manifest.canonical.document_count} canonical docs, ${result.manifest.canonical.warning_count} warnings)\n`,
|
package/src/decision-lineage.js
CHANGED
|
@@ -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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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,14 +1571,13 @@ 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
|
-
|
|
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
|
}
|
|
1484
1578
|
continue;
|
|
1485
1579
|
}
|
|
1486
|
-
if (
|
|
1580
|
+
if (opensFence(marker, line)) {
|
|
1487
1581
|
fence = { char: marker[1][0], length: marker[1].length, start: lineStart };
|
|
1488
1582
|
}
|
|
1489
1583
|
}
|
|
@@ -1491,6 +1585,20 @@ export function findFencedRanges(text) {
|
|
|
1491
1585
|
return ranges;
|
|
1492
1586
|
}
|
|
1493
1587
|
|
|
1588
|
+
// A fence opens on three or more backticks or tildes indented at most three
|
|
1589
|
+
// spaces; a backtick opener's info string holds no backtick, so ```a`b is
|
|
1590
|
+
// prose. Shared by masking and the section index.
|
|
1591
|
+
function opensFence(marker, line) {
|
|
1592
|
+
return Boolean(marker) && !(marker[1][0] === '`' && line.slice(line.indexOf(marker[1]) + marker[1].length).includes('`'));
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
// A closing fence holds only the opener's character, at least as many times,
|
|
1596
|
+
// so ```~~~ never closes. Shared by masking and the section index.
|
|
1597
|
+
function closesFence(fence, marker, line) {
|
|
1598
|
+
return Boolean(marker) && marker[1][0] === fence.char && marker[1].length >= fence.length &&
|
|
1599
|
+
(fence.char === '`' ? /^ {0,3}`+\s*$/ : /^ {0,3}~+\s*$/).test(line);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1494
1602
|
function insideRanges(offset, ranges) {
|
|
1495
1603
|
return ranges.some(([start, end]) => offset >= start && offset < end);
|
|
1496
1604
|
}
|
|
@@ -1580,12 +1688,12 @@ function buildSectionIndex(content, bodyStart) {
|
|
|
1580
1688
|
|
|
1581
1689
|
const fenceMatch = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
1582
1690
|
if (fence) {
|
|
1583
|
-
if (
|
|
1691
|
+
if (closesFence(fence, fenceMatch, line)) {
|
|
1584
1692
|
fence = null;
|
|
1585
1693
|
}
|
|
1586
1694
|
continue;
|
|
1587
1695
|
}
|
|
1588
|
-
if (fenceMatch) {
|
|
1696
|
+
if (opensFence(fenceMatch, line)) {
|
|
1589
1697
|
fence = { char: fenceMatch[1][0], length: fenceMatch[1].length };
|
|
1590
1698
|
continue;
|
|
1591
1699
|
}
|
|
@@ -88,13 +88,15 @@ function renderProjectSnapshot(context) {
|
|
|
88
88
|
return lines.length > 0 ? lines : ['- No project details recorded yet.'];
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// A local-folder source shows its folder; a Git repo shows its remote, plus
|
|
92
|
+
// the local path where one is recorded beside it (PC2).
|
|
91
93
|
function formatRepoDescriptor(repo) {
|
|
92
|
-
if (repo?.source === 'local' || repo?.path) {
|
|
94
|
+
if (repo?.source === 'local' || (repo?.path && !repo?.remote)) {
|
|
93
95
|
return `local folder ${repo.path ?? '.'}`;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
if (repo?.remote) {
|
|
97
|
-
return repo.remote;
|
|
99
|
+
return repo.path ? `${repo.remote} (local path ${repo.path})` : repo.remote;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
return 'source not recorded';
|
package/src/git-binding.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/init.js
CHANGED
|
@@ -34,7 +34,7 @@ export async function initializeProjectMemory(options) {
|
|
|
34
34
|
const gitignorePath = path.join(normalized.rootDir, '.gitignore');
|
|
35
35
|
|
|
36
36
|
await writeProjectFile(projectFilePath, normalized.projectConfig, normalized.force);
|
|
37
|
-
const gitignoreUpdated = await
|
|
37
|
+
const gitignoreUpdated = await ensureLocalOnlyPathsIgnored(gitignorePath);
|
|
38
38
|
const scaffoldResult = await scaffoldInitFiles(normalized.bootstrap);
|
|
39
39
|
const manifestResult = await writeStateManifest(normalized.rootDir, {
|
|
40
40
|
generatedAt: normalized.generatedAt,
|
|
@@ -461,22 +461,25 @@ async function writeProjectFile(projectFilePath, projectConfig, force) {
|
|
|
461
461
|
await writeFile(projectFilePath, source, 'utf8');
|
|
462
462
|
}
|
|
463
463
|
|
|
464
|
-
|
|
465
|
-
|
|
464
|
+
// Local-only paths a versioned memory root never commits: derived state, and
|
|
465
|
+
// lane scratch under sessions/active/ (D-278; PC5).
|
|
466
|
+
const LOCAL_ONLY_IGNORE_ENTRIES = ['state/', 'sessions/active/'];
|
|
466
467
|
|
|
468
|
+
async function ensureLocalOnlyPathsIgnored(gitignorePath) {
|
|
467
469
|
if (!(await fileExists(gitignorePath))) {
|
|
468
|
-
await writeFile(gitignorePath, `${
|
|
470
|
+
await writeFile(gitignorePath, `${LOCAL_ONLY_IGNORE_ENTRIES.join('\n')}\n`, 'utf8');
|
|
469
471
|
return true;
|
|
470
472
|
}
|
|
471
473
|
|
|
472
474
|
const current = await readFile(gitignorePath, 'utf8');
|
|
473
475
|
const entries = current.split(/\r?\n/).map((line) => line.trim());
|
|
474
|
-
|
|
476
|
+
const missing = LOCAL_ONLY_IGNORE_ENTRIES.filter((entry) => !entries.includes(entry));
|
|
477
|
+
if (missing.length === 0) {
|
|
475
478
|
return false;
|
|
476
479
|
}
|
|
477
480
|
|
|
478
481
|
const separator = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
|
|
479
|
-
await writeFile(gitignorePath, `${current}${separator}${
|
|
482
|
+
await writeFile(gitignorePath, `${current}${separator}${missing.join('\n')}\n`, 'utf8');
|
|
480
483
|
return true;
|
|
481
484
|
}
|
|
482
485
|
|
|
@@ -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`), which follows the same opening and closing rules.
|
|
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/project-memory.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { validateBriefSettings } from './brief-settings.js';
|
|
4
4
|
import { parseFrontmatter } from './frontmatter.js';
|
|
5
5
|
import { sha256Text } from './hash.js';
|
|
6
|
+
import { splitSections } from './markdown-sections.js';
|
|
6
7
|
import { parseSimpleYaml } from './simple-yaml.js';
|
|
7
8
|
|
|
8
9
|
const PROJECT_FILE = 'project.yaml';
|
|
@@ -886,12 +887,11 @@ function findDuplicateDecisionErrors(documents) {
|
|
|
886
887
|
return errors;
|
|
887
888
|
}
|
|
888
889
|
|
|
890
|
+
// Level-2 section titles, lowercased, read fence-aware with the section reader
|
|
891
|
+
// the read model and the brief share (D-372; H10): a heading inside fenced
|
|
892
|
+
// code is an example, so it never satisfies a recommended section.
|
|
889
893
|
function extractLevelTwoSections(content) {
|
|
890
|
-
|
|
891
|
-
for (const match of content.matchAll(/^##\s+(.+)$/gm)) {
|
|
892
|
-
sections.add(match[1].trim().toLowerCase());
|
|
893
|
-
}
|
|
894
|
-
return sections;
|
|
894
|
+
return new Set([...splitSections(content, 2).keys()].map((title) => title.trim().toLowerCase()));
|
|
895
895
|
}
|
|
896
896
|
|
|
897
897
|
// D-292 changelog-shape detector, shared by the scanner and the docs-review
|
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
|
|
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
|
-
|
|
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 [];
|
package/src/scaffold.js
CHANGED
|
@@ -503,12 +503,18 @@ ${repoLines}
|
|
|
503
503
|
`;
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
+
// A local-folder source shows its folder; a Git repo shows its remote, plus
|
|
507
|
+
// the local path where one is recorded beside it (PC2).
|
|
506
508
|
function formatRepoDescriptor(repo) {
|
|
507
|
-
if (repo.source === 'local' || repo.path) {
|
|
509
|
+
if (repo.source === 'local' || (repo.path && !repo.remote)) {
|
|
508
510
|
return `local folder ${repo.path}`;
|
|
509
511
|
}
|
|
510
512
|
|
|
511
|
-
|
|
513
|
+
if (repo.remote) {
|
|
514
|
+
return repo.path ? `${repo.remote} (local path ${repo.path})` : repo.remote;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return 'source not recorded';
|
|
512
518
|
}
|
|
513
519
|
|
|
514
520
|
function generateArchitectureGuide(projectConfig) {
|
package/src/serialization.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, readdir, readFile, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -16,6 +16,15 @@ import path from 'node:path';
|
|
|
16
16
|
* re-enter instead of deadlocking (e.g. close-session refreshing the
|
|
17
17
|
* manifest, which is itself lock-wrapped)
|
|
18
18
|
*
|
|
19
|
+
* Every command takes the disk lock, on an uninitialized root too: a locked
|
|
20
|
+
* command can create a root there (bootstrap, or apply-export from an external
|
|
21
|
+
* bundle), so skipping the lock would let two of them validate the same
|
|
22
|
+
* missing files and both write. The lock leaves nothing behind (PC1): at
|
|
23
|
+
* release it removes the directories it created to hold itself (state/, and
|
|
24
|
+
* the root when it was missing) while they are still empty, and a root the
|
|
25
|
+
* lock created still counts as missing to the command
|
|
26
|
+
* (`memoryRootCreatedByLock`).
|
|
27
|
+
*
|
|
19
28
|
* This tier is intentionally local-only. Cross-machine serialization belongs
|
|
20
29
|
* to git merge (local-primary teams) or the hosted database (hosted-only);
|
|
21
30
|
* no distributed locking is ever built on top of this file.
|
|
@@ -28,6 +37,7 @@ const DEFAULT_ATTEMPTS = 120;
|
|
|
28
37
|
const RETRY_BASE_DELAY_MS = 25;
|
|
29
38
|
const RETRY_MAX_DELAY_MS = 100;
|
|
30
39
|
|
|
40
|
+
// Held root key -> { createdRoot }: whether this lock made the root directory.
|
|
31
41
|
const heldRoots = new AsyncLocalStorage();
|
|
32
42
|
const processQueues = new Map();
|
|
33
43
|
|
|
@@ -88,13 +98,14 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
|
88
98
|
await previous;
|
|
89
99
|
|
|
90
100
|
try {
|
|
91
|
-
const
|
|
101
|
+
const lock = await acquireDiskLock(key, label, options);
|
|
92
102
|
try {
|
|
93
|
-
const nextHeld = new
|
|
94
|
-
nextHeld.
|
|
103
|
+
const nextHeld = new Map(held ?? []);
|
|
104
|
+
nextHeld.set(key, { createdRoot: lock.createdRoot });
|
|
95
105
|
return await heldRoots.run(nextHeld, fn);
|
|
96
106
|
} finally {
|
|
97
|
-
await releaseDiskLock(key, ownerToken);
|
|
107
|
+
await releaseDiskLock(key, lock.ownerToken);
|
|
108
|
+
await removeLockCreatedDirectories(key, lock.created);
|
|
98
109
|
}
|
|
99
110
|
} finally {
|
|
100
111
|
releaseQueue();
|
|
@@ -104,6 +115,57 @@ export async function withMemoryRootLock(rootDir, label, fn, options = {}) {
|
|
|
104
115
|
}
|
|
105
116
|
}
|
|
106
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Whether `rootDir` exists only because the lock this caller holds made it
|
|
120
|
+
* (PC1): the root was missing when the command started and still holds
|
|
121
|
+
* nothing but the lock, so a command that requires an existing root reports
|
|
122
|
+
* it missing instead of reading the lock's own directory as one. Once the
|
|
123
|
+
* command writes into it (bootstrap), it is a root in its own right. False
|
|
124
|
+
* outside a lock holder.
|
|
125
|
+
*/
|
|
126
|
+
export async function memoryRootCreatedByLock(rootDir) {
|
|
127
|
+
const held = heldRoots.getStore();
|
|
128
|
+
if (!held) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
const heldKey = await findHeldMemoryRootKey(held, await canonicalizeMemoryRootKey(rootDir));
|
|
132
|
+
if (!heldKey || !held.get(heldKey).createdRoot) {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const rootEntries = await readdir(heldKey);
|
|
137
|
+
const stateEntries = await readdir(path.dirname(memoryRootLockPath(heldKey)));
|
|
138
|
+
return rootEntries.every((entry) => entry === 'state') && stateEntries.every((entry) => entry.startsWith(LOCK_DIR_NAME));
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Removes the directories acquisition created (state/, then the root and any
|
|
146
|
+
* missing parents up to `created`) while each is still empty (PC1). A command
|
|
147
|
+
* that wrote memory there, or another holder's lock, keeps a directory
|
|
148
|
+
* non-empty, so it stays. Two commands racing on the same missing root can
|
|
149
|
+
* leave an empty directory when the second found it already made; it never
|
|
150
|
+
* holds memory.
|
|
151
|
+
*/
|
|
152
|
+
async function removeLockCreatedDirectories(key, created) {
|
|
153
|
+
if (!created) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const top = path.resolve(created);
|
|
157
|
+
for (let dir = path.dirname(memoryRootLockPath(key)); ; dir = path.dirname(dir)) {
|
|
158
|
+
try {
|
|
159
|
+
await rmdir(dir);
|
|
160
|
+
} catch {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (dir === top || path.dirname(dir) === dir) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
107
169
|
async function canonicalizeMemoryRootKey(rootDir) {
|
|
108
170
|
const resolved = path.resolve(rootDir);
|
|
109
171
|
try {
|
|
@@ -126,7 +188,7 @@ async function findHeldMemoryRootKey(held, key) {
|
|
|
126
188
|
return key;
|
|
127
189
|
}
|
|
128
190
|
|
|
129
|
-
for (const heldKey of held) {
|
|
191
|
+
for (const heldKey of held.keys()) {
|
|
130
192
|
if (await memoryRootKeysReferToSamePath(heldKey, key)) {
|
|
131
193
|
return heldKey;
|
|
132
194
|
}
|
|
@@ -145,9 +207,11 @@ async function memoryRootKeysReferToSamePath(left, right) {
|
|
|
145
207
|
|
|
146
208
|
async function acquireDiskLock(key, label, options) {
|
|
147
209
|
const lockDir = memoryRootLockPath(key);
|
|
210
|
+
const stateDir = path.dirname(lockDir);
|
|
148
211
|
const attempts = options.attempts ?? DEFAULT_ATTEMPTS;
|
|
149
212
|
const staleMs = options.staleMs ?? DEFAULT_STALE_MS;
|
|
150
|
-
|
|
213
|
+
// The topmost directory this acquisition created, if any (mkdir reports it).
|
|
214
|
+
let created = await mkdir(stateDir, { recursive: true });
|
|
151
215
|
|
|
152
216
|
for (let attempt = 1; ; attempt += 1) {
|
|
153
217
|
const ownerToken = randomUUID();
|
|
@@ -158,9 +222,17 @@ async function acquireDiskLock(key, label, options) {
|
|
|
158
222
|
`${JSON.stringify({ pid: process.pid, token: ownerToken, label, acquired_at: new Date().toISOString() })}\n`,
|
|
159
223
|
'utf8',
|
|
160
224
|
);
|
|
161
|
-
return
|
|
225
|
+
return {
|
|
226
|
+
ownerToken,
|
|
227
|
+
created,
|
|
228
|
+
createdRoot: Boolean(created) && path.resolve(created) !== path.resolve(stateDir),
|
|
229
|
+
};
|
|
162
230
|
} catch (error) {
|
|
163
|
-
if (error?.code
|
|
231
|
+
if (error?.code === 'ENOENT') {
|
|
232
|
+
// A releasing holder removed the state/ (or root) it had created; make
|
|
233
|
+
// it again, and remove it at release in turn.
|
|
234
|
+
created = topmostDirectory(created, await mkdir(stateDir, { recursive: true }));
|
|
235
|
+
} else if (error?.code !== 'EEXIST') {
|
|
164
236
|
throw error;
|
|
165
237
|
}
|
|
166
238
|
}
|
|
@@ -186,6 +258,13 @@ async function acquireDiskLock(key, label, options) {
|
|
|
186
258
|
}
|
|
187
259
|
}
|
|
188
260
|
|
|
261
|
+
function topmostDirectory(left, right) {
|
|
262
|
+
if (!left || !right) {
|
|
263
|
+
return left ?? right;
|
|
264
|
+
}
|
|
265
|
+
return path.resolve(left).length <= path.resolve(right).length ? left : right;
|
|
266
|
+
}
|
|
267
|
+
|
|
189
268
|
/**
|
|
190
269
|
* A lock is abandoned only when its owner process is provably gone (dead PID),
|
|
191
270
|
* or the owner file never became readable within the grace window (a crash
|
package/src/session.js
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
removeLaneWorktreesAtClose,
|
|
17
17
|
rollbackGitBinding,
|
|
18
18
|
} from './git-binding.js';
|
|
19
|
-
import { withMemoryRootLock } from './serialization.js';
|
|
19
|
+
import { memoryRootCreatedByLock, withMemoryRootLock } from './serialization.js';
|
|
20
20
|
import { findDuplicateDecisionIds, formatDecisionId } from './decisions.js';
|
|
21
21
|
import {
|
|
22
22
|
assignLanePort,
|
|
@@ -1782,7 +1782,9 @@ function getLanePaths(normalized, sessionId) {
|
|
|
1782
1782
|
}
|
|
1783
1783
|
|
|
1784
1784
|
async function ensureInitializedProjectMemory(rootDir, options = {}) {
|
|
1785
|
-
|
|
1785
|
+
// The lock makes a missing root to hold its state/ and removes it again at
|
|
1786
|
+
// release (PC1), so a root it created is still missing here.
|
|
1787
|
+
if (!(await fileExists(rootDir)) || (await memoryRootCreatedByLock(rootDir))) {
|
|
1786
1788
|
throw new Error(
|
|
1787
1789
|
`No project memory root was found at ${rootDir}. Run "vibecompass init" before using session commands.`,
|
|
1788
1790
|
);
|