@fakto/core 0.0.0-stage → 0.0.1

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/README.md CHANGED
@@ -1,3 +1,226 @@
1
- # Temporary Holding Version
1
+ # Fakto
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keep everything that depends on a fact in sync with the fact. Docs, code, a
4
+ website, a pitch deck, the copy on a box: one record is the truth, every file that
5
+ states it points back at the record, and a change ripples out with a script finding
6
+ the dependents and an AI drafting the edits for a human to approve.
7
+
8
+ Zero dependencies. Zero knowledge of your project: it reads `fakto.config.json`.
9
+
10
+ One npm package, `@fakto/core` (the command is `fakto`): the CLI, the library and the canvas,
11
+ so they can never drift apart.
12
+ This page is also the conventions spec all three follow.
13
+
14
+ ## The whole system in five rules
15
+
16
+ 1. **One record per fact.** `facts/*.yaml`, flat records with `id`, `value`,
17
+ `statement`, `status` (`live | draft | retired`), `updated`.
18
+ 2. **Artifacts anchor the lines that state a fact.** An inline comment
19
+ `fact:<id>` on that line. Any file type, any comment syntax.
20
+ 3. **Forward links only.** Artifacts point at facts. "What depends on X" is
21
+ derived (`fakto graph`), never stored, so it cannot drift.
22
+ 4. **Dates drive propagation.** A fact's `updated` moves when its value changes.
23
+ An artifact's `reviewed` moves when a human confirmed it. `updated > reviewed`
24
+ means stale, and stale is the work queue.
25
+ 5. **Living vs snapshot.** Living artifacts are kept true. `lifecycle: snapshot`
26
+ artifacts (a published post, last year's deck) are frozen history and are
27
+ never edited, only checked for typos in their ids.
28
+
29
+ ## What the script does, what the AI does, what the human does
30
+
31
+ | Step | Who | Command |
32
+ |---|---|---|
33
+ | Find what depends on a fact | script | `fakto graph`, `fakto show <id>` |
34
+ | Catch broken state: unknown id, retired fact cited, line no longer contains the value, missing stamp | script | `fakto check` (exit 1, CI gate) |
35
+ | List the artifacts a change made stale, with the exact lines | script | `fakto stale` |
36
+ | Rewrite each stale line in that artifact's own voice | AI | the `fakto` skill |
37
+ | Approve, then stamp the artifact as reviewed | human | `fakto touch <file>` |
38
+ | A file that facts or docs follow changed: copy examples, list what needs judgment | script | `fakto sync` |
39
+ | Re-derive the facts and rewrite the sections from the diff | AI | the `fakto` skill, case F |
40
+ | See all of it, edit a fact, watch it propagate | all three | `fakto canvas` |
41
+
42
+ ## Install
43
+
44
+ In the host repo:
45
+
46
+ ```json
47
+ // fakto.config.json
48
+ { "facts": "facts", "artifacts": ["docs", "site", "src", "marketing"], "houseRules": "house-rules.md" }
49
+ ```
50
+
51
+ `houseRules` is optional: a file of voice and wording rules Claude reads before it
52
+ rewrites anything.
53
+
54
+ As a Claude Code plugin (the propagate skill and an after-edit hook that runs `fakto check`):
55
+ add the Fakto marketplace and install `fakto@fakto`. The plugin calls the `fakto` CLI the
56
+ repo has installed.
57
+
58
+ ```bash
59
+ npm install -D @fakto/core
60
+ npx fakto check # exit 1 when a line contradicts its fact (CI gate)
61
+ npx fakto stale # files a fact change left needing an update, with the lines
62
+ npx fakto sync # facts and sections behind the files they follow
63
+ npx fakto graph [--json] # facts and the files that cite them, both directions
64
+ npx fakto show <id | file> # one fact and every line that depends on it, or what a file draws on
65
+ npx fakto touch <file | id> # stamp files as reviewed today
66
+ npx fakto render [path…] # rebuild declared outputs (a slide deck from its Markdown)
67
+ npx fakto canvas # the live map on http://localhost:4848 (--background, --stop, --port)
68
+ ```
69
+
70
+ As a library: `import { loadRepo, checkRepo, staleList } from '@fakto/core'`.
71
+
72
+ ## Canvas
73
+
74
+ A local web app that draws the whole graph, lets you edit or add a fact, and runs your
75
+ own Claude Code CLI to propagate the change while the canvas animates it.
76
+
77
+ ```bash
78
+ npx fakto canvas # http://localhost:4848, --port to change
79
+ ```
80
+
81
+ Architecture, API, security and limits: `canvas/README.md` in this package.
82
+
83
+ ## Conventions in detail
84
+
85
+ ### Fact record
86
+
87
+ ```yaml
88
+ - id: power-levels # kebab-case, topic prefix helps grep
89
+ value: "7" # the string every citing line must contain
90
+ statement: "The device has 7 power levels."
91
+ status: live # live | draft | retired
92
+ updated: 2026-09-01 # bump whenever value or statement changes
93
+ owner: hardware # optional
94
+ note: "Measured on white bread" # optional: rules, caveats, why it changed
95
+ ```
96
+
97
+ `value` is matched case-insensitively against the anchored line. Choose it so it
98
+ fits prose and code alike. A text-only fact (no `value`) skips the drift check.
99
+
100
+ ### Artifact
101
+
102
+ ```md
103
+ ---
104
+ reviewed: 2026-09-01 # when a human last confirmed the file
105
+ lifecycle: snapshot # optional: frozen, never edited
106
+ ---
107
+ Seven levels? No: 7 power levels. <!-- fact:power-levels -->
108
+ ```
109
+
110
+ ```ts
111
+ // reviewed: 2026-09-01
112
+ export const MAX_LEVEL = 7; // fact:power-levels
113
+ ```
114
+
115
+ The head is the first 12 lines. Several anchors may share a line. A line that
116
+ documents a retirement may cite a retired fact if it contains `RETIRED`.
117
+
118
+ **As-of citations.** `fact:id@2026-09-01` says "this line states the fact as it was
119
+ on that date". Use it in changelogs, release notes and "was 7, now 9" asides: the
120
+ graph still links the line to the fact, but drift, retired and staleness checks skip
121
+ it. It is the line-level version of `lifecycle: snapshot`.
122
+
123
+ ### Renders
124
+
125
+ Some artifacts cannot carry anchors (a slide deck, a PDF). Keep their text in a material
126
+ with anchors, and declare the artifact as build output:
127
+
128
+ ```json
129
+ "renders": [{ "from": "pitch/investor-deck.md", "to": "out/investor-deck.html", "with": "slides", "title": "Seed round" }]
130
+ ```
131
+
132
+ `fakto render` builds every output that is out of date or missing; the canvas rebuilds them
133
+ at the end of a run and when you open one. An output embeds the hash of the source it was
134
+ built from, carries no anchors, is never scanned as a material, and is never edited by
135
+ hand. The built-in `slides` renderer turns one `# heading` per slide into a single HTML
136
+ deck (arrow keys to move, P to print to PDF).
137
+
138
+ ### Narratives
139
+
140
+ A narrative is a fact without a `value`: a story each channel words its own way.
141
+
142
+ ```yaml
143
+ - id: story-platform
144
+ statement: "We are a platform, not a point tool: anything that can call our API can build on us."
145
+ status: live
146
+ updated: 2026-10-21
147
+ supports: [api-base-url, sdk-package-name] # the facts the story rests on
148
+ ```
149
+
150
+ Anchor the section that tells it (`<!-- fact:story-platform -->` on a heading or a
151
+ sentence). It goes stale like any fact; there is no wording to compare, so the drift
152
+ check skips it and Claude checks the story when it rewrites (the skill's consistency
153
+ pass). `supports` must name existing facts.
154
+
155
+ ### Sources: when a file is the truth
156
+
157
+ Sometimes the code is the truth and the facts and docs follow it. Three edges, one grammar:
158
+
159
+ | Edge | Declared | Changed when |
160
+ |---|---|---|
161
+ | file → fact | `source:` on the fact record | the anchored line no longer says the value, or the selection's hash moved |
162
+ | file region → doc section | `<!-- from:path#selector -->` on a heading | the selection's hash moved |
163
+ | file region → fenced block | `<!-- example:path#selector -->` before a fence | the fence no longer contains the selection verbatim |
164
+
165
+ ```yaml
166
+ - id: api-rate-limit
167
+ value: "10"
168
+ source: sdk/src/config.ts # anchored: the file's fact:api-rate-limit line wins
169
+ - id: api-surface
170
+ statement: "Endpoints: POST /v1/toast, GET /v1/status."
171
+ source: sdk/openapi.yaml#paths@3f9a1c2e7b4d # hashed: any selection, stamped by fakto
172
+ ```
173
+
174
+ ```md
175
+ ## Start a job <!-- from:sdk/openapi.yaml#paths./v1/jobs@3f9a1c2e7b4d -->
176
+
177
+ <!-- example:sdk/examples/quickstart.ts#quickstart -->
178
+ ```ts
179
+ …
180
+ ```
181
+ ```
182
+
183
+ **Selectors.** None (the whole file); `#name` for a region between `fakto:region name` and
184
+ `fakto:end` comments in any language; `#a.b.c` for a key path into YAML or JSON (quote keys
185
+ that contain dots). `#exports` (the public surface of a TypeScript file) arrives in 1.1.
186
+
187
+ **Stamps.** You write the path and selector; `fakto touch` writes the hash (a git blob hash
188
+ of the normalised selection, 12 hex). A `from:` scope runs to the next heading of the same
189
+ or a higher level; `sources: [path#selector, …]` in the head makes the whole file draw on
190
+ them.
191
+
192
+ **Sync.** `fakto sync [path | fact-id …]` copies examples (no AI needed) and lists the facts
193
+ and sections that need judgment, with the diff since their last sync when git has it. The
194
+ skill (case F) or the canvas's Sync button re-derives the facts, rewrites the sections in
195
+ their own voice, and propagates any fact that changed. A restamp alone is
196
+ `fakto touch <file | fact-id>`. A derived fact is never edited by hand: change its source.
197
+
198
+ ### Check rules
199
+
200
+ | Rule | Level | Why |
201
+ |---|---|---|
202
+ | Anchor to an unknown id | error | typo, or the record was deleted |
203
+ | Living artifact cites a `retired` fact | error | the fact must not resurrect |
204
+ | Living artifact cites a `draft` fact | warning | not confirmed yet |
205
+ | Anchored line lacks the fact's `value` | error | someone edited the artifact, not the fact: drift |
206
+ | A narrative's `supports` names a missing fact | error | the story rests on something that is gone |
207
+ | A render output is older than its source, or missing | warning | run `fakto render` |
208
+ | A render names a missing source or an unknown renderer | error | fix `renders` in the config |
209
+ | As-of anchor (`fact:id@date`) | skipped | historical citation; only the id is checked |
210
+ | Living artifact without `reviewed` | error | nothing can go stale without it |
211
+ | Artifact without any anchor | warning | orphan: tag it or move it out |
212
+ | Fact `updated` after artifact `reviewed` | warning + queue | propagate |
213
+ | A derived fact or a `from:` section is behind its source, or was never synced | warning + queue | `fakto sync` |
214
+ | A fenced example no longer matches its source | error | `fakto sync` copies it |
215
+ | A source path or selector does not resolve | error | fix the anchor or the region |
216
+ | `from:` sources form a cycle | error | a file cannot describe itself through another |
217
+ | A marked region nothing draws on | info | remove the markers or use them |
218
+
219
+ ## Where this came from
220
+
221
+ A simplified, project-agnostic version of the messaging system behind Alokai's
222
+ website: a claims database with a status ladder, pitch docs that cite claims by id,
223
+ pages that declare which docs they rely on, a graph script, a citation linter, and
224
+ an approval pipeline that propagates rulings. This keeps the ideas (ids, one-way
225
+ links, living vs frozen, propagate = find + draft + approve) and drops the middle
226
+ layer, the app and the database.
package/bin/fakto.mjs ADDED
@@ -0,0 +1,250 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * fakto — keep every file that states a fact in line with the fact.
4
+ * The CLI over the library in ../lib; it reads fakto.config.json in the repo root.
5
+ *
6
+ * fakto graph [--json] facts -> artifacts, both directions (--json: machine-readable)
7
+ * fakto check gate: exit 1 on dangling / retired / drift / missing stamp
8
+ * fakto stale the propagate queue (fact changed after the artifact's review)
9
+ * fakto show <fact-id | file> one fact + every line that depends on it; a file: what it draws on and feeds
10
+ * fakto render [path…] build declared outputs (renders) that are stale or missing, or the given ones
11
+ * fakto touch <file | fact-id>… stamp reviewed: today and the source hashes (after a human reviewed it)
12
+ * fakto sync [path | fact-id …] bring facts and sections in line with the files they draw on (--all)
13
+ * fakto canvas open the canvas on localhost (--port 4848; --background to detach, --stop)
14
+ *
15
+ * Conventions (full spec in this package's README):
16
+ * facts <facts>/*.yaml, flat records: - id / value / statement / status / updated
17
+ * artifact any text file under <artifacts>; a dependency is an inline anchor
18
+ * `fact:<id>` in a comment on the line that states the fact.
19
+ * `reviewed: YYYY-MM-DD` in the file head = when a human last confirmed it.
20
+ * `lifecycle: snapshot` = frozen; checked for unknown ids only.
21
+ * `fact:<id>@YYYY-MM-DD` = an as-of citation (a changelog entry, a
22
+ * "was 7" aside): historical, checked for unknown ids only.
23
+ * sources a fact with `source: <path>[#selector][@hash]` follows that file; a doc
24
+ * section with `from:<path>[#selector]@<hash>` describes that code; a fence
25
+ * after `example:<path>#<selector>` is that code verbatim.
26
+ *
27
+ * All logic lives in ../lib; this file only formats and dispatches.
28
+ */
29
+ import { findRoot, loadRepo, staleList, checkRepo, graphJson, touch as touchFiles, renderOutputs, renderStatus, syncPlan, syncExamples, feedsIndex, previousSelection, lineDiff } from '../lib/core.mjs';
30
+
31
+ const ROOT = findRoot(process.cwd());
32
+ const cmd = process.argv[2];
33
+ const args = process.argv.slice(3);
34
+ const red = (s) => `\x1b[31m${s}\x1b[0m`, yel = (s) => `\x1b[33m${s}\x1b[0m`, grn = (s) => `\x1b[32m${s}\x1b[0m`, dim = (s) => `\x1b[2m${s}\x1b[0m`;
35
+
36
+ function graph(repo) {
37
+ if (args.includes('--json')) { console.log(JSON.stringify(graphJson(repo), null, 2)); return; }
38
+ console.log('FACT -> ARTIFACTS');
39
+ for (const f of [...repo.facts.values()].sort((a, b) => a.id.localeCompare(b.id))) {
40
+ const deps = repo.dependents(f.id);
41
+ const tag = f.status === 'live' ? '' : yel(` [${f.status}]`);
42
+ console.log(` ${f.id.padEnd(28)}${tag} -> ${deps.map((a) => a.file).join(', ') || dim('(no dependents)')}`);
43
+ }
44
+ console.log('\nARTIFACT -> FACTS');
45
+ for (const a of [...repo.artifacts].sort((x, y) => x.file.localeCompare(y.file))) {
46
+ const ids = [...new Set(a.anchors.map((x) => x.id))];
47
+ const flags = [a.snapshot ? 'snapshot' : '', a.reviewed ? `reviewed ${a.reviewed}` : red('no reviewed stamp')].filter(Boolean).join(', ');
48
+ console.log(` ${a.file.padEnd(28)} ${dim(flags)}\n ${ids.join(', ') || red('(no anchors — orphan)')}`);
49
+ }
50
+ }
51
+
52
+ function check(repo) {
53
+ const problems = checkRepo(repo);
54
+ let errors = 0, warnings = 0;
55
+ for (const p of problems) {
56
+ if (p.level === 'error') errors++; else if (p.level === 'warning') warnings++;
57
+ const extra = p.kind === 'drift' ? `\n ${dim(p.text)}` : '';
58
+ console.log((p.level === 'error' ? red('ERROR ') : p.level === 'warning' ? yel('WARN ') : dim('INFO ')) + (p.level === 'info' ? dim(p.message) : p.message) + extra);
59
+ }
60
+ const stale = problems.filter((p) => p.kind === 'stale').length;
61
+ const behind = problems.filter((p) => p.kind === 'source-changed').length;
62
+ const notes = [stale ? `${stale} stale artifact(s): run the propagate workflow` : '', behind ? `${behind} behind their sources: run fakto sync` : ''].filter(Boolean);
63
+ console.log(`\n${errors ? red(errors + ' error(s)') : grn('0 errors')}, ${warnings} warning(s)${notes.length ? ' — ' + notes.join('; ') : ''}`);
64
+ process.exit(errors ? 1 : 0);
65
+ }
66
+
67
+ function stale(repo) {
68
+ const list = staleList(repo);
69
+ if (!list.length) { console.log(grn('Nothing stale. Every artifact was reviewed after its facts last changed.')); return; }
70
+ console.log(`${list.length} artifact(s) depend on facts that changed after their last review:\n`);
71
+ for (const { artifact, changed } of list) {
72
+ console.log(`${artifact.file} ${dim('reviewed ' + artifact.reviewed)}`);
73
+ for (const f of changed) {
74
+ console.log(` ${yel(f.id)} ${dim('updated ' + f.updated)} → ${f.value ? `"${f.value}"` : ''} ${f.statement}`);
75
+ for (const x of artifact.anchors.filter((x) => x.id === f.id)) console.log(` L${x.line}: ${dim(x.text.trim())}`);
76
+ }
77
+ console.log();
78
+ }
79
+ }
80
+
81
+ const srcStatus = (s) => (s === 'fresh' ? grn('in sync') : s === 'changed' ? yel('source changed') : s === 'unstamped' ? yel('never synced') : s === 'drift' ? red('out of date') : red(s));
82
+
83
+ function showFile(repo, file) {
84
+ const a = repo.artifacts.find((x) => x.file === file);
85
+ const feeds = feedsIndex(repo).get(file) || [];
86
+ if (!a && !feeds.length) { console.log(red(`No fact or scanned file "${file}"`)); process.exit(1); }
87
+ const g = a && graphJson(repo).artifacts.find((x) => x.file === file);
88
+ console.log(`${file}${a ? ` ${dim(a.snapshot ? 'snapshot' : a.reviewed ? 'reviewed ' + a.reviewed : 'no reviewed stamp')}` : dim(' (not scanned as a material)')}`);
89
+ if (a) {
90
+ const ids = [...new Set(a.anchors.map((x) => x.id))];
91
+ console.log(` cites: ${ids.join(', ') || dim('(nothing)')}`);
92
+ console.log(`\n draws on:`);
93
+ if (!g.drawsOn.length && !g.examples.length) console.log(dim(' (nothing: no from: or example: anchors)'));
94
+ for (const d of g.drawsOn) console.log(` L${d.line} ${d.section ? `"${d.section}"` : 'whole file'} <- ${d.path}${d.selector ? '#' + d.selector : ''} ${srcStatus(d.status)}${d.message ? dim(' ' + d.message) : ''}`);
95
+ for (const e of g.examples) console.log(` L${e.line} example <- ${e.path}#${e.selector} ${srcStatus(e.status)}${e.message ? dim(' ' + e.message) : ''}`);
96
+ }
97
+ console.log(`\n feeds:`);
98
+ if (!feeds.length) console.log(dim(' (nothing draws on this file)'));
99
+ for (const x of feeds) {
100
+ if (x.kind === 'fact') console.log(` fact ${x.id}${x.selector ? dim(' (#' + x.selector + ')') : ''}`);
101
+ else console.log(` ${x.kind} ${x.file} L${x.line}${x.section ? ` "${x.section}"` : ''}${x.selector ? dim(' (#' + x.selector + ')') : ''}`);
102
+ }
103
+ }
104
+
105
+ function show(repo, id) {
106
+ const f = repo.facts.get(id);
107
+ if (!f) return showFile(repo, id);
108
+ console.log(`${f.id} [${f.status}] updated ${f.updated} (${f.file})`);
109
+ console.log(` value: ${f.value ?? dim('(none: a narrative, worded per channel)')}`);
110
+ console.log(` statement: ${f.statement}`);
111
+ if (f.note) console.log(` note: ${f.note}`);
112
+ if (f.supports?.length) console.log(` supports: ${f.supports.map((id) => (repo.facts.has(id) ? id : red(id + ' (missing)'))).join(', ')}`);
113
+ if (f.source) {
114
+ const s = graphJson(repo).facts.find((x) => x.id === id).source;
115
+ console.log(` source: ${s.path}${s.selector ? '#' + s.selector : ''} ${srcStatus(s.status)}${s.message ? dim(' ' + s.message) : ''}`);
116
+ }
117
+ const deps = repo.dependents(id);
118
+ console.log(`\n${deps.length} dependent artifact(s):`);
119
+ for (const a of deps) {
120
+ console.log(` ${a.file}${a.snapshot ? dim(' (snapshot — never edited)') : ''}`);
121
+ for (const x of a.anchors.filter((x) => x.id === id)) console.log(` L${x.line}: ${dim(x.text.trim())}`);
122
+ }
123
+ }
124
+
125
+ function touch(repo, targets) {
126
+ for (const r of touchFiles(repo, targets)) {
127
+ if (r.fact) { console.log(r.ok ? `${r.fact}: source restamped` : red(`${r.fact}: no hashed source to restamp`)); continue; }
128
+ const re = r.restamped ? dim(`, ${r.restamped} source stamp(s) updated`) : '';
129
+ console.log(r.ok ? `${r.file}: ${r.reviewed ? `reviewed ${r.reviewed}` : 'source stamps updated'}${r.reviewed ? re : ''}` : red(`${r.file}: no reviewed stamp or source stamp to update`));
130
+ }
131
+ }
132
+
133
+ function sync(repo, targets) {
134
+ const only = targets.filter((t) => !t.startsWith('--'));
135
+ const plan = syncPlan(repo, only.length ? only : null);
136
+ const total = plan.facts.length + plan.sections.length + plan.examples.length;
137
+ if (!total) { console.log(grn('Everything is in sync with its sources.')); return; }
138
+ const copied = syncExamples(repo, only.length ? only : null);
139
+ for (const r of copied) console.log(r.ok ? `${r.file}:${r.line}: example copied from its source` : red(`${r.file}:${r.line}: ${r.message}`));
140
+ if (!plan.facts.length && !plan.sections.length) { console.log(grn('\nExamples synced; nothing else needs judgment.')); return; }
141
+ console.log(`\n${plan.facts.length} fact(s) and ${plan.sections.length} section(s) are behind their sources:\n`);
142
+ for (const f of plan.facts) {
143
+ console.log(`${yel(f.id)} ${dim(f.file)} <- ${f.ref ? f.ref.path + (f.ref.selector ? '#' + f.ref.selector : '') : '?'} ${srcStatus(f.status)}`);
144
+ if (f.form === 'anchored') console.log(` L${f.line}: ${dim(f.text)}`);
145
+ else if (f.status === 'changed') { const old = previousSelection(repo.root, f.ref); if (old) console.log(dim(lineDiff(old, resolveText(repo, f.ref), 40).replace(/^/gm, ' '))); }
146
+ if (f.message) console.log(` ${dim(f.message)}`);
147
+ }
148
+ for (const s of plan.sections) {
149
+ console.log(`${yel(s.file)}:${s.line} ${s.section ? `"${s.section}"` : 'whole file'} <- ${s.ref.path}${s.ref.selector ? '#' + s.ref.selector : ''} ${srcStatus(s.status)}`);
150
+ if (s.status === 'changed') { const old = previousSelection(repo.root, s.ref); if (old) console.log(dim(lineDiff(old, resolveText(repo, s.ref), 40).replace(/^/gm, ' '))); }
151
+ if (s.message) console.log(` ${dim(s.message)}`);
152
+ }
153
+ console.log(`\nThese need judgment: run the fakto skill (case F, "sync") or press Sync in the canvas. A restamp alone is \`fakto touch <file | fact-id>\`.`);
154
+ }
155
+ const resolveText = (repo, ref) => { const r = resolveRefSafe(repo.root, ref); return r.ok ? r.text : ''; };
156
+ import { resolveRef as resolveRefSafe } from '../lib/core.mjs';
157
+
158
+ function render(repo, only) {
159
+ const declared = renderStatus(repo);
160
+ if (!declared.length) { console.log(dim('No renders declared in fakto.config.json.')); return; }
161
+ const done = renderOutputs(repo, only.length ? only : null);
162
+ for (const r of done) console.log(r.ok ? `${r.to}: rendered from ${r.from} (${r.with})` : red(`${r.to}: ${r.status === 'no-source' ? `source ${r.from} missing` : `unknown renderer "${r.with}"`}`));
163
+ if (!done.length) console.log(grn('Every render is up to date.'));
164
+ if (done.some((r) => !r.ok)) process.exit(1);
165
+ }
166
+
167
+ /** What answers on a port: { root } for a fakto canvas, 'other' for anything else, null when free. */
168
+ async function probe(port) {
169
+ try {
170
+ const r = await fetch(`http://127.0.0.1:${port}/api/graph`, { signal: AbortSignal.timeout(1500) });
171
+ const g = await r.json().catch(() => null);
172
+ return g && typeof g.root === 'string' ? { root: g.root, pid: g.pid } : 'other';
173
+ } catch (e) {
174
+ return e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED' ? null : 'other';
175
+ }
176
+ }
177
+
178
+ /**
179
+ * fakto canvas [--port N] [--background | --stop]
180
+ * One canvas per repo: reuse a running one, or step to the next free port (unless --port).
181
+ * --background starts it detached and returns once it answers (what agents should use:
182
+ * nothing is left attached to the caller); --stop stops this repo's canvas.
183
+ */
184
+ async function canvas() {
185
+ const opt = (name) => { const i = args.indexOf(name); return i === -1 ? null : args[i + 1]; };
186
+ const fixed = Number(opt('--port')) || null;
187
+ const mine = async () => {
188
+ for (let port = fixed || 4848; port < (fixed || 4848) + 10; port++) {
189
+ const p = await probe(port);
190
+ if (p && p !== 'other' && p.root === ROOT) return { port, pid: p.pid };
191
+ if (fixed) break;
192
+ }
193
+ return null;
194
+ };
195
+
196
+ if (args.includes('--stop')) {
197
+ const m = await mine();
198
+ if (!m) { console.log(dim(`No fakto canvas is running for ${ROOT}.`)); return; }
199
+ try { process.kill(m.pid); console.log(`fakto canvas on http://localhost:${m.port} stopped.`); }
200
+ catch (e) { console.log(red(`Could not stop the canvas on port ${m.port}: ${e.message}`)); process.exit(1); }
201
+ return;
202
+ }
203
+
204
+ let start;
205
+ ({ start } = await import('../canvas/server/server.mjs'));
206
+ let port = fixed || 4848;
207
+ for (let tries = 0; tries < 10; tries++, port++) {
208
+ const p = await probe(port);
209
+ if (p && p !== 'other' && p.root === ROOT) { console.log(`fakto canvas: http://localhost:${port} (already running for ${ROOT})`); return; }
210
+ if (!p) break;
211
+ if (fixed) { console.log(red(`Port ${port} is taken. Pick another with --port.`)); process.exit(1); }
212
+ }
213
+
214
+ if (args.includes('--background')) {
215
+ const { spawn } = await import('node:child_process');
216
+ const { openSync } = await import('node:fs');
217
+ const { tmpdir } = await import('node:os');
218
+ const { join } = await import('node:path');
219
+ const log = join(tmpdir(), `fakto-canvas-${port}.log`);
220
+ const fd = openSync(log, 'a');
221
+ const child = spawn(process.execPath, [process.argv[1], 'canvas', '--port', String(port)], { cwd: ROOT, detached: true, stdio: ['ignore', fd, fd], env: process.env });
222
+ child.unref();
223
+ for (let i = 0; i < 50; i++) {
224
+ await new Promise((r) => setTimeout(r, 200));
225
+ const p = await probe(port);
226
+ if (p && p !== 'other' && p.root === ROOT) {
227
+ console.log(`fakto canvas: http://localhost:${port} (started in the background for ${ROOT}; stop it with: npx fakto canvas --stop)`);
228
+ return;
229
+ }
230
+ }
231
+ console.log(red(`The canvas did not answer on port ${port} within 10 s. Its log: ${log}`));
232
+ process.exit(1);
233
+ }
234
+ await start({ root: ROOT, port });
235
+ }
236
+
237
+ const repo = cmd && cmd !== 'canvas' ? loadRepo(ROOT) : null;
238
+ switch (cmd) {
239
+ case 'graph': graph(repo); break;
240
+ case 'check': check(repo); break;
241
+ case 'stale': stale(repo); break;
242
+ case 'show': show(repo, args[0]); break;
243
+ case 'touch': touch(repo, args); break;
244
+ case 'render': render(repo, args); break;
245
+ case 'sync': sync(repo, args); break;
246
+ case 'canvas': await canvas(); break;
247
+ default:
248
+ console.log('usage: fakto graph | check | stale | show <fact-id | file> | touch <file | fact-id>… | render [path…] | sync [path | fact-id …] | canvas');
249
+ process.exit(cmd ? 1 : 0);
250
+ }
@@ -0,0 +1,158 @@
1
+ # The Fakto canvas
2
+
3
+ A local web app for Fakto. It draws which facts feed which files, lets you
4
+ edit or add a fact, runs your own Claude Code CLI to bring every citing file in line,
5
+ and animates the change travelling through the repo.
6
+
7
+ ```bash
8
+ npx fakto canvas # http://localhost:4848
9
+ npx fakto canvas --port 5000
10
+ ```
11
+
12
+ It works on any repo with a `fakto.config.json`. The repo is the one the command runs
13
+ in (or the nearest parent holding `fakto.config.json`). It ships inside the `fakto`
14
+ package.
15
+
16
+ ## What the states mean
17
+
18
+ The canvas speaks in what the reader should do; the CLI and the code keep the
19
+ internal names.
20
+
21
+ | On screen | Internal | Meaning |
22
+ |---|---|---|
23
+ | Up to date | sync | Every line matches its fact |
24
+ | Needs update | stale | A fact changed after this file was last checked; Claude fixes it |
25
+ | Conflicts | drift | A line says something different from its fact; someone edited it by hand |
26
+ | Frozen | snapshot | Historical file, never updated on purpose |
27
+ | Retired facts | retired | Facts no longer in use |
28
+
29
+ The top bar groups the filters as Files (the first four) and Facts (retired). Every
30
+ state shows its meaning as a tooltip.
31
+
32
+ ## Files tab
33
+
34
+ The top bar's **Canvas / Files** tabs switch between the graph and a file explorer:
35
+
36
+ - **Tree.** Every fact file (with its fact count) and every material (with its state and
37
+ how many facts it cites).
38
+ - **File view.** A material shows its anchored lines only, with the gaps between them
39
+ collapsed; a fact file shows its YAML. Click a tag (a line, a chip or a side card) to
40
+ highlight its lines; every other line fades.
41
+ - **Side panel.** For a material, the facts it cites with their values and line numbers.
42
+ For a fact file, pick a fact to list the files that cite it; click one to open it.
43
+ - **Rendered outputs** sit under their source as a second layer, marked Rendered, Out of
44
+ date or Not rendered. Clicking one opens it in a new tab, rebuilt first if needed.
45
+ - **Show on canvas** jumps to the open material on the canvas and opens its panel.
46
+ Starting a run always switches back to the Canvas tab.
47
+
48
+ On the canvas, each material node shows how many facts it cites, and the material panel
49
+ lists them under "Tags used".
50
+
51
+ Rendered outputs also appear on the map: an indented node under their source, joined by a
52
+ short tree line, with the same Rendered / Out of date / Not rendered label. Hovering one
53
+ lights up its source; clicking opens it in a new tab.
54
+
55
+ ## Requirements
56
+
57
+ - Node 18 or newer. The server has no dependencies.
58
+ - The built client in `dist/`. The npm package ships it prebuilt; in the monorepo run
59
+ `npm run build` at the root.
60
+ - For Ask Claude and propagation: the Claude Code CLI. The server looks for it in
61
+ `FAKTO_CLAUDE_BIN`, then `PATH`, then the usual install folders (`~/.claude/local`,
62
+ `~/.local/bin`, Homebrew, nvm). Without it, the canvas still browses and edits facts,
63
+ and the Ask Claude buttons say the CLI is missing.
64
+
65
+ ## Architecture
66
+
67
+ ```
68
+ ../lib/ all repo logic (shared with the CLI): parse, graph, check, stale, touch, write/add facts
69
+ server/
70
+ server.mjs http + SSE + static files; the only thing that touches the repo
71
+ run.mjs spawns `claude -p … --output-format stream-json`, turns it into feed steps
72
+ src/
73
+ graph/layout.ts the design's layout maths (columns, rows, clusters, links, bundles)
74
+ graph/model.ts API graph -> view model (material states, links, problems)
75
+ state/run.ts client side of a run: event queue with the design's pacing
76
+ app/ App, TopBar, Stage (camera, minimap), Graph (nodes, links), RunUI, panels/
77
+ ```
78
+
79
+ ### Data flow
80
+
81
+ ```
82
+ repo files -> core.loadRepo -> GET /api/graph -> model -> layout -> DOM + SVG
83
+ fs.watch (facts + artifact folders, 150 ms debounce) -> SSE "graph"
84
+ claude stream-json -> run.mjs -> SSE "run" / "step" / "check" -> useRun queue -> feed + canvas overlay
85
+ ```
86
+
87
+ ## API
88
+
89
+ | Method, path | Purpose |
90
+ |---|---|
91
+ | `GET /api/graph` | `core.graphJson` plus `repoName`, `factGroups`, `artifactGroups`, `checkProblems`, `claude: { available, running }`. |
92
+ | `GET /api/file?path=` | Text of one file inside the facts or artifact folders (403 outside them). |
93
+ | `PUT /api/facts/:id` | `{ value, statement, status, owner, note }`. Text-level edit that keeps comments and order; bumps `updated` only when something changed. |
94
+ | `POST /api/facts` | `{ group, id, value, statement, status }`. Appends a record (default `draft`). |
95
+ | `POST /api/run` | `{ mode: 'edit' \| 'ask' \| 'fix', prompt?, fact?, before? }`. Starts one run; 409 while one is running, 503 without the CLI. |
96
+ | `GET /api/run` | Whether a run is active, plus its steps so far. |
97
+ | `POST /api/check` | `{ ok, problems, links, passed, seconds }`. Problems are errors plus stale files. |
98
+ | `GET /api/output?path=` | A declared render output, rebuilt first if its source changed. |
99
+ | `GET /api/events` | SSE: `graph`, `run`, `step`, `check`. |
100
+
101
+ ### Run events
102
+
103
+ | Event | Data | Canvas |
104
+ |---|---|---|
105
+ | `run` saved | `{ fact, old, new }` | fact node pulses, feed opens |
106
+ | `run` tracing | `{ fact, stale, snapshots, lines }` | the wave: one link every 120 ms, front links partly drawn |
107
+ | `run` rewriting | | first file edit seen |
108
+ | `step` | `{ kind: cur \| done \| fail \| ok, text, file?, line? }` | a feed line; `cur` with a file marks it Editing |
109
+ | `check` | `{ ok, problems, links, passed, seconds }` | banner text, failed panel |
110
+ | `run` passed / failed | `{ error? }` | banner |
111
+
112
+ A file turns green ("Just now") when Claude edited it and none of its lines fail, or
113
+ when it is no longer stale. That comes from live graph data, not from timing.
114
+
115
+ ### How feed steps are derived
116
+
117
+ `run.mjs` never shows raw model output. It maps tool calls:
118
+
119
+ - Edit, Write or MultiEdit on an artifact: "Editing <file> line N", then "Rewrote <file> line N".
120
+ The line is found by locating the edit's old text in the file before the edit lands.
121
+ - Bash running `fakto touch`: counted for "Stamped N files as reviewed".
122
+ - Bash running `fakto check`: "Running the check".
123
+ - Fact changes are detected from the files, not from tool calls, so Ask Claude requests
124
+ that change a fact get the same saved and tracing steps as an edit made in the panel.
125
+ - After the process exits, the server runs the check itself. The verdict is never taken
126
+ from the model's text.
127
+
128
+ The prompt asks Claude to propagate autonomously, stamp files with `fakto touch` and finish
129
+ with `fakto check` green, to follow the repo's `houseRules` file when it has one, and to use the `fakto` skill when
130
+ the repo has it wired (a `.claude/skills/fakto` folder or the plugin enabled). Claude runs
131
+ with `--permission-mode acceptEdits` and a narrow tool list: read, edit and search tools,
132
+ `npx fakto`, `npm run`, `git diff` and `git status`.
133
+
134
+ ## Security
135
+
136
+ The server can start Claude with edit rights in your repo, so:
137
+
138
+ - It listens on `127.0.0.1` only.
139
+ - Every write (`POST`, `PUT`) must carry a JSON content type, which forces a CORS preflight
140
+ the server never answers, and any `Origin` header must be the canvas itself. Other sites
141
+ open in your browser cannot trigger a run.
142
+ - File reads are limited to the facts and artifact folders from `fakto.config.json`.
143
+
144
+ ## Many facts
145
+
146
+ The layout fits every row into the window, as designed, until a row would be shorter
147
+ than its text (22px). From there rows keep that height and the canvas opens zoomed out
148
+ just enough to show everything, never below the 60% clusters threshold. Fit returns to
149
+ that view; once you pan or zoom yourself, it stays where you put it.
150
+
151
+ ## Known limits
152
+
153
+ - One run at a time. There is no cancel button; a run times out after 10 minutes.
154
+ - A page reload during a run loses the animation state; the run itself continues and the
155
+ canvas shows the result when the files change.
156
+ - Panels are dark glass in both themes, as designed.
157
+ - Presenter mode is laid out for 1920 x 1080; on smaller screens the activity feed can
158
+ run past the right edge.