@fakto/core 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,242 @@
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
+ ## Claude Code skills
73
+
74
+ The package ships the two skills Claude uses in a Fakto repo (`fakto`: the change workflow;
75
+ `fakto-canvas`: starts the live map) and the hooks that run the check after every edit and
76
+ start the canvas at session start. Claude Code loads skills from the project, not from
77
+ `node_modules`, so install them into the repo:
78
+
79
+ ```bash
80
+ npx fakto skills # write .claude/skills/* and merge the hooks into .claude/settings.json
81
+ npx fakto skills --check # exit 1 when they are missing, edited, or from another version
82
+ ```
83
+
84
+ Put `"postinstall": "fakto skills"` in the repo's package.json so every `npm install` refreshes
85
+ them; the session-start hook runs it too, and `fakto check` warns when they are not current.
86
+ Commit them: they are readable on GitHub and work before the first install.
87
+
88
+ ## Canvas
89
+
90
+ A local web app that draws the whole graph, lets you edit or add a fact, and runs your
91
+ own Claude Code CLI to propagate the change while the canvas animates it.
92
+
93
+ ```bash
94
+ npx fakto canvas # http://localhost:4848, --port to change
95
+ ```
96
+
97
+ Architecture, API, security and limits: `canvas/README.md` in this package.
98
+
99
+ ## Conventions in detail
100
+
101
+ ### Fact record
102
+
103
+ ```yaml
104
+ - id: power-levels # kebab-case, topic prefix helps grep
105
+ value: "7" # the string every citing line must contain
106
+ statement: "The device has 7 power levels."
107
+ status: live # live | draft | retired
108
+ updated: 2026-09-01 # bump whenever value or statement changes
109
+ owner: hardware # optional
110
+ note: "Measured on white bread" # optional: rules, caveats, why it changed
111
+ ```
112
+
113
+ `value` is matched case-insensitively against the anchored line. Choose it so it
114
+ fits prose and code alike. A text-only fact (no `value`) skips the drift check.
115
+
116
+ ### Artifact
117
+
118
+ ```md
119
+ ---
120
+ reviewed: 2026-09-01 # when a human last confirmed the file
121
+ lifecycle: snapshot # optional: frozen, never edited
122
+ ---
123
+ Seven levels? No: 7 power levels. <!-- fact:power-levels -->
124
+ ```
125
+
126
+ ```ts
127
+ // reviewed: 2026-09-01
128
+ export const MAX_LEVEL = 7; // fact:power-levels
129
+ ```
130
+
131
+ The head is the first 12 lines. Several anchors may share a line. A line that
132
+ documents a retirement may cite a retired fact if it contains `RETIRED`.
133
+
134
+ **As-of citations.** `fact:id@2026-09-01` says "this line states the fact as it was
135
+ on that date". Use it in changelogs, release notes and "was 7, now 9" asides: the
136
+ graph still links the line to the fact, but drift, retired and staleness checks skip
137
+ it. It is the line-level version of `lifecycle: snapshot`.
138
+
139
+ ### Renders
140
+
141
+ Some artifacts cannot carry anchors (a slide deck, a PDF). Keep their text in a material
142
+ with anchors, and declare the artifact as build output:
143
+
144
+ ```json
145
+ "renders": [{ "from": "pitch/investor-deck.md", "to": "out/investor-deck.html", "with": "slides", "title": "Seed round" }]
146
+ ```
147
+
148
+ `fakto render` builds every output that is out of date or missing; the canvas rebuilds them
149
+ at the end of a run and when you open one. An output embeds the hash of the source it was
150
+ built from, carries no anchors, is never scanned as a material, and is never edited by
151
+ hand. The built-in `slides` renderer turns one `# heading` per slide into a single HTML
152
+ deck (arrow keys to move, P to print to PDF).
153
+
154
+ ### Narratives
155
+
156
+ A narrative is a fact without a `value`: a story each channel words its own way.
157
+
158
+ ```yaml
159
+ - id: story-platform
160
+ statement: "We are a platform, not a point tool: anything that can call our API can build on us."
161
+ status: live
162
+ updated: 2026-10-21
163
+ supports: [api-base-url, sdk-package-name] # the facts the story rests on
164
+ ```
165
+
166
+ Anchor the section that tells it (`<!-- fact:story-platform -->` on a heading or a
167
+ sentence). It goes stale like any fact; there is no wording to compare, so the drift
168
+ check skips it and Claude checks the story when it rewrites (the skill's consistency
169
+ pass). `supports` must name existing facts.
170
+
171
+ ### Sources: when a file is the truth
172
+
173
+ Sometimes the code is the truth and the facts and docs follow it. Three edges, one grammar:
174
+
175
+ | Edge | Declared | Changed when |
176
+ |---|---|---|
177
+ | file → fact | `source:` on the fact record | the anchored line no longer says the value, or the selection's hash moved |
178
+ | file region → doc section | `<!-- from:path#selector -->` on a heading | the selection's hash moved |
179
+ | file region → fenced block | `<!-- example:path#selector -->` before a fence | the fence no longer contains the selection verbatim |
180
+
181
+ ```yaml
182
+ - id: api-rate-limit
183
+ value: "10"
184
+ source: sdk/src/config.ts # anchored: the file's fact:api-rate-limit line wins
185
+ - id: api-surface
186
+ statement: "Endpoints: POST /v1/toast, GET /v1/status."
187
+ source: sdk/openapi.yaml#paths@3f9a1c2e7b4d # hashed: any selection, stamped by fakto
188
+ ```
189
+
190
+ ```md
191
+ ## Start a job <!-- from:sdk/openapi.yaml#paths./v1/jobs@3f9a1c2e7b4d -->
192
+
193
+ <!-- example:sdk/examples/quickstart.ts#quickstart -->
194
+ ```ts
195
+ …
196
+ ```
197
+ ```
198
+
199
+ **Selectors.** None (the whole file); `#name` for a region between `fakto:region name` and
200
+ `fakto:end` comments in any language; `#a.b.c` for a key path into YAML or JSON (quote keys
201
+ that contain dots). `#exports` (the public surface of a TypeScript file) arrives in 1.1.
202
+
203
+ **Stamps.** You write the path and selector; `fakto touch` writes the hash (a git blob hash
204
+ of the normalised selection, 12 hex). A `from:` scope runs to the next heading of the same
205
+ or a higher level; `sources: [path#selector, …]` in the head makes the whole file draw on
206
+ them.
207
+
208
+ **Sync.** `fakto sync [path | fact-id …]` copies examples (no AI needed) and lists the facts
209
+ and sections that need judgment, with the diff since their last sync when git has it. The
210
+ skill (case F) or the canvas's Sync button re-derives the facts, rewrites the sections in
211
+ their own voice, and propagates any fact that changed. A restamp alone is
212
+ `fakto touch <file | fact-id>`. A derived fact is never edited by hand: change its source.
213
+
214
+ ### Check rules
215
+
216
+ | Rule | Level | Why |
217
+ |---|---|---|
218
+ | Anchor to an unknown id | error | typo, or the record was deleted |
219
+ | Living artifact cites a `retired` fact | error | the fact must not resurrect |
220
+ | Living artifact cites a `draft` fact | warning | not confirmed yet |
221
+ | Anchored line lacks the fact's `value` | error | someone edited the artifact, not the fact: drift |
222
+ | A narrative's `supports` names a missing fact | error | the story rests on something that is gone |
223
+ | A render output is older than its source, or missing | warning | run `fakto render` |
224
+ | A render names a missing source or an unknown renderer | error | fix `renders` in the config |
225
+ | As-of anchor (`fact:id@date`) | skipped | historical citation; only the id is checked |
226
+ | Living artifact without `reviewed` | error | nothing can go stale without it |
227
+ | Artifact without any anchor | warning | orphan: tag it or move it out |
228
+ | Fact `updated` after artifact `reviewed` | warning + queue | propagate |
229
+ | A derived fact or a `from:` section is behind its source, or was never synced | warning + queue | `fakto sync` |
230
+ | A fenced example no longer matches its source | error | `fakto sync` copies it |
231
+ | A source path or selector does not resolve | error | fix the anchor or the region |
232
+ | `from:` sources form a cycle | error | a file cannot describe itself through another |
233
+ | A marked region nothing draws on | info | remove the markers or use them |
234
+
235
+ ## Where this came from
236
+
237
+ A simplified, project-agnostic version of the messaging system behind Alokai's
238
+ website: a claims database with a status ladder, pitch docs that cite claims by id,
239
+ pages that declare which docs they rely on, a graph script, a citation linter, and
240
+ an approval pipeline that propagates rulings. This keeps the ideas (ids, one-way
241
+ links, living vs frozen, propagate = find + draft + approve) and drops the middle
242
+ layer, the app and the database.
package/bin/fakto.mjs ADDED
@@ -0,0 +1,273 @@
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
+ * fakto skills install or refresh the Claude Code skills and hooks in .claude/ (--check, --quiet)
15
+ *
16
+ * Conventions (full spec in this package's README):
17
+ * facts <facts>/*.yaml, flat records: - id / value / statement / status / updated
18
+ * artifact any text file under <artifacts>; a dependency is an inline anchor
19
+ * `fact:<id>` in a comment on the line that states the fact.
20
+ * `reviewed: YYYY-MM-DD` in the file head = when a human last confirmed it.
21
+ * `lifecycle: snapshot` = frozen; checked for unknown ids only.
22
+ * `fact:<id>@YYYY-MM-DD` = an as-of citation (a changelog entry, a
23
+ * "was 7" aside): historical, checked for unknown ids only.
24
+ * sources a fact with `source: <path>[#selector][@hash]` follows that file; a doc
25
+ * section with `from:<path>[#selector]@<hash>` describes that code; a fence
26
+ * after `example:<path>#<selector>` is that code verbatim.
27
+ *
28
+ * All logic lives in ../lib; this file only formats and dispatches.
29
+ */
30
+ import { installSkills, skillsStatus, findRoot, loadRepo, staleList, checkRepo, graphJson, touch as touchFiles, renderOutputs, renderStatus, syncPlan, syncExamples, feedsIndex, previousSelection, lineDiff } from '../lib/core.mjs';
31
+
32
+ const ROOT = findRoot(process.cwd());
33
+ const cmd = process.argv[2];
34
+ const args = process.argv.slice(3);
35
+ 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`;
36
+
37
+ function graph(repo) {
38
+ if (args.includes('--json')) { console.log(JSON.stringify(graphJson(repo), null, 2)); return; }
39
+ console.log('FACT -> ARTIFACTS');
40
+ for (const f of [...repo.facts.values()].sort((a, b) => a.id.localeCompare(b.id))) {
41
+ const deps = repo.dependents(f.id);
42
+ const tag = f.status === 'live' ? '' : yel(` [${f.status}]`);
43
+ console.log(` ${f.id.padEnd(28)}${tag} -> ${deps.map((a) => a.file).join(', ') || dim('(no dependents)')}`);
44
+ }
45
+ console.log('\nARTIFACT -> FACTS');
46
+ for (const a of [...repo.artifacts].sort((x, y) => x.file.localeCompare(y.file))) {
47
+ const ids = [...new Set(a.anchors.map((x) => x.id))];
48
+ const flags = [a.snapshot ? 'snapshot' : '', a.reviewed ? `reviewed ${a.reviewed}` : red('no reviewed stamp')].filter(Boolean).join(', ');
49
+ console.log(` ${a.file.padEnd(28)} ${dim(flags)}\n ${ids.join(', ') || red('(no anchors — orphan)')}`);
50
+ }
51
+ }
52
+
53
+ function check(repo) {
54
+ const problems = checkRepo(repo);
55
+ let errors = 0, warnings = 0;
56
+ for (const p of problems) {
57
+ if (p.level === 'error') errors++; else if (p.level === 'warning') warnings++;
58
+ const extra = p.kind === 'drift' ? `\n ${dim(p.text)}` : '';
59
+ console.log((p.level === 'error' ? red('ERROR ') : p.level === 'warning' ? yel('WARN ') : dim('INFO ')) + (p.level === 'info' ? dim(p.message) : p.message) + extra);
60
+ }
61
+ const stale = problems.filter((p) => p.kind === 'stale').length;
62
+ const behind = problems.filter((p) => p.kind === 'source-changed').length;
63
+ const notes = [stale ? `${stale} stale artifact(s): run the propagate workflow` : '', behind ? `${behind} behind their sources: run fakto sync` : ''].filter(Boolean);
64
+ console.log(`\n${errors ? red(errors + ' error(s)') : grn('0 errors')}, ${warnings} warning(s)${notes.length ? ' — ' + notes.join('; ') : ''}`);
65
+ process.exit(errors ? 1 : 0);
66
+ }
67
+
68
+ function stale(repo) {
69
+ const list = staleList(repo);
70
+ if (!list.length) { console.log(grn('Nothing stale. Every artifact was reviewed after its facts last changed.')); return; }
71
+ console.log(`${list.length} artifact(s) depend on facts that changed after their last review:\n`);
72
+ for (const { artifact, changed } of list) {
73
+ console.log(`${artifact.file} ${dim('reviewed ' + artifact.reviewed)}`);
74
+ for (const f of changed) {
75
+ console.log(` ${yel(f.id)} ${dim('updated ' + f.updated)} → ${f.value ? `"${f.value}"` : ''} ${f.statement}`);
76
+ for (const x of artifact.anchors.filter((x) => x.id === f.id)) console.log(` L${x.line}: ${dim(x.text.trim())}`);
77
+ }
78
+ console.log();
79
+ }
80
+ }
81
+
82
+ 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));
83
+
84
+ function showFile(repo, file) {
85
+ const a = repo.artifacts.find((x) => x.file === file);
86
+ const feeds = feedsIndex(repo).get(file) || [];
87
+ if (!a && !feeds.length) { console.log(red(`No fact or scanned file "${file}"`)); process.exit(1); }
88
+ const g = a && graphJson(repo).artifacts.find((x) => x.file === file);
89
+ console.log(`${file}${a ? ` ${dim(a.snapshot ? 'snapshot' : a.reviewed ? 'reviewed ' + a.reviewed : 'no reviewed stamp')}` : dim(' (not scanned as a material)')}`);
90
+ if (a) {
91
+ const ids = [...new Set(a.anchors.map((x) => x.id))];
92
+ console.log(` cites: ${ids.join(', ') || dim('(nothing)')}`);
93
+ console.log(`\n draws on:`);
94
+ if (!g.drawsOn.length && !g.examples.length) console.log(dim(' (nothing: no from: or example: anchors)'));
95
+ 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) : ''}`);
96
+ for (const e of g.examples) console.log(` L${e.line} example <- ${e.path}#${e.selector} ${srcStatus(e.status)}${e.message ? dim(' ' + e.message) : ''}`);
97
+ }
98
+ console.log(`\n feeds:`);
99
+ if (!feeds.length) console.log(dim(' (nothing draws on this file)'));
100
+ for (const x of feeds) {
101
+ if (x.kind === 'fact') console.log(` fact ${x.id}${x.selector ? dim(' (#' + x.selector + ')') : ''}`);
102
+ else console.log(` ${x.kind} ${x.file} L${x.line}${x.section ? ` "${x.section}"` : ''}${x.selector ? dim(' (#' + x.selector + ')') : ''}`);
103
+ }
104
+ }
105
+
106
+ function show(repo, id) {
107
+ const f = repo.facts.get(id);
108
+ if (!f) return showFile(repo, id);
109
+ console.log(`${f.id} [${f.status}] updated ${f.updated} (${f.file})`);
110
+ console.log(` value: ${f.value ?? dim('(none: a narrative, worded per channel)')}`);
111
+ console.log(` statement: ${f.statement}`);
112
+ if (f.note) console.log(` note: ${f.note}`);
113
+ if (f.supports?.length) console.log(` supports: ${f.supports.map((id) => (repo.facts.has(id) ? id : red(id + ' (missing)'))).join(', ')}`);
114
+ if (f.source) {
115
+ const s = graphJson(repo).facts.find((x) => x.id === id).source;
116
+ console.log(` source: ${s.path}${s.selector ? '#' + s.selector : ''} ${srcStatus(s.status)}${s.message ? dim(' ' + s.message) : ''}`);
117
+ }
118
+ const deps = repo.dependents(id);
119
+ console.log(`\n${deps.length} dependent artifact(s):`);
120
+ for (const a of deps) {
121
+ console.log(` ${a.file}${a.snapshot ? dim(' (snapshot — never edited)') : ''}`);
122
+ for (const x of a.anchors.filter((x) => x.id === id)) console.log(` L${x.line}: ${dim(x.text.trim())}`);
123
+ }
124
+ }
125
+
126
+ function touch(repo, targets) {
127
+ for (const r of touchFiles(repo, targets)) {
128
+ if (r.fact) { console.log(r.ok ? `${r.fact}: source restamped` : red(`${r.fact}: no hashed source to restamp`)); continue; }
129
+ const re = r.restamped ? dim(`, ${r.restamped} source stamp(s) updated`) : '';
130
+ 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`));
131
+ }
132
+ }
133
+
134
+ function sync(repo, targets) {
135
+ const only = targets.filter((t) => !t.startsWith('--'));
136
+ const plan = syncPlan(repo, only.length ? only : null);
137
+ const total = plan.facts.length + plan.sections.length + plan.examples.length;
138
+ if (!total) { console.log(grn('Everything is in sync with its sources.')); return; }
139
+ const copied = syncExamples(repo, only.length ? only : null);
140
+ 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}`));
141
+ if (!plan.facts.length && !plan.sections.length) { console.log(grn('\nExamples synced; nothing else needs judgment.')); return; }
142
+ console.log(`\n${plan.facts.length} fact(s) and ${plan.sections.length} section(s) are behind their sources:\n`);
143
+ for (const f of plan.facts) {
144
+ console.log(`${yel(f.id)} ${dim(f.file)} <- ${f.ref ? f.ref.path + (f.ref.selector ? '#' + f.ref.selector : '') : '?'} ${srcStatus(f.status)}`);
145
+ if (f.form === 'anchored') console.log(` L${f.line}: ${dim(f.text)}`);
146
+ 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, ' '))); }
147
+ if (f.message) console.log(` ${dim(f.message)}`);
148
+ }
149
+ for (const s of plan.sections) {
150
+ 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)}`);
151
+ 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, ' '))); }
152
+ if (s.message) console.log(` ${dim(s.message)}`);
153
+ }
154
+ 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>\`.`);
155
+ }
156
+ const resolveText = (repo, ref) => { const r = resolveRefSafe(repo.root, ref); return r.ok ? r.text : ''; };
157
+ import { resolveRef as resolveRefSafe } from '../lib/core.mjs';
158
+
159
+ function render(repo, only) {
160
+ const declared = renderStatus(repo);
161
+ if (!declared.length) { console.log(dim('No renders declared in fakto.config.json.')); return; }
162
+ const done = renderOutputs(repo, only.length ? only : null);
163
+ 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}"`}`));
164
+ if (!done.length) console.log(grn('Every render is up to date.'));
165
+ if (done.some((r) => !r.ok)) process.exit(1);
166
+ }
167
+
168
+ /**
169
+ * fakto skills [--check | --quiet]
170
+ * Installs this package's Claude Code skills into .claude/skills (stamped with the version) and
171
+ * merges its hooks and permissions into .claude/settings.json. Runs on postinstall and at session
172
+ * start. --check exits 1 when anything is missing or out of date; --quiet prints only changes.
173
+ */
174
+ function skills() {
175
+ if (args.includes('--check')) {
176
+ const s = skillsStatus(ROOT);
177
+ for (const k of s.skills) console.log(`${k.status === 'ok' ? grn('ok ') : red(k.status.padEnd(8))} .claude/skills/${k.name}${k.installed ? dim(` (installed ${k.installed}, package ${s.version})`) : ''}`);
178
+ console.log(`${s.settings === 'ok' ? grn('ok ') : red(s.settings.padEnd(8))} .claude/settings.json`);
179
+ if (!s.ok) { console.log(`\nRun ${'npx fakto skills'} to install the skills for ${s.version}.`); process.exit(1); }
180
+ return;
181
+ }
182
+ const { changed, version } = installSkills(ROOT);
183
+ const quiet = args.includes('--quiet');
184
+ if (!changed.length) { if (!quiet) console.log(grn(`Fakto skills are up to date (${version}).`)); return; }
185
+ if (quiet) console.log(`The Fakto skills were just updated to ${version} (${changed.join(', ')}). Read .claude/skills/fakto/SKILL.md and .claude/skills/fakto-canvas/SKILL.md again before you use them.`);
186
+ else for (const c of changed) console.log(`${c}: updated to ${version}`);
187
+ }
188
+
189
+ /** What answers on a port: { root } for a fakto canvas, 'other' for anything else, null when free. */
190
+ async function probe(port) {
191
+ try {
192
+ const r = await fetch(`http://127.0.0.1:${port}/api/graph`, { signal: AbortSignal.timeout(1500) });
193
+ const g = await r.json().catch(() => null);
194
+ return g && typeof g.root === 'string' ? { root: g.root, pid: g.pid } : 'other';
195
+ } catch (e) {
196
+ return e?.cause?.code === 'ECONNREFUSED' || e?.code === 'ECONNREFUSED' ? null : 'other';
197
+ }
198
+ }
199
+
200
+ /**
201
+ * fakto canvas [--port N] [--background | --stop]
202
+ * One canvas per repo: reuse a running one, or step to the next free port (unless --port).
203
+ * --background starts it detached and returns once it answers (what agents should use:
204
+ * nothing is left attached to the caller); --stop stops this repo's canvas.
205
+ */
206
+ async function canvas() {
207
+ const opt = (name) => { const i = args.indexOf(name); return i === -1 ? null : args[i + 1]; };
208
+ const fixed = Number(opt('--port')) || null;
209
+ const mine = async () => {
210
+ for (let port = fixed || 4848; port < (fixed || 4848) + 10; port++) {
211
+ const p = await probe(port);
212
+ if (p && p !== 'other' && p.root === ROOT) return { port, pid: p.pid };
213
+ if (fixed) break;
214
+ }
215
+ return null;
216
+ };
217
+
218
+ if (args.includes('--stop')) {
219
+ const m = await mine();
220
+ if (!m) { console.log(dim(`No fakto canvas is running for ${ROOT}.`)); return; }
221
+ try { process.kill(m.pid); console.log(`fakto canvas on http://localhost:${m.port} stopped.`); }
222
+ catch (e) { console.log(red(`Could not stop the canvas on port ${m.port}: ${e.message}`)); process.exit(1); }
223
+ return;
224
+ }
225
+
226
+ let start;
227
+ ({ start } = await import('../canvas/server/server.mjs'));
228
+ let port = fixed || 4848;
229
+ for (let tries = 0; tries < 10; tries++, port++) {
230
+ const p = await probe(port);
231
+ if (p && p !== 'other' && p.root === ROOT) { console.log(`fakto canvas: http://localhost:${port} (already running for ${ROOT})`); return; }
232
+ if (!p) break;
233
+ if (fixed) { console.log(red(`Port ${port} is taken. Pick another with --port.`)); process.exit(1); }
234
+ }
235
+
236
+ if (args.includes('--background')) {
237
+ const { spawn } = await import('node:child_process');
238
+ const { openSync } = await import('node:fs');
239
+ const { tmpdir } = await import('node:os');
240
+ const { join } = await import('node:path');
241
+ const log = join(tmpdir(), `fakto-canvas-${port}.log`);
242
+ const fd = openSync(log, 'a');
243
+ const child = spawn(process.execPath, [process.argv[1], 'canvas', '--port', String(port)], { cwd: ROOT, detached: true, stdio: ['ignore', fd, fd], env: process.env });
244
+ child.unref();
245
+ for (let i = 0; i < 50; i++) {
246
+ await new Promise((r) => setTimeout(r, 200));
247
+ const p = await probe(port);
248
+ if (p && p !== 'other' && p.root === ROOT) {
249
+ console.log(`fakto canvas: http://localhost:${port} (started in the background for ${ROOT}; stop it with: npx fakto canvas --stop)`);
250
+ return;
251
+ }
252
+ }
253
+ console.log(red(`The canvas did not answer on port ${port} within 10 s. Its log: ${log}`));
254
+ process.exit(1);
255
+ }
256
+ await start({ root: ROOT, port });
257
+ }
258
+
259
+ const repo = cmd && cmd !== 'canvas' && cmd !== 'skills' ? loadRepo(ROOT) : null;
260
+ switch (cmd) {
261
+ case 'graph': graph(repo); break;
262
+ case 'check': check(repo); break;
263
+ case 'stale': stale(repo); break;
264
+ case 'show': show(repo, args[0]); break;
265
+ case 'touch': touch(repo, args); break;
266
+ case 'render': render(repo, args); break;
267
+ case 'sync': sync(repo, args); break;
268
+ case 'skills': skills(); break;
269
+ case 'canvas': await canvas(); break;
270
+ default:
271
+ console.log('usage: fakto graph | check | stale | show <fact-id | file> | touch <file | fact-id>… | render [path…] | sync [path | fact-id …] | skills | canvas');
272
+ process.exit(cmd ? 1 : 0);
273
+ }