@latysh-development/monolith 0.1.0-alpha.1 → 0.1.0-alpha.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.
@@ -0,0 +1,767 @@
1
+ /**
2
+ * Deterministic story finalization (DOCSYNC-001, ADR-022).
3
+ *
4
+ * `planFinalization` is read-only: it validates a proposal against the
5
+ * instance, evaluates explicit preconditions (PASS / FAIL / NOT_VERIFIED),
6
+ * classifies every write target and returns a digest that binds an owner's
7
+ * approval to exactly what was inspected. `applyFinalization` re-plans under a
8
+ * cross-process lock and writes only if the digest still matches.
9
+ *
10
+ * Consistency model (narrower than a transaction, deliberately): there is no
11
+ * rollback. Targets are written one at a time in a fixed order with `active.md`
12
+ * LAST, every write is an atomic single-file publish (src/root safe-write), and
13
+ * each target has a deterministic "already done" predicate. An interruption
14
+ * leaves a state that `planFinalization` reports as partial and that re-running
15
+ * plan + apply rolls forward; success is only reported after a post-write
16
+ * re-read of every target. See ADR-022.
17
+ *
18
+ * Reused, not reimplemented: safe reads/writes and containment (src/root),
19
+ * the cross-process lock (src/coordinatorium/lock), the Current Story section
20
+ * extractor of `monolith brief` (src/brief/story).
21
+ */
22
+ import { createHash } from 'crypto';
23
+ import { constants as fsc, closeSync, fstatSync, lstatSync, openSync, readFileSync, readdirSync } from 'fs';
24
+ import { dirname, join } from 'path';
25
+ import { RootContainmentError, SafeWriteError, createFile, openParent, projectBoundary, readConfigFile, readProjectFile, replaceFile, resolveMonolithRoot, } from '../root/index.js';
26
+ import { acquireLock } from '../coordinatorium/lock.js';
27
+ import { extractCurrentStory } from '../brief/story.js';
28
+ import { validDate, validateRelPath } from './proposal.js';
29
+ const MAX_DOC_BYTES = 4 * 1024 * 1024;
30
+ const LOCK_NAME = 'finalize.lock';
31
+ const sha = (s) => createHash('sha256').update(s).digest('hex');
32
+ const norm = (s) => s.replace(/\r\n/g, '\n');
33
+ export function readFinalizeConfig(config) {
34
+ const out = { requiredDocs: [], allowedDocs: [], errors: [] };
35
+ if (config.status !== 'ok')
36
+ return out;
37
+ const f = config.cfg.finalization;
38
+ if (f === undefined)
39
+ return out;
40
+ if (typeof f !== 'object' || f === null || Array.isArray(f)) {
41
+ out.errors.push('finalization must be an object.');
42
+ return out;
43
+ }
44
+ const rec = f;
45
+ for (const k of Object.keys(rec))
46
+ if (k !== 'requiredDocs' && k !== 'allowedDocs')
47
+ out.errors.push(`finalization.${k}: unknown field.`);
48
+ if (rec.requiredDocs !== undefined) {
49
+ if (!Array.isArray(rec.requiredDocs))
50
+ out.errors.push('finalization.requiredDocs must be an array of {path, label}.');
51
+ else {
52
+ rec.requiredDocs.forEach((d, i) => {
53
+ const o = d;
54
+ const bad = typeof o === 'object' && o !== null ? validateRelPath(o.path) : 'entry must be an object';
55
+ if (bad !== null || typeof o?.label !== 'string')
56
+ out.errors.push(`finalization.requiredDocs[${i}]: ${bad ?? 'label must be a string'}.`);
57
+ else
58
+ out.requiredDocs.push({ path: o.path, label: o.label });
59
+ });
60
+ }
61
+ }
62
+ if (rec.allowedDocs !== undefined) {
63
+ if (!Array.isArray(rec.allowedDocs))
64
+ out.errors.push('finalization.allowedDocs must be an array of paths.');
65
+ else {
66
+ rec.allowedDocs.forEach((p, i) => {
67
+ const bad = validateRelPath(p);
68
+ if (bad !== null)
69
+ out.errors.push(`finalization.allowedDocs[${i}]: ${bad}.`);
70
+ else
71
+ out.allowedDocs.push(p);
72
+ });
73
+ }
74
+ }
75
+ return out;
76
+ }
77
+ function readDoc(projectRoot, rel) {
78
+ const r = readProjectFile(projectRoot, rel, { allowEmpty: true, strictUtf8: true, maxBytes: MAX_DOC_BYTES });
79
+ if (r.ok)
80
+ return { kind: 'present', content: r.content, sha: sha(r.content) };
81
+ if (r.code === 'FILE_MISSING')
82
+ return { kind: 'missing' };
83
+ return { kind: 'unreadable', code: r.code, detail: r.detail };
84
+ }
85
+ // ---------------------------------------------------------------------------
86
+ // Rendering
87
+ // ---------------------------------------------------------------------------
88
+ function bullets(items) {
89
+ return items.length === 0 ? 'None' : items.map((i) => `- ${i}`).join('\n');
90
+ }
91
+ export function renderCapsule(p) {
92
+ const s = p.story;
93
+ const w = p.work;
94
+ return [
95
+ '---',
96
+ 'type: story-capsule',
97
+ `id: story-${s.id.toLowerCase()}`,
98
+ `phase: ${s.phase}`,
99
+ `title: ${JSON.stringify(s.title)}`,
100
+ `date: ${s.date}`,
101
+ 'status: COMPLETED',
102
+ '---',
103
+ '',
104
+ `# STORY ${s.id} — ${s.title}`,
105
+ '',
106
+ '## Objective',
107
+ w.objective,
108
+ '',
109
+ '## Implementation Summary',
110
+ w.implementationSummary,
111
+ '',
112
+ '## Files Changed',
113
+ bullets(w.filesChanged),
114
+ '',
115
+ '## Tests',
116
+ w.tests,
117
+ '',
118
+ '## Key Decisions',
119
+ w.keyDecisions,
120
+ '',
121
+ '## ADRs Written',
122
+ bullets(w.adrs),
123
+ '',
124
+ '## Lessons Learned',
125
+ w.lessons,
126
+ '',
127
+ ].join('\n');
128
+ }
129
+ const capsulePathOf = (root, p) => `${root}/memory/working/phases/${p.story.phase}/stories/${p.story.slug}.md`;
130
+ function historyHeading(p) {
131
+ return `## ${p.story.id} — ${p.story.title} (${p.story.date})`;
132
+ }
133
+ function historyFragment(root, p) {
134
+ return [
135
+ historyHeading(p),
136
+ '',
137
+ `**Phase:** ${p.history.phaseName}`,
138
+ `**Decision:** ${p.history.decision}`,
139
+ `**Build:** ${p.history.build}`,
140
+ `**Capsule:** \`${capsulePathOf(root, p)}\``,
141
+ ].join('\n');
142
+ }
143
+ function indexHeading(p) {
144
+ return `### ${p.story.id} — ${p.story.title}`;
145
+ }
146
+ function indexFragment(root, p) {
147
+ return [
148
+ indexHeading(p),
149
+ '',
150
+ `**Date:** ${p.story.date}`,
151
+ '**Status:** COMPLETED',
152
+ `**Capsule:** \`${capsulePathOf(root, p)}\``,
153
+ '',
154
+ `**Summary:** ${p.summary}`,
155
+ '',
156
+ `**Key outputs:** ${p.keyOutputs.length === 0 ? 'None' : p.keyOutputs.join(', ')}`,
157
+ '',
158
+ '---',
159
+ ].join('\n');
160
+ }
161
+ function activeBodies(root, p) {
162
+ const s = p.story;
163
+ return {
164
+ current: `No active story recorded.\n\nPrevious recorded story: **${s.id} — ${s.title}** (COMPLETED ${s.date})`,
165
+ last: `**Story:** ${s.id} — ${s.title}\n**Date:** ${s.date}\n**Capsule:** \`${capsulePathOf(root, p)}\``,
166
+ };
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // Text transforms (pure)
170
+ // ---------------------------------------------------------------------------
171
+ const eolOf = (s) => (s.includes('\r\n') ? '\r\n' : '\n');
172
+ /** Boundaries as in brief/story.ts: the next heading or a `---` rule ends a section. */
173
+ function findSection(lines, re) {
174
+ const hits = [];
175
+ lines.forEach((l, i) => {
176
+ if (re.test(l.trim()))
177
+ hits.push(i);
178
+ });
179
+ if (hits.length === 0)
180
+ return 'absent';
181
+ if (hits.length > 1)
182
+ return 'ambiguous';
183
+ let end = lines.length;
184
+ for (let i = hits[0] + 1; i < lines.length; i++) {
185
+ const t = lines[i].trim();
186
+ if (/^#{1,6}\s+/.test(t) || /^-{3,}\s*$/.test(t)) {
187
+ end = i;
188
+ break;
189
+ }
190
+ }
191
+ return { start: hits[0], end };
192
+ }
193
+ const sectionBody = (lines, s) => lines
194
+ .slice(s.start + 1, s.end)
195
+ .map((l) => l.replace(/\r$/, ''))
196
+ .join('\n')
197
+ .trim();
198
+ const CURRENT_RE = /^##\s+Current Story\s*$/i;
199
+ const LAST_RE = /^##\s+Last Finalization\s*$/i;
200
+ function editActive(content, bodies) {
201
+ const lines = content.split('\n');
202
+ const cur = findSection(lines, CURRENT_RE);
203
+ const last = findSection(lines, LAST_RE);
204
+ for (const [name, s] of [['## Current Story', cur], ['## Last Finalization', last]]) {
205
+ if (s === 'absent')
206
+ return { state: 'conflict', reason: `active.md has no "${name}" section; it is never created automatically.` };
207
+ if (s === 'ambiguous')
208
+ return { state: 'conflict', reason: `active.md has more than one "${name}" section.` };
209
+ }
210
+ const c = cur;
211
+ const l = last;
212
+ if (sectionBody(lines, c) === bodies.current && sectionBody(lines, l) === bodies.last)
213
+ return { state: 'done' };
214
+ const eol = eolOf(content);
215
+ const cr = eol === '\r\n' ? '\r' : '';
216
+ const fill = (body) => [cr, ...body.split('\n').map((x) => x + cr), cr];
217
+ const out = lines.slice();
218
+ const ordered = [[c, bodies.current], [l, bodies.last]];
219
+ ordered.sort((a, b) => b[0].start - a[0].start);
220
+ for (const [sec, body] of ordered)
221
+ out.splice(sec.start + 1, sec.end - sec.start - 1, ...fill(body));
222
+ return { state: 'pending', content: out.join('\n') };
223
+ }
224
+ /**
225
+ * Structural identity of an entry: a heading line `<level> <story id> — …`
226
+ * (id compared case-insensitively, the em dash separator is part of the
227
+ * contract). Title, slug and date are NOT part of the identity, so a repeat
228
+ * with different wording is found instead of being appended again.
229
+ */
230
+ function entryHeadings(content, level, id) {
231
+ const prefix = `${level} ${id.toLowerCase()} — `;
232
+ return norm(content)
233
+ .split('\n')
234
+ .map((l) => l.trim())
235
+ .filter((l) => l.toLowerCase().startsWith(prefix));
236
+ }
237
+ function checkRecorded(content, level, id, heading, fragment, where) {
238
+ const hits = entryHeadings(content, level, id);
239
+ if (hits.length > 1)
240
+ return { state: 'conflict', reason: `story ${id} is already recorded ${hits.length} times in ${where}; one story has one finalization.` };
241
+ if (hits.length === 1) {
242
+ return hits[0] === heading && norm(content).includes(fragment)
243
+ ? { state: 'done' }
244
+ : { state: 'conflict', reason: `story ${id} is already recorded in ${where} ("${hits[0].slice(0, 120)}") with different content; one story has one finalization and recorded entries are never edited.` };
245
+ }
246
+ return null;
247
+ }
248
+ function editAppendEntry(content, id, heading, fragment) {
249
+ const recorded = checkRecorded(content, '##', id, heading, fragment, 'execution-history.md');
250
+ if (recorded)
251
+ return recorded;
252
+ const eol = eolOf(content);
253
+ const body = content.replace(/\s+$/, '');
254
+ return { state: 'pending', content: `${body}${eol}${eol}---${eol}${eol}${fragment.replace(/\n/g, eol)}${eol}` };
255
+ }
256
+ function editIndex(content, phaseHeading, id, heading, fragment) {
257
+ const recorded = checkRecorded(content, '###', id, heading, fragment, 'story-index.md');
258
+ if (recorded)
259
+ return recorded;
260
+ const eol = eolOf(content);
261
+ const lines = content.split('\n').map((l) => l.replace(/\r$/, ''));
262
+ const hits = lines.map((l, i) => (l.trim() === `## ${phaseHeading}` ? i : -1)).filter((i) => i >= 0);
263
+ if (hits.length > 1)
264
+ return { state: 'conflict', reason: `phase heading "## ${phaseHeading}" appears more than once.` };
265
+ const fragLines = fragment.split('\n');
266
+ if (hits.length === 0) {
267
+ const body = content.replace(/\s+$/, '');
268
+ return { state: 'pending', content: `${body}${eol}${eol}## ${phaseHeading}${eol}${eol}${fragLines.join(eol)}${eol}` };
269
+ }
270
+ let j = lines.length;
271
+ for (let i = hits[0] + 1; i < lines.length; i++) {
272
+ if (/^##\s/.test(lines[i])) {
273
+ j = i;
274
+ break;
275
+ }
276
+ }
277
+ const before = lines.slice(0, j);
278
+ while (before.length > 0 && before[before.length - 1].trim() === '')
279
+ before.pop();
280
+ const after = lines.slice(j);
281
+ const out = [...before, '', ...fragLines, '', ...after];
282
+ return { state: 'pending', content: out.join(eol) };
283
+ }
284
+ /** Evidence older than this many days before the finalization date is not accepted. */
285
+ export const EVIDENCE_MAX_AGE_DAYS = 7;
286
+ /** Lines `**Field:** value` at the start of a line (CR tolerant). */
287
+ function fieldLines(content, field) {
288
+ const re = new RegExp(`^\\*\\*${field}:\\*\\*[ \\t]*(.*)$`, 'gm');
289
+ return [...content.matchAll(re)].map((m) => m[1].replace(/\r$/, '').trim());
290
+ }
291
+ const FIX_HINT = 'To finalize, put exactly one line "**Story:** <id> — <title>" (at the start of the line, outside code fences and comments) in the "## Current Story" section of active.md; MONOLITH never rewrites it for you.';
292
+ /**
293
+ * Structural identity of the active story (DOCSYNC-007). The ONLY source is the
294
+ * `**Story:** <id> …` field line inside `## Current Story` (same grammar as the
295
+ * evidence files and `## Last Finalization`); its FIRST token is the id. Ids
296
+ * mentioned in titles, descriptions, comments, code fences or other fields carry
297
+ * no authority. Zero, several, or malformed field lines → no identity (fail closed).
298
+ */
299
+ export function activeStoryIdentity(currentStoryBody) {
300
+ const found = [];
301
+ let fence = null;
302
+ let comment = false;
303
+ for (const raw of norm(currentStoryBody).split('\n')) {
304
+ const line = raw.trim();
305
+ if (comment) {
306
+ if (line.includes('-->'))
307
+ comment = false;
308
+ continue;
309
+ }
310
+ const f = /^(`{3,}|~{3,})/.exec(line);
311
+ if (fence !== null) {
312
+ if (f !== null && f[1][0] === fence[0] && f[1].length >= fence.length && line === f[1])
313
+ fence = null;
314
+ continue;
315
+ }
316
+ if (f !== null) {
317
+ fence = f[1];
318
+ continue;
319
+ }
320
+ if (line.startsWith('<!--')) {
321
+ if (!line.includes('-->'))
322
+ comment = true;
323
+ continue;
324
+ }
325
+ const m = /^\*\*Story:\*\*[ \t]*(.*)$/.exec(raw.replace(/\r$/, ''));
326
+ if (m !== null)
327
+ found.push(m[1].trim());
328
+ }
329
+ if (found.length === 0)
330
+ return { ok: false, reason: `the Current Story section has no "**Story:** <id>" line, so the active story cannot be identified (ids in free text, headings, comments or code blocks are not identity). ${FIX_HINT}` };
331
+ if (found.length > 1)
332
+ return { ok: false, reason: `the Current Story section has ${found.length} "**Story:**" lines (${found.map((x) => `"${x.slice(0, 40)}"`).join(', ')}): ${new Set(found.map((x) => /^(\S+)/.exec(x)?.[1] ?? '')).size > 1 ? 'conflicting' : 'duplicate'} identity. ${FIX_HINT}` };
333
+ const id = /^(\S+)(?:\s|$)/.exec(found[0])?.[1];
334
+ if (id === undefined || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(id))
335
+ return { ok: false, reason: `the "**Story:**" line ("${found[0].slice(0, 60)}") does not start with a valid story id. ${FIX_HINT}` };
336
+ return { ok: true, id };
337
+ }
338
+ /**
339
+ * Attested evidence. Contract: exactly one `**Status:**` (PASS or FAIL, nothing
340
+ * else on the line), exactly one `**Story:**` whose FIRST token is the story id,
341
+ * exactly one valid `**Date:**` not after the finalization date and at most
342
+ * EVIDENCE_MAX_AGE_DAYS before it. Anything repeated, contradictory or
343
+ * malformed is NOT_VERIFIED (never PASS); only a single clean `FAIL` for this
344
+ * story is FAIL. This is still the assistant's own report: it ties the claim to
345
+ * a story and a date, not to a commit, and nobody here ran the tests.
346
+ */
347
+ function evidenceCheck(ctx, p, id, label, file) {
348
+ const rel = `${ctx.root}/memory/working/${file}`;
349
+ const source = `${rel} (attested by the assistant that ran the ${label} step; MONOLITH does not execute it)`;
350
+ const nv = (detail, sha) => ({ check: { id, status: 'NOT_VERIFIED', detail, source }, sha });
351
+ const d = readDoc(ctx.projectRoot, rel);
352
+ if (d.kind === 'missing')
353
+ return nv(`${rel} does not exist; ${label} was not recorded.`, 'ABSENT');
354
+ if (d.kind === 'unreadable')
355
+ return nv(`${rel} is unreadable (${d.code}).`, 'UNREADABLE');
356
+ const stories = fieldLines(d.content, 'Story');
357
+ if (stories.length !== 1)
358
+ return nv(`expected exactly one "**Story:**" line, found ${stories.length}; ambiguous evidence is not evidence for ${p.story.id}.`, d.sha);
359
+ const first = /^(\S+)(?:\s|$)/.exec(stories[0])?.[1];
360
+ if (first !== p.story.id)
361
+ return nv(`the recorded result is for another story ("${stories[0].slice(0, 120)}"), not ${p.story.id}; the first token of the Story line must be exactly the story id.`, d.sha);
362
+ const statuses = fieldLines(d.content, 'Status');
363
+ if (statuses.length !== 1)
364
+ return nv(`expected exactly one "**Status:**" line, found ${statuses.length}${statuses.length > 1 ? ` (${statuses.map((x) => x.slice(0, 20)).join(' / ')}): contradictory or repeated` : ''}.`, d.sha);
365
+ const dates = fieldLines(d.content, 'Date');
366
+ if (dates.length !== 1 || !validDate(dates[0]))
367
+ return nv(`expected exactly one valid "**Date:** YYYY-MM-DD" line, found ${dates.length === 1 ? `"${dates[0].slice(0, 30)}"` : dates.length}.`, d.sha);
368
+ const ageDays = (Date.parse(`${p.story.date}T00:00:00Z`) - Date.parse(`${dates[0]}T00:00:00Z`)) / 86_400_000;
369
+ if (ageDays < 0)
370
+ return nv(`the recorded result is dated ${dates[0]}, after the finalization date ${p.story.date}.`, d.sha);
371
+ if (ageDays > EVIDENCE_MAX_AGE_DAYS)
372
+ return nv(`the recorded result is dated ${dates[0]}, more than ${EVIDENCE_MAX_AGE_DAYS} days before ${p.story.date}; it is stale. Re-run and re-record it.`, d.sha);
373
+ const status = statuses[0].toUpperCase();
374
+ if (status === 'PASS')
375
+ return { check: { id, status: 'PASS', detail: `recorded Status: PASS for ${p.story.id} on ${dates[0]}.`, source }, sha: d.sha };
376
+ if (status === 'FAIL')
377
+ return { check: { id, status: 'FAIL', detail: `recorded Status: FAIL for ${p.story.id} on ${dates[0]}.`, source }, sha: d.sha };
378
+ return nv(`the Status line must be exactly PASS or FAIL, found "${statuses[0].slice(0, 40)}".`, d.sha);
379
+ }
380
+ const MAX_CAPSULE_SCAN = 2000;
381
+ /**
382
+ * Other capsules of the same story id (frontmatter line `id: story-<id>`),
383
+ * found by scanning `phases/<any>/stories/*.md` with the safe reader. Symlinked
384
+ * directories/files are not followed. A file that cannot be read safely cannot
385
+ * be shown to be a different story, so it is reported (fail closed).
386
+ */
387
+ function findOtherCapsules(ctx, p, ownPath) {
388
+ const phasesRel = `${ctx.root}/memory/working/phases`;
389
+ const wanted = `id: story-${p.story.id.toLowerCase()}`;
390
+ const found = [];
391
+ let scanned = 0;
392
+ let phases = [];
393
+ try {
394
+ phases = readdirSync(join(ctx.projectRoot, phasesRel), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
395
+ }
396
+ catch {
397
+ return found;
398
+ }
399
+ for (const ph of phases.sort()) {
400
+ let files = [];
401
+ try {
402
+ files = readdirSync(join(ctx.projectRoot, phasesRel, ph, 'stories'), { withFileTypes: true }).filter((e) => e.isFile() && e.name.endsWith('.md')).map((e) => e.name);
403
+ }
404
+ catch {
405
+ continue;
406
+ }
407
+ for (const f of files.sort()) {
408
+ const rel = `${phasesRel}/${ph}/stories/${f}`;
409
+ if (rel === ownPath)
410
+ continue;
411
+ if (++scanned > MAX_CAPSULE_SCAN) {
412
+ found.push(`(more than ${MAX_CAPSULE_SCAN} capsules: cannot rule out another capsule of this story)`);
413
+ return found;
414
+ }
415
+ const d = readDoc(ctx.projectRoot, rel);
416
+ if (d.kind === 'unreadable') {
417
+ found.push(`${rel} (unreadable: ${d.code}; cannot rule out that it is this story)`);
418
+ continue;
419
+ }
420
+ if (d.kind !== 'present')
421
+ continue;
422
+ const lines = norm(d.content).split('\n');
423
+ if (lines[0]?.trim() !== '---')
424
+ continue;
425
+ for (let i = 1; i < lines.length && lines[i].trim() !== '---'; i++) {
426
+ if (lines[i].trim().toLowerCase() === wanted) {
427
+ found.push(rel);
428
+ break;
429
+ }
430
+ }
431
+ }
432
+ }
433
+ return found;
434
+ }
435
+ const STAGING_RE = (name) => new RegExp(`^${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.tmp\\.\\d+\\.[0-9a-f]{16}$`);
436
+ /**
437
+ * Explains a hard-linked capsule. `createFile` publishes with link(2) and then
438
+ * removes its staging name; a process killed in between leaves the capsule with
439
+ * a second name. Nothing is ever deleted here: this only reports whether the
440
+ * second name looks like exactly that residue, so the OWNER can remove it by
441
+ * hand. Without a journal the origin cannot be proven (anyone can create a
442
+ * file with such a name), so the verdict is deliberately worded as "consistent
443
+ * with", and any deviation is reported as not recoverable.
444
+ */
445
+ function diagnoseCapsuleResidue(ctx, rel, expected) {
446
+ const generic = 'the capsule has more than one hard link, so it is not trusted. Nothing was changed. Do not delete anything unless you have verified the other link yourself.';
447
+ try {
448
+ const abs = join(ctx.projectRoot, rel);
449
+ const dir = dirname(abs);
450
+ const name = rel.slice(rel.lastIndexOf('/') + 1);
451
+ const st = lstatSync(abs, { bigint: true });
452
+ if (!st.isFile() || st.nlink !== 2n)
453
+ return `${generic} (link count ${st.nlink}, not exactly 2.)`;
454
+ const fd = openSync(abs, fsc.O_RDONLY | fsc.O_NOFOLLOW);
455
+ let content;
456
+ try {
457
+ const fst = fstatSync(fd, { bigint: true });
458
+ if (fst.ino !== st.ino || fst.dev !== st.dev)
459
+ return `${generic} (the file changed while being inspected.)`;
460
+ content = readFileSync(fd, 'utf8');
461
+ }
462
+ finally {
463
+ closeSync(fd);
464
+ }
465
+ if (content !== expected)
466
+ return `${generic} (its content differs from what this proposal would create, so it is not this operation's staging residue.)`;
467
+ const twins = readdirSync(dir)
468
+ .filter((n) => STAGING_RE(name).test(n))
469
+ .filter((n) => {
470
+ const t = lstatSync(join(dir, n), { bigint: true });
471
+ return t.isFile() && !t.isSymbolicLink() && t.ino === st.ino && t.dev === st.dev;
472
+ });
473
+ if (twins.length !== 1)
474
+ return `${generic} (found ${twins.length} matching staging names, expected exactly 1.)`;
475
+ const twinRel = `${dirname(rel)}/${twins[0]}`;
476
+ return `consistent with an interrupted create (the process stopped after link(), before removing its staging name): the second name is "${twinRel}", same file, identical content, no other links. Owner recovery, taking care to remove ONLY that staging name: rm -- '${twinRel}' — then run \`monolith finalize plan\` again. MONOLITH never deletes it automatically because the origin of a hard link cannot be proven.`;
477
+ }
478
+ catch (err) {
479
+ return `${generic} (could not be inspected: ${err.message}.)`;
480
+ }
481
+ }
482
+ function buildInternals(ctx, p) {
483
+ const out = [];
484
+ const r = ctx.root;
485
+ // capsule — created, never overwritten
486
+ {
487
+ const path = capsulePathOf(r, p);
488
+ const content = renderCapsule(p);
489
+ const d = readDoc(ctx.projectRoot, path);
490
+ const t = { id: 'capsule', kind: 'capsule', path, state: 'pending', reason: 'will be created', base: 'ABSENT' };
491
+ const others = findOtherCapsules(ctx, p, path);
492
+ if (others.length > 0) {
493
+ out.push({ target: { ...t, state: 'conflict', reason: `story ${p.story.id} already has a capsule elsewhere (${others.join(', ')}); a second capsule is never created`, base: d.kind === 'present' ? d.sha : 'ABSENT' } });
494
+ }
495
+ else if (d.kind === 'missing')
496
+ out.push({ target: { ...t, preview: content }, write: { mode: 'create', content, baseSha: null } });
497
+ else if (d.kind === 'unreadable') {
498
+ const why = d.code === 'FILE_HARDLINKED' ? `${d.code}: ${diagnoseCapsuleResidue(ctx, path, content)}` : `${d.code}: ${d.detail}`;
499
+ out.push({ target: { ...t, state: 'unreadable', reason: why, base: 'UNREADABLE' } });
500
+ }
501
+ else if (d.content === content)
502
+ out.push({ target: { ...t, state: 'done', reason: 'capsule already present with identical content', base: 'DONE' } });
503
+ else
504
+ out.push({ target: { ...t, state: 'conflict', reason: 'a different file already exists at the capsule path; it is never overwritten', base: d.sha } });
505
+ }
506
+ const textTarget = (id, kind, path, edit, preview, missingReason) => {
507
+ const d = readDoc(ctx.projectRoot, path);
508
+ const t = { id, kind, path, state: 'pending', reason: '', base: 'ABSENT' };
509
+ if (d.kind === 'missing') {
510
+ out.push({ target: { ...t, state: 'missing', reason: missingReason } });
511
+ return;
512
+ }
513
+ if (d.kind === 'unreadable') {
514
+ out.push({ target: { ...t, state: 'unreadable', reason: `${d.code}: ${d.detail}`, base: 'UNREADABLE' } });
515
+ return;
516
+ }
517
+ const e = edit(d.content);
518
+ if (e.state === 'done')
519
+ out.push({ target: { ...t, state: 'done', reason: 'already in its final state', base: 'DONE' } });
520
+ else if (e.state === 'conflict')
521
+ out.push({ target: { ...t, state: 'conflict', reason: e.reason, base: d.sha } });
522
+ else
523
+ out.push({ target: { ...t, reason: 'will be updated', base: d.sha, preview }, write: { mode: 'replace', content: e.content, baseSha: d.sha } });
524
+ };
525
+ textTarget('execution-history', 'execution-history', `${r}/memory/long-term/execution-history.md`, (c) => editAppendEntry(c, p.story.id, historyHeading(p), historyFragment(r, p)), historyFragment(r, p), 'execution-history.md does not exist; it is never created automatically');
526
+ textTarget('story-index', 'story-index', `${r}/memory/working/story-index.md`, (c) => editIndex(c, p.story.phaseHeading, p.story.id, indexHeading(p), indexFragment(r, p)), indexFragment(r, p), 'story-index.md does not exist; it is never created automatically');
527
+ for (const [i, u] of (p.docUpdates ?? []).entries()) {
528
+ const allowed = new Set([...ctx.config.requiredDocs.map((x) => x.path), ...ctx.config.allowedDocs]);
529
+ const id = `doc:${i}`;
530
+ if (!allowed.has(u.path)) {
531
+ out.push({ target: { id, kind: 'doc', path: u.path, state: 'conflict', reason: 'path is not an allowed write target (monolith.config.json finalization.requiredDocs / allowedDocs)', base: 'ABSENT' } });
532
+ continue;
533
+ }
534
+ textTarget(id, 'doc', u.path, (c) => (c === u.newContent ? { state: 'done' } : { state: 'pending', content: u.newContent }), `(${Buffer.byteLength(u.newContent)} bytes, sha256 ${sha(u.newContent).slice(0, 12)}…) ${u.reason}`, 'document does not exist; it is never created automatically');
535
+ }
536
+ if (p.handoff) {
537
+ const content = p.handoff.content;
538
+ textTarget('handoff', 'handoff', `${r}/context/boot/HANDOFF.md`, (c) => (c === content ? { state: 'done' } : { state: 'pending', content }), `(full replacement, ${Buffer.byteLength(content)} bytes, sha256 ${sha(content).slice(0, 12)}…)`, 'HANDOFF.md does not exist; it is never created automatically');
539
+ }
540
+ const bodies = activeBodies(r, p);
541
+ textTarget('active', 'active', `${r}/memory/working/active.md`, (c) => editActive(c, bodies), `## Current Story ← ${bodies.current.split('\n')[0]} …\n## Last Finalization ← ${bodies.last.split('\n')[0]} …`, 'active.md does not exist; it is never created automatically');
542
+ // two targets must never share a path (a doc update cannot alias a canonical file)
543
+ const seen = new Map();
544
+ for (const it of out) {
545
+ const prior = seen.get(it.target.path);
546
+ if (prior !== undefined) {
547
+ it.target.state = 'conflict';
548
+ it.target.reason = `path is also the target "${prior}"`;
549
+ delete it.write;
550
+ }
551
+ else
552
+ seen.set(it.target.path, it.target.id);
553
+ }
554
+ return out;
555
+ }
556
+ function makeContext(targetDir) {
557
+ let config;
558
+ try {
559
+ config = readConfigFile(targetDir);
560
+ }
561
+ catch (err) {
562
+ return { error: `cannot read monolith.config.json: ${err.message}` };
563
+ }
564
+ let resolved;
565
+ try {
566
+ resolved = resolveMonolithRoot(targetDir, config);
567
+ }
568
+ catch (err) {
569
+ if (err instanceof RootContainmentError)
570
+ return { error: `${err.code}: ${err.message}` };
571
+ throw err;
572
+ }
573
+ const fc = readFinalizeConfig(config);
574
+ return { ctx: { projectRoot: resolved.projectRoot, root: resolved.monolithRoot, config: fc, configProblem: fc.errors.length > 0 ? fc.errors.join(' ') : null } };
575
+ }
576
+ function refusedPlan(p, message) {
577
+ return {
578
+ status: 'NOT_READY',
579
+ storyId: p.story.id,
580
+ digest: '',
581
+ partial: false,
582
+ checks: [{ id: 'instance', status: 'FAIL', detail: message, source: 'monolith.config.json' }],
583
+ targets: [],
584
+ diagnostics: [message],
585
+ };
586
+ }
587
+ function plan(ctx, p) {
588
+ const internals = buildInternals(ctx, p);
589
+ const targets = internals.map((i) => i.target);
590
+ const checks = [];
591
+ const diagnostics = [];
592
+ if (ctx.configProblem !== null)
593
+ checks.push({ id: 'config', status: 'FAIL', detail: ctx.configProblem, source: 'monolith.config.json' });
594
+ else if (ctx.config.requiredDocs.length === 0)
595
+ diagnostics.push('finalization.requiredDocs is not configured in monolith.config.json: no documents were required.');
596
+ const bad = targets.filter((t) => t.state === 'conflict' || t.state === 'missing' || t.state === 'unreadable');
597
+ checks.push(bad.length === 0
598
+ ? { id: 'targets', status: 'PASS', detail: `${targets.length} write target(s) are inside the instance, regular files and applicable.`, source: 'MONOLITH (safe read, containment, no symlinks)' }
599
+ : { id: 'targets', status: 'FAIL', detail: bad.map((t) => `${t.id} [${t.state}]: ${t.reason}`).join(' | '), source: 'MONOLITH (safe read, containment, no symlinks)' });
600
+ const active = targets.find((t) => t.id === 'active');
601
+ if (active?.state === 'done') {
602
+ checks.push({ id: 'expected-state', status: 'PASS', detail: 'active.md is already in its final state.', source: 'MONOLITH' });
603
+ }
604
+ else {
605
+ const d = readDoc(ctx.projectRoot, `${ctx.root}/memory/working/active.md`);
606
+ const exp = p.expectedCurrentStory;
607
+ if (d.kind !== 'present')
608
+ checks.push({ id: 'expected-state', status: 'FAIL', detail: 'active.md could not be read.', source: 'MONOLITH' });
609
+ else {
610
+ const ex = extractCurrentStory(d.content);
611
+ const excerpt = ex.excerpt ?? '';
612
+ let ok;
613
+ let want;
614
+ let why = '';
615
+ if (exp === 'none')
616
+ (ok = ex.hasCurrentStorySection && /no active story/i.test(excerpt)), (want = 'no active story');
617
+ else {
618
+ const ident = activeStoryIdentity(excerpt);
619
+ want = `Current Story containing "${exp.contains}" whose "**Story:**" field is exactly ${p.story.id}`;
620
+ if (!excerpt.includes(exp.contains))
621
+ (ok = false), (why = ' The expected text is not in the Current Story section.');
622
+ else if (!ident.ok)
623
+ (ok = false), (why = ` ${ident.reason}`);
624
+ else if (ident.id !== p.story.id)
625
+ (ok = false), (why = ` The active story is "${ident.id}", not ${p.story.id}.`);
626
+ else
627
+ ok = true;
628
+ }
629
+ checks.push({ id: 'expected-state', status: ok ? 'PASS' : 'FAIL', detail: ok ? `Current Story matches the expected state: ${want}.` : `Current Story does not match the expected state: ${want}.${why}`, source: 'MONOLITH (brief Current Story extraction + structural **Story:** field)' });
630
+ }
631
+ }
632
+ const reqShas = [];
633
+ if (ctx.config.requiredDocs.length > 0) {
634
+ const problems = [];
635
+ for (const rd of ctx.config.requiredDocs) {
636
+ const d = readDoc(ctx.projectRoot, rd.path);
637
+ if (d.kind === 'missing')
638
+ (problems.push(`${rd.path} (${rd.label}): MISSING`), reqShas.push(`${rd.path}:ABSENT`));
639
+ else if (d.kind === 'unreadable')
640
+ (problems.push(`${rd.path} (${rd.label}): UNREADABLE ${d.code}`), reqShas.push(`${rd.path}:UNREADABLE`));
641
+ else
642
+ reqShas.push(`${rd.path}:${d.sha}`);
643
+ }
644
+ checks.push(problems.length === 0
645
+ ? { id: 'required-docs', status: 'PASS', detail: `${ctx.config.requiredDocs.length} required document(s) exist and are readable.`, source: 'monolith.config.json finalization.requiredDocs' }
646
+ : { id: 'required-docs', status: 'FAIL', detail: problems.join('; '), source: 'monolith.config.json finalization.requiredDocs' });
647
+ }
648
+ const integrity = evidenceCheck(ctx, p, 'integrity', 'integrity verification', 'integrity-result.md');
649
+ const governance = evidenceCheck(ctx, p, 'governance', 'governance evaluation', 'governance-result.md');
650
+ checks.push(integrity.check, governance.check);
651
+ const allDone = targets.length > 0 && targets.every((t) => t.state === 'done');
652
+ const someDone = targets.some((t) => t.state === 'done');
653
+ const ready = checks.every((c) => c.status === 'PASS');
654
+ const status = allDone ? 'ALREADY_FINALIZED' : ready ? 'READY' : 'NOT_READY';
655
+ const digest = sha(JSON.stringify({
656
+ v: 1,
657
+ proposal: p,
658
+ root: ctx.root,
659
+ targets: targets.map((t) => [t.id, t.path, t.state, t.base]),
660
+ required: reqShas,
661
+ evidence: [integrity.sha, governance.sha],
662
+ config: [ctx.config.requiredDocs, ctx.config.allowedDocs],
663
+ }));
664
+ return { plan: { status, storyId: p.story.id, digest, partial: someDone && !allDone, checks, targets, diagnostics }, internals };
665
+ }
666
+ export function planFinalization(targetDir, proposal) {
667
+ const c = makeContext(targetDir);
668
+ if ('error' in c)
669
+ return refusedPlan(proposal, c.error);
670
+ return plan(c.ctx, proposal).plan;
671
+ }
672
+ // ---------------------------------------------------------------------------
673
+ // Apply
674
+ // ---------------------------------------------------------------------------
675
+ function refused(p, pl, msg) {
676
+ return { status: 'REFUSED', storyId: p.story.id, plan: pl, applied: [], pending: pl.targets.filter((t) => t.state !== 'done').map((t) => t.id), diagnostics: [msg] };
677
+ }
678
+ export async function applyFinalization(targetDir, proposal, approvedDigest, opts = {}) {
679
+ const c = makeContext(targetDir);
680
+ if ('error' in c)
681
+ return refused(proposal, refusedPlan(proposal, c.error), c.error);
682
+ const { ctx } = c;
683
+ const boundary = projectBoundary(ctx.projectRoot);
684
+ let release = null;
685
+ try {
686
+ const workingAbs = join(ctx.projectRoot, ctx.root, 'memory', 'working');
687
+ const lockParent = openParent(boundary, workingAbs, { ops: opts.ops });
688
+ release = await acquireLock(join(workingAbs, LOCK_NAME), { timeoutMs: 10_000, ...opts.lock }, {}, lockParent);
689
+ }
690
+ catch (err) {
691
+ const msg = `cannot take the finalization lock (another finalization may be running, or this is not an instance): ${err.message}`;
692
+ return refused(proposal, plan(ctx, proposal).plan, msg);
693
+ }
694
+ try {
695
+ const { plan: pl, internals } = plan(ctx, proposal);
696
+ if (pl.status === 'ALREADY_FINALIZED') {
697
+ return { status: 'ALREADY_FINALIZED', storyId: proposal.story.id, plan: pl, applied: [], pending: [], diagnostics: ['Every target is already in its final state; nothing was written.'] };
698
+ }
699
+ if (pl.status !== 'READY')
700
+ return refused(proposal, pl, 'Preconditions are not satisfied; nothing was written.');
701
+ if (approvedDigest !== pl.digest) {
702
+ return refused(proposal, pl, 'STALE: the approved digest does not match the current state (a document, the evidence or the proposal changed since the plan was approved). Run `monolith finalize plan` again and re-approve; nothing was written.');
703
+ }
704
+ const order = ['capsule', 'execution-history', 'story-index', 'doc', 'handoff', 'active'];
705
+ const todo = internals.filter((i) => i.write !== undefined).sort((a, b) => order.indexOf(a.target.kind) - order.indexOf(b.target.kind));
706
+ const applied = [];
707
+ for (const it of todo) {
708
+ const w = it.write;
709
+ const t = it.target;
710
+ try {
711
+ if (w.mode === 'replace') {
712
+ const cur = readDoc(ctx.projectRoot, t.path);
713
+ if (cur.kind !== 'present' || cur.sha !== w.baseSha) {
714
+ throw new SafeWriteError(`${t.path} changed between planning and writing; refusing to overwrite it.`, { code: 'WRITE_TARGET_EXISTS', phase: 'validate', publication: 'NOT_PUBLISHED', path: t.path });
715
+ }
716
+ }
717
+ const parent = openParent(boundary, join(ctx.projectRoot, dirname(t.path)), { create: t.kind === 'capsule', ops: opts.ops });
718
+ const name = t.path.slice(t.path.lastIndexOf('/') + 1);
719
+ const wo = { mode: 0o666, exactMode: false, durability: 'full', maxBytes: MAX_DOC_BYTES, ops: opts.ops };
720
+ const outcome = w.mode === 'create' ? createFile(parent, name, w.content, wo) : replaceFile(parent, name, w.content, wo);
721
+ for (const warn of outcome.warnings)
722
+ opts.onWarning?.(warn);
723
+ applied.push(t.id);
724
+ }
725
+ catch (err) {
726
+ const pending = todo.filter((x) => !applied.includes(x.target.id)).map((x) => x.target.id);
727
+ const msg = err instanceof Error ? err.message : String(err);
728
+ const publication = err instanceof SafeWriteError ? err.publication : undefined;
729
+ const nothing = applied.length === 0 && publication === 'NOT_PUBLISHED';
730
+ return {
731
+ status: nothing ? 'REFUSED' : 'INCOMPLETE',
732
+ storyId: proposal.story.id,
733
+ plan: pl,
734
+ applied,
735
+ pending,
736
+ failed: { target: t.id, message: msg, publication },
737
+ diagnostics: [
738
+ nothing
739
+ ? 'The first write failed before anything was published; nothing changed.'
740
+ : `Finalization stopped after ${applied.length} of ${todo.length} write(s). The story is NOT finalized. Fix the cause, then run \`monolith finalize plan\` (it reports the partial state) and apply the new digest to roll forward.`,
741
+ ],
742
+ };
743
+ }
744
+ }
745
+ const after = plan(ctx, proposal).plan;
746
+ const notDone = after.targets.filter((t) => t.state !== 'done');
747
+ if (notDone.length > 0) {
748
+ return {
749
+ status: 'INCOMPLETE',
750
+ storyId: proposal.story.id,
751
+ plan: after,
752
+ applied,
753
+ pending: notDone.map((t) => t.id),
754
+ diagnostics: [`Post-write verification failed: ${notDone.map((t) => `${t.id} [${t.state}] ${t.reason}`).join('; ')}. The story is NOT reported as finalized.`],
755
+ };
756
+ }
757
+ return { status: 'FINALIZED', storyId: proposal.story.id, plan: after, applied, pending: [], diagnostics: ['All targets were written and re-read in their final state.'] };
758
+ }
759
+ finally {
760
+ try {
761
+ release?.();
762
+ }
763
+ catch {
764
+ /* the lock helper reports its own residue; the outcome above is already decided */
765
+ }
766
+ }
767
+ }