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/viewer.js
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { serve } from '@hono/node-server';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { addComment, loadDoc, readMeta, resolveComment } from './core.js';
|
|
7
|
+
import { render, renderDiffPage, renderRevisionHtml } from './render.js';
|
|
8
|
+
import { findPentimentoDocs } from './verify.js';
|
|
9
|
+
const ASSETS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../assets');
|
|
10
|
+
const escapeHtml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
11
|
+
const SSE_SNIPPET = `<script>new EventSource('/__events').onmessage = () => location.reload()</script>`;
|
|
12
|
+
const viewerBar = (rel, meta, current) => {
|
|
13
|
+
const latest = meta.revisions[meta.revisions.length - 1]?.id ?? null;
|
|
14
|
+
const selected = current ?? 'canonical';
|
|
15
|
+
const options = [
|
|
16
|
+
`<option value="canonical"${selected === 'canonical' ? ' selected' : ''}>canonical (now)</option>`,
|
|
17
|
+
...[...meta.revisions].reverse().map((r) => `<option value="${r.id}"${selected === r.id ? ' selected' : ''}>${r.id} · ${escapeHtml(r.summary.slice(0, 48))}</option>`),
|
|
18
|
+
].join('');
|
|
19
|
+
const idx = current ? meta.revisions.findIndex((r) => r.id === current) : meta.revisions.length - 1;
|
|
20
|
+
const prev = idx > 0 ? meta.revisions[idx - 1].id : null;
|
|
21
|
+
const diffTo = current ?? 'canonical';
|
|
22
|
+
const diffLink = prev
|
|
23
|
+
? `<a href="/diff/${encodeURI(rel)}?a=${prev}&b=${diffTo}">diff vs ${prev}</a>`
|
|
24
|
+
: '';
|
|
25
|
+
return `<div class="vbar-pad"></div>
|
|
26
|
+
<nav class="vbar">
|
|
27
|
+
<a href="/">◂ documents</a>
|
|
28
|
+
<span class="vbar-name">${escapeHtml(rel)}${latest ? ` · ${latest}` : ''}</span>
|
|
29
|
+
<select id="vrev" aria-label="Revision">${options}</select>
|
|
30
|
+
${diffLink}
|
|
31
|
+
</nav>
|
|
32
|
+
<script>
|
|
33
|
+
document.getElementById('vrev').addEventListener('change', (e) => {
|
|
34
|
+
const v = e.target.value
|
|
35
|
+
location.href = v === 'canonical' ? location.pathname : location.pathname + '?rev=' + v
|
|
36
|
+
})
|
|
37
|
+
</script>
|
|
38
|
+
${SSE_SNIPPET}`;
|
|
39
|
+
};
|
|
40
|
+
const indexPage = (root) => {
|
|
41
|
+
const css = fs.readFileSync(path.join(ASSETS, 'theme.css'), 'utf8');
|
|
42
|
+
const cards = findPentimentoDocs(root)
|
|
43
|
+
.map((p) => {
|
|
44
|
+
const doc = loadDoc(p);
|
|
45
|
+
let meta = { revisions: [], comments: [] };
|
|
46
|
+
try {
|
|
47
|
+
meta = readMeta(doc.historyDir);
|
|
48
|
+
}
|
|
49
|
+
catch { /* show the doc anyway */ }
|
|
50
|
+
const latest = meta.revisions[meta.revisions.length - 1];
|
|
51
|
+
const rel = path.relative(root, p).split(path.sep).join('/');
|
|
52
|
+
const archetype = String(doc.frontmatter['Archetype'] ?? 'design-doc');
|
|
53
|
+
return {
|
|
54
|
+
rel,
|
|
55
|
+
name: doc.name,
|
|
56
|
+
archetype,
|
|
57
|
+
rev: String(doc.frontmatter['Current Revision'] ?? '—'),
|
|
58
|
+
summary: latest?.summary ?? '',
|
|
59
|
+
date: latest?.created_at?.slice(0, 10) ?? '',
|
|
60
|
+
};
|
|
61
|
+
})
|
|
62
|
+
.sort((a, b) => b.date.localeCompare(a.date))
|
|
63
|
+
.map((d) => `<a class="vcard" href="/doc/${encodeURI(d.rel)}">
|
|
64
|
+
<div class="meta-row">
|
|
65
|
+
<span class="badge badge-${escapeHtml(d.archetype)}">${escapeHtml(d.archetype)}</span>
|
|
66
|
+
<span class="chip">${escapeHtml(d.rev)}</span>
|
|
67
|
+
${d.date ? `<span class="chip">${escapeHtml(d.date)}</span>` : ''}
|
|
68
|
+
</div>
|
|
69
|
+
<h3>${escapeHtml(d.name)}</h3>
|
|
70
|
+
${d.summary ? `<p>${escapeHtml(d.summary)}</p>` : ''}
|
|
71
|
+
</a>`)
|
|
72
|
+
.join('\n');
|
|
73
|
+
return `<!doctype html>
|
|
74
|
+
<html lang="en">
|
|
75
|
+
<head>
|
|
76
|
+
<meta charset="utf-8">
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
78
|
+
<title>Pentimento — living documents</title>
|
|
79
|
+
<style>
|
|
80
|
+
${css}</style>
|
|
81
|
+
</head>
|
|
82
|
+
<body>
|
|
83
|
+
<div class="wrap">
|
|
84
|
+
<header class="doc">
|
|
85
|
+
<div class="meta-row"><span class="badge">Pentimento viewer</span><span class="chip">${escapeHtml(root)}</span></div>
|
|
86
|
+
<h1>Living documents</h1>
|
|
87
|
+
</header>
|
|
88
|
+
<main>
|
|
89
|
+
<div class="vcards">
|
|
90
|
+
${cards || '<p>No Pentimento documents found under this directory.</p>'}
|
|
91
|
+
</div>
|
|
92
|
+
</main>
|
|
93
|
+
</div>
|
|
94
|
+
${SSE_SNIPPET}
|
|
95
|
+
</body>
|
|
96
|
+
</html>
|
|
97
|
+
`;
|
|
98
|
+
};
|
|
99
|
+
export const createApp = (root, viewerOpts = {}) => {
|
|
100
|
+
const absRoot = path.resolve(root);
|
|
101
|
+
const viewerJs = fs.readFileSync(path.join(ASSETS, 'viewer.js'), 'utf8');
|
|
102
|
+
const app = new Hono();
|
|
103
|
+
const clients = new Set();
|
|
104
|
+
const broadcast = () => clients.forEach((send) => send());
|
|
105
|
+
const resolveDoc = (rel) => {
|
|
106
|
+
const abs = path.resolve(absRoot, rel);
|
|
107
|
+
if (!abs.startsWith(absRoot + path.sep) && abs !== absRoot)
|
|
108
|
+
return null;
|
|
109
|
+
if (!abs.endsWith('.md') || !fs.existsSync(abs))
|
|
110
|
+
return null;
|
|
111
|
+
return abs;
|
|
112
|
+
};
|
|
113
|
+
app.get('/', (c) => c.html(indexPage(absRoot)));
|
|
114
|
+
app.get('/doc/*', (c) => {
|
|
115
|
+
const rel = decodeURIComponent(c.req.path.slice('/doc/'.length));
|
|
116
|
+
const abs = resolveDoc(rel);
|
|
117
|
+
if (!abs)
|
|
118
|
+
return c.notFound();
|
|
119
|
+
const rev = c.req.query('rev') ?? null;
|
|
120
|
+
let html;
|
|
121
|
+
try {
|
|
122
|
+
html = rev ? renderRevisionHtml(abs, rev) : render(abs);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return c.text(e instanceof Error ? e.message : String(e), 500);
|
|
126
|
+
}
|
|
127
|
+
const meta = readMeta(loadDoc(abs).historyDir);
|
|
128
|
+
const cfg = {
|
|
129
|
+
rel,
|
|
130
|
+
canComment: !rev,
|
|
131
|
+
comments: meta.comments
|
|
132
|
+
.filter((cm) => cm.status === 'open')
|
|
133
|
+
.map((cm) => ({ id: cm.id, quote: cm.quote ?? null, text: cm.text })),
|
|
134
|
+
};
|
|
135
|
+
const cfgScript = `<script>window.__pentimento=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>\n<script>\n${viewerJs}</script>`;
|
|
136
|
+
return c.html(html.replace('</body>', `${viewerBar(rel, meta, rev)}\n${cfgScript}\n</body>`));
|
|
137
|
+
});
|
|
138
|
+
app.post('/api/comment', async (c) => {
|
|
139
|
+
const b = await c.req.json().catch(() => null);
|
|
140
|
+
if (!b)
|
|
141
|
+
return c.text('bad json', 400);
|
|
142
|
+
const abs = resolveDoc(String(b.rel ?? ''));
|
|
143
|
+
if (!abs)
|
|
144
|
+
return c.notFound();
|
|
145
|
+
const text = String(b.text ?? '').trim();
|
|
146
|
+
if (!text)
|
|
147
|
+
return c.text('missing text', 400);
|
|
148
|
+
const entry = addComment(abs, {
|
|
149
|
+
text,
|
|
150
|
+
anchor: typeof b.anchor === 'string' ? b.anchor : '',
|
|
151
|
+
quote: typeof b.quote === 'string' && b.quote ? b.quote.slice(0, 600) : undefined,
|
|
152
|
+
prefix: typeof b.prefix === 'string' && b.prefix ? b.prefix : undefined,
|
|
153
|
+
suffix: typeof b.suffix === 'string' && b.suffix ? b.suffix : undefined,
|
|
154
|
+
author: viewerOpts.author ?? 'reader',
|
|
155
|
+
});
|
|
156
|
+
return c.json(entry);
|
|
157
|
+
});
|
|
158
|
+
app.post('/api/resolve', async (c) => {
|
|
159
|
+
const b = await c.req.json().catch(() => null);
|
|
160
|
+
if (!b)
|
|
161
|
+
return c.text('bad json', 400);
|
|
162
|
+
const abs = resolveDoc(String(b.rel ?? ''));
|
|
163
|
+
if (!abs)
|
|
164
|
+
return c.notFound();
|
|
165
|
+
try {
|
|
166
|
+
return c.json(resolveComment(abs, String(b.id ?? '')));
|
|
167
|
+
}
|
|
168
|
+
catch (e) {
|
|
169
|
+
return c.text(e instanceof Error ? e.message : String(e), 404);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
app.get('/diff/*', (c) => {
|
|
173
|
+
const rel = decodeURIComponent(c.req.path.slice('/diff/'.length));
|
|
174
|
+
const abs = resolveDoc(rel);
|
|
175
|
+
if (!abs)
|
|
176
|
+
return c.notFound();
|
|
177
|
+
const a = c.req.query('a');
|
|
178
|
+
const b = c.req.query('b') ?? 'canonical';
|
|
179
|
+
if (!a)
|
|
180
|
+
return c.text('missing ?a=<rev>', 400);
|
|
181
|
+
try {
|
|
182
|
+
const html = renderDiffPage(abs, a, b);
|
|
183
|
+
return c.html(html.replace('</body>', `${SSE_SNIPPET}\n</body>`));
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
return c.text(e instanceof Error ? e.message : String(e), 500);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
app.get('/__events', () => {
|
|
190
|
+
let send;
|
|
191
|
+
const stream = new ReadableStream({
|
|
192
|
+
start(controller) {
|
|
193
|
+
const encoder = new TextEncoder();
|
|
194
|
+
send = () => {
|
|
195
|
+
try {
|
|
196
|
+
controller.enqueue(encoder.encode('data: reload\n\n'));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
clients.delete(send);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
clients.add(send);
|
|
203
|
+
controller.enqueue(encoder.encode(': connected\n\n'));
|
|
204
|
+
},
|
|
205
|
+
cancel() {
|
|
206
|
+
clients.delete(send);
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
return new Response(stream, {
|
|
210
|
+
headers: {
|
|
211
|
+
'content-type': 'text/event-stream',
|
|
212
|
+
'cache-control': 'no-cache',
|
|
213
|
+
connection: 'keep-alive',
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
return { app, broadcast };
|
|
218
|
+
};
|
|
219
|
+
export const serveViewer = (root, { host, port, author }) => {
|
|
220
|
+
if (!host.trim() || host === '0.0.0.0' || host === '::' || host === '*') {
|
|
221
|
+
throw new Error('refusing to bind all interfaces — use 127.0.0.1 or a Tailscale IP (--tailscale)');
|
|
222
|
+
}
|
|
223
|
+
const absRoot = path.resolve(root);
|
|
224
|
+
const { app, broadcast } = createApp(absRoot, { author });
|
|
225
|
+
let timer = null;
|
|
226
|
+
fs.watch(absRoot, { recursive: true }, (_event, fname) => {
|
|
227
|
+
if (!fname)
|
|
228
|
+
return;
|
|
229
|
+
const f = String(fname);
|
|
230
|
+
if (f.includes('node_modules') || f.includes('.git/'))
|
|
231
|
+
return;
|
|
232
|
+
if (!/\.(md|yml)$/.test(f))
|
|
233
|
+
return;
|
|
234
|
+
if (timer)
|
|
235
|
+
clearTimeout(timer);
|
|
236
|
+
timer = setTimeout(broadcast, 200);
|
|
237
|
+
});
|
|
238
|
+
serve({ fetch: app.fetch, hostname: host, port });
|
|
239
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pentimento",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Living documents: one canonical markdown file, hidden revision history, deterministic HTML rendering",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Lucas Traba",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=20.13"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"pentimento": "./dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/lucastraba/pentimento.git"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"markdown",
|
|
20
|
+
"revision-history",
|
|
21
|
+
"versioning",
|
|
22
|
+
"living-documents",
|
|
23
|
+
"ai-agents",
|
|
24
|
+
"plans",
|
|
25
|
+
"html-renderer",
|
|
26
|
+
"obsidian"
|
|
27
|
+
],
|
|
28
|
+
"files": [
|
|
29
|
+
"dist",
|
|
30
|
+
"assets",
|
|
31
|
+
"skill"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"prepare": "tsc"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@hono/node-server": "^2.0.8",
|
|
40
|
+
"diff": "^7.0.0",
|
|
41
|
+
"hono": "^4.12.27",
|
|
42
|
+
"markdown-it": "^14.1.0",
|
|
43
|
+
"yaml": "^2.5.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/diff": "^6.0.0",
|
|
47
|
+
"@types/markdown-it": "^14.1.2",
|
|
48
|
+
"@types/node": "^22.0.0",
|
|
49
|
+
"typescript": "^5.6.0",
|
|
50
|
+
"vitest": "^2.1.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pentimento-plan
|
|
3
|
+
description: Produce a plan as a living document — markdown source, versioned history, rendered to a constrained HTML artifact. Use when the user asks for a plan, design doc, brainstorm, audit report, or invokes /pentimento-plan. Also for revising an existing Pentimento plan after feedback.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Pentimento plans
|
|
7
|
+
|
|
8
|
+
A Pentimento plan is one canonical markdown file with hidden revision history and a deterministic HTML render. You write **markdown + directives only** — the toolchain owns every pixel.
|
|
9
|
+
|
|
10
|
+
## Hard rules
|
|
11
|
+
|
|
12
|
+
- Never write CSS, `<style>`, `style=""` attributes, or JavaScript. No exceptions.
|
|
13
|
+
- Never hand-write the HTML output. `pentimento render` is the only way to produce it.
|
|
14
|
+
- Rich elements come from the directive vocabulary below — nothing else.
|
|
15
|
+
- One inline-SVG figure is allowed per `::: figure` block, composed only of `theme.css` classes (`nodebox`, `accentbox`, `flow`, `lbl`).
|
|
16
|
+
- The markdown plan is what the user approves; the HTML is how they read it.
|
|
17
|
+
|
|
18
|
+
## The loop
|
|
19
|
+
|
|
20
|
+
1. Research and think as normal (in plan mode if active). Decide the archetype: `implementation` | `brainstorm` | `audit` | `design-doc` (see `references/archetypes.md` for section skeletons).
|
|
21
|
+
2. Write `<Name>.md` with frontmatter:
|
|
22
|
+
```yaml
|
|
23
|
+
---
|
|
24
|
+
Archetype: implementation
|
|
25
|
+
Palette: iris # optional: verdigris | mist | iris (default iris)
|
|
26
|
+
---
|
|
27
|
+
```
|
|
28
|
+
Structure: `# Title`, then a `> standfirst` blockquote, then `##` sections. Add
|
|
29
|
+
`<!-- id: short-id; eyebrow: Section Label -->` at the end of `##` heading lines.
|
|
30
|
+
3. Snapshot — this drafts the decision log the reader sees at the top:
|
|
31
|
+
```bash
|
|
32
|
+
pentimento snapshot <Name>.md --summary "what changed" --why "why" --author <who>
|
|
33
|
+
```
|
|
34
|
+
(CLI: `pentimento` on PATH — install once with `npm install -g pentimento`)
|
|
35
|
+
4. Render: `pentimento render <Name>.md -o <name>.html` — standalone HTML, works anywhere. For claude.ai Artifact publishing add `--artifact` (the platform wraps the fragment itself; a full document would nest invalidly).
|
|
36
|
+
5. Publish: Claude Code renders with `--artifact` and publishes via the Artifact tool (favicon 📜, same file path every round so the URL is stable). Alternatively (or additionally), `pentimento serve <dir> [--tailscale]` runs the live viewer: document index, revision picker, diff pages, hot reload — no per-round publishing needed.
|
|
37
|
+
6. Feedback round: revise the markdown → `snapshot` → `render` → republish. The rendered page automatically shows a collapsible "What changed in rNNN" diff, so write snapshot summaries for the reader. Never edit history files; `pentimento list` / `pentimento diff` / `pentimento revert` manage them.
|
|
38
|
+
7. If the user asks what changed between older revisions: `pentimento diff <Name>.md rA rB --html -o changes.html` renders a readable word-level diff page you can publish alongside the plan.
|
|
39
|
+
8. Periodically (or in CI): `pentimento verify .` cross-checks every Pentimento document's frontmatter, history files, and meta.yml.
|
|
40
|
+
|
|
41
|
+
## Addressing review comments
|
|
42
|
+
|
|
43
|
+
Readers leave comments by selecting text in the viewer (`pentimento serve`); comments land in `meta.yml` with the quoted text and context. When the user says "I left comments" (or before any feedback round):
|
|
44
|
+
|
|
45
|
+
1. `pentimento address <Name>.md` — lists each open comment with its anchor, quoted text, and ask.
|
|
46
|
+
2. Revise the markdown to address them — the quote + context tells you the exact spot even if the section moved.
|
|
47
|
+
3. One snapshot for the round: `pentimento snapshot <Name>.md --summary "Address review comments" --why "<what the comments asked>"`
|
|
48
|
+
4. `pentimento resolve <Name>.md <comment-id> --rev <new revision>` for each comment you addressed. Leave genuinely unresolved ones open and say why.
|
|
49
|
+
5. Re-render and republish.
|
|
50
|
+
|
|
51
|
+
You can also leave inline notes in the markdown as `%% @c: a note %%` — snapshot extracts them into meta.yml anchored to the nearest heading (useful for flagging open questions to the reader).
|
|
52
|
+
|
|
53
|
+
## Directive vocabulary
|
|
54
|
+
|
|
55
|
+
Full syntax and examples: `references/directives.md`. Summary:
|
|
56
|
+
|
|
57
|
+
| Directive | Use for |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `::: callout decision\|info\|warn\|risk` | decisions (link ids like `d-1`), notes, risks |
|
|
60
|
+
| `::: verdict` | 2–4 headline question :: answer cells |
|
|
61
|
+
| `::: findings` + `@collapse` | severity-graded findings (CRIT/HIGH/MED/LOW) |
|
|
62
|
+
| `::: timeline` | numbered phases with `[next]`/`[later]`/`[done]` pills |
|
|
63
|
+
| `::: diff head="file · what"` | proposed file changes, written as a unified diff |
|
|
64
|
+
| `::: figure aria="..."` | inline SVG diagrams |
|
|
65
|
+
| `{dot:impl}` etc. | color swatches in tables |
|
|
66
|
+
| plain markdown tables | comparisons, file-touch lists (auto-wrapped, scrollable) |
|
|
67
|
+
|
|
68
|
+
Pick components the archetype calls for; leave the rest out. Flexibility = archetype + component choice. Everything visual is fixed.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Archetype skeletons
|
|
2
|
+
|
|
3
|
+
Every plan uses the shared chrome (header, TOC, evolution strip). The archetype sets the badge/accent and the section skeleton below. Sections marked (opt) are used only when the content calls for them.
|
|
4
|
+
|
|
5
|
+
## implementation — approve and execute
|
|
6
|
+
|
|
7
|
+
```markdown
|
|
8
|
+
---
|
|
9
|
+
Archetype: implementation
|
|
10
|
+
---
|
|
11
|
+
# <What gets built>
|
|
12
|
+
|
|
13
|
+
> One-sentence scope: what will exist when this is done.
|
|
14
|
+
|
|
15
|
+
## Goal <!-- id: goal; eyebrow: Outcome -->
|
|
16
|
+
## Approach <!-- id: approach; eyebrow: How -->
|
|
17
|
+
(::: callout decision for each load-bearing choice)
|
|
18
|
+
## Changes <!-- id: changes; eyebrow: Diffs -->
|
|
19
|
+
(file-touch table: | File | Change | Risk | — then ::: diff per non-trivial change)
|
|
20
|
+
## Phases <!-- id: phases; eyebrow: Sequence -->
|
|
21
|
+
(::: timeline with [next]/[later])
|
|
22
|
+
## Risks (opt) <!-- id: risks; eyebrow: Watch out -->
|
|
23
|
+
(::: callout risk per real risk, with mitigation)
|
|
24
|
+
## Verification <!-- id: verify; eyebrow: Done means -->
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## brainstorm — compare directions
|
|
28
|
+
|
|
29
|
+
```markdown
|
|
30
|
+
---
|
|
31
|
+
Archetype: brainstorm
|
|
32
|
+
---
|
|
33
|
+
# <The question being explored>
|
|
34
|
+
|
|
35
|
+
> The tension in one sentence.
|
|
36
|
+
|
|
37
|
+
## Framing <!-- id: framing; eyebrow: The question -->
|
|
38
|
+
## Options <!-- id: options; eyebrow: Directions -->
|
|
39
|
+
(one ### per option; comparison table with {dot:...} keys; be opinionated in prose)
|
|
40
|
+
## Recommendation <!-- id: recommendation; eyebrow: My take -->
|
|
41
|
+
(::: callout decision — the recommendation and what it forecloses)
|
|
42
|
+
## Open questions <!-- id: open; eyebrow: Unresolved -->
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## audit — report findings
|
|
46
|
+
|
|
47
|
+
```markdown
|
|
48
|
+
---
|
|
49
|
+
Archetype: audit
|
|
50
|
+
---
|
|
51
|
+
# <What was audited>
|
|
52
|
+
|
|
53
|
+
> Scope and date in one line.
|
|
54
|
+
|
|
55
|
+
## Verdict <!-- id: verdict; eyebrow: Summary -->
|
|
56
|
+
(::: verdict banner, then a short prose paragraph)
|
|
57
|
+
## Findings <!-- id: findings; eyebrow: Evidence -->
|
|
58
|
+
(::: findings, CRIT/HIGH open, @collapse the rest; cite file:line in the text)
|
|
59
|
+
## Remediation <!-- id: remediation; eyebrow: Next -->
|
|
60
|
+
(::: timeline or checklist; note what needs separate approval)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## design-doc — record a vision
|
|
64
|
+
|
|
65
|
+
```markdown
|
|
66
|
+
---
|
|
67
|
+
Archetype: design-doc
|
|
68
|
+
---
|
|
69
|
+
# <The system>
|
|
70
|
+
|
|
71
|
+
> The thesis in one sentence.
|
|
72
|
+
|
|
73
|
+
## Why <!-- id: why; eyebrow: Diagnosis -->
|
|
74
|
+
## Decisions <!-- id: decisions; eyebrow: Locked -->
|
|
75
|
+
(::: callout decision per locked decision — these are the doc's spine)
|
|
76
|
+
## Architecture <!-- id: architecture; eyebrow: System -->
|
|
77
|
+
(::: figure diagram; ### per component)
|
|
78
|
+
## Build order <!-- id: build; eyebrow: Sequence -->
|
|
79
|
+
(::: timeline)
|
|
80
|
+
## Open questions <!-- id: open; eyebrow: Unresolved -->
|
|
81
|
+
```
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Pentimento directive reference
|
|
2
|
+
|
|
3
|
+
Directives are fenced with `:::` on their own lines. A leading bare word is the variant; the rest is `key="value"` attrs. Content inside is markdown unless noted.
|
|
4
|
+
|
|
5
|
+
## Callout
|
|
6
|
+
|
|
7
|
+
```markdown
|
|
8
|
+
::: callout decision id=d-source-of-truth
|
|
9
|
+
**Markdown is the source of truth.** HTML is a build product.
|
|
10
|
+
:::
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Variants: `decision` (accent wash), `info`, `warn`, `risk` (red label). `id` is optional but give decisions stable ids — they're comment/link anchors.
|
|
14
|
+
|
|
15
|
+
## Verdict banner
|
|
16
|
+
|
|
17
|
+
Headline answers, 2–4 cells. One `question :: answer` list item per cell.
|
|
18
|
+
|
|
19
|
+
```markdown
|
|
20
|
+
::: verdict
|
|
21
|
+
- Works correctly? :: Partially
|
|
22
|
+
- Efficient? :: Convention yes, tooling no
|
|
23
|
+
- Portable? :: Yes — easily
|
|
24
|
+
:::
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Findings (audit archetype)
|
|
28
|
+
|
|
29
|
+
Severity is one of `CRIT`, `HIGH`, `MED`, `LOW`. Everything after `@collapse <label>` folds into a details element.
|
|
30
|
+
|
|
31
|
+
```markdown
|
|
32
|
+
::: findings
|
|
33
|
+
- CRIT :: `snapshot` silently overwrites history.
|
|
34
|
+
- HIGH :: 3 of 6 real docs are inconsistent.
|
|
35
|
+
@collapse Medium and low findings (2)
|
|
36
|
+
- MED :: Hardcoded UTC+2 timezone.
|
|
37
|
+
- LOW :: History invisible inside Obsidian.
|
|
38
|
+
:::
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Timeline (phases)
|
|
42
|
+
|
|
43
|
+
Numbered items; bold title, optional `[next]`/`[later]`/`[done]` pill, `—` then description.
|
|
44
|
+
|
|
45
|
+
```markdown
|
|
46
|
+
::: timeline
|
|
47
|
+
1. **CLI + renderer + skill** [done] — Shipped; loop closes end to end.
|
|
48
|
+
2. **Local viewer** [next] — Hono daemon, Tailscale-bound, SSE reload.
|
|
49
|
+
3. **Anchored comments** [later] — The Antigravity loop.
|
|
50
|
+
:::
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Diff (proposed file changes)
|
|
54
|
+
|
|
55
|
+
Write a plain unified diff (` ` context, `-` removed, `+` added) inside a `txt` fence. It renders side-by-side on desktop and stacks unified on phones. Never paste raw HTML diffs.
|
|
56
|
+
|
|
57
|
+
````markdown
|
|
58
|
+
::: diff head="src/core.ts · refuse to overwrite history"
|
|
59
|
+
```txt
|
|
60
|
+
const file = historyFile(next)
|
|
61
|
+
-fs.writeFileSync(file, content)
|
|
62
|
+
+if (fs.existsSync(file)) throw new Error('refusing to overwrite')
|
|
63
|
+
+fs.writeFileSync(file, content, { flag: 'wx' })
|
|
64
|
+
```
|
|
65
|
+
:::
|
|
66
|
+
````
|
|
67
|
+
|
|
68
|
+
## Figure (diagrams)
|
|
69
|
+
|
|
70
|
+
Inline SVG only, composed from `theme.css` classes: `nodebox` (plain node), `accentbox` (highlighted node), `flow` (arrow path; add `marker-end="url(#arr)"` and define the `arr` marker in `<defs>`), `lbl` (small caption text). Colors come from CSS variables — never hardcode fills beyond those classes. Always set `aria`.
|
|
71
|
+
|
|
72
|
+
```markdown
|
|
73
|
+
::: figure aria="PLAN.md flows through the pentimento CLI to plan.html"
|
|
74
|
+
<svg viewBox="0 0 640 120" xmlns="http://www.w3.org/2000/svg">
|
|
75
|
+
<defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="var(--soft)"/></marker></defs>
|
|
76
|
+
<rect class="nodebox" x="8" y="40" width="110" height="36" rx="5"/>
|
|
77
|
+
<text x="63" y="62" text-anchor="middle">PLAN.md</text>
|
|
78
|
+
<rect class="accentbox" x="180" y="40" width="120" height="36" rx="5"/>
|
|
79
|
+
<text x="240" y="62" text-anchor="middle">pentimento CLI</text>
|
|
80
|
+
<path class="flow" d="M118 58 H176"/>
|
|
81
|
+
</svg>
|
|
82
|
+
:::
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
## Headings, ids, eyebrows
|
|
86
|
+
|
|
87
|
+
```markdown
|
|
88
|
+
## Why past HTML plans failed <!-- id: why; eyebrow: Diagnosis -->
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`id` gives the section a short stable anchor (default: slugified title). `eyebrow` is the small-caps label above the heading. `###` subheadings take `<!-- id: ... -->` too.
|
|
92
|
+
|
|
93
|
+
## Swatches
|
|
94
|
+
|
|
95
|
+
`{dot:impl}` `{dot:brain}` `{dot:audit}` `{dot:design}` `{dot:verdigris}` `{dot:mist}` `{dot:iris}` render as colored dots — use in table cells to key rows to accent hues.
|
|
96
|
+
|
|
97
|
+
## Header, TOC, evolution strip, what-changed, footer
|
|
98
|
+
|
|
99
|
+
Generated — never write them. The header reads `Archetype` and `Current Revision` from frontmatter; the evolution strip reads `meta.yml` (that's why snapshot summaries must be written for the reader); the TOC comes from `##` sections; the collapsible "What changed in rNNN" panel is a word-level diff against the previous snapshot, computed at render time.
|
|
100
|
+
|
|
101
|
+
## Frontmatter keys
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
Archetype: implementation | brainstorm | audit | design-doc # badge + accent
|
|
105
|
+
Palette: iris | verdigris | mist # default palette (reader's own pick wins)
|
|
106
|
+
```
|
|
107
|
+
`Pentimento`, `Current Revision`, and `History Folder` are managed by the CLI — never hand-edit them.
|