pentimento 0.3.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.
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/assets/chrome.js +16 -0
- package/assets/theme.css +286 -0
- package/assets/viewer.js +105 -0
- package/dist/cli.js +289 -0
- package/dist/core.js +223 -0
- package/dist/render.js +433 -0
- package/dist/semdiff.js +125 -0
- package/dist/verify.js +120 -0
- package/dist/viewer.js +239 -0
- package/package.json +52 -0
- package/skill/SKILL.md +68 -0
- package/skill/references/archetypes.md +81 -0
- package/skill/references/directives.md +107 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execSync } from 'node:child_process';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { createTwoFilesPatch } from 'diff';
|
|
7
|
+
import { addComment, loadDoc, readMeta, readRevision, resolveComment, revert, snapshot } from './core.js';
|
|
8
|
+
import { renderDiffPage, renderToFile } from './render.js';
|
|
9
|
+
import { findPentimentoDocs, verifyDoc } from './verify.js';
|
|
10
|
+
import { serveViewer } from './viewer.js';
|
|
11
|
+
const USAGE = `pentimento — living documents
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
pentimento snapshot <doc> --summary "..." [--why "..."] [--source "..."] [--author name]
|
|
15
|
+
pentimento list <doc>
|
|
16
|
+
pentimento diff <doc> [revA] [revB] [--html [-o out.html]]
|
|
17
|
+
(defaults: latest two; one arg diffs it against the canonical;
|
|
18
|
+
--html renders a readable word-level diff page)
|
|
19
|
+
pentimento verify <doc-or-directory> (check canonical/history/meta consistency)
|
|
20
|
+
pentimento revert <doc> <rev> [--author name]
|
|
21
|
+
pentimento render <doc> [-o out.html] [--artifact]
|
|
22
|
+
(--artifact: fragment for claude.ai Artifact publishing;
|
|
23
|
+
default: standalone HTML that works anywhere)
|
|
24
|
+
pentimento serve [dir] [--port 4820] [--host 127.0.0.1 | --tailscale] [--author name]
|
|
25
|
+
(live viewer: document index, revision picker, diffs,
|
|
26
|
+
hot reload, select-to-comment; never binds 0.0.0.0)
|
|
27
|
+
pentimento comments <doc> (list open comments, plus a resolved count)
|
|
28
|
+
pentimento comment <doc> --text "..." [--anchor "#id"] [--quote "..."] [--author name]
|
|
29
|
+
pentimento address <doc> (open comments formatted for an agent to act on)
|
|
30
|
+
pentimento resolve <doc> <comment-id> [--rev rNNN]
|
|
31
|
+
|
|
32
|
+
Inline comments: leave %% @c: a note %% in the markdown — snapshot extracts them
|
|
33
|
+
into meta.yml anchored to the nearest heading.
|
|
34
|
+
`;
|
|
35
|
+
const parseArgs = (argv) => {
|
|
36
|
+
const positional = [];
|
|
37
|
+
const flags = {};
|
|
38
|
+
const boolean = new Set(['artifact', 'html', 'tailscale']);
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a.startsWith('--') && boolean.has(a.slice(2)))
|
|
42
|
+
flags[a.slice(2)] = 'true';
|
|
43
|
+
else if (a.startsWith('--'))
|
|
44
|
+
flags[a.slice(2)] = argv[++i] ?? '';
|
|
45
|
+
else if (a === '-o')
|
|
46
|
+
flags.out = argv[++i] ??
|
|
47
|
+
'';
|
|
48
|
+
else if (a === '-s')
|
|
49
|
+
flags.summary = argv[++i] ?? '';
|
|
50
|
+
else
|
|
51
|
+
positional.push(a);
|
|
52
|
+
}
|
|
53
|
+
return { positional, flags };
|
|
54
|
+
};
|
|
55
|
+
const fail = (msg) => {
|
|
56
|
+
console.error(`pentimento: ${msg}`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
};
|
|
59
|
+
const tailscaleIp = () => {
|
|
60
|
+
try {
|
|
61
|
+
const out = execSync('tailscale ip -4', { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0];
|
|
62
|
+
if (/^100\./.test(out))
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
catch { /* fall through to interface scan */ }
|
|
66
|
+
for (const addrs of Object.values(os.networkInterfaces())) {
|
|
67
|
+
for (const a of addrs ?? []) {
|
|
68
|
+
// Tailscale hands out CGNAT range 100.64.0.0/10
|
|
69
|
+
if (a.family === 'IPv4' && /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(a.address))
|
|
70
|
+
return a.address;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return fail('could not determine a Tailscale IP (is tailscale up?)');
|
|
74
|
+
};
|
|
75
|
+
const defaultAuthor = () => {
|
|
76
|
+
try {
|
|
77
|
+
const name = execSync('git config user.name', { encoding: 'utf8', timeout: 3000 }).trim();
|
|
78
|
+
if (name)
|
|
79
|
+
return name;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// not in a git repo or git missing — fall through
|
|
83
|
+
}
|
|
84
|
+
return process.env.USER || process.env.USERNAME || undefined;
|
|
85
|
+
};
|
|
86
|
+
const main = () => {
|
|
87
|
+
const [cmd, ...rest] = process.argv.slice(2);
|
|
88
|
+
const { positional, flags } = parseArgs(rest);
|
|
89
|
+
const doc = positional[0];
|
|
90
|
+
switch (cmd) {
|
|
91
|
+
case 'snapshot': {
|
|
92
|
+
if (!doc)
|
|
93
|
+
fail('snapshot needs a document path');
|
|
94
|
+
if (!flags.summary)
|
|
95
|
+
fail('snapshot needs --summary "what changed"');
|
|
96
|
+
const res = snapshot(doc, {
|
|
97
|
+
summary: flags.summary,
|
|
98
|
+
why: flags.why,
|
|
99
|
+
source: flags.source,
|
|
100
|
+
author: flags.author ?? defaultAuthor(),
|
|
101
|
+
});
|
|
102
|
+
console.log(`${res.rev} → ${res.historyFile}`);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case 'list': {
|
|
106
|
+
if (!doc)
|
|
107
|
+
fail('list needs a document path');
|
|
108
|
+
const meta = readMeta(loadDoc(doc).historyDir);
|
|
109
|
+
if (!meta.revisions.length) {
|
|
110
|
+
console.log('no revisions');
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
for (const r of meta.revisions) {
|
|
114
|
+
console.log(`${r.id} ${r.created_at} ${r.author}`);
|
|
115
|
+
console.log(` ${r.summary}`);
|
|
116
|
+
if (r.why)
|
|
117
|
+
console.log(` why: ${r.why}`);
|
|
118
|
+
}
|
|
119
|
+
const open = meta.comments.filter((c) => c.status === 'open');
|
|
120
|
+
if (open.length)
|
|
121
|
+
console.log(`\n${open.length} open comment(s)`);
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
case 'diff': {
|
|
125
|
+
if (!doc)
|
|
126
|
+
fail('diff needs a document path');
|
|
127
|
+
const d = loadDoc(doc);
|
|
128
|
+
const meta = readMeta(d.historyDir);
|
|
129
|
+
const revs = meta.revisions.map((r) => r.id);
|
|
130
|
+
let a;
|
|
131
|
+
let b;
|
|
132
|
+
let bContent;
|
|
133
|
+
if (positional.length >= 3) {
|
|
134
|
+
;
|
|
135
|
+
[a, b] = [positional[1], positional[2]];
|
|
136
|
+
bContent = readRevision(doc, b);
|
|
137
|
+
}
|
|
138
|
+
else if (positional.length === 2) {
|
|
139
|
+
a = positional[1];
|
|
140
|
+
b = 'canonical';
|
|
141
|
+
bContent = d.raw;
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
if (revs.length < 2)
|
|
145
|
+
fail('need at least two revisions to diff (or pass revisions explicitly)');
|
|
146
|
+
[a, b] = revs.slice(-2);
|
|
147
|
+
bContent = readRevision(doc, b);
|
|
148
|
+
}
|
|
149
|
+
if (flags.html === 'true') {
|
|
150
|
+
const out = flags.out
|
|
151
|
+
? path.resolve(flags.out)
|
|
152
|
+
: path.join(path.dirname(d.canonicalPath), `${d.name.toLowerCase()}-${a}-${b === 'canonical' ? 'now' : b}.html`);
|
|
153
|
+
fs.writeFileSync(out, renderDiffPage(doc, a, b), 'utf8');
|
|
154
|
+
console.log(out);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
process.stdout.write(createTwoFilesPatch(`${d.name} ${a}`, `${d.name} ${b}`, readRevision(doc, a), bContent));
|
|
158
|
+
}
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
case 'verify': {
|
|
162
|
+
if (!doc)
|
|
163
|
+
fail('verify needs a document path or a directory');
|
|
164
|
+
const targets = fs.statSync(doc).isDirectory() ? findPentimentoDocs(doc) : [doc];
|
|
165
|
+
if (!targets.length) {
|
|
166
|
+
console.log('no Pentimento documents found');
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
let errors = 0;
|
|
170
|
+
for (const t of targets) {
|
|
171
|
+
const issues = verifyDoc(t);
|
|
172
|
+
const label = path.relative(process.cwd(), t) || t;
|
|
173
|
+
if (!issues.length) {
|
|
174
|
+
console.log(`✓ ${label}`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
console.log(`${issues.some((i) => i.level === 'error') ? '✗' : '•'} ${label}`);
|
|
178
|
+
for (const i of issues) {
|
|
179
|
+
console.log(` ${i.level.toUpperCase().padEnd(5)} ${i.message}`);
|
|
180
|
+
if (i.level === 'error')
|
|
181
|
+
errors++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (errors)
|
|
185
|
+
process.exit(1);
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
case 'revert': {
|
|
189
|
+
if (!doc || !positional[1])
|
|
190
|
+
fail('revert needs a document path and a revision (e.g. r002)');
|
|
191
|
+
const res = revert(doc, positional[1], flags.author ?? defaultAuthor());
|
|
192
|
+
console.log(`canonical restored from ${positional[1]}; recorded as ${res.rev}`);
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
case 'render': {
|
|
196
|
+
if (!doc)
|
|
197
|
+
fail('render needs a document path');
|
|
198
|
+
const out = renderToFile(doc, flags.out, { artifact: flags.artifact === 'true' });
|
|
199
|
+
console.log(out);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case 'serve': {
|
|
203
|
+
const root = path.resolve(doc ?? '.');
|
|
204
|
+
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory())
|
|
205
|
+
fail(`not a directory: ${root}`);
|
|
206
|
+
const port = Number(flags.port ?? 4820);
|
|
207
|
+
const host = flags.tailscale === 'true' ? tailscaleIp() : (flags.host?.trim() || '127.0.0.1');
|
|
208
|
+
serveViewer(root, { host, port, author: flags.author ?? defaultAuthor() });
|
|
209
|
+
console.log(`pentimento viewer → http://${host}:${port}/ (watching ${root})`);
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
case 'comments': {
|
|
213
|
+
if (!doc)
|
|
214
|
+
fail('comments needs a document path');
|
|
215
|
+
const meta = readMeta(loadDoc(doc).historyDir);
|
|
216
|
+
const open = meta.comments.filter((c) => c.status === 'open');
|
|
217
|
+
const resolved = meta.comments.length - open.length;
|
|
218
|
+
if (!meta.comments.length) {
|
|
219
|
+
console.log('no comments');
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
for (const c of open) {
|
|
223
|
+
console.log(`[${c.id}] OPEN ${c.anchor || '(document)'} — ${c.author}, ${c.created_at.slice(0, 10)}`);
|
|
224
|
+
if (c.quote)
|
|
225
|
+
console.log(` > ${c.quote}`);
|
|
226
|
+
console.log(` ${c.text}`);
|
|
227
|
+
}
|
|
228
|
+
if (resolved)
|
|
229
|
+
console.log(`(+ ${resolved} resolved)`);
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
case 'comment': {
|
|
233
|
+
if (!doc)
|
|
234
|
+
fail('comment needs a document path');
|
|
235
|
+
if (!flags.text)
|
|
236
|
+
fail('comment needs --text "..."');
|
|
237
|
+
const entry = addComment(doc, {
|
|
238
|
+
text: flags.text,
|
|
239
|
+
anchor: flags.anchor,
|
|
240
|
+
quote: flags.quote,
|
|
241
|
+
author: flags.author ?? defaultAuthor(),
|
|
242
|
+
});
|
|
243
|
+
console.log(entry.id);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
case 'address': {
|
|
247
|
+
if (!doc)
|
|
248
|
+
fail('address needs a document path');
|
|
249
|
+
const d = loadDoc(doc);
|
|
250
|
+
const meta = readMeta(d.historyDir);
|
|
251
|
+
const open = meta.comments.filter((c) => c.status === 'open');
|
|
252
|
+
if (!open.length) {
|
|
253
|
+
console.log('no open comments — nothing to address');
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
console.log(`${d.name} (${d.frontmatter['Current Revision'] ?? 'no revision'}) has ${open.length} open comment${open.length > 1 ? 's' : ''}:\n`);
|
|
257
|
+
for (const c of open) {
|
|
258
|
+
console.log(`[${c.id}] anchored at ${c.anchor || '(document)'} — ${c.author}, ${c.created_at.slice(0, 10)}`);
|
|
259
|
+
if (c.quote)
|
|
260
|
+
console.log(` quoted text: "${c.quote}"`);
|
|
261
|
+
if (c.prefix || c.suffix)
|
|
262
|
+
console.log(` context: …${c.prefix ?? ''}[quote]${c.suffix ?? ''}…`);
|
|
263
|
+
console.log(` comment: ${c.text}\n`);
|
|
264
|
+
}
|
|
265
|
+
console.log('To address: revise the canonical markdown accordingly, then:');
|
|
266
|
+
console.log(` pentimento snapshot ${positional[0]} --summary "Address review comments" --why "..."`);
|
|
267
|
+
console.log(` pentimento resolve ${positional[0]} <comment-id> --rev <new revision> # once per addressed comment`);
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
case 'resolve': {
|
|
271
|
+
if (!doc || !positional[1])
|
|
272
|
+
fail('resolve needs a document path and a comment id');
|
|
273
|
+
const c = resolveComment(doc, positional[1], flags.rev);
|
|
274
|
+
console.log(`${c.id} resolved${c.resolved_in ? ` in ${c.resolved_in}` : ''}`);
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
default: {
|
|
278
|
+
const wantsHelp = !cmd || cmd === 'help' || cmd === '--help' || cmd === '-h';
|
|
279
|
+
console.log(USAGE);
|
|
280
|
+
process.exit(wantsHelp ? 0 : 1);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
try {
|
|
285
|
+
main();
|
|
286
|
+
}
|
|
287
|
+
catch (err) {
|
|
288
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
289
|
+
}
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseDocument, parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
4
|
+
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
5
|
+
export const splitRaw = (raw) => {
|
|
6
|
+
const m = FRONTMATTER_RE.exec(raw);
|
|
7
|
+
return m ? { frontmatterRaw: m[1], body: raw.slice(m[0].length) } : { frontmatterRaw: null, body: raw };
|
|
8
|
+
};
|
|
9
|
+
export const slugify = (s) => s.toLowerCase().replace(/`/g, '').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
10
|
+
const INLINE_COMMENT_RE = /[ \t]*%%\s*@c:\s*([\s\S]*?)%%/g;
|
|
11
|
+
/** Pull `%% @c: ... %%` inline comments out of the source, anchored to the nearest preceding heading. */
|
|
12
|
+
export const extractInlineComments = (raw) => {
|
|
13
|
+
const found = [];
|
|
14
|
+
const headingFor = (index) => {
|
|
15
|
+
const matches = [...raw.slice(0, index).matchAll(/^#{1,3}\s+(.+)$/gm)];
|
|
16
|
+
if (!matches.length)
|
|
17
|
+
return '';
|
|
18
|
+
const title = matches[matches.length - 1][1];
|
|
19
|
+
const idm = /<!--[^>]*\bid:\s*([\w-]+)/.exec(title);
|
|
20
|
+
if (idm)
|
|
21
|
+
return `#${idm[1]}`;
|
|
22
|
+
return `#${slugify(title.replace(/<!--.*?-->/, '').trim())}`;
|
|
23
|
+
};
|
|
24
|
+
const cleaned = raw.replace(INLINE_COMMENT_RE, (_m, text, offset) => {
|
|
25
|
+
found.push({ text: text.trim(), anchor: headingFor(offset) });
|
|
26
|
+
return '';
|
|
27
|
+
});
|
|
28
|
+
return { cleaned, found };
|
|
29
|
+
};
|
|
30
|
+
export const nowStamp = () => {
|
|
31
|
+
const d = new Date();
|
|
32
|
+
const offsetMin = -d.getTimezoneOffset();
|
|
33
|
+
const sign = offsetMin >= 0 ? '+' : '-';
|
|
34
|
+
const abs = Math.abs(offsetMin);
|
|
35
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
36
|
+
return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
37
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
|
|
38
|
+
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`);
|
|
39
|
+
};
|
|
40
|
+
export const atomicWrite = (filePath, content) => {
|
|
41
|
+
const tmp = `${filePath}.tmp-${process.pid}`;
|
|
42
|
+
fs.writeFileSync(tmp, content, 'utf8');
|
|
43
|
+
fs.renameSync(tmp, filePath);
|
|
44
|
+
};
|
|
45
|
+
export const revId = (n) => `r${String(n).padStart(3, '0')}`;
|
|
46
|
+
export const parseRevId = (value) => {
|
|
47
|
+
const m = /^r(\d{3,})$/.exec(String(value ?? '').trim());
|
|
48
|
+
if (!m)
|
|
49
|
+
throw new Error(`Invalid Current Revision value: ${JSON.stringify(value)} — expected rNNN`);
|
|
50
|
+
return Number(m[1]);
|
|
51
|
+
};
|
|
52
|
+
export const loadDoc = (docPath) => {
|
|
53
|
+
const canonicalPath = path.resolve(docPath);
|
|
54
|
+
if (!fs.existsSync(canonicalPath))
|
|
55
|
+
throw new Error(`No such document: ${canonicalPath}`);
|
|
56
|
+
const raw = fs.readFileSync(canonicalPath, 'utf8');
|
|
57
|
+
const m = FRONTMATTER_RE.exec(raw);
|
|
58
|
+
const frontmatter = m ? (parseYaml(m[1]) ?? {}) : {};
|
|
59
|
+
const body = m ? raw.slice(m[0].length) : raw;
|
|
60
|
+
const name = path.basename(canonicalPath).replace(/\.md$/i, '');
|
|
61
|
+
// History Folder is canonical-relative — the single path contract (audit C3)
|
|
62
|
+
const historyRel = typeof frontmatter['History Folder'] === 'string'
|
|
63
|
+
? frontmatter['History Folder']
|
|
64
|
+
: path.join('.history', name);
|
|
65
|
+
const historyDir = path.resolve(path.dirname(canonicalPath), historyRel);
|
|
66
|
+
return { canonicalPath, name, historyDir, frontmatter, body, raw };
|
|
67
|
+
};
|
|
68
|
+
/** Rewrite one scalar frontmatter key, preserving all other formatting (audit H3). */
|
|
69
|
+
export const stampFrontmatter = (raw, updates) => {
|
|
70
|
+
const m = FRONTMATTER_RE.exec(raw);
|
|
71
|
+
if (m) {
|
|
72
|
+
const doc = parseDocument(m[1]);
|
|
73
|
+
for (const [k, v] of Object.entries(updates))
|
|
74
|
+
v === null ? doc.delete(k) : doc.set(k, v);
|
|
75
|
+
return `---\n${String(doc).replace(/\n$/, '')}\n---\n${raw.slice(m[0].length)}`;
|
|
76
|
+
}
|
|
77
|
+
const kept = Object.fromEntries(Object.entries(updates).filter(([, v]) => v !== null));
|
|
78
|
+
const fmDoc = stringifyYaml(kept).replace(/\n$/, '');
|
|
79
|
+
return `---\n${fmDoc}\n---\n\n${raw}`;
|
|
80
|
+
};
|
|
81
|
+
export const readMeta = (historyDir) => {
|
|
82
|
+
const metaPath = path.join(historyDir, 'meta.yml');
|
|
83
|
+
if (!fs.existsSync(metaPath))
|
|
84
|
+
return { revisions: [], comments: [] };
|
|
85
|
+
const parsed = parseYaml(fs.readFileSync(metaPath, 'utf8'));
|
|
86
|
+
if (parsed === null || typeof parsed !== 'object') {
|
|
87
|
+
throw new Error(`Unreadable meta.yml at ${metaPath} — refusing to touch it`);
|
|
88
|
+
}
|
|
89
|
+
return { revisions: parsed.revisions ?? [], comments: parsed.comments ?? [] };
|
|
90
|
+
};
|
|
91
|
+
export const writeMeta = (historyDir, meta) => {
|
|
92
|
+
const metaPath = path.join(historyDir, 'meta.yml');
|
|
93
|
+
atomicWrite(metaPath, stringifyYaml({ revisions: meta.revisions, comments: meta.comments }));
|
|
94
|
+
};
|
|
95
|
+
const withLock = (historyDir, fn) => {
|
|
96
|
+
const lockDir = path.join(historyDir, '.lock');
|
|
97
|
+
fs.mkdirSync(historyDir, { recursive: true });
|
|
98
|
+
try {
|
|
99
|
+
fs.mkdirSync(lockDir);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
const age = Date.now() - fs.statSync(lockDir).mtimeMs;
|
|
103
|
+
if (age < 10 * 60 * 1000)
|
|
104
|
+
throw new Error(`Another pentimento operation holds the lock: ${lockDir}`);
|
|
105
|
+
fs.rmdirSync(lockDir);
|
|
106
|
+
fs.mkdirSync(lockDir);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
return fn();
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
fs.rmdirSync(lockDir);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/**
|
|
116
|
+
* Capture the canonical as the next revision.
|
|
117
|
+
* Order: history file first (refusing to overwrite), then meta, then canonical stamp —
|
|
118
|
+
* a crash mid-way surfaces loudly on the next run instead of silently overwriting (audit C1).
|
|
119
|
+
*/
|
|
120
|
+
export const snapshot = (docPath, opts) => {
|
|
121
|
+
const doc = loadDoc(docPath);
|
|
122
|
+
return withLock(doc.historyDir, () => {
|
|
123
|
+
// 'Vellum: true' is the pre-rename marker — keep reading it so old docs snapshot cleanly
|
|
124
|
+
const isPentimento = doc.frontmatter['Pentimento'] === true || doc.frontmatter['Vellum'] === true;
|
|
125
|
+
const current = isPentimento && doc.frontmatter['Current Revision'] !== undefined
|
|
126
|
+
? parseRevId(doc.frontmatter['Current Revision'])
|
|
127
|
+
: 0;
|
|
128
|
+
const next = revId(current + 1);
|
|
129
|
+
const historyFile = path.join(doc.historyDir, `${next}.md`);
|
|
130
|
+
if (fs.existsSync(historyFile)) {
|
|
131
|
+
throw new Error(`${historyFile} already exists — history and frontmatter disagree; refusing to overwrite`);
|
|
132
|
+
}
|
|
133
|
+
const historyRel = path
|
|
134
|
+
.relative(path.dirname(doc.canonicalPath), doc.historyDir)
|
|
135
|
+
.split(path.sep)
|
|
136
|
+
.join('/');
|
|
137
|
+
const { cleaned, found } = extractInlineComments(doc.raw);
|
|
138
|
+
const stamped = stampFrontmatter(cleaned, {
|
|
139
|
+
Pentimento: true,
|
|
140
|
+
Vellum: null, // migrate pre-rename docs: drop the legacy marker on first snapshot
|
|
141
|
+
'Current Revision': next,
|
|
142
|
+
'History Folder': historyRel,
|
|
143
|
+
});
|
|
144
|
+
fs.writeFileSync(historyFile, stamped, { flag: 'wx' });
|
|
145
|
+
const meta = readMeta(doc.historyDir);
|
|
146
|
+
meta.revisions.push({
|
|
147
|
+
id: next,
|
|
148
|
+
created_at: nowStamp(),
|
|
149
|
+
author: opts.author ?? 'unknown',
|
|
150
|
+
summary: opts.summary,
|
|
151
|
+
...(opts.why ? { why: opts.why } : {}),
|
|
152
|
+
...(opts.source ? { source: opts.source } : {}),
|
|
153
|
+
});
|
|
154
|
+
for (const f of found) {
|
|
155
|
+
meta.comments.push(makeComment(meta, { text: f.text, anchor: f.anchor, author: opts.author }));
|
|
156
|
+
}
|
|
157
|
+
writeMeta(doc.historyDir, meta);
|
|
158
|
+
atomicWrite(doc.canonicalPath, stamped);
|
|
159
|
+
return { rev: next, historyFile };
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const makeComment = (meta, input) => {
|
|
163
|
+
const stamp = nowStamp();
|
|
164
|
+
const date = stamp.slice(0, 10);
|
|
165
|
+
const seq = meta.comments.filter((c) => c.id.startsWith(`c-${date}`)).length + 1;
|
|
166
|
+
return {
|
|
167
|
+
id: `c-${date}-${String(seq).padStart(3, '0')}`,
|
|
168
|
+
anchor: input.anchor ?? '',
|
|
169
|
+
...(input.quote ? { quote: input.quote } : {}),
|
|
170
|
+
...(input.prefix ? { prefix: input.prefix } : {}),
|
|
171
|
+
...(input.suffix ? { suffix: input.suffix } : {}),
|
|
172
|
+
text: input.text,
|
|
173
|
+
status: 'open',
|
|
174
|
+
created_at: stamp,
|
|
175
|
+
author: input.author ?? 'reader',
|
|
176
|
+
resolved_in: null,
|
|
177
|
+
};
|
|
178
|
+
};
|
|
179
|
+
export const addComment = (docPath, input) => {
|
|
180
|
+
const doc = loadDoc(docPath);
|
|
181
|
+
return withLock(doc.historyDir, () => {
|
|
182
|
+
const meta = readMeta(doc.historyDir);
|
|
183
|
+
const entry = makeComment(meta, input);
|
|
184
|
+
meta.comments.push(entry);
|
|
185
|
+
writeMeta(doc.historyDir, meta);
|
|
186
|
+
return entry;
|
|
187
|
+
});
|
|
188
|
+
};
|
|
189
|
+
export const resolveComment = (docPath, id, rev) => {
|
|
190
|
+
const doc = loadDoc(docPath);
|
|
191
|
+
return withLock(doc.historyDir, () => {
|
|
192
|
+
const meta = readMeta(doc.historyDir);
|
|
193
|
+
const c = meta.comments.find((x) => x.id === id);
|
|
194
|
+
if (!c)
|
|
195
|
+
throw new Error(`no comment ${id} on ${doc.name}`);
|
|
196
|
+
c.status = 'resolved';
|
|
197
|
+
c.resolved_in = rev ?? null;
|
|
198
|
+
writeMeta(doc.historyDir, meta);
|
|
199
|
+
return c;
|
|
200
|
+
});
|
|
201
|
+
};
|
|
202
|
+
export const readRevision = (docPath, rev) => {
|
|
203
|
+
const doc = loadDoc(docPath);
|
|
204
|
+
const file = path.join(doc.historyDir, `${rev}.md`);
|
|
205
|
+
if (!fs.existsSync(file))
|
|
206
|
+
throw new Error(`No revision ${rev} for ${doc.name} (looked at ${file})`);
|
|
207
|
+
return fs.readFileSync(file, 'utf8');
|
|
208
|
+
};
|
|
209
|
+
/** Restore an earlier revision's body as a new revision (history stays append-only). */
|
|
210
|
+
export const revert = (docPath, rev, author) => {
|
|
211
|
+
const target = readRevision(docPath, rev);
|
|
212
|
+
const doc = loadDoc(docPath);
|
|
213
|
+
const targetBody = FRONTMATTER_RE.exec(target)
|
|
214
|
+
? target.slice(FRONTMATTER_RE.exec(target)[0].length)
|
|
215
|
+
: target;
|
|
216
|
+
const restored = doc.raw.slice(0, doc.raw.length - doc.body.length) + targetBody;
|
|
217
|
+
atomicWrite(doc.canonicalPath, restored);
|
|
218
|
+
return snapshot(docPath, {
|
|
219
|
+
summary: `Restored content of ${rev}`,
|
|
220
|
+
why: `pentimento revert ${rev}`,
|
|
221
|
+
author,
|
|
222
|
+
});
|
|
223
|
+
};
|