@vibecompass/vibecompass 0.10.8 → 0.11.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.
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
+ import { resolveLaneMarkerContext } from './lane-marker.js';
3
4
  import { STATE_VERSION } from './manifest.js';
4
5
  import { parseSimpleYaml } from './simple-yaml.js';
5
6
  import { PACKAGE_VERSION } from './version.js';
@@ -10,12 +11,21 @@ import { PACKAGE_VERSION } from './version.js';
10
11
  */
11
12
  export async function inspectProjectCompatibility(options = {}) {
12
13
  const cwd = options.cwd ? path.resolve(options.cwd) : process.cwd();
13
- const rootDir = path.resolve(cwd, options.rootDir ?? '.compass');
14
+ const rootResolution = await resolveInspectionRootDir(cwd, options.rootDir);
15
+ const rootDir = rootResolution.rootDir;
14
16
  const cliPackageVersion = normalizePackageVersion(options.packageVersion ?? PACKAGE_VERSION);
15
17
  const projectFilePath = path.join(rootDir, 'project.yaml');
16
18
  const manifestPath = path.join(rootDir, 'state', 'manifest.json');
17
19
  const warnings = [];
18
20
 
21
+ if (rootResolution.markerError) {
22
+ warnings.push(warning({
23
+ code: 'lane-marker-unreadable',
24
+ message: `A lane marker near ${cwd} could not be resolved (${rootResolution.markerError}); inspecting ${rootDir} instead. Lane-scoped commands will fail closed on it.`,
25
+ errorMessage: rootResolution.markerError,
26
+ }));
27
+ }
28
+
19
29
  const project = await readProjectStamp(projectFilePath);
20
30
  const manifest = await readManifestState(manifestPath);
21
31
  const packageStatus = project.status === 'unreadable'
@@ -101,6 +111,29 @@ export function formatCompatibilityWarnings(result) {
101
111
  return (result?.warnings ?? []).map((warning) => warning.message);
102
112
  }
103
113
 
114
+ /**
115
+ * D-280: read-only inspection adopts the nearest valid marker's memory root
116
+ * the same way lane-scoped commands do, so a worktree cwd is inspected
117
+ * against the root it is bound to instead of a nonexistent `cwd/.compass`.
118
+ * The preflight must never block a command, so marker resolution failures
119
+ * fall back to the cwd default — the command that follows surfaces the
120
+ * marker error itself.
121
+ */
122
+ async function resolveInspectionRootDir(cwd, explicitRootDir) {
123
+ if (explicitRootDir) {
124
+ return { rootDir: path.resolve(cwd, explicitRootDir), markerError: null };
125
+ }
126
+
127
+ try {
128
+ return { rootDir: (await resolveLaneMarkerContext({ cwd })).rootDir, markerError: null };
129
+ } catch (error) {
130
+ return {
131
+ rootDir: path.resolve(cwd, '.compass'),
132
+ markerError: error instanceof Error ? error.message : String(error),
133
+ };
134
+ }
135
+ }
136
+
104
137
  async function readProjectStamp(projectFilePath) {
105
138
  try {
106
139
  const config = parseSimpleYaml(await readFile(projectFilePath, 'utf8'), {
@@ -0,0 +1,386 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { formatDecisionId, listDecisionFileNames } from './decisions.js';
4
+ import { resolveLaneSelection } from './lane-marker.js';
5
+ import { listProjectSessions } from './session.js';
6
+ import { withMemoryRootLock } from './serialization.js';
7
+
8
+ /**
9
+ * Grouped structure-preserving decisions/INDEX.md generator (D-283).
10
+ *
11
+ * The existing index file is the source of structure: hand-authored session
12
+ * group headings ("## 2026-07-03 — Session 1 (lane-x lane)") are editorial
13
+ * history no canonical entry records, so regeneration preserves every
14
+ * existing line verbatim and only appends missing rows. Anything the parser
15
+ * cannot round-trip byte-identically fails closed with named findings — the
16
+ * hand-maintained file is never "repaired" by regeneration.
17
+ */
18
+
19
+ const INDEX_H1 = '# Decision Index';
20
+ const TABLE_HEADER = '| # | Decision | Domain |';
21
+ const TABLE_SEPARATOR = '|---|----------|--------|';
22
+ const TABLE_SEPARATOR_PATTERN = /^\|-+\|-+\|-+\|$/;
23
+ const GROUP_HEADING_PATTERN = /^## (.+)$/;
24
+ const INDEX_ROW_PATTERN = /^\| (D-\d{3,}) \| (.*) \| \[([A-Za-z0-9_-]+)\]\(([A-Za-z0-9_.-]+)\) \|$/;
25
+ const CANONICAL_DECISION_PATTERN = /^###\s+D-(\d+)\s+—\s+(.+)$/gm;
26
+ const HYPHEN_ONLY_DECISION_PATTERN = /^###\s+D-(\d+)\s+-\s+(.+)$/gm;
27
+
28
+ /**
29
+ * Conservative predicate for the flat-generator quarantine (D-209/D-255):
30
+ * any `## ` heading marks the content as grouped-shaped, including malformed
31
+ * grouped files — a broken grouped index must survive a flat overwrite too.
32
+ */
33
+ export function looksLikeGroupedDecisionIndex(content) {
34
+ return typeof content === 'string' && /^## /m.test(content);
35
+ }
36
+
37
+ /** Strict structural parse. Returns { ok, problems, lines, groups }. */
38
+ export function parseGroupedDecisionIndex(content) {
39
+ const problems = [];
40
+
41
+ if (typeof content !== 'string' || content.trim() === '') {
42
+ return { ok: false, problems: ['decisions/INDEX.md is empty or unreadable.'], lines: [], groups: [] };
43
+ }
44
+
45
+ if (content.includes('\r')) {
46
+ return { ok: false, problems: ['decisions/INDEX.md uses CRLF line endings; the grouped generator preserves LF files only.'], lines: [], groups: [] };
47
+ }
48
+
49
+ const lines = content.split('\n');
50
+ const groups = [];
51
+ const firstNonEmpty = lines.find((line) => line.trim() !== '');
52
+ if (firstNonEmpty !== INDEX_H1) {
53
+ problems.push(`decisions/INDEX.md does not start with the "${INDEX_H1}" title.`);
54
+ }
55
+
56
+ let index = 0;
57
+ // Preamble: everything before the first group heading, kept verbatim.
58
+ for (; index < lines.length; index += 1) {
59
+ const line = lines[index];
60
+ if (GROUP_HEADING_PATTERN.test(line)) {
61
+ break;
62
+ }
63
+ if (line.startsWith('|')) {
64
+ problems.push(`Line ${index + 1} is a table row outside any "## <date> — <session>" group (flat-index shape?).`);
65
+ }
66
+ }
67
+
68
+ while (index < lines.length) {
69
+ const headingMatch = lines[index].match(GROUP_HEADING_PATTERN);
70
+ if (!headingMatch) {
71
+ problems.push(`Line ${index + 1} ("${truncate(lines[index])}") was not recognized inside the grouped index structure.`);
72
+ index += 1;
73
+ continue;
74
+ }
75
+
76
+ const group = { label: headingMatch[1], headingLineIndex: index, rows: [], lastRowLineIndex: -1 };
77
+ index += 1;
78
+ while (index < lines.length && lines[index].trim() === '') {
79
+ index += 1;
80
+ }
81
+
82
+ if (lines[index] !== TABLE_HEADER) {
83
+ problems.push(`Group "${group.label}" is missing the exact "${TABLE_HEADER}" header row (line ${index + 1}).`);
84
+ } else {
85
+ index += 1;
86
+ if (!TABLE_SEPARATOR_PATTERN.test(lines[index] ?? '')) {
87
+ problems.push(`Group "${group.label}" is missing the table separator row (line ${index + 1}).`);
88
+ } else {
89
+ index += 1;
90
+ while (index < lines.length && lines[index].startsWith('|')) {
91
+ const rowMatch = lines[index].match(INDEX_ROW_PATTERN);
92
+ if (!rowMatch) {
93
+ problems.push(`Group "${group.label}" row on line ${index + 1} ("${truncate(lines[index])}") does not match the "| D-NNN | <title> | [<domain>](<domain>.md) |" shape.`);
94
+ } else if (rowMatch[4] !== `${rowMatch[3]}.md`) {
95
+ problems.push(`Group "${group.label}" row ${rowMatch[1]} links [${rowMatch[3]}] to ${rowMatch[4]}; domain links must be [<domain>](<domain>.md).`);
96
+ } else {
97
+ group.rows.push({
98
+ id: Number(rowMatch[1].slice(2)),
99
+ idText: rowMatch[1],
100
+ title: unescapeRowTitle(rowMatch[2]),
101
+ domain: rowMatch[3],
102
+ lineIndex: index,
103
+ });
104
+ group.lastRowLineIndex = index;
105
+ }
106
+ index += 1;
107
+ }
108
+ if (group.rows.length === 0) {
109
+ problems.push(`Group "${group.label}" has no decision rows.`);
110
+ }
111
+ }
112
+ }
113
+
114
+ // Only blank lines may separate a group's table from the next heading.
115
+ while (index < lines.length && !GROUP_HEADING_PATTERN.test(lines[index])) {
116
+ if (lines[index].trim() !== '') {
117
+ problems.push(`Line ${index + 1} ("${truncate(lines[index])}") was not recognized inside the grouped index structure.`);
118
+ }
119
+ index += 1;
120
+ }
121
+
122
+ groups.push(group);
123
+ }
124
+
125
+ if (groups.length === 0) {
126
+ problems.push('decisions/INDEX.md has no "## <date> — <session>" groups; the grouped generator only maintains the grouped shape.');
127
+ }
128
+
129
+ return { ok: problems.length === 0, problems, lines, groups };
130
+ }
131
+
132
+ /**
133
+ * Collects canonical decisions from the domain files using the strict
134
+ * em-dash heading form. Hyphen-only headings cannot be indexed and are
135
+ * reported, never silently dropped (D-283).
136
+ */
137
+ export async function collectCanonicalDecisions(rootDir) {
138
+ const decisionsDir = path.join(rootDir, 'decisions');
139
+ const decisions = [];
140
+ const problems = [];
141
+ const seen = new Map();
142
+
143
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
144
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
145
+ const strictIds = new Set();
146
+ for (const match of content.matchAll(CANONICAL_DECISION_PATTERN)) {
147
+ const id = Number(match[1]);
148
+ strictIds.add(id);
149
+ const domain = path.basename(fileName, '.md');
150
+ const previous = seen.get(id);
151
+ if (previous) {
152
+ problems.push(`Canonical decision ${formatDecisionId(id)} appears in both ${previous} and ${fileName}; repair the duplicate before regenerating the index (D-276).`);
153
+ continue;
154
+ }
155
+ seen.set(id, fileName);
156
+ decisions.push({ id, title: match[2].trim(), domain });
157
+ }
158
+ for (const match of content.matchAll(HYPHEN_ONLY_DECISION_PATTERN)) {
159
+ if (!strictIds.has(Number(match[1]))) {
160
+ problems.push(`Decision heading "D-${match[1]}" in ${fileName} uses "-" instead of "—" and cannot be indexed; fix the canonical heading.`);
161
+ }
162
+ }
163
+ }
164
+
165
+ decisions.sort((left, right) => left.id - right.id);
166
+ return { decisions, problems };
167
+ }
168
+
169
+ /**
170
+ * Canonical↔index correspondence: every canonical decision exactly once with
171
+ * a matching title and domain link; no orphan or duplicate rows. Returns the
172
+ * mismatch findings plus the canonical decisions absent from the index.
173
+ */
174
+ function diffIndexAgainstCanonical(parsed, decisions) {
175
+ const problems = [];
176
+ const canonicalById = new Map(decisions.map((decision) => [decision.id, decision]));
177
+ const rowsById = new Map();
178
+
179
+ for (const group of parsed.groups) {
180
+ for (const row of group.rows) {
181
+ if (rowsById.has(row.id)) {
182
+ problems.push(`Index row ${row.idText} appears more than once (groups "${rowsById.get(row.id).groupLabel}" and "${group.label}").`);
183
+ continue;
184
+ }
185
+ rowsById.set(row.id, { ...row, groupLabel: group.label });
186
+
187
+ const canonical = canonicalById.get(row.id);
188
+ if (!canonical) {
189
+ problems.push(`Index row ${row.idText} ("${truncate(row.title)}") has no canonical decision entry.`);
190
+ continue;
191
+ }
192
+ if (canonical.title !== row.title) {
193
+ problems.push(`Index row ${row.idText} title ("${truncate(row.title)}") does not match the canonical heading ("${truncate(canonical.title)}").`);
194
+ }
195
+ if (canonical.domain !== row.domain) {
196
+ problems.push(`Index row ${row.idText} links domain [${row.domain}] but the canonical entry lives in ${canonical.domain}.md.`);
197
+ }
198
+ }
199
+ }
200
+
201
+ const missing = decisions.filter((decision) => !rowsById.has(decision.id));
202
+ return { problems, missing, rowCount: rowsById.size };
203
+ }
204
+
205
+ /** Read-only validation (`refresh-decision-index --check`). */
206
+ export async function checkGroupedDecisionIndex(options) {
207
+ const rootDir = requireRootDir(options);
208
+ const indexPath = path.join(rootDir, 'decisions', 'INDEX.md');
209
+ const content = await readFile(indexPath, 'utf8').catch(() => null);
210
+ if (content === null) {
211
+ return { ok: false, indexPath, problems: [`No decisions/INDEX.md exists at ${indexPath}.`], stats: null };
212
+ }
213
+
214
+ const parsed = parseGroupedDecisionIndex(content);
215
+ const canonical = await collectCanonicalDecisions(rootDir);
216
+ const problems = [...parsed.problems, ...canonical.problems];
217
+ let stats = null;
218
+
219
+ if (parsed.ok) {
220
+ const diff = diffIndexAgainstCanonical(parsed, canonical.decisions);
221
+ problems.push(...diff.problems);
222
+ for (const decision of diff.missing) {
223
+ problems.push(`Canonical decision ${formatDecisionId(decision.id)} ("${truncate(decision.title)}") is missing from the index.`);
224
+ }
225
+ stats = {
226
+ groupCount: parsed.groups.length,
227
+ rowCount: diff.rowCount,
228
+ decisionCount: canonical.decisions.length,
229
+ };
230
+ }
231
+
232
+ return { ok: problems.length === 0, indexPath, problems, stats };
233
+ }
234
+
235
+ /**
236
+ * Structure-preserving refresh: appends canonical decisions missing from the
237
+ * index to the group named by `groupLabel` (created at the tail when absent).
238
+ * Existing lines are preserved verbatim; with nothing missing the file is not
239
+ * rewritten, so a no-op refresh is byte-idempotent. Any parse or
240
+ * correspondence problem refuses the write with named findings.
241
+ */
242
+ export async function refreshGroupedDecisionIndex(options) {
243
+ const rootDir = requireRootDir(options);
244
+ return withMemoryRootLock(rootDir, 'refresh-decision-index', () => refreshGroupedDecisionIndexLocked(rootDir, options));
245
+ }
246
+
247
+ async function refreshGroupedDecisionIndexLocked(rootDir, options) {
248
+ const indexPath = path.join(rootDir, 'decisions', 'INDEX.md');
249
+ const groupLabel = normalizeOptionalString(options.groupLabel);
250
+ const result = {
251
+ indexPath,
252
+ refreshed: false,
253
+ upToDate: false,
254
+ added: [],
255
+ groupLabel,
256
+ problems: [],
257
+ warnings: [],
258
+ };
259
+
260
+ const content = await readFile(indexPath, 'utf8').catch(() => null);
261
+ if (content === null) {
262
+ result.problems.push(
263
+ `No decisions/INDEX.md exists at ${indexPath}; the grouped generator preserves an existing index and never creates one from scratch (D-283).`,
264
+ );
265
+ return result;
266
+ }
267
+
268
+ const parsed = parseGroupedDecisionIndex(content);
269
+ const canonical = await collectCanonicalDecisions(rootDir);
270
+ result.problems.push(...parsed.problems);
271
+ // Duplicate canonical IDs refuse the refresh; hyphen-heading findings are
272
+ // reported as warnings but do not block indexing the decisions that parse.
273
+ const duplicateProblems = canonical.problems.filter((problem) => problem.includes('appears in both'));
274
+ const headingWarnings = canonical.problems.filter((problem) => !problem.includes('appears in both'));
275
+ result.problems.push(...duplicateProblems);
276
+ result.warnings.push(...headingWarnings);
277
+ if (result.problems.length > 0) {
278
+ return result;
279
+ }
280
+
281
+ const diff = diffIndexAgainstCanonical(parsed, canonical.decisions);
282
+ result.problems.push(...diff.problems);
283
+ if (result.problems.length > 0) {
284
+ return result;
285
+ }
286
+
287
+ if (diff.missing.length === 0) {
288
+ result.upToDate = true;
289
+ return result;
290
+ }
291
+
292
+ if (!groupLabel) {
293
+ result.problems.push(
294
+ `${diff.missing.length} decision(s) are missing from the index but no group label was determined; pass --group "<date> — Session <N> (<lane-id> lane)" or run with a resolvable lane (D-283).`,
295
+ );
296
+ return result;
297
+ }
298
+
299
+ const rowLines = diff.missing.map(renderIndexRow);
300
+ const lines = [...parsed.lines];
301
+ const targetGroups = parsed.groups.filter((group) => group.label === groupLabel);
302
+ if (targetGroups.length > 0) {
303
+ const target = targetGroups[targetGroups.length - 1];
304
+ lines.splice(target.lastRowLineIndex + 1, 0, ...rowLines);
305
+ } else {
306
+ // New trailing group. The file ends with a trailing newline, so the last
307
+ // split element is an empty string — insert before it to keep the EOF
308
+ // byte layout unchanged.
309
+ const insertAt = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
310
+ lines.splice(insertAt, 0, '', `## ${groupLabel}`, '', TABLE_HEADER, TABLE_SEPARATOR, ...rowLines);
311
+ }
312
+
313
+ await writeFile(indexPath, lines.join('\n'), 'utf8');
314
+ result.refreshed = true;
315
+ result.added = diff.missing;
316
+ return result;
317
+ }
318
+
319
+ /**
320
+ * Default group label per D-283: `<session_date> — Session <N> (<lane-id>
321
+ * lane)` for the lane resolved by the shared D-277/D-280 selection order.
322
+ * Returns null when the resolved lane cannot supply a date/number; callers
323
+ * that need a label anyway surface the refresh problem. The marker is passed
324
+ * in from the caller's already-resolved command context (so a worktree
325
+ * marker's root has already been adopted); this function only selects the
326
+ * lane and does not re-resolve the root — re-resolving with the defaulted
327
+ * root would null a valid marker as a root mismatch (D-280).
328
+ */
329
+ export async function resolveDefaultIndexGroupLabel(options) {
330
+ const rootDir = requireRootDir(options);
331
+ const sessions = await listProjectSessions({ rootDir, cwd: options.cwd });
332
+ const selection = resolveLaneSelection({
333
+ explicitSessionId: options.sessionId ?? null,
334
+ marker: options.marker ?? null,
335
+ laneIds: sessions.lanes.map((lane) => lane.id),
336
+ rootDir,
337
+ purpose: 'label the decision index group for',
338
+ });
339
+
340
+ if (!selection.sessionId) {
341
+ return null;
342
+ }
343
+
344
+ const lane = sessions.lanes.find((item) => item.id === selection.sessionId);
345
+ if (!lane?.sessionDate || !lane.sessionNumber) {
346
+ return null;
347
+ }
348
+
349
+ return {
350
+ label: `${lane.sessionDate} — Session ${lane.sessionNumber} (${lane.id} lane)`,
351
+ sessionId: selection.sessionId,
352
+ warnings: [...selection.warnings],
353
+ };
354
+ }
355
+
356
+ function renderIndexRow(decision) {
357
+ return `| ${formatDecisionId(decision.id)} | ${escapeRowTitle(decision.title)} | [${decision.domain}](${decision.domain}.md) |`;
358
+ }
359
+
360
+ function escapeRowTitle(title) {
361
+ return title.replace(/\|/g, '\\|');
362
+ }
363
+
364
+ function unescapeRowTitle(title) {
365
+ return title.replace(/\\\|/g, '|');
366
+ }
367
+
368
+ function truncate(value, max = 80) {
369
+ const text = String(value);
370
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
371
+ }
372
+
373
+ function requireRootDir(options) {
374
+ if (typeof options?.rootDir !== 'string' || options.rootDir.trim() === '') {
375
+ throw new Error('The grouped decision index requires a rootDir.');
376
+ }
377
+ return options.rootDir;
378
+ }
379
+
380
+ function normalizeOptionalString(value) {
381
+ if (typeof value !== 'string') {
382
+ return null;
383
+ }
384
+ const trimmed = value.trim();
385
+ return trimmed.length > 0 ? trimmed : null;
386
+ }
@@ -0,0 +1,195 @@
1
+ import { readdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { withMemoryRootLock } from './serialization.js';
4
+
5
+ /**
6
+ * Canonical decision-log primitives (D-276).
7
+ *
8
+ * D-numbers are allocated from the current shared root at write time, never
9
+ * reserved ahead of a write. `appendDecisionEntry` is the mediated atomic
10
+ * append path: it allocates and appends under the memory-root lock as one
11
+ * operation. `readNextDecisionId` exists for advisory previews and for the
12
+ * allocation step itself; callers outside the lock must treat its result as
13
+ * informational only.
14
+ */
15
+
16
+ const DECISION_HEADING_PATTERN = /^###\s+D-(\d+)\b/gm;
17
+
18
+ /**
19
+ * Canonical headings use zero-padded D-NNN (the manifest scanner rejects
20
+ * shorter forms); IDs past 999 grow naturally.
21
+ */
22
+ export function formatDecisionId(id) {
23
+ return `D-${String(id).padStart(3, '0')}`;
24
+ }
25
+ const NEXT_ID_PLACEHOLDER_PATTERN = /^###\s+D-NEXT\s+(—|-)\s+\S/m;
26
+ const EXCLUDED_DECISION_FILES = ['INDEX.md', 'README.md', 'EXAMPLE.md'];
27
+
28
+ export async function listDecisionFileNames(decisionsDir) {
29
+ try {
30
+ return (await readdir(decisionsDir))
31
+ .filter((fileName) => fileName.endsWith('.md') && !EXCLUDED_DECISION_FILES.includes(fileName))
32
+ .sort((left, right) => left.localeCompare(right));
33
+ } catch (error) {
34
+ if (error?.code === 'ENOENT') return [];
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ export async function readNextDecisionId(rootDir) {
40
+ const decisionsDir = path.join(rootDir, 'decisions');
41
+ let max = 0;
42
+
43
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
44
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
45
+ for (const match of content.matchAll(DECISION_HEADING_PATTERN)) {
46
+ max = Math.max(max, Number(match[1]));
47
+ }
48
+ }
49
+
50
+ return max + 1;
51
+ }
52
+
53
+ /**
54
+ * Scans every canonical decision file and returns duplicate D-number usage as
55
+ * `[{ id, occurrences: ["file.md", ...] }]`. Duplicates block close-session
56
+ * for human repair (D-276); they are never auto-renumbered.
57
+ */
58
+ export async function findDuplicateDecisionIds(rootDir) {
59
+ const decisionsDir = path.join(rootDir, 'decisions');
60
+ const occurrencesById = new Map();
61
+
62
+ for (const fileName of await listDecisionFileNames(decisionsDir)) {
63
+ const content = await readFile(path.join(decisionsDir, fileName), 'utf8');
64
+ for (const match of content.matchAll(DECISION_HEADING_PATTERN)) {
65
+ const id = Number(match[1]);
66
+ const occurrences = occurrencesById.get(id) ?? [];
67
+ occurrences.push(fileName);
68
+ occurrencesById.set(id, occurrences);
69
+ }
70
+ }
71
+
72
+ return [...occurrencesById.entries()]
73
+ .filter(([, occurrences]) => occurrences.length > 1)
74
+ .map(([id, occurrences]) => ({ id, occurrences }))
75
+ .sort((left, right) => left.id - right.id);
76
+ }
77
+
78
+ /**
79
+ * Atomically appends a staged decision entry to a canonical domain file.
80
+ *
81
+ * The staged entry must open with `### D-NEXT — <title>`; the placeholder is
82
+ * replaced with the D-number allocated at write time under the memory-root
83
+ * lock. Returns the allocated ID plus a derived-index reminder, because the
84
+ * grouped `decisions/INDEX.md` refresh stays with the closer until a
85
+ * structure-preserving generator exists (S4).
86
+ */
87
+ export async function appendDecisionEntry(options) {
88
+ const rootDir = requireNonEmptyString(options?.rootDir, 'append-decision requires a rootDir.');
89
+ const targetName = normalizeTargetName(options?.target);
90
+ const entrySource = requireNonEmptyString(options?.entryContent, 'append-decision requires non-empty staged entry content.').trim();
91
+
92
+ if (!NEXT_ID_PLACEHOLDER_PATTERN.test(entrySource)) {
93
+ throw new Error(
94
+ 'Staged decision entries must start with a "### D-NEXT — <title>" heading. ' +
95
+ 'The D-number is allocated at write time (D-276); do not pre-assign one.',
96
+ );
97
+ }
98
+
99
+ const warnings = [];
100
+ if (!/\*\*Timestamp:\*\*/.test(entrySource)) {
101
+ warnings.push('Staged entry has no "**Timestamp:**" line; canonical decisions should carry a real timestamp.');
102
+ }
103
+ if (!/\*\*Rationale:\*\*/.test(entrySource)) {
104
+ warnings.push('Staged entry has no "**Rationale:**" line; canonical decisions should record why.');
105
+ }
106
+
107
+ return withMemoryRootLock(rootDir, 'append-decision', async () => {
108
+ const targetPath = path.join(rootDir, 'decisions', targetName);
109
+ const existing = await readFile(targetPath, 'utf8').catch((error) => {
110
+ if (error?.code === 'ENOENT') {
111
+ throw new Error(
112
+ `Decision target ${targetName} does not exist under ${path.join(rootDir, 'decisions')}. ` +
113
+ 'Create the domain file first (or pick an existing domain).',
114
+ );
115
+ }
116
+ throw error;
117
+ });
118
+
119
+ const decisionId = await readNextDecisionId(rootDir);
120
+ const entry = entrySource.replace(/^(###\s+)D-NEXT\b/m, `$1${formatDecisionId(decisionId)}`);
121
+ const separator = existing.endsWith('\n') ? '\n---\n\n' : '\n\n---\n\n';
122
+ await writeFile(targetPath, `${existing}${separator}${entry}\n`, 'utf8');
123
+
124
+ const duplicates = await findDuplicateDecisionIds(rootDir);
125
+ if (duplicates.length > 0) {
126
+ warnings.push(
127
+ `Duplicate decision IDs detected after append: ${duplicates
128
+ .map((duplicate) => `${formatDecisionId(duplicate.id)} (${duplicate.occurrences.join(', ')})`)
129
+ .join('; ')}. Repair by hand before close-session; duplicates are never auto-renumbered.`,
130
+ );
131
+ }
132
+
133
+ // D-283: when the caller supplies a structure-preserving index refresher
134
+ // (a group label was determinable), the derived index is refreshed inside
135
+ // this same lock (D-276 — reentrant for the holder). The canonical append
136
+ // never fails because of the index: a refusal degrades to a warning plus
137
+ // the hand-refresh reminder.
138
+ let indexReminder =
139
+ 'decisions/INDEX.md is derived; add the grouped index row for this entry by hand (or run `vibecompass refresh-decision-index`).';
140
+ if (typeof options.indexRefresher === 'function') {
141
+ // The canonical entry is already written above; the derived-index
142
+ // refresh must never make append-decision report failure. A structural
143
+ // refusal returns problems (degraded below); an fs error (EACCES,
144
+ // ENOSPC, an unreadable domain file) would otherwise throw here after
145
+ // the append landed and make a retry duplicate the entry, so wrap it and
146
+ // degrade the throw to the same named warning (D-283).
147
+ let refresh = null;
148
+ try {
149
+ refresh = await options.indexRefresher({ decisionId });
150
+ } catch (error) {
151
+ warnings.push(
152
+ `decisions/INDEX.md was not refreshed (D-283): the grouped index refresh failed after the canonical entry was appended — ${error instanceof Error ? error.message : String(error)}. The decision was recorded; do not re-run append-decision — refresh the index by hand with \`vibecompass refresh-decision-index\`.`,
153
+ );
154
+ }
155
+ warnings.push(...(refresh?.warnings ?? []));
156
+ if (refresh?.refreshed) {
157
+ indexReminder = `decisions/INDEX.md refreshed (structure-preserving, D-283): added ${refresh.added
158
+ .map((decision) => formatDecisionId(decision.id))
159
+ .join(', ')} under "## ${refresh.groupLabel}".`;
160
+ } else if (refresh?.upToDate) {
161
+ indexReminder = 'decisions/INDEX.md already lists this entry (structure-preserving refresh found nothing missing).';
162
+ } else if (refresh) {
163
+ warnings.push(
164
+ `decisions/INDEX.md was not refreshed (D-283 fail-closed): ${refresh.problems[0] ?? 'unknown refusal'}`,
165
+ );
166
+ }
167
+ }
168
+
169
+ return {
170
+ decisionId,
171
+ target: targetName,
172
+ targetPath,
173
+ warnings,
174
+ indexReminder,
175
+ };
176
+ });
177
+ }
178
+
179
+ function normalizeTargetName(value) {
180
+ const raw = requireNonEmptyString(value, 'append-decision requires a --target decision domain file.');
181
+ const name = path.basename(raw);
182
+ if (!name.endsWith('.md') || EXCLUDED_DECISION_FILES.includes(name) || name !== raw.replace(/^decisions\//, '')) {
183
+ throw new Error(
184
+ `Invalid decision target "${raw}". Use a domain file name such as "cross-cutting.md" (optionally prefixed with "decisions/").`,
185
+ );
186
+ }
187
+ return name;
188
+ }
189
+
190
+ function requireNonEmptyString(value, message) {
191
+ if (typeof value !== 'string' || value.trim() === '') {
192
+ throw new Error(message);
193
+ }
194
+ return value;
195
+ }