flowviant 0.95.0 → 0.97.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/cli.mjs +31 -16
- package/bin/lib/artifacts.mjs +38 -7
- package/bin/lib/claude.mjs +241 -4
- package/bin/lib/credentials.mjs +39 -0
- package/bin/lib/env.mjs +17 -0
- package/bin/lib/fleet.mjs +26 -2
- package/bin/lib/hooks/readGuard.mjs +138 -0
- package/bin/lib/knowledge.mjs +330 -13
- package/bin/lib/login.mjs +16 -73
- package/bin/lib/machines.mjs +6 -6
- package/bin/lib/prompts.mjs +339 -22
- package/bin/lib/runtimes.mjs +81 -2
- package/bin/lib/work.mjs +193 -16
- package/package.json +1 -1
package/bin/lib/fleet.mjs
CHANGED
|
@@ -73,9 +73,11 @@ import { processDeployJobs, reportDeployConfig } from './deploy.mjs';
|
|
|
73
73
|
import { machineSnapshot } from './resources.mjs';
|
|
74
74
|
import {
|
|
75
75
|
detectRuntimes,
|
|
76
|
+
knownMcpServers,
|
|
76
77
|
knownSkills,
|
|
77
78
|
pickRuntimeFor,
|
|
78
79
|
probeSkillsOnce,
|
|
80
|
+
recordMcpServers,
|
|
79
81
|
recordSkills,
|
|
80
82
|
RUNTIMES,
|
|
81
83
|
THINK_MARKER,
|
|
@@ -283,6 +285,22 @@ async function fetchRoster(
|
|
|
283
285
|
} catch {
|
|
284
286
|
/* best-effort — the poll must never fail on a readout */
|
|
285
287
|
}
|
|
288
|
+
// WHICH MCP SERVERS AND CONNECTORS THE CLI MOUNTED, AND HOW EACH STANDS
|
|
289
|
+
// (0.97.0) — `[{n, s}]`, learned off the same init event as the skills, this
|
|
290
|
+
// daemon's own `flowviant` server excluded AND every `connected` one with it:
|
|
291
|
+
// only what needs something leaves the box (runtimes.mjs, recordMcpServers —
|
|
292
|
+
// a teammate has no business reading which services the operator signed
|
|
293
|
+
// into). The same three states: NOT SENT
|
|
294
|
+
// until a turn (or the one probe) has taught us, `[]` sent as the fact it is.
|
|
295
|
+
// The app names every server that is not connected — a connector that needs
|
|
296
|
+
// a sign-in AT THIS BOX above all — under the serving machine's row. A
|
|
297
|
+
// daemon→server report: an older server ignores the unknown param.
|
|
298
|
+
try {
|
|
299
|
+
const mcp = knownMcpServers();
|
|
300
|
+
if (mcp !== null) url.searchParams.set('mcp', JSON.stringify(mcp));
|
|
301
|
+
} catch {
|
|
302
|
+
/* best-effort — the poll must never fail on a readout */
|
|
303
|
+
}
|
|
286
304
|
// THIS BOX'S IDENTITY — `envpub`, and since 2026-09-21 nothing else.
|
|
287
305
|
//
|
|
288
306
|
// It carried two more params while the secrets vault existed: `envv` (the
|
|
@@ -2120,7 +2138,10 @@ export async function runFleetDaemon() {
|
|
|
2120
2138
|
// only thing missing was the handler. The wiki turn runs in a
|
|
2121
2139
|
// detached worktree of THIS repo, so its `.claude/skills` and the
|
|
2122
2140
|
// machine's personal ones resolve identically to a tab's.
|
|
2123
|
-
onInit: (i) =>
|
|
2141
|
+
onInit: (i) => {
|
|
2142
|
+
recordSkills(i.skills);
|
|
2143
|
+
recordMcpServers(i.mcpServers);
|
|
2144
|
+
},
|
|
2124
2145
|
onSpawn: (ch) => {
|
|
2125
2146
|
wikiChild = ch;
|
|
2126
2147
|
},
|
|
@@ -2169,7 +2190,10 @@ export async function runFleetDaemon() {
|
|
|
2169
2190
|
streamJson: true,
|
|
2170
2191
|
onActivity,
|
|
2171
2192
|
// Same free harvest as the sweep above.
|
|
2172
|
-
onInit: (i) =>
|
|
2193
|
+
onInit: (i) => {
|
|
2194
|
+
recordSkills(i.skills);
|
|
2195
|
+
recordMcpServers(i.mcpServers);
|
|
2196
|
+
},
|
|
2173
2197
|
onSpawn: (ch) => {
|
|
2174
2198
|
wikiChild = ch;
|
|
2175
2199
|
},
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE READ GUARD (2026-09-23, 0.97.0) — a Claude Code `PreToolUse` hook that
|
|
4
|
+
* refuses a Bash command able to WRITE A FILE OR RUN A PROGRAM, attached to
|
|
5
|
+
* every curated posture (consult, plan, wiki, design, research) and never to
|
|
6
|
+
* the build posture, whose `--dangerously-skip-permissions` is the operator's
|
|
7
|
+
* own choice.
|
|
8
|
+
*
|
|
9
|
+
* WHY A HOOK AND NOT A NARROWER LIST. `--allowedTools 'Bash(git log:*)'` is a
|
|
10
|
+
* PREFIX match: it says which program may start, never what its arguments may
|
|
11
|
+
* do. And the "read-only" git readers carry arguments that are writes or
|
|
12
|
+
* executions in disguise — `--output=<path>` (log, show and diff write their
|
|
13
|
+
* output to any file you name), `--ext-diff` and `--textconv` (run a
|
|
14
|
+
* configured program), `-c core.fsmonitor=…` / `--config-env` (run one named
|
|
15
|
+
* inline), `-O<pager>`, `--exec-path`, `--git-dir` / `--work-tree` (point git
|
|
16
|
+
* at a planted repository whose config does the above). A reviewer proved it:
|
|
17
|
+
* `git log -1 --format='tformat:x' --output=pwned.txt` landed under
|
|
18
|
+
* RESEARCH_PERM with `permission_denials: []`. Re-measured here with a
|
|
19
|
+
* friendlier phrasing — the file was written and nothing refused it. There is
|
|
20
|
+
* no permission-rule syntax for "this prefix, minus these arguments".
|
|
21
|
+
*
|
|
22
|
+
* PROBED on Claude Code 2.1.281 before relying on it, in a scratch git repo,
|
|
23
|
+
* `-p … --output-format stream-json --verbose --settings '<json>'` with the
|
|
24
|
+
* curated list beside it: the hook FIRED under `--allowedTools` (the input on
|
|
25
|
+
* stdin carried `tool_name: "Bash"` and the exact `tool_input.command`), exit 2
|
|
26
|
+
* BLOCKED the call (the file was not written; the result listed it under
|
|
27
|
+
* `permission_denials`), and the stderr sentence REACHED THE MODEL, which
|
|
28
|
+
* quoted it back. So this is a real fence, not a hint.
|
|
29
|
+
*
|
|
30
|
+
* WHAT IT REFUSES, after one normalisation: every quote and backslash is
|
|
31
|
+
* dropped before matching, because the shell splices `--out''put` and
|
|
32
|
+
* `--out\put` into `--output` and a raw substring test would read neither.
|
|
33
|
+
* Dropping them can only make a harmless command look worse, never a harmful
|
|
34
|
+
* one look better — over-refusal is the safe direction for a read-only turn.
|
|
35
|
+
*
|
|
36
|
+
* · shell composition — `>`, `<(`, `|`, `;`, `&`, a backtick, any `$`
|
|
37
|
+
* (command substitution, `${…}`, `$'…'` escapes, a variable that expands
|
|
38
|
+
* into a flag), a newline. A read-only turn runs ONE plain command; every
|
|
39
|
+
* one of these is a second command or a redirect wearing the first one's
|
|
40
|
+
* permission.
|
|
41
|
+
* · the git arguments above, as substrings (so `--config` covers
|
|
42
|
+
* `--config-env`, `--output` covers `--output-directory`), plus a bare `-c`
|
|
43
|
+
* token and any token beginning `-O`.
|
|
44
|
+
* · an environment assignment — any `GIT_*=` anywhere, and any `NAME=` as the
|
|
45
|
+
* command's first word (`GIT_DIR`, `GIT_EXTERNAL_DIFF`, `PAGER`,
|
|
46
|
+
* `LESSOPEN` all change what a reader runs).
|
|
47
|
+
*
|
|
48
|
+
* FAILS CLOSED: input it cannot parse is refused, because a fence that opens
|
|
49
|
+
* whenever the CLI changes its hook payload is a fence that opens silently.
|
|
50
|
+
* The Read, Grep and Glob tools are untouched by it — the matcher is Bash —
|
|
51
|
+
* so a refused command always has a tool-shaped way to do the reading.
|
|
52
|
+
*
|
|
53
|
+
* OUT OF ITS REACH, stated: an operator who sets `disableAllHooks` (or a
|
|
54
|
+
* managed `allowManagedHooksOnly`) in their own settings turns this off, the
|
|
55
|
+
* way they could turn off anything on their own box; the prefix list is still
|
|
56
|
+
* the first fence and this is the second.
|
|
57
|
+
*
|
|
58
|
+
* Run by the CLI as `node readGuard.mjs`, the hook JSON on stdin. Imported by
|
|
59
|
+
* the tests for `readGuardRefusal`, which is the whole decision.
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
import { pathToFileURL } from 'node:url';
|
|
63
|
+
|
|
64
|
+
/** Substrings that turn a git reader into a writer or an executor. */
|
|
65
|
+
const BANNED_FLAGS = ['--output', '--ext-diff', '--textconv', '--exec-path', '--git-dir', '--work-tree', '--config'];
|
|
66
|
+
|
|
67
|
+
/** Shell composition: each is a second command or a redirect. */
|
|
68
|
+
const SHELL = [
|
|
69
|
+
['>', 'a redirect'],
|
|
70
|
+
['<(', 'process substitution'],
|
|
71
|
+
['|', 'a pipe'],
|
|
72
|
+
[';', 'a second command'],
|
|
73
|
+
['&', 'a second command'],
|
|
74
|
+
['`', 'command substitution'],
|
|
75
|
+
['$', 'an expansion'],
|
|
76
|
+
['\n', 'a second line'],
|
|
77
|
+
['\r', 'a second line'],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The reason this command is refused, or null when it may run.
|
|
82
|
+
* @param {string} command
|
|
83
|
+
*/
|
|
84
|
+
export function readGuardRefusal(command) {
|
|
85
|
+
if (typeof command !== 'string') return 'the command could not be read';
|
|
86
|
+
const flat = command.replace(/['"\\]/g, '');
|
|
87
|
+
for (const [op, what] of SHELL) {
|
|
88
|
+
if (flat.includes(op)) return `${what} (\`${op === '\n' || op === '\r' ? '\\n' : op}\`)`;
|
|
89
|
+
}
|
|
90
|
+
for (const f of BANNED_FLAGS) if (flat.includes(f)) return `\`${f}\``;
|
|
91
|
+
const words = flat.trim().split(/\s+/).filter(Boolean);
|
|
92
|
+
for (const w of words) {
|
|
93
|
+
if (w === '-c') return '`-c` (an inline config)';
|
|
94
|
+
if (w.startsWith('-O')) return '`-O`';
|
|
95
|
+
if (/^GIT_[A-Za-z0-9_]*=/.test(w)) return `an environment assignment (\`${w.split('=')[0]}=\`)`;
|
|
96
|
+
}
|
|
97
|
+
if (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0])) {
|
|
98
|
+
return `an environment assignment (\`${words[0].split('=')[0]}=\`)`;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The sentence the model reads when a call is refused. */
|
|
104
|
+
export const readGuardSentence = (why) =>
|
|
105
|
+
`flowviant: this turn is read-only, so ${why} is refused here — it can write a file or run a program. ` +
|
|
106
|
+
'Run one plain read command at a time with no redirects, pipes or expansions, or use the Read, Grep and Glob tools.';
|
|
107
|
+
|
|
108
|
+
function main() {
|
|
109
|
+
// A hook that CRASHES is not a hook that refused: Claude Code treats any
|
|
110
|
+
// exit other than 2 as a non-blocking error and runs the command anyway. So
|
|
111
|
+
// every way out of this process that is not a verdict is a refusal.
|
|
112
|
+
process.on('uncaughtException', () => {
|
|
113
|
+
process.stderr.write(readGuardSentence('a command the guard could not judge'));
|
|
114
|
+
process.exit(2);
|
|
115
|
+
});
|
|
116
|
+
let buf = '';
|
|
117
|
+
process.stdin.setEncoding('utf8');
|
|
118
|
+
process.stdin.on('data', (d) => (buf += d));
|
|
119
|
+
process.stdin.on('end', () => {
|
|
120
|
+
let payload;
|
|
121
|
+
try {
|
|
122
|
+
payload = JSON.parse(buf);
|
|
123
|
+
} catch {
|
|
124
|
+
process.stderr.write(readGuardSentence('a command the guard could not read'));
|
|
125
|
+
process.exit(2);
|
|
126
|
+
}
|
|
127
|
+
// The matcher is Bash; anything else reaching here is not ours to judge.
|
|
128
|
+
if (payload?.tool_name !== 'Bash') process.exit(0);
|
|
129
|
+
const why = readGuardRefusal(payload?.tool_input?.command);
|
|
130
|
+
if (why) {
|
|
131
|
+
process.stderr.write(readGuardSentence(why));
|
|
132
|
+
process.exit(2);
|
|
133
|
+
}
|
|
134
|
+
process.exit(0);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
package/bin/lib/knowledge.mjs
CHANGED
|
@@ -54,6 +54,30 @@
|
|
|
54
54
|
* ten seconds. A file refused on SIZE is not a failure to retry: it is
|
|
55
55
|
* permanent for that rev, skipped with a warning, and the rest of the library
|
|
56
56
|
* is written.
|
|
57
|
+
*
|
|
58
|
+
* ── THE KEPT LIBRARY (2026-09-23, 0.97.0) ──
|
|
59
|
+
*
|
|
60
|
+
* The manifest may carry `library: { items: [...] }` — the designs and
|
|
61
|
+
* write-ups the project KEPT when a person accepted them — and each item lands
|
|
62
|
+
* at its own relative path, `designs/<slug>-v<N>.html` or
|
|
63
|
+
* `research/<slug>-v<N>.md`, with a generated `LIBRARY.md` at the root naming
|
|
64
|
+
* every one (path, title, kind, the card that asked for it, the date, what it
|
|
65
|
+
* supersedes). So "implement design A" is an agent opening LIBRARY.md, finding
|
|
66
|
+
* design A, and reading the page.
|
|
67
|
+
*
|
|
68
|
+
* SUBDIRECTORIES, AND EXACTLY TWO. The safe-name rule for a knowledge file
|
|
69
|
+
* forbids every separator; a library item is the one thing allowed ONE, and
|
|
70
|
+
* only after one of the two literal prefixes, with a stem that is already
|
|
71
|
+
* safe and the extension its kind hands back. `..`, a third segment, another
|
|
72
|
+
* prefix, a leading dot — refused, never rewritten, because a rewritten path
|
|
73
|
+
* could collide with another item's real one. The two directories are OURS the
|
|
74
|
+
* way the library directory is: a symlink or a file planted there is removed
|
|
75
|
+
* (the link, never its target) and a real directory made.
|
|
76
|
+
*
|
|
77
|
+
* ABSENT MEANS "LEAVE IT ALONE", the same rule the whole key keeps: a manifest
|
|
78
|
+
* with no `library` key is an older server, and `designs/`, `research/` and
|
|
79
|
+
* `LIBRARY.md` are neither synced nor swept. `library: { items: [] }` is the
|
|
80
|
+
* emptied catalog, SAID — both directories and the catalog go.
|
|
57
81
|
*/
|
|
58
82
|
|
|
59
83
|
import {
|
|
@@ -102,6 +126,16 @@ export const FLOWVIANT_OWN_PATHS = [
|
|
|
102
126
|
'.flowviant/artifacts/',
|
|
103
127
|
'.flowviant/uploads/',
|
|
104
128
|
];
|
|
129
|
+
/** The generated catalog of the kept library, at the knowledge root. Reserved
|
|
130
|
+
* like INSTRUCTIONS.md: a person's file of that name is suffixed, because the
|
|
131
|
+
* prompt tells the CLI this one is the catalog. */
|
|
132
|
+
export const LIBRARY_FILE = 'LIBRARY.md';
|
|
133
|
+
/** The two subdirectories a kept item may live in, by kind. */
|
|
134
|
+
export const LIBRARY_DIRS = { design: 'designs', research: 'research' };
|
|
135
|
+
const LIBRARY_EXT = { design: '.html', research: '.md' };
|
|
136
|
+
/** The server caps a library at 200 items; a longer manifest is cut. */
|
|
137
|
+
const MAX_LIBRARY_ITEMS = 256;
|
|
138
|
+
|
|
105
139
|
/** The person's standing brief, written from the manifest's `instructions`.
|
|
106
140
|
* A RESERVED name: a library FILE called this gets a suffix, because the
|
|
107
141
|
* prompt tells the CLI to read this one first and a stranger's file must
|
|
@@ -143,7 +177,14 @@ export function safeKnowledgeName(raw) {
|
|
|
143
177
|
* Windows disks are).
|
|
144
178
|
*/
|
|
145
179
|
export function planKnowledgeNames(files) {
|
|
146
|
-
|
|
180
|
+
// The catalog's name and the two library directories are reserved beside
|
|
181
|
+
// the brief (0.97.0): a person's `LIBRARY.md` or a file called `designs`
|
|
182
|
+
// must never stand where the catalog or a directory has to.
|
|
183
|
+
const taken = new Set([
|
|
184
|
+
INSTRUCTIONS_FILE.toLowerCase(),
|
|
185
|
+
LIBRARY_FILE.toLowerCase(),
|
|
186
|
+
...Object.values(LIBRARY_DIRS),
|
|
187
|
+
]);
|
|
147
188
|
const out = [];
|
|
148
189
|
for (const f of files) {
|
|
149
190
|
const name = safeKnowledgeName(f.name);
|
|
@@ -232,25 +273,141 @@ function ensureDir(checkoutDir) {
|
|
|
232
273
|
return dir;
|
|
233
274
|
}
|
|
234
275
|
|
|
235
|
-
|
|
276
|
+
/**
|
|
277
|
+
* THE MARKER SAYS WHICH REV WAS WRITTEN, AND WHETHER THE LIBRARY WAS PART OF
|
|
278
|
+
* IT (2026-09-23): `<rev>` or `<rev>:lib`.
|
|
279
|
+
*
|
|
280
|
+
* The rev alone could not tell an upgrade from a sync. A 0.96.0 box wrote the
|
|
281
|
+
* marker for rev N while ignoring the manifest's `library` key (it did not
|
|
282
|
+
* know one); updated to 0.97.0 with `knowledge_rev` still N, it read "N",
|
|
283
|
+
* matched the roster, and never synced the library — then the server handed
|
|
284
|
+
* `task.references` naming `designs/x-v1.html` and the agent was told to read
|
|
285
|
+
* a file that was not there. `:lib` records that THIS rev's sync carried a
|
|
286
|
+
* library key, so the upgrade case is one string compare and re-syncs at
|
|
287
|
+
* once. An older daemon reading `N:lib` sees NaN and re-syncs once, which is
|
|
288
|
+
* the harmless direction.
|
|
289
|
+
*/
|
|
290
|
+
function parseMarker(checkoutDir) {
|
|
236
291
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
292
|
+
const m = /^(\d+)(:lib)?$/.exec(readFileSync(join(checkoutDir, MARKER), 'utf8').trim());
|
|
293
|
+
if (!m) return null;
|
|
294
|
+
const n = Number(m[1]);
|
|
295
|
+
return Number.isSafeInteger(n) ? { rev: n, lib: Boolean(m[2]) } : null;
|
|
239
296
|
} catch {
|
|
240
297
|
return null;
|
|
241
298
|
}
|
|
242
299
|
}
|
|
243
300
|
|
|
244
|
-
function
|
|
301
|
+
export function readKnowledgeMarker(checkoutDir) {
|
|
302
|
+
return parseMarker(checkoutDir)?.rev ?? null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Did the sync that wrote the marker carry a `library` key? */
|
|
306
|
+
export function knowledgeMarkerHasLibrary(checkoutDir) {
|
|
307
|
+
return parseMarker(checkoutDir)?.lib ?? false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function writeKnowledgeMarker(checkoutDir, rev, lib = false) {
|
|
245
311
|
try {
|
|
246
312
|
mkdirSync(join(checkoutDir, '.flowviant'), { recursive: true });
|
|
247
|
-
writeAtomic(join(checkoutDir, MARKER), `${rev}\n`);
|
|
313
|
+
writeAtomic(join(checkoutDir, MARKER), `${rev}${lib ? ':lib' : ''}\n`);
|
|
248
314
|
} catch {
|
|
249
315
|
/* a lost marker costs one re-sync after a restart — every file already on
|
|
250
316
|
disk matches by sha and is not downloaded again */
|
|
251
317
|
}
|
|
252
318
|
}
|
|
253
319
|
|
|
320
|
+
/**
|
|
321
|
+
* A kept item's relative path, or null when it is not EXACTLY
|
|
322
|
+
* `<designs|research>/<safe stem>.<its kind's extension>` — see the header.
|
|
323
|
+
* Refused rather than rewritten: a rewritten path is one the server never
|
|
324
|
+
* named, and it could land on another item's real one.
|
|
325
|
+
*/
|
|
326
|
+
export function safeLibraryPath(name, kind) {
|
|
327
|
+
if (typeof name !== 'string' || !(kind in LIBRARY_DIRS)) return null;
|
|
328
|
+
const parts = name.split('/');
|
|
329
|
+
if (parts.length !== 2) return null;
|
|
330
|
+
const [dir, file] = parts;
|
|
331
|
+
if (dir !== LIBRARY_DIRS[kind]) return null;
|
|
332
|
+
if (!file || file !== safeKnowledgeName(file)) return null;
|
|
333
|
+
if (!file.toLowerCase().endsWith(LIBRARY_EXT[kind]) || file.length <= LIBRARY_EXT[kind].length) return null;
|
|
334
|
+
return `${dir}/${file}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The manifest's library items that are safe to write, in manifest order, or
|
|
338
|
+
* null when the key is ABSENT (an older server — leave everything alone). */
|
|
339
|
+
function libraryItemsOf(manifest) {
|
|
340
|
+
const lib = manifest?.library;
|
|
341
|
+
if (lib === undefined || lib === null) return null;
|
|
342
|
+
if (typeof lib !== 'object' || !Array.isArray(lib.items)) return null;
|
|
343
|
+
const out = [];
|
|
344
|
+
const seen = new Set();
|
|
345
|
+
for (const it of lib.items.slice(0, MAX_LIBRARY_ITEMS)) {
|
|
346
|
+
if (!it || typeof it.id !== 'string' || !/^[0-9a-f-]{8,64}$/i.test(it.id)) continue;
|
|
347
|
+
const path = safeLibraryPath(it.name, it.kind);
|
|
348
|
+
if (!path || seen.has(path.toLowerCase())) continue;
|
|
349
|
+
seen.add(path.toLowerCase());
|
|
350
|
+
out.push({ ...it, path });
|
|
351
|
+
}
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One line of the catalog: server strings are made single-line and capped —
|
|
356
|
+
* they are a person's card title and an artifact's name, headed into a file
|
|
357
|
+
* an agent reads, so they may not carry a line break that forges a second
|
|
358
|
+
* entry. */
|
|
359
|
+
const catalogText = (v, max) =>
|
|
360
|
+
String(v ?? '')
|
|
361
|
+
.replace(/[\r\n\t]+/g, ' ')
|
|
362
|
+
.replace(/\s+/g, ' ')
|
|
363
|
+
.trim()
|
|
364
|
+
.slice(0, max);
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* THE CATALOG, generated whole from the manifest on every sync:
|
|
368
|
+
*
|
|
369
|
+
* - designs/landing-v2.html — Landing mockup (design, from card "Redesign
|
|
370
|
+
* landing page", 2026-09-23, supersedes designs/landing-v1.html)
|
|
371
|
+
*
|
|
372
|
+
* (one line per item, oldest first — the server's order — so a sync that adds
|
|
373
|
+
* one item adds one line and moves nothing). Null when there are no items, in
|
|
374
|
+
* which case the file is removed rather than written empty.
|
|
375
|
+
*/
|
|
376
|
+
export function renderLibraryCatalog(items) {
|
|
377
|
+
if (!items || items.length === 0) return null;
|
|
378
|
+
const lines = items.map((it) => {
|
|
379
|
+
const facts = [it.kind === 'research' ? 'research' : 'design'];
|
|
380
|
+
const card = catalogText(it.taskTitle, 200);
|
|
381
|
+
if (card) facts.push(`from card "${card.replace(/"/g, "'")}"`);
|
|
382
|
+
const day = typeof it.createdAt === 'string' ? it.createdAt.slice(0, 10) : '';
|
|
383
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(day)) facts.push(day);
|
|
384
|
+
const sup = typeof it.supersedes === 'string' ? catalogText(it.supersedes, 200) : '';
|
|
385
|
+
if (sup) facts.push(`supersedes ${sup}`);
|
|
386
|
+
const title = catalogText(it.title, 200) || it.path;
|
|
387
|
+
return `- ${it.path} — ${title} (${facts.join(', ')})`;
|
|
388
|
+
});
|
|
389
|
+
return (
|
|
390
|
+
'# Library\n\n' +
|
|
391
|
+
'The designs and research this project kept. Paths are relative to this directory;\n' +
|
|
392
|
+
'a newer version supersedes an older one of the same name.\n\n' +
|
|
393
|
+
`${lines.join('\n')}\n`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A library subdirectory as a real directory — ours, like the library's own
|
|
398
|
+
* path: a symlink or a file planted there is removed, never followed. */
|
|
399
|
+
function ensureSubdir(dir, name) {
|
|
400
|
+
const path = join(dir, name);
|
|
401
|
+
try {
|
|
402
|
+
const st = lstatSync(path);
|
|
403
|
+
if (st.isSymbolicLink() || !st.isDirectory()) unlinkSync(path);
|
|
404
|
+
} catch {
|
|
405
|
+
/* absent */
|
|
406
|
+
}
|
|
407
|
+
mkdirSync(path, { recursive: true });
|
|
408
|
+
return path;
|
|
409
|
+
}
|
|
410
|
+
|
|
254
411
|
/** Does this manifest look like one the server produced? Anything else is
|
|
255
412
|
* ignored whole rather than half-applied. */
|
|
256
413
|
export function isKnowledgeManifest(k) {
|
|
@@ -285,6 +442,8 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
285
442
|
typeof manifest.instructions === 'string' && manifest.instructions.trim()
|
|
286
443
|
? manifest.instructions
|
|
287
444
|
: null;
|
|
445
|
+
// null = the key is ABSENT (leave the library's paths alone); [] = emptied.
|
|
446
|
+
const library = libraryItemsOf(manifest);
|
|
288
447
|
|
|
289
448
|
if (!flowviantDirOk(checkoutDir)) {
|
|
290
449
|
// Not a failure to retry every poll — nothing changes until a person
|
|
@@ -295,8 +454,13 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
295
454
|
return result;
|
|
296
455
|
}
|
|
297
456
|
|
|
298
|
-
// EMPTIED: the directory goes, and the prompt paragraph with it
|
|
299
|
-
|
|
457
|
+
// EMPTIED: the directory goes, and the prompt paragraph with it — unless the
|
|
458
|
+
// library key is ABSENT and a synced library is on disk, which an older
|
|
459
|
+
// server's emptied shelf has no say over.
|
|
460
|
+
const libraryOnDisk =
|
|
461
|
+
library === null &&
|
|
462
|
+
[LIBRARY_FILE, ...Object.values(LIBRARY_DIRS)].some((n) => existsSync(join(dirPath, n)));
|
|
463
|
+
if (files.length === 0 && !instructions && (library === null ? !libraryOnDisk : library.length === 0)) {
|
|
300
464
|
if (existsSync(dirPath)) {
|
|
301
465
|
try {
|
|
302
466
|
const st = lstatSync(dirPath);
|
|
@@ -314,6 +478,11 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
314
478
|
const planned = planKnowledgeNames(files);
|
|
315
479
|
const keep = new Set(planned.map((p) => p.local));
|
|
316
480
|
if (instructions) keep.add(INSTRUCTIONS_FILE);
|
|
481
|
+
// ABSENT library key: its paths are neither synced nor swept.
|
|
482
|
+
if (library === null) {
|
|
483
|
+
keep.add(LIBRARY_FILE);
|
|
484
|
+
for (const d of Object.values(LIBRARY_DIRS)) keep.add(d);
|
|
485
|
+
}
|
|
317
486
|
|
|
318
487
|
for (const f of planned) {
|
|
319
488
|
const path = join(dir, f.local);
|
|
@@ -363,6 +532,10 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
363
532
|
}
|
|
364
533
|
}
|
|
365
534
|
|
|
535
|
+
if (library !== null) {
|
|
536
|
+
await syncLibrary({ dir, items: library, fetchFile, maxBytes, keep, result });
|
|
537
|
+
}
|
|
538
|
+
|
|
366
539
|
// Everything the manifest no longer names — files, stray temp names, and
|
|
367
540
|
// anything that is not a regular file — leaves.
|
|
368
541
|
let entries = [];
|
|
@@ -383,6 +556,96 @@ export async function syncKnowledge({ checkoutDir, manifest, fetchFile, maxBytes
|
|
|
383
556
|
return result;
|
|
384
557
|
}
|
|
385
558
|
|
|
559
|
+
/**
|
|
560
|
+
* THE KEPT LIBRARY'S HALF OF A SYNC: each item into its subdirectory (fetched
|
|
561
|
+
* only when its sha differs, written atomically, the bytes checked against the
|
|
562
|
+
* manifest), every other entry in the two subdirectories removed, and
|
|
563
|
+
* `LIBRARY.md` regenerated from the items that are on disk. A download that
|
|
564
|
+
* fails keeps whatever copy was there and clears `ok`, the file rule; an item
|
|
565
|
+
* over the cap is refused for this rev and left out of the catalog.
|
|
566
|
+
*/
|
|
567
|
+
async function syncLibrary({ dir, items, fetchFile, maxBytes, keep, result }) {
|
|
568
|
+
const wanted = { designs: new Set(), research: new Set() };
|
|
569
|
+
const onDisk = [];
|
|
570
|
+
for (const kind of Object.keys(LIBRARY_DIRS)) {
|
|
571
|
+
if (items.some((it) => it.kind === kind)) {
|
|
572
|
+
ensureSubdir(dir, LIBRARY_DIRS[kind]);
|
|
573
|
+
keep.add(LIBRARY_DIRS[kind]);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const it of items) {
|
|
577
|
+
const [sub, file] = it.path.split('/');
|
|
578
|
+
const path = join(dir, sub, file);
|
|
579
|
+
wanted[sub].add(file);
|
|
580
|
+
if (Number(it.bytes) > maxBytes) {
|
|
581
|
+
result.refused.push(it.path);
|
|
582
|
+
wanted[sub].delete(file);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (typeof it.sha256 === 'string' && localSha(path) === it.sha256.toLowerCase()) {
|
|
586
|
+
onDisk.push(it);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
try {
|
|
590
|
+
const buf = await fetchFile(it.id, { library: true });
|
|
591
|
+
if (!Buffer.isBuffer(buf) || buf.byteLength > maxBytes) {
|
|
592
|
+
result.refused.push(it.path);
|
|
593
|
+
wanted[sub].delete(file);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (typeof it.sha256 === 'string' && sha256Of(buf) !== it.sha256.toLowerCase()) {
|
|
597
|
+
throw new Error('sha256 mismatch');
|
|
598
|
+
}
|
|
599
|
+
writeAtomic(path, buf);
|
|
600
|
+
result.wrote.push(it.path);
|
|
601
|
+
onDisk.push(it);
|
|
602
|
+
} catch {
|
|
603
|
+
result.ok = false;
|
|
604
|
+
result.failed.push(it.path);
|
|
605
|
+
// A stale copy is better than none until the retry lands — and it stays
|
|
606
|
+
// in the catalog only if it is actually there.
|
|
607
|
+
if (localSha(path) !== null) onDisk.push(it);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Each subdirectory holds exactly what the manifest names.
|
|
611
|
+
for (const sub of Object.values(LIBRARY_DIRS)) {
|
|
612
|
+
const subPath = join(dir, sub);
|
|
613
|
+
let entries = [];
|
|
614
|
+
try {
|
|
615
|
+
if (!lstatSync(subPath).isDirectory()) continue;
|
|
616
|
+
entries = readdirSync(subPath);
|
|
617
|
+
} catch {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
for (const name of entries) {
|
|
621
|
+
if (wanted[sub].has(name)) continue;
|
|
622
|
+
try {
|
|
623
|
+
rmSync(join(subPath, name), { recursive: true, force: true });
|
|
624
|
+
result.removed.push(`${sub}/${name}`);
|
|
625
|
+
} catch {
|
|
626
|
+
/* best-effort */
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
// An emptied subdirectory leaves with its last item.
|
|
630
|
+
if (wanted[sub].size === 0) keep.delete(sub);
|
|
631
|
+
}
|
|
632
|
+
const catalog = renderLibraryCatalog(onDisk);
|
|
633
|
+
if (catalog) {
|
|
634
|
+
keep.add(LIBRARY_FILE);
|
|
635
|
+
const path = join(dir, LIBRARY_FILE);
|
|
636
|
+
let same = false;
|
|
637
|
+
try {
|
|
638
|
+
same = lstatSync(path).isFile() && readFileSync(path, 'utf8') === catalog;
|
|
639
|
+
} catch {
|
|
640
|
+
/* absent */
|
|
641
|
+
}
|
|
642
|
+
if (!same) {
|
|
643
|
+
writeAtomic(path, catalog);
|
|
644
|
+
result.wrote.push(LIBRARY_FILE);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
386
649
|
/**
|
|
387
650
|
* The absolute library path, or null when there is nothing there for a turn
|
|
388
651
|
* to read. The prompt paragraph is rendered only when this is non-null, so a
|
|
@@ -404,7 +667,8 @@ export function knowledgeDirFor(checkoutDir) {
|
|
|
404
667
|
function libraryMissing(checkoutDir, manifest) {
|
|
405
668
|
const named =
|
|
406
669
|
(Array.isArray(manifest.files) && manifest.files.length > 0) ||
|
|
407
|
-
(typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '')
|
|
670
|
+
(typeof manifest.instructions === 'string' && manifest.instructions.trim() !== '') ||
|
|
671
|
+
(Array.isArray(manifest.library?.items) && manifest.library.items.length > 0);
|
|
408
672
|
if (!named) return false;
|
|
409
673
|
try {
|
|
410
674
|
lstatSync(join(checkoutDir, KNOWLEDGE_DIR));
|
|
@@ -414,6 +678,39 @@ function libraryMissing(checkoutDir, manifest) {
|
|
|
414
678
|
}
|
|
415
679
|
}
|
|
416
680
|
|
|
681
|
+
/**
|
|
682
|
+
* Does the manifest name LIBRARY items the disk no longer holds — or a library
|
|
683
|
+
* this box never synced at all? The same-rev skip's second question, beside
|
|
684
|
+
* `libraryMissing`'s "is the directory there".
|
|
685
|
+
*
|
|
686
|
+
* `synced` is the marker's `:lib` (or the driver's memory of it): false means
|
|
687
|
+
* the rev on the marker was written by a sync that ignored the library, the
|
|
688
|
+
* 0.96.0-to-0.97.0 upgrade. Otherwise every named item's path AND `LIBRARY.md`
|
|
689
|
+
* must be regular files — a hand-deleted `designs/`, or one item removed, is a
|
|
690
|
+
* re-sync, and the files still on disk match by hash and are not fetched
|
|
691
|
+
* again. Items the manifest itself puts over the cap, and items this driver
|
|
692
|
+
* already saw refused for this rev, are not expected: a refusal is permanent
|
|
693
|
+
* for the rev, and expecting it would re-fetch ten megabytes every poll.
|
|
694
|
+
* An ABSENT `library` key expects nothing (an older server has no say).
|
|
695
|
+
*/
|
|
696
|
+
function libraryStale(checkoutDir, manifest, { synced, refused }) {
|
|
697
|
+
const items = libraryItemsOf(manifest);
|
|
698
|
+
if (items === null) return false;
|
|
699
|
+
if (!synced) return true;
|
|
700
|
+
const expected = items.filter((it) => !(Number(it.bytes) > KNOWLEDGE_FILE_MAX_BYTES) && !refused.has(it.path));
|
|
701
|
+
if (expected.length === 0) return false;
|
|
702
|
+
const dir = join(checkoutDir, KNOWLEDGE_DIR);
|
|
703
|
+
const isFile = (p) => {
|
|
704
|
+
try {
|
|
705
|
+
return lstatSync(p).isFile();
|
|
706
|
+
} catch {
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
if (!isFile(join(dir, LIBRARY_FILE))) return true;
|
|
711
|
+
return expected.some((it) => !isFile(join(dir, ...it.path.split('/'))));
|
|
712
|
+
}
|
|
713
|
+
|
|
417
714
|
/**
|
|
418
715
|
* The per-process driver: called with every roster's `knowledge` key.
|
|
419
716
|
*
|
|
@@ -430,6 +727,11 @@ export function createKnowledgeSync({
|
|
|
430
727
|
now = () => Date.now(),
|
|
431
728
|
}) {
|
|
432
729
|
let rev = readKnowledgeMarker(checkoutDir);
|
|
730
|
+
/** Whether the sync that wrote `rev` carried a library key — the marker's
|
|
731
|
+
* `:lib`, then this process's own memory. */
|
|
732
|
+
let libSynced = knowledgeMarkerHasLibrary(checkoutDir);
|
|
733
|
+
/** Library paths refused for size at `rev` — not expected on disk. */
|
|
734
|
+
let libRefused = new Set();
|
|
433
735
|
let busy = false;
|
|
434
736
|
let failures = 0;
|
|
435
737
|
let retryAt = 0;
|
|
@@ -459,7 +761,17 @@ export function createKnowledgeSync({
|
|
|
459
761
|
* something over a directory that does not exist is re-synced; files
|
|
460
762
|
* already on disk match by hash and are not fetched again.
|
|
461
763
|
*/
|
|
462
|
-
|
|
764
|
+
//
|
|
765
|
+
// …AND THE SAME REV IS NOT ALWAYS THE SAME LIBRARY: an upgrade from a
|
|
766
|
+
// daemon that ignored the `library` key, or a hand-deleted `designs/`,
|
|
767
|
+
// leaves the marker current over files a card's references name. See
|
|
768
|
+
// `libraryStale`.
|
|
769
|
+
if (
|
|
770
|
+
knowledge.rev === rev &&
|
|
771
|
+
!libraryMissing(checkoutDir, knowledge) &&
|
|
772
|
+
!libraryStale(checkoutDir, knowledge, { synced: libSynced, refused: libRefused })
|
|
773
|
+
)
|
|
774
|
+
return null;
|
|
463
775
|
if (busy) return null;
|
|
464
776
|
if (knowledge.rev === failedRev && now() < retryAt) return null;
|
|
465
777
|
busy = true;
|
|
@@ -472,10 +784,12 @@ export function createKnowledgeSync({
|
|
|
472
784
|
const r = await syncKnowledge({ checkoutDir, manifest: knowledge, fetchFile });
|
|
473
785
|
if (r.ok) {
|
|
474
786
|
rev = knowledge.rev;
|
|
787
|
+
libSynced = libraryItemsOf(knowledge) !== null;
|
|
788
|
+
libRefused = new Set(r.refused);
|
|
475
789
|
failures = 0;
|
|
476
790
|
retryAt = 0;
|
|
477
791
|
failedRev = null;
|
|
478
|
-
writeKnowledgeMarker(checkoutDir, rev);
|
|
792
|
+
writeKnowledgeMarker(checkoutDir, rev, libSynced);
|
|
479
793
|
if (r.wrote.length || r.removed.length) {
|
|
480
794
|
log(
|
|
481
795
|
`knowledge · synced rev ${rev}` +
|
|
@@ -515,8 +829,11 @@ export function createKnowledgeSync({
|
|
|
515
829
|
*/
|
|
516
830
|
export function knowledgeFetcher({ fleetUrl, token, userAgent, maxBytes = KNOWLEDGE_FILE_MAX_BYTES }) {
|
|
517
831
|
const base = String(fleetUrl).replace(/\/agents\/?$/, '/knowledge');
|
|
518
|
-
|
|
519
|
-
|
|
832
|
+
// A kept library item (0.97.0) is its sibling, `GET /fleet/library/:id` —
|
|
833
|
+
// the same credential, the same shape, the same caps.
|
|
834
|
+
const libraryBase = String(fleetUrl).replace(/\/agents\/?$/, '/library');
|
|
835
|
+
return async (id, { library = false } = {}) => {
|
|
836
|
+
const res = await fetch(`${library ? libraryBase : base}/${encodeURIComponent(id)}`, {
|
|
520
837
|
headers: { Authorization: `Bearer ${token}`, 'User-Agent': userAgent },
|
|
521
838
|
signal: AbortSignal.timeout(60_000),
|
|
522
839
|
});
|