@latysh-development/monolith 0.1.0-alpha.1 → 0.1.0-alpha.3

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