flowviant 0.24.0 → 0.26.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.
@@ -8,7 +8,7 @@ import { spawn } from 'node:child_process';
8
8
  import { mkdtempSync, writeFileSync } from 'node:fs';
9
9
  import { tmpdir } from 'node:os';
10
10
  import { join } from 'node:path';
11
- import { SAFE } from './config.mjs';
11
+ import { SAFE, MODEL } from './config.mjs';
12
12
 
13
13
  // Multi-task loop (TOKEN / TOKENS modes): drain the whole queue in one session.
14
14
  export const SYSTEM_MULTI = `You are a Flowviant build agent running FULLY AUTONOMOUSLY via the "flowviant" MCP
@@ -77,83 +77,122 @@ export const SINGLE_RESUME =
77
77
  'Resume your current task. Call get_blocker_resolution for the blocker you reported; ' +
78
78
  'if resolved, apply the human’s answer and finish this one intent, then stop.';
79
79
 
80
- // Wiki-gen turn: the local Claude READS the repo and writes the living code wiki
81
- // via MCP. It never edits/commits code the ONLY writes are emit_wiki_node calls.
82
- export const SYSTEM_WIKI = `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY via the
83
- "flowviant" MCP server. There is NO interactive user and NO terminal. You do NOT
84
- write, edit, or commit code — you READ this repository and document it as a living
85
- wiki by calling MCP tools.
86
-
87
- Goal: map the WHOLE codebase into a graph of wiki nodes an engineer new to the
88
- project could read to understand it. Explore the REAL files (Read, Grep, Glob, ls,
89
- git) — never guess. Ground every claim in files you actually read.
90
-
91
- Emit each node with emit_wiki_node. Cover, at least:
92
- - ONE "overview" node (id: "overview") what the product is, the big picture, how to run it.
93
- - ONE "architecture" node (id: "architecture") — the major pieces, how they fit, the data flow.
94
- - "schema" node(s) the data model (DB tables / core types) when the repo has one.
95
- - "module" nodes ONE per significant area/package/subsystem, at the level a developer
96
- thinks in (NOT one per file).
97
- - "api" / "testing" / "adr" / "note" nodes where warranted (public API surface, how tests
98
- run, notable decisions, cross-cutting flows).
99
-
100
- For each node:
101
- - id: a STABLE slug YOU choose ("overview", "architecture", "schema", "module:apps/web",
102
- "api:rest", "note:auth-flow"). Reuse the SAME id to refine a node.
103
- - title: human-readable.
104
- - body: the markdown page an engineer would write after reading the code purpose, key
105
- files and what they do, important flows, gotchas. Link related nodes with [[their-id]].
106
- - citations: the real repo-relative files the page draws from.
107
- - filePaths: the files the node covers (module nodes especially).
108
- - edges: links FROM this node to related node ids (targetId + kind ref|coupling|bridge).
109
- - groundedAtSha: the commit you were told to ground to.
110
-
111
- Judge significance YOURSELF: a big/important area gets its own node; trivial things fold
112
- into a parent node's body. Do NOT emit a node per file.
113
-
114
- When the whole codebase is mapped, call finish_wiki_generation ONCE with keepNodeIds =
115
- EVERY id you emitted, then output exactly WIKI_DONE on its own line and stop.
116
-
117
- Be efficient — this spends the user's Claude quota. Read broadly and sample enough to
118
- document each area accurately; you needn't read every file. If a tool errors, retry a
119
- couple of times, then move on — never stall waiting on a human.`;
120
-
121
- export const WIKI_KICKOFF = (sha) =>
122
- `Map this repository into the living code wiki now. Ground everything to commit ${sha}. ` +
123
- `Read the real files, emit a node per significant area with emit_wiki_node, then call ` +
124
- `finish_wiki_generation with all your node ids and output WIKI_DONE.`;
125
-
126
- // Delivery re-ground turn: a feature just MERGED. Update only the touched wiki
127
- // nodes + record a persistent feature-history node. INCREMENTALnever a full
128
- // rewrite, never finish_wiki_generation (that prunes; this only adds/updates).
129
- export const SYSTEM_REGROUND = `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY via the
130
- "flowviant" MCP server. There is NO interactive user and NO terminal. You do NOT
131
- write, edit, or commit code a feature just MERGED and you update the living code
132
- wiki to reflect it, by calling MCP tools.
80
+ // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
81
+ // knowledge VAULT a plain directory of markdown files with [[wikilinks]]
82
+ // (Obsidian-style). No MCP tools involved: the vault is just files, and the
83
+ // daemon hash-diff syncs them to Flowviant after the turn. The repo itself is
84
+ // strictly read-only.
85
+ export const SYSTEM_WIKI = (vaultDir) => `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY. There is
86
+ NO interactive user and NO terminal to ask in. You READ the repository you are
87
+ running in and maintain a knowledge VAULT of markdown files at:
88
+
89
+ ${vaultDir}
90
+
91
+ That vault directory is the ONLY place you may create, edit, or delete files.
92
+ NEVER modify the repository itselfno code edits, no commits, no git writes.
93
+
94
+ The vault is an LLM wiki: its readers are AI agents (including future you), so
95
+ optimize for machine-usable DETAIL and DENSITY over human polish. Depth
96
+ compounds a page should teach its code area to an agent that has never read
97
+ the code. Conventions:
98
+
99
+ - One markdown file per topic: each significant module/subsystem, core concept,
100
+ data model, key flow, notable decision. Organize with folders as you see fit
101
+ (e.g. modules/, concepts/, decisions/). More pages is fine granular beats
102
+ monolithic.
103
+ - Link related pages inline with [[wikilinks]] — link LIBERALLY; the link graph
104
+ IS the map. A [[link]] to a page you haven't written yet marks it as worth
105
+ writing.
106
+ - index.md the entry point: a categorized catalog of every page with a
107
+ one-line summary each. Keep it current.
108
+ - log.md append-only history: one "## [<sha7>] <what happened>" entry per
109
+ pass. When log.md grows past ~150KB, compact its OLDEST entries into a short
110
+ summary section at the top (never let it exceed the 256KB sync cap).
111
+ - Every page STARTS with YAML frontmatter listing the REAL repo files it
112
+ documents, then a "# Title" heading, then the body:
113
+
114
+ ---
115
+ files:
116
+ - apps/web/src/example.ts
117
+ ---
118
+ # Page Title
119
+
120
+ Body: purpose, how it works, key functions/types/tables, invariants, gotchas,
121
+ cross-references to [[related-pages]].
122
+
123
+ Ground EVERY claim in files you actually read (Read, Grep, Glob, ls, git in the
124
+ repo) never guess.
125
+
126
+ THE HUMAN DOCS docs/ inside the vault. After the vault pages are current,
127
+ COMPILE human documentation FROM them (distill your own vault pages don't
128
+ re-read the whole repo; spot-check a cited file only when something looks off).
129
+ Docs are for humans: clear prose, short sections, a reading order. Fixed spine:
130
+ - docs/00-start-here.md "Start Here": what this codebase is, how to run it,
131
+ the handful of files that matter most, where to go next.
132
+ - docs/01-architecture.md the big picture: major pieces, how they fit, data
133
+ flow, and a map of the chapters below.
134
+ - docs/1N-<chapter>.md — ONE chapter per major subsystem (10, 11, 12 …), YOUR
135
+ choice of chapters, derived from the vault's hub pages.
136
+ - docs/90-decisions.md — notable design decisions and their why.
137
+ - docs/91-glossary.md — the project's terms of art.
138
+ Docs pages use the same frontmatter files: lists and [[wikilinks]] (they may
139
+ link to vault pages); numeric prefixes are the reading order.
140
+
141
+ Full-sweep protocol:
142
+ 1. If the vault already has pages, read index.md + log.md FIRST — update and
143
+ extend rather than rewrite; delete vault pages whose code no longer exists.
144
+ 2. Explore the repo broadly, then write/refresh pages area by area.
145
+ 3. Compile/refresh the docs/ chapters from the finished vault pages.
146
+ 4. Refresh index.md, append a log.md entry, then output exactly WIKI_DONE on
147
+ its own line and stop.
148
+
149
+ Be efficient — this spends the user's Claude quota. Read broadly and sample
150
+ enough to document each area accurately; you needn't read every file. If a tool
151
+ errors, retry a couple of times, then move on — never stall waiting on a human.`;
152
+
153
+ export const WIKI_KICKOFF = (sha, vaultDir) =>
154
+ `Map this repository into the knowledge vault now (vault: ${vaultDir}). Ground ` +
155
+ `everything to commit ${sha}. Read the real files, write/refresh the vault pages, ` +
156
+ `compile the docs/ chapters from them, update index.md and log.md, then output WIKI_DONE.`;
157
+
158
+ // Delivery re-ground turn: a feature just MERGED. Update only the vault pages
159
+ // the change touched + append the durable feature-history log entry.
160
+ // INCREMENTAL — never a full rewrite.
161
+ export const SYSTEM_REGROUND = (vaultDir) => `You are Flowviant's codebase cartographer, running FULLY AUTONOMOUSLY. There is
162
+ NO interactive user and NO terminal. A feature just MERGED and you update the
163
+ knowledge VAULT of markdown files at:
164
+
165
+ ${vaultDir}
166
+
167
+ That vault directory is the ONLY place you may create, edit, or delete files.
168
+ NEVER modify the repository itself — no code edits, no commits, no git writes.
133
169
 
134
170
  Steps:
135
- 1. Call list_wiki_nodes to see the current wiki (node ids + the files each covers).
136
- 2. For each existing node whose files OVERLAP the changed files, RE-READ that area's
137
- real code and re-emit the node with emit_wiki_node using the SAME id (updating it
138
- in place). Touch ONLY nodes the change actually affected — this is incremental.
139
- If the change adds a genuinely new area with no node, emit a new one.
140
- 3. Emit ONE feature-history node recording what shipped: id "feature:<short-slug>",
141
- kind "note", state "built", title = the feature, body = what it added and why
142
- (a durable record), citations = the changed files, edges linking to the code
143
- nodes it touched. state "built" makes it permanent — a future full sweep keeps it.
144
- 4. Do NOT call finish_wiki_generation that is only for a full sweep and would
145
- prune. Just emit, then output exactly REGROUND_DONE on its own line and stop.
171
+ 1. Read the vault's index.md (and log.md tail) to see the current pages and the
172
+ repo files each documents (their frontmatter "files:" lists).
173
+ 2. For each existing page whose files OVERLAP the changed files, RE-READ that
174
+ area's real code and update the page in place. Touch ONLY pages the change
175
+ actually affected — this is incremental. If the change adds a genuinely new
176
+ area, write a new page (with frontmatter + [[links]]) and add it to index.md.
177
+ 3. If any docs/ chapter cites or covers the updated vault pages, refresh THAT
178
+ chapter (docs are compiled from the vault keep them consistent; touch only
179
+ affected chapters).
180
+ 4. Append ONE feature-history entry to log.md:
181
+ "## [<sha7>] shipped: <feature title>" followed by a short durable record of
182
+ what it added and why, citing the changed files and [[touched-pages]].
183
+ 5. Output exactly REGROUND_DONE on its own line and stop.
146
184
 
147
185
  Ground every claim in files you actually read. Be efficient — look only at the
148
186
  changed area, not the whole repo; spend little quota.`;
149
187
 
150
- export const REGROUND_KICKOFF = ({ sha, title, files }) =>
151
- `A feature just merged. Re-ground the living wiki for it.\n\n` +
188
+ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir }) =>
189
+ `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
152
190
  `Feature: ${title}\n` +
153
191
  `Grounded commit: ${sha}\n` +
154
192
  `Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
155
- `Follow your instructions: list_wiki_nodes, re-emit the touched nodes (same ids), ` +
156
- `emit the feature-history node (state "built"), then output REGROUND_DONE.`;
193
+ `Follow your instructions: update the touched vault pages (and any docs/\n` +
194
+ `chapter that covers them), append the feature-history entry to log.md,\n` +
195
+ `then output REGROUND_DONE.`;
157
196
 
158
197
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
159
198
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
@@ -173,6 +212,36 @@ const PERM = SAFE
173
212
  ]
174
213
  : ['--dangerously-skip-permissions'];
175
214
 
215
+ // Wiki turns are read-the-repo + write-the-vault ONLY — always curated, never
216
+ // --dangerously-skip-permissions: no gh, no push-capable git, no package
217
+ // managers, and nothing that can EXECUTE arbitrary commands — no `find`
218
+ // (-exec/-delete) and no `git grep` (-O<pager> runs a shell; the Grep tool
219
+ // covers search). Command execution is the line: it enables network exfil,
220
+ // which plain file writes never do. `rm` IS allowed: pruning a stale vault
221
+ // page requires a real file deletion (that's how the sync protocol learns of
222
+ // it), and the blast radius is bounded — the daemon resets the repo worktree
223
+ // after every wiki turn, and the vault has its own git history.
224
+ // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
225
+ const WIKI_PERM = [
226
+ '--allowedTools',
227
+ 'Read',
228
+ 'Grep',
229
+ 'Glob',
230
+ 'Edit',
231
+ 'Write',
232
+ 'Bash(ls:*)',
233
+ 'Bash(wc:*)',
234
+ 'Bash(head:*)',
235
+ 'Bash(cat:*)',
236
+ 'Bash(mkdir:*)',
237
+ 'Bash(rm:*)',
238
+ 'Bash(git status:*)',
239
+ 'Bash(git log:*)',
240
+ 'Bash(git show:*)',
241
+ 'Bash(git diff:*)',
242
+ 'Bash(git rev-parse:*)',
243
+ ];
244
+
176
245
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
177
246
 
178
247
  // Sentinels must appear on their OWN line (the prompts require it). Substring
@@ -215,6 +284,15 @@ export function humanizeToolUse(name, input = {}, cwd = '') {
215
284
  switch (name) {
216
285
  case 'Read':
217
286
  return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
287
+ // Vault authoring: Write/Edit of a markdown page is the "writing" signal
288
+ // (the wiki turn's only legal writes are vault files). Label with the last
289
+ // two path segments — the vault lives outside cwd, so shortPath can't trim.
290
+ case 'Write':
291
+ case 'Edit': {
292
+ const p = String(input.file_path ?? '');
293
+ const tail = p.split('/').slice(-2).join('/');
294
+ return { kind: 'write', label: `${name === 'Write' ? '+ page' : '~ page'} ${tail}` };
295
+ }
218
296
  case 'Grep':
219
297
  return {
220
298
  kind: 'search',
@@ -286,13 +364,18 @@ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
286
364
  // returned string for sentinel detection, and each activity is handed to
287
365
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
288
366
  // off and keep the raw text passthrough + line sentinels.
289
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity }) {
367
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity, wikiPerm }) {
290
368
  return new Promise((resolve) => {
291
369
  const args = [];
292
370
  if (resume) args.push('--continue');
293
- args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
371
+ args.push('-p', prompt, '--append-system-prompt', system);
372
+ // Wiki-vault turns are pure file work — no MCP server at all.
373
+ if (mcpConfig) args.push('--mcp-config', mcpConfig);
374
+ // Pin the model — never inherit the user's global default (which may be a
375
+ // 1M/long-context tier their subscription can't bill autonomous work on).
376
+ args.push('--model', MODEL);
294
377
  if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
295
- args.push(...PERM);
378
+ args.push(...(wikiPerm ? WIKI_PERM : PERM));
296
379
  // Force the user's Claude Code subscription — never the API. A key exported in
297
380
  // the shell would otherwise silently bill every poll-mode turn as raw API
298
381
  // usage (same invariant live mode enforces on its SDK session env).
@@ -4,7 +4,15 @@ import { readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { homedir } from 'node:os';
6
6
 
7
- export const VERSION = '0.24.0';
7
+ export const VERSION = '0.26.0';
8
+
9
+ // The model EVERY daemon Claude turn runs on — pinned so autonomous work never
10
+ // inherits your interactive `~/.claude/settings.json` default. That matters: a
11
+ // default of `opus[1m]` puts big prompts (wiki-gen over a whole repo, >200K
12
+ // tokens) onto the 1M long-context premium tier, which a Max plan does NOT cover
13
+ // — the turn dies with "usage credits required for this model". Standard `opus`
14
+ // is fully covered. Override with FLOWVIANT_MODEL (e.g. `sonnet` for cheaper/faster).
15
+ export const MODEL = process.env.FLOWVIANT_MODEL || 'opus';
8
16
 
9
17
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
18
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/fleet.mjs CHANGED
@@ -54,6 +54,7 @@ import { runLiveWorker } from './live.mjs';
54
54
  import { reapOrphanPreviews } from './preview.mjs';
55
55
  import { preflight } from './preflight.mjs';
56
56
  import { connectStream } from './stream.mjs';
57
+ import { ensureVault, syncVault } from './vault.mjs';
57
58
 
58
59
  async function fetchRoster(haveIds) {
59
60
  const url = new URL(FLEET_URL);
@@ -221,6 +222,14 @@ export async function runFleetDaemon() {
221
222
  } catch {
222
223
  /* best-effort */
223
224
  }
225
+ // A mid-sweep wiki Claude must die with the daemon — orphaning it leaves it
226
+ // burning quota, and a restarted daemon would start a SECOND sweep racing
227
+ // it on the same vault dir + sync state.
228
+ try {
229
+ wikiChild?.kill('SIGKILL');
230
+ } catch {
231
+ /* best-effort */
232
+ }
224
233
  for (const [, w] of workers) {
225
234
  w.state.alive = false;
226
235
  try {
@@ -398,36 +407,31 @@ export async function runFleetDaemon() {
398
407
  }
399
408
  };
400
409
 
401
- // Living-wiki work runs ONE turn at a time in a dedicated worktree (off the
402
- // agents' checkouts), via the emit_wiki_node MCP tool never code edits. Two
403
- // triggers enqueue: a Regenerate click (full SWEEP) and a successful merge
404
- // (incremental RE-GROUND feature-history). One queue + runner serializes
405
- // them so they never collide on the worktree. Each turn runs under its own
406
- // wiki-scoped credential (minted per task below) the server allows ONLY the
407
- // wiki tools on it, and build agents' worker tokens can't touch the map. Also
408
- // means wiki work needs no agent online.
410
+ // Living-wiki work runs ONE turn at a time in a dedicated repo worktree (off
411
+ // the agents' checkouts). Claude READS the repo there and writes the markdown
412
+ // VAULT (~/.flowviant/vaults/<projectId>) plain files, no MCP tools; the
413
+ // daemon hash-diff syncs the vault to the server after each turn. Two
414
+ // triggers enqueue: a Regenerate click (full SWEEP, finalize-prunes) and a
415
+ // successful merge (incremental RE-GROUND). One queue + runner serializes
416
+ // them so they never collide on the worktree or the vault. Wiki work needs no
417
+ // agent online.
409
418
  const wikiWt = join(baseDir, 'wiki');
410
419
  const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
411
- const WIKI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-token');
420
+ const WIKI_VAULT_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-vault');
412
421
  const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
413
422
  const wikiQueue = [];
414
423
  let wikiBusy = false;
424
+ let wikiChild = null; // the wiki turn's Claude process — tracked so teardown can kill it
415
425
  let lastSweepAt = null; // dedup: run each Regenerate request once
416
426
  const groundedIntents = new Set(); // dedup: re-ground each delivery once
417
-
418
- const mintWikiToken = async () => {
419
- try {
420
- const res = await fetch(WIKI_TOKEN_URL, {
421
- method: 'POST',
422
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
423
- signal: AbortSignal.timeout(30_000),
424
- });
425
- if (!res.ok) return null;
426
- return (await res.json())?.data?.token ?? null;
427
- } catch {
428
- return null;
429
- }
430
- };
427
+ // The vault is keyed by the server project this fleet credential serves
428
+ // (learned from the roster); until the first poll names it, fall back to a
429
+ // repo-keyed dir so a stale-server daemon still works.
430
+ let wikiProjectId = null;
431
+ const vaultDirFor = () =>
432
+ wikiProjectId && isSafePathSegment(wikiProjectId)
433
+ ? join(homedir(), '.flowviant', 'vaults', wikiProjectId)
434
+ : join(homedir(), '.flowviant', 'vaults', repoKey);
431
435
 
432
436
  // Stream what the wiki turn is doing to the app (the canvas renders the read
433
437
  // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
@@ -456,6 +460,11 @@ export async function runFleetDaemon() {
456
460
  const enqueueSweep = (job) => {
457
461
  if (!job || job.requestedAt === lastSweepAt) return;
458
462
  lastSweepAt = job.requestedAt;
463
+ // A full sweep is expensive — never stack two. One queued sweep already
464
+ // covers any newer Regenerate click (it reads the repo fresh when it runs).
465
+ // A failed/partial sweep stays recoverable: re-clicking Regenerate always
466
+ // refreshes requestedAt server-side, beating this dedup.
467
+ if (wikiQueue.some((t) => t.type === 'sweep')) return;
459
468
  wikiQueue.push({ type: 'sweep' });
460
469
  void drainWiki();
461
470
  };
@@ -468,6 +477,9 @@ export async function runFleetDaemon() {
468
477
 
469
478
  // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
470
479
  // PR can't blow up the prompt. prUrl was already validated before the merge.
480
+ // Returns null on a gh FAILURE (network/auth) — distinct from a PR that
481
+ // genuinely changed nothing — so the caller can retry instead of silently
482
+ // consuming the durable job with no re-ground run.
471
483
  const changedFilesForPr = (prUrl) => {
472
484
  try {
473
485
  const out = execFileSync('gh', ['pr', 'view', prUrl, '--json', 'files'], {
@@ -477,24 +489,22 @@ export async function runFleetDaemon() {
477
489
  });
478
490
  return (JSON.parse(out).files ?? []).map((f) => f.path).filter(Boolean).slice(0, 60);
479
491
  } catch {
480
- return [];
492
+ return null;
481
493
  }
482
494
  };
495
+ const regroundAttempts = new Map(); // intentId -> gh-failure count
483
496
 
484
497
  async function drainWiki() {
485
498
  if (wikiBusy || wikiQueue.length === 0) return;
486
499
  wikiBusy = true;
487
500
  try {
488
501
  while (wikiQueue.length) {
489
- // Fresh wiki-scoped credential per task — dedicated, so no roster
490
- // re-mint can rotate it out from under a long sweep.
491
- const token = await mintWikiToken();
492
- if (!token) {
493
- warn('wiki: could not mint the cartographer token — retrying on a later poll');
494
- break; // queue intact — the reconcile loop re-drains
495
- }
496
502
  const task = wikiQueue.shift();
497
- const { dir, path: mcpConfig } = mcpConfigFor(token, mcpUrl);
503
+ // The vault is plain files the turn needs no MCP server and no
504
+ // cartographer token; the daemon itself syncs afterwards on the fleet
505
+ // credential.
506
+ const vaultDir = vaultDirFor();
507
+ ensureVault(vaultDir);
498
508
  // Live progress for this turn: a rolling FEED of everything Claude does
499
509
  // (thinking, narration, reads, node writes), the file count, and the
500
510
  // phase — streamed to the app (throttled; each frame carries the whole
@@ -550,56 +560,113 @@ export async function runFleetDaemon() {
550
560
  } catch {
551
561
  /* detached/no HEAD — still writes the map, just ungrounded */
552
562
  }
563
+ // Sync the vault after the turn regardless of the sentinel: a died
564
+ // sweep's partial pages still persist (merge, no prune) — only a
565
+ // COMPLETED sweep finalizes, so an interrupted one can't erase pages.
566
+ const runSync = async (finalize) => {
567
+ try {
568
+ const r = await syncVault({
569
+ dir: vaultDir,
570
+ url: WIKI_VAULT_URL,
571
+ token: FLEET_TOKEN,
572
+ userAgent: USER_AGENT,
573
+ finalize,
574
+ groundedAtSha: sha || undefined,
575
+ // Powers the GitHub blob links behind every cited file path.
576
+ repoFullName: originSlug(repoRoot) || undefined,
577
+ warn,
578
+ });
579
+ if (r.skipped) note(`${c.cyan('wiki')} ${c.dim('— vault unchanged, nothing to sync')}`);
580
+ else
581
+ ok(
582
+ `${c.cyan('wiki')} ${c.dim(
583
+ `— synced ${r.uploaded} page${r.uploaded === 1 ? '' : 's'} (${r.pages} total${r.deleted ? `, ${r.deleted} removed` : ''})`
584
+ )}`
585
+ );
586
+ } catch (e) {
587
+ warn(`wiki vault sync failed: ${e.message} — pages stay local; next turn retries`);
588
+ }
589
+ };
553
590
  if (task.type === 'sweep') {
554
591
  note(`${c.cyan('wiki')} ${c.dim('— regenerating: your Claude is reading the repo…')}`);
555
592
  const out = await runTurn({
556
- prompt: WIKI_KICKOFF(sha),
593
+ prompt: WIKI_KICKOFF(sha, vaultDir),
557
594
  resume: false,
558
- system: SYSTEM_WIKI,
595
+ system: SYSTEM_WIKI(vaultDir),
559
596
  cwd: wikiWt,
560
- mcpConfig,
597
+ wikiPerm: true,
561
598
  label: c.cyan('[wiki]'),
562
599
  streamJson: true,
563
600
  onActivity,
601
+ onSpawn: (ch) => {
602
+ wikiChild = ch;
603
+ },
564
604
  });
565
- if (sawSentinel(out, 'WIKI_DONE'))
566
- ok(`${c.cyan('wiki')} ${c.dim('— regenerated from your code.')}`);
605
+ const complete = sawSentinel(out, 'WIKI_DONE');
606
+ if (complete) ok(`${c.cyan('wiki')} ${c.dim('— vault regenerated from your code.')}`);
567
607
  else
568
- warn('code-wiki regeneration ended without WIKI_DONE — retry from the app if incomplete.');
608
+ warn('wiki sweep ended without WIKI_DONE — partial pages synced; retry from the app.');
609
+ await runSync(complete);
569
610
  } else {
570
611
  const files = changedFilesForPr(task.prUrl);
571
- if (files.length === 0) {
612
+ if (files === null) {
613
+ // gh failed (network/auth) — retry via the durable job a couple
614
+ // of times before consuming it, so a transient outage doesn't
615
+ // silently drop the re-ground.
616
+ const n = (regroundAttempts.get(task.intentId) ?? 0) + 1;
617
+ regroundAttempts.set(task.intentId, n);
618
+ if (n < 3) {
619
+ warn(`wiki re-ground for "${task.title}": gh failed — will retry (${n}/3)`);
620
+ groundedIntents.delete(task.intentId); // let the roster re-offer it
621
+ continue;
622
+ }
623
+ warn(`wiki re-ground for "${task.title}": gh failed ${n} times — giving up (heals on the next full sweep)`);
624
+ } else if (files.length === 0) {
572
625
  note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
573
626
  } else {
574
627
  note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
575
628
  const out = await runTurn({
576
- prompt: REGROUND_KICKOFF({ sha, title: task.title, files }),
629
+ prompt: REGROUND_KICKOFF({ sha, title: task.title, files, vaultDir }),
577
630
  resume: false,
578
- system: SYSTEM_REGROUND,
631
+ system: SYSTEM_REGROUND(vaultDir),
579
632
  cwd: wikiWt,
580
- mcpConfig,
633
+ wikiPerm: true,
581
634
  label: c.cyan('[wiki]'),
582
635
  streamJson: true,
583
636
  onActivity,
637
+ onSpawn: (ch) => {
638
+ wikiChild = ch;
639
+ },
584
640
  });
585
641
  if (sawSentinel(out, 'REGROUND_DONE'))
586
- ok(`${c.cyan('wiki')} ${c.dim(`— wiki updated for "${task.title}".`)}`);
642
+ ok(`${c.cyan('wiki')} ${c.dim(`— vault updated for "${task.title}".`)}`);
587
643
  else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE.`);
644
+ await runSync(false);
588
645
  }
589
- // Consume the durable job: attempted = done (success or not — emits
590
- // are idempotent and a failed turn heals on the next full sweep), so
591
- // a failing re-ground can't loop-burn quota. Only a crash BEFORE
592
- // this line leaves the job listed for a retry after restart.
646
+ // Consume the durable job: attempted = done (success or not — the
647
+ // sync is idempotent and a failed turn heals on the next full
648
+ // sweep), so a failing re-ground can't loop-burn quota. Only a
649
+ // crash BEFORE this line leaves the job listed for a retry.
650
+ regroundAttempts.delete(task.intentId);
593
651
  await reportMergeOutcome(REGROUND_DONE_URL, { intentId: task.intentId });
594
652
  }
595
653
  } catch (e) {
596
654
  warn(`wiki ${task.type} failed: ${e.message}`);
597
655
  } finally {
656
+ wikiChild = null;
598
657
  if (heartbeat) clearInterval(heartbeat);
599
658
  // Terminal frame so the app cover clears promptly (don't wait for the
600
659
  // freshness window to lapse). force-sent past the throttle.
601
660
  await postWikiProgress(frame({ done: true }), true);
602
- rmSync(dir, { recursive: true, force: true });
661
+ // Safety net: the wiki turn is read-only on the repo by CONTRACT, but
662
+ // permission enforcement is a curated tool list, not a path jail —
663
+ // discard anything a confused turn wrote to the worktree so it can
664
+ // never leak into a later turn or a push.
665
+ try {
666
+ resetWorktree(wikiWt, baseRef);
667
+ } catch {
668
+ /* best-effort */
669
+ }
603
670
  }
604
671
  }
605
672
  } finally {
@@ -684,12 +751,17 @@ export async function runFleetDaemon() {
684
751
  }
685
752
  }
686
753
  if (roster.mcpUrl) mcpUrl = roster.mcpUrl;
754
+ if (roster.project?.id) wikiProjectId = roster.project.id; // keys the vault dir
687
755
  if (roster.leaseTtlSeconds) leaseTtlSeconds = roster.leaseTtlSeconds;
688
756
  // Keep the daemon current. Safe = no worker mid-task (true at startup, since
689
757
  // no workers are spawned yet). If it self-updates it re-execs into the new
690
758
  // version and this process becomes a proxy — stop the loop.
691
759
  if (roster.daemon) {
692
- const safeToUpdate = [...workers.values()].every((w) => w.state.child == null);
760
+ // "No worker mid-task" must include the wiki runner: updating mid-sweep
761
+ // re-execs the daemon, orphans the wiki Claude, and the fresh process
762
+ // starts a second sweep racing it on the same vault.
763
+ const safeToUpdate =
764
+ !wikiBusy && [...workers.values()].every((w) => w.state.child == null);
693
765
  const updating = handleVersionSignal({
694
766
  latest: roster.daemon.latest,
695
767
  min: roster.daemon.min,
package/bin/lib/live.mjs CHANGED
@@ -22,6 +22,7 @@ import { query } from '@anthropic-ai/claude-agent-sdk';
22
22
  import {
23
23
  MCP_URL,
24
24
  SAFE,
25
+ MODEL,
25
26
  POLL_SECONDS,
26
27
  IDLE_SECONDS,
27
28
  PARK_TIMEOUT_SECONDS,
@@ -419,6 +420,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
419
420
  options: {
420
421
  cwd,
421
422
  env,
423
+ // Pin the model — never inherit the user's global default (which may be a
424
+ // 1M/long-context tier their subscription can't bill autonomous work on).
425
+ model: MODEL,
422
426
  permissionMode: SAFE ? 'default' : 'bypassPermissions',
423
427
  ...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
424
428
  systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Knowledge-vault plumbing: the local Obsidian-style wiki directory Claude
3
+ * writes (plain markdown + [[wikilinks]]) and the hash-diff sync that ships it
4
+ * to Flowviant. The vault lives OUTSIDE the repo and its worktrees
5
+ * (~/.flowviant/vaults/<projectId>) so it persists across sweeps, and gets a
6
+ * private `git init` so every pass is versioned locally for free — the user's
7
+ * repository is never touched.
8
+ *
9
+ * Sync protocol (POST /api/v2/fleet/wiki-vault, fleet-token auth): only files
10
+ * whose sha256 changed since the last successful sync are uploaded, chunked;
11
+ * the LAST request carries the finalize.manifest of a completed full sweep so
12
+ * the server prunes pages the sweep no longer has. The last-synced hashes live
13
+ * in `.flowviant-sync.json` inside the vault (dotfile — never walked, never
14
+ * uploaded).
15
+ *
16
+ * HARD RULE — deletion is opt-in, never inferred: a page we can't read, can't
17
+ * upload (oversized / invalid path), or truncated past the cap is CARRIED
18
+ * FORWARD at its last-synced state, not turned into a deletion. Only a page
19
+ * that verifiably vanished from a readable vault becomes a delete. Otherwise
20
+ * an append-only log.md crossing the size cap would silently erase itself
21
+ * server-side.
22
+ */
23
+
24
+ import {
25
+ readdirSync,
26
+ readFileSync,
27
+ writeFileSync,
28
+ mkdirSync,
29
+ existsSync,
30
+ } from 'node:fs';
31
+ import { createHash } from 'node:crypto';
32
+ import { execFileSync } from 'node:child_process';
33
+ import { join, relative, sep } from 'node:path';
34
+
35
+ const SYNC_STATE = '.flowviant-sync.json';
36
+ // Mirror the server contract (shared schema) — a page that violates it is
37
+ // skipped with a warning (and carried forward if previously synced), never
38
+ // allowed to 400 the whole request and wedge the sync.
39
+ const MAX_FILE_BYTES = 262_144;
40
+ const MAX_PATH_CHARS = 300;
41
+ const MAX_FILES = 400;
42
+ const CHUNK_FILES = 30;
43
+ const CHUNK_BYTES = 700_000;
44
+ const MAX_DELETIONS_PER_REQ = 200;
45
+
46
+ /** Daemon-side mirror of the server's isSafeVaultPath. */
47
+ const isSafePath = (p) =>
48
+ p.length > 0 &&
49
+ p.length <= MAX_PATH_CHARS &&
50
+ p.endsWith('.md') &&
51
+ !p.includes('\\') &&
52
+ !p.includes('\0') &&
53
+ !p.startsWith('/') &&
54
+ p.split('/').every((seg) => seg.length > 0 && seg !== '.' && seg !== '..' && !seg.startsWith('.'));
55
+
56
+ /** Create the vault dir + its private git history (best-effort). */
57
+ export function ensureVault(dir) {
58
+ mkdirSync(dir, { recursive: true });
59
+ if (!existsSync(join(dir, '.git'))) {
60
+ try {
61
+ execFileSync('git', ['init', '-q'], { cwd: dir, stdio: 'ignore' });
62
+ } catch {
63
+ /* git unavailable — the vault still works, just unversioned */
64
+ }
65
+ }
66
+ }
67
+
68
+ /** All vault-relative .md paths (forward slashes), dotfiles/dirs skipped.
69
+ * Splits on the PLATFORM separator only — a literal backslash in a Linux
70
+ * filename must not be mangled into a bogus subpath. A failed directory read
71
+ * bumps `errors.count` — the caller MUST treat the walk as partial then
72
+ * (pages under an unreadable subtree are absent, not deleted). */
73
+ function walkMd(dir, base = dir, out = [], errors = { count: 0 }) {
74
+ let entries;
75
+ try {
76
+ entries = readdirSync(dir, { withFileTypes: true });
77
+ } catch {
78
+ errors.count++;
79
+ return out;
80
+ }
81
+ for (const e of entries) {
82
+ if (e.name.startsWith('.')) continue;
83
+ const p = join(dir, e.name);
84
+ if (e.isDirectory()) walkMd(p, base, out, errors);
85
+ else if (e.isFile() && e.name.endsWith('.md'))
86
+ out.push(relative(base, p).split(sep).join('/'));
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /** Best-effort local history commit — identity pinned so it works on machines
92
+ * with no global git config, and never touches the user's identity. */
93
+ function commitVault(dir, message) {
94
+ try {
95
+ execFileSync('git', ['add', '-A'], { cwd: dir, stdio: 'ignore' });
96
+ execFileSync(
97
+ 'git',
98
+ ['-c', 'user.name=flowviant', '-c', 'user.email=wiki@flowviant.local', 'commit', '-q', '-m', message],
99
+ { cwd: dir, stdio: 'ignore' }
100
+ );
101
+ } catch {
102
+ /* nothing to commit / git unavailable — fine */
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Hash-diff sync the vault to the server. Returns counts; throws on a failed
108
+ * upload (the sync state is only advanced after EVERY request lands, so a
109
+ * partial failure re-uploads next time — server upserts are idempotent).
110
+ */
111
+ export async function syncVault({ dir, url, token, userAgent, finalize, groundedAtSha, repoFullName, warn = () => {} }) {
112
+ const walkErrors = { count: 0 };
113
+ const found = walkMd(dir, dir, [], walkErrors).sort();
114
+ if (walkErrors.count > 0 && found.length === 0) {
115
+ // Vault root (or everything under it) unreadable — nothing to diff against.
116
+ warn(`vault at ${dir} is unreadable — skipping sync; check the vault dir`);
117
+ return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
118
+ }
119
+
120
+ // Partition into uploadable pages and carried-forward ones. Carried = we
121
+ // know the page exists (or existed) but can't ship this state — keep the
122
+ // server's last-good copy: tracked in `current` (prev hash) + manifest,
123
+ // never a deletion.
124
+ const current = {}; // path -> sha256 tracked as the post-sync state
125
+ const contents = {}; // path -> markdown to upload (subset of current)
126
+ let prev = {};
127
+ try {
128
+ prev = JSON.parse(readFileSync(join(dir, SYNC_STATE), 'utf8'));
129
+ } catch {
130
+ /* first sync */
131
+ }
132
+ const carry = (p, why) => {
133
+ if (prev[p]) {
134
+ current[p] = prev[p];
135
+ warn(`vault page ${p}: ${why} — keeping the last synced copy`);
136
+ } else {
137
+ warn(`vault page ${p}: ${why} — not synced`);
138
+ }
139
+ };
140
+
141
+ let kept = 0;
142
+ for (const p of found) {
143
+ if (!isSafePath(p)) {
144
+ carry(p, 'name violates the sync contract (length/characters)');
145
+ continue;
146
+ }
147
+ if (kept >= MAX_FILES) {
148
+ carry(p, `vault exceeds ${MAX_FILES} pages`);
149
+ continue;
150
+ }
151
+ let text;
152
+ try {
153
+ text = readFileSync(join(dir, p), 'utf8');
154
+ } catch {
155
+ carry(p, 'unreadable');
156
+ continue;
157
+ }
158
+ if (Buffer.byteLength(text) > MAX_FILE_BYTES) {
159
+ carry(p, 'exceeds 256KB');
160
+ continue;
161
+ }
162
+ kept++;
163
+ contents[p] = text;
164
+ current[p] = createHash('sha256').update(text).digest('hex');
165
+ }
166
+
167
+ // Partial walk (an unreadable SUBdirectory): every previously-synced page the
168
+ // walk failed to reach must be carried forward, not inferred deleted — a
169
+ // transient EMFILE/EACCES on e.g. docs/ must never erase those pages
170
+ // server-side. The hard rule: deletion is opt-in, never inferred.
171
+ if (walkErrors.count > 0) {
172
+ warn(
173
+ `vault walk hit ${walkErrors.count} unreadable director${walkErrors.count === 1 ? 'y' : 'ies'} — carrying missing pages forward, no deletions this pass`
174
+ );
175
+ for (const p of Object.keys(prev)) {
176
+ if (!(p in current)) current[p] = prev[p];
177
+ }
178
+ }
179
+
180
+ // A readable vault that suddenly presents ZERO pages while the server holds
181
+ // many is almost always a broken/moved dir, not an intentional wipe — refuse
182
+ // to mass-delete. (An intentional reset is a fresh Regenerate: the sweep
183
+ // rewrites pages, then finalize prunes precisely.)
184
+ const prevCount = Object.keys(prev).length;
185
+ if (Object.keys(current).length === 0 && prevCount > 0) {
186
+ warn(`vault at ${dir} presents 0 pages but ${prevCount} were synced — refusing to delete; check the vault dir`);
187
+ return { pages: 0, uploaded: 0, deleted: 0, skipped: true };
188
+ }
189
+
190
+ const changed = Object.keys(current).filter((p) => p in contents && prev[p] !== current[p]);
191
+ const deletions = Object.keys(prev).filter((p) => !(p in current));
192
+ const pages = Object.keys(current).length;
193
+ if (changed.length === 0 && deletions.length === 0 && !finalize) {
194
+ return { pages, uploaded: 0, deleted: 0, skipped: true };
195
+ }
196
+
197
+ // Finalize manifests are schema-capped server-side at MAX_FILES; carried
198
+ // pages can push the tracked set past it. Downgrade to a plain merge (no
199
+ // prune) rather than wedge the whole sync on a 400 — nothing is lost, the
200
+ // regen request stays pending, and the warning names the cause.
201
+ let doFinalize = !!finalize;
202
+ if (doFinalize && pages > MAX_FILES) {
203
+ warn(`vault tracks ${pages} pages (> ${MAX_FILES}) — skipping the finalize prune this pass`);
204
+ doFinalize = false;
205
+ }
206
+
207
+ // Build the request series: file chunks (count+byte capped), then however
208
+ // many deletion batches the 200-cap needs. finalize/sha ride the LAST
209
+ // request only, so the server prunes exactly once, after every upsert landed.
210
+ const fileChunks = [];
211
+ let cur = [];
212
+ let bytes = 0;
213
+ for (const p of changed) {
214
+ const size = Buffer.byteLength(contents[p]);
215
+ if (cur.length && (cur.length >= CHUNK_FILES || bytes + size > CHUNK_BYTES)) {
216
+ fileChunks.push(cur);
217
+ cur = [];
218
+ bytes = 0;
219
+ }
220
+ cur.push(p);
221
+ bytes += size;
222
+ }
223
+ if (cur.length) fileChunks.push(cur);
224
+
225
+ const requests = fileChunks.map((paths) => ({ files: paths.map((p) => ({ path: p, content: contents[p] })), deletions: [] }));
226
+ for (let i = 0; i < deletions.length; i += MAX_DELETIONS_PER_REQ) {
227
+ requests.push({ files: [], deletions: deletions.slice(i, i + MAX_DELETIONS_PER_REQ) });
228
+ }
229
+ if (requests.length === 0) requests.push({ files: [], deletions: [] }); // finalize-only
230
+
231
+ for (let i = 0; i < requests.length; i++) {
232
+ const last = i === requests.length - 1;
233
+ const res = await fetch(url, {
234
+ method: 'POST',
235
+ headers: {
236
+ Authorization: `Bearer ${token}`,
237
+ 'User-Agent': userAgent,
238
+ 'Content-Type': 'application/json',
239
+ },
240
+ signal: AbortSignal.timeout(60_000),
241
+ body: JSON.stringify({
242
+ ...requests[i],
243
+ ...(last && doFinalize ? { finalize: { manifest: Object.keys(current) } } : {}),
244
+ ...(last && groundedAtSha ? { groundedAtSha } : {}),
245
+ ...(last && repoFullName ? { repoFullName } : {}),
246
+ }),
247
+ });
248
+ if (!res.ok) throw new Error(`wiki-vault sync failed (${res.status})`);
249
+ }
250
+
251
+ writeFileSync(join(dir, SYNC_STATE), JSON.stringify(current));
252
+ commitVault(dir, doFinalize ? `sweep${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}` : `update${groundedAtSha ? ` @ ${groundedAtSha.slice(0, 7)}` : ''}`);
253
+ return { pages, uploaded: changed.length, deleted: deletions.length };
254
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Run your own Claude Code as headless build agents for Flowviant — on your own credentials. Claims dispatched work, opens PRs, captures review evidence, and routes questions back to you.",
5
5
  "type": "module",
6
6
  "bin": {