@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 +225 -2
- package/bin/fakto.mjs +250 -0
- package/canvas/README.md +158 -0
- package/canvas/dist/assets/index-C4GTZw6T.js +44 -0
- package/canvas/dist/assets/index-COVlBX_C.css +1 -0
- package/canvas/dist/index.html +16 -0
- package/canvas/server/run.mjs +348 -0
- package/canvas/server/server.mjs +185 -0
- package/lib/core.mjs +387 -0
- package/lib/render.mjs +194 -0
- package/lib/sources.mjs +338 -0
- package/package.json +45 -4
package/README.md
CHANGED
|
@@ -1,3 +1,226 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Fakto
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+
}
|
package/canvas/README.md
ADDED
|
@@ -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.
|