flowviant 0.25.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.
- package/bin/lib/claude.mjs +151 -71
- package/bin/lib/config.mjs +1 -1
- package/bin/lib/fleet.mjs +121 -49
- package/bin/lib/vault.mjs +254 -0
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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 itself — no 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.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
in place
|
|
139
|
-
If the change adds a genuinely new
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
(
|
|
143
|
-
|
|
144
|
-
4.
|
|
145
|
-
|
|
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
|
|
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:
|
|
156
|
-
`
|
|
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,16 +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, '--
|
|
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);
|
|
294
374
|
// Pin the model — never inherit the user's global default (which may be a
|
|
295
375
|
// 1M/long-context tier their subscription can't bill autonomous work on).
|
|
296
376
|
args.push('--model', MODEL);
|
|
297
377
|
if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
|
|
298
|
-
args.push(...PERM);
|
|
378
|
+
args.push(...(wikiPerm ? WIKI_PERM : PERM));
|
|
299
379
|
// Force the user's Claude Code subscription — never the API. A key exported in
|
|
300
380
|
// the shell would otherwise silently bill every poll-mode turn as raw API
|
|
301
381
|
// usage (same invariant live mode enforces on its SDK session env).
|
package/bin/lib/config.mjs
CHANGED
|
@@ -4,7 +4,7 @@ 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.
|
|
7
|
+
export const VERSION = '0.26.0';
|
|
8
8
|
|
|
9
9
|
// The model EVERY daemon Claude turn runs on — pinned so autonomous work never
|
|
10
10
|
// inherits your interactive `~/.claude/settings.json` default. That matters: a
|
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
|
|
402
|
-
// agents' checkouts)
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
407
|
-
//
|
|
408
|
-
//
|
|
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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
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(`—
|
|
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 —
|
|
590
|
-
//
|
|
591
|
-
// a failing re-ground can't loop-burn quota. Only a
|
|
592
|
-
// this line leaves the job listed for a retry
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
@@ -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.
|
|
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": {
|