flowviant 0.19.0 → 0.21.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.
@@ -77,6 +77,84 @@ 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. INCREMENTAL — never 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.
133
+
134
+ 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.
146
+
147
+ Ground every claim in files you actually read. Be efficient — look only at the
148
+ changed area, not the whole repo; spend little quota.`;
149
+
150
+ export const REGROUND_KICKOFF = ({ sha, title, files }) =>
151
+ `A feature just merged. Re-ground the living wiki for it.\n\n` +
152
+ `Feature: ${title}\n` +
153
+ `Grounded commit: ${sha}\n` +
154
+ `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.`;
157
+
80
158
  // Unattended (default) skips prompts so the agent never stalls with no terminal;
81
159
  // FLOWVIANT_SAFE=1 restricts to a curated toolset instead.
82
160
  const PERM = SAFE
@@ -121,13 +199,88 @@ export function mcpConfigFor(token, mcpUrl) {
121
199
  return { dir, path: p };
122
200
  }
123
201
 
202
+ // Shorten an absolute tool path to a repo-relative one for legible output.
203
+ const shortPath = (p, cwd) => {
204
+ if (typeof p !== 'string') return '';
205
+ let s = p;
206
+ if (cwd && s.startsWith(cwd)) s = s.slice(cwd.length).replace(/^\/+/, '');
207
+ return s;
208
+ };
209
+
210
+ // Turn one Claude tool_use into a compact activity {kind, label}, or null for
211
+ // tools not worth surfacing. `kind:'read'` is what the file counter counts;
212
+ // an emit_wiki_node flips the phase to "writing". Used by wiki turns to stream
213
+ // exactly which files Claude is touching (daemon console + app cover).
214
+ export function humanizeToolUse(name, input = {}, cwd = '') {
215
+ switch (name) {
216
+ case 'Read':
217
+ return { kind: 'read', label: `read ${shortPath(input.file_path, cwd)}` };
218
+ case 'Grep':
219
+ return {
220
+ kind: 'search',
221
+ label: `grep ${JSON.stringify(input.pattern ?? '')}${input.path ? ` in ${shortPath(input.path, cwd)}` : ''}`,
222
+ };
223
+ case 'Glob':
224
+ return { kind: 'glob', label: `glob ${input.pattern ?? ''}` };
225
+ case 'LS':
226
+ return { kind: 'list', label: `ls ${shortPath(input.path ?? '.', cwd)}` };
227
+ case 'Bash':
228
+ return { kind: 'bash', label: `$ ${String(input.command ?? '').replace(/\s+/g, ' ').slice(0, 60)}` };
229
+ default:
230
+ if (typeof name !== 'string') return null;
231
+ if (name.includes('emit_wiki_node')) return { kind: 'write', label: `+ node ${input.id ?? ''}` };
232
+ if (name.includes('finish_wiki_generation')) return { kind: 'write', label: 'finalize wiki' };
233
+ if (name.includes('list_wiki_nodes')) return { kind: 'mcp', label: 'list wiki nodes' };
234
+ return null; // other tools: silent
235
+ }
236
+ }
237
+
238
+ // Parse ONE line of `--output-format stream-json` NDJSON. Pulls assistant text
239
+ // into `out` (so sentinel detection still works) and turns each tool_use into a
240
+ // streamed activity line. A line that isn't JSON (a stray warning) is treated as
241
+ // raw text so nothing is lost.
242
+ function handleStreamLine(line, { cwd, emit, onActivity, appendText }) {
243
+ let ev;
244
+ try {
245
+ ev = JSON.parse(line);
246
+ } catch {
247
+ appendText(line + '\n');
248
+ emit(line + '\n');
249
+ return;
250
+ }
251
+ if (ev.type === 'assistant' && Array.isArray(ev.message?.content)) {
252
+ for (const b of ev.message.content) {
253
+ if (b.type === 'text' && b.text) appendText(b.text + '\n');
254
+ else if (b.type === 'tool_use') {
255
+ const a = humanizeToolUse(b.name, b.input || {}, cwd);
256
+ if (a) {
257
+ emit(a.label + '\n');
258
+ onActivity?.(a);
259
+ }
260
+ }
261
+ }
262
+ } else if (ev.type === 'result' && typeof ev.result === 'string') {
263
+ // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
264
+ appendText(ev.result + '\n');
265
+ }
266
+ }
267
+
124
268
  // One Claude Code turn. Output is captured (for sentinel detection) and streamed
125
269
  // through, line-prefixed with the worker label so a fleet stays legible.
126
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn }) {
270
+ //
271
+ // `streamJson` switches to `--output-format stream-json` and parses the event
272
+ // stream: only the humanized tool activity reaches the console (a legible
273
+ // stream of `read …`, `grep …`, `+ node …`), assistant text is folded into the
274
+ // returned string for sentinel detection, and each activity is handed to
275
+ // `onActivity` so the caller can forward progress. Build-agent turns leave it
276
+ // off and keep the raw text passthrough + line sentinels.
277
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn, streamJson, onActivity }) {
127
278
  return new Promise((resolve) => {
128
279
  const args = [];
129
280
  if (resume) args.push('--continue');
130
- args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system, ...PERM);
281
+ args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system);
282
+ if (streamJson) args.push('--output-format', 'stream-json', '--verbose');
283
+ args.push(...PERM);
131
284
  // Force the user's Claude Code subscription — never the API. A key exported in
132
285
  // the shell would otherwise silently bill every poll-mode turn as raw API
133
286
  // usage (same invariant live mode enforces on its SDK session env).
@@ -138,10 +291,47 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
138
291
  onSpawn?.(child);
139
292
  let out = '';
140
293
  const pfx = label ? `${label} ` : '';
294
+ const emit = (s) => process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
295
+
296
+ if (streamJson) {
297
+ let buf = '';
298
+ const appendText = (t) => {
299
+ out += t;
300
+ };
301
+ child.stdout.on('data', (d) => {
302
+ buf += d.toString();
303
+ let nl;
304
+ while ((nl = buf.indexOf('\n')) >= 0) {
305
+ const line = buf.slice(0, nl);
306
+ buf = buf.slice(nl + 1);
307
+ if (line.trim()) handleStreamLine(line, { cwd, emit, onActivity, appendText });
308
+ }
309
+ });
310
+ // stderr is not JSON (warnings/errors) — pass through and keep for sentinels.
311
+ child.stderr.on('data', (d) => {
312
+ const s = d.toString();
313
+ out += s;
314
+ emit(s);
315
+ });
316
+ child.on('error', (e) => {
317
+ if (e.code === 'ENOENT') {
318
+ console.error("\nerror: 'claude' CLI not found on PATH. Install Claude Code first.");
319
+ process.exit(1);
320
+ }
321
+ console.error(e);
322
+ resolve(out);
323
+ });
324
+ child.on('close', () => {
325
+ if (buf.trim()) handleStreamLine(buf, { cwd, emit, onActivity, appendText });
326
+ resolve(out);
327
+ });
328
+ return;
329
+ }
330
+
141
331
  const onChunk = (d) => {
142
332
  const s = d.toString();
143
333
  out += s;
144
- process.stdout.write(pfx ? s.replace(/\n/g, `\n${pfx}`) : s);
334
+ emit(s);
145
335
  };
146
336
  child.stdout.on('data', onChunk);
147
337
  child.stderr.on('data', onChunk);
@@ -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.19.0';
7
+ export const VERSION = '0.21.0';
8
8
 
9
9
  // Credential stored by `flowviant login` (device auth) — the no-token,
10
10
  // no-env-var path. An explicit --fleet flag or FLOWVIANT_FLEET env still wins.
package/bin/lib/fleet.mjs CHANGED
@@ -45,6 +45,10 @@ import {
45
45
  SYSTEM_SINGLE,
46
46
  SINGLE_KICKOFF,
47
47
  SINGLE_RESUME,
48
+ SYSTEM_WIKI,
49
+ WIKI_KICKOFF,
50
+ SYSTEM_REGROUND,
51
+ REGROUND_KICKOFF,
48
52
  } from './claude.mjs';
49
53
  import { runLiveWorker } from './live.mjs';
50
54
  import { reapOrphanPreviews } from './preview.mjs';
@@ -306,6 +310,12 @@ export async function runFleetDaemon() {
306
310
  mergeAttempts.delete(job.id);
307
311
  await reportMergeOutcome(MERGE_DONE_URL, { intentId: job.id });
308
312
  ok(`${c.cyan('merged')} ${c.dim(`— ${job.title} → ${baseRef}`)}`);
313
+ // The code just landed — re-ground the living wiki for what shipped
314
+ // (touched nodes re-read + a persistent feature-history node).
315
+ // Direct enqueue = immediacy; the server's durable regroundJobs list
316
+ // (created by merge-done above, cleared by our reground-done report)
317
+ // is the restart-safe backstop — dedup'd here by groundedIntents.
318
+ enqueueReground(job.id, job.prUrl, job.title);
309
319
  } else if (failedReason) {
310
320
  // Report into the thread (server narrates + re-arms the merge
311
321
  // button + notifies) — the job disappears from the roster.
@@ -379,6 +389,203 @@ export async function runFleetDaemon() {
379
389
  }
380
390
  };
381
391
 
392
+ // Living-wiki work runs ONE turn at a time in a dedicated worktree (off the
393
+ // agents' checkouts), via the emit_wiki_node MCP tool — never code edits. Two
394
+ // triggers enqueue: a Regenerate click (full SWEEP) and a successful merge
395
+ // (incremental RE-GROUND → feature-history). One queue + runner serializes
396
+ // them so they never collide on the worktree. Each turn runs under its own
397
+ // wiki-scoped credential (minted per task below) — the server allows ONLY the
398
+ // wiki tools on it, and build agents' worker tokens can't touch the map. Also
399
+ // means wiki work needs no agent online.
400
+ const wikiWt = join(baseDir, 'wiki');
401
+ const REGROUND_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/reground-done');
402
+ const WIKI_TOKEN_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-token');
403
+ const WIKI_PROGRESS_URL = FLEET_URL.replace(/\/agents\/?$/, '/wiki-progress');
404
+ const wikiQueue = [];
405
+ let wikiBusy = false;
406
+ let lastSweepAt = null; // dedup: run each Regenerate request once
407
+ const groundedIntents = new Set(); // dedup: re-ground each delivery once
408
+
409
+ const mintWikiToken = async () => {
410
+ try {
411
+ const res = await fetch(WIKI_TOKEN_URL, {
412
+ method: 'POST',
413
+ headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT },
414
+ signal: AbortSignal.timeout(30_000),
415
+ });
416
+ if (!res.ok) return null;
417
+ return (await res.json())?.data?.token ?? null;
418
+ } catch {
419
+ return null;
420
+ }
421
+ };
422
+
423
+ // Stream what the wiki turn is doing to the app (the canvas renders the read
424
+ // phase). Throttled to ~1/s — the FIRST activity of a run and the terminal
425
+ // `done` frame force-send so the cover appears fast and clears cleanly.
426
+ let lastProgressAt = 0;
427
+ const postWikiProgress = async (body, force = false) => {
428
+ const now = Date.now();
429
+ if (!force && now - lastProgressAt < 1000) return;
430
+ lastProgressAt = now;
431
+ try {
432
+ await fetch(WIKI_PROGRESS_URL, {
433
+ method: 'POST',
434
+ headers: {
435
+ Authorization: `Bearer ${FLEET_TOKEN}`,
436
+ 'User-Agent': USER_AGENT,
437
+ 'Content-Type': 'application/json',
438
+ },
439
+ signal: AbortSignal.timeout(15_000),
440
+ body: JSON.stringify(body),
441
+ });
442
+ } catch {
443
+ /* best-effort — a dropped frame is harmless, the next one supersedes it */
444
+ }
445
+ };
446
+
447
+ const enqueueSweep = (job) => {
448
+ if (!job || job.requestedAt === lastSweepAt) return;
449
+ lastSweepAt = job.requestedAt;
450
+ wikiQueue.push({ type: 'sweep' });
451
+ void drainWiki();
452
+ };
453
+ const enqueueReground = (intentId, prUrl, title) => {
454
+ if (!intentId || groundedIntents.has(intentId)) return;
455
+ groundedIntents.add(intentId);
456
+ wikiQueue.push({ type: 'reground', intentId, prUrl, title: title || 'a delivered task' });
457
+ void drainWiki();
458
+ };
459
+
460
+ // Changed files of a (merged) PR, for the re-ground prompt. Capped so a huge
461
+ // PR can't blow up the prompt. prUrl was already validated before the merge.
462
+ const changedFilesForPr = (prUrl) => {
463
+ try {
464
+ const out = execFileSync('gh', ['pr', 'view', prUrl, '--json', 'files'], {
465
+ cwd: repoRoot,
466
+ encoding: 'utf8',
467
+ stdio: ['ignore', 'pipe', 'pipe'],
468
+ });
469
+ return (JSON.parse(out).files ?? []).map((f) => f.path).filter(Boolean).slice(0, 60);
470
+ } catch {
471
+ return [];
472
+ }
473
+ };
474
+
475
+ async function drainWiki() {
476
+ if (wikiBusy || wikiQueue.length === 0) return;
477
+ wikiBusy = true;
478
+ try {
479
+ while (wikiQueue.length) {
480
+ // Fresh wiki-scoped credential per task — dedicated, so no roster
481
+ // re-mint can rotate it out from under a long sweep.
482
+ const token = await mintWikiToken();
483
+ if (!token) {
484
+ warn('wiki: could not mint the cartographer token — retrying on a later poll');
485
+ break; // queue intact — the reconcile loop re-drains
486
+ }
487
+ const task = wikiQueue.shift();
488
+ const { dir, path: mcpConfig } = mcpConfigFor(token, mcpUrl);
489
+ // Live progress for this turn: count the files Claude reads, flip to the
490
+ // "writing" phase once it starts emitting nodes, and stream each action
491
+ // to the app (throttled). elapsedSec is on the daemon's own clock.
492
+ const mode = task.type === 'sweep' ? 'sweep' : 'reground';
493
+ const startedAt = Date.now();
494
+ let filesRead = 0;
495
+ let phase = 'reading';
496
+ const onActivity = (a) => {
497
+ if (a.kind === 'read') filesRead++;
498
+ if (a.kind === 'write') phase = 'writing';
499
+ void postWikiProgress({
500
+ mode,
501
+ phase,
502
+ activity: a.label,
503
+ filesRead,
504
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
505
+ });
506
+ };
507
+ try {
508
+ if (!existsSync(wikiWt)) {
509
+ try {
510
+ git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
511
+ } catch {
512
+ git(['worktree', 'prune'], repoRoot);
513
+ git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
514
+ }
515
+ }
516
+ resetWorktree(wikiWt, baseRef);
517
+ let sha = '';
518
+ try {
519
+ sha = git(['rev-parse', 'HEAD'], wikiWt);
520
+ } catch {
521
+ /* detached/no HEAD — still writes the map, just ungrounded */
522
+ }
523
+ if (task.type === 'sweep') {
524
+ note(`${c.cyan('wiki')} ${c.dim('— regenerating: your Claude is reading the repo…')}`);
525
+ const out = await runTurn({
526
+ prompt: WIKI_KICKOFF(sha),
527
+ resume: false,
528
+ system: SYSTEM_WIKI,
529
+ cwd: wikiWt,
530
+ mcpConfig,
531
+ label: c.cyan('[wiki]'),
532
+ streamJson: true,
533
+ onActivity,
534
+ });
535
+ if (sawSentinel(out, 'WIKI_DONE'))
536
+ ok(`${c.cyan('wiki')} ${c.dim('— regenerated from your code.')}`);
537
+ else
538
+ warn('code-wiki regeneration ended without WIKI_DONE — retry from the app if incomplete.');
539
+ } else {
540
+ const files = changedFilesForPr(task.prUrl);
541
+ if (files.length === 0) {
542
+ note(`${c.cyan('wiki')} ${c.dim(`— "${task.title}": no changed files to re-ground`)}`);
543
+ } else {
544
+ note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
545
+ const out = await runTurn({
546
+ prompt: REGROUND_KICKOFF({ sha, title: task.title, files }),
547
+ resume: false,
548
+ system: SYSTEM_REGROUND,
549
+ cwd: wikiWt,
550
+ mcpConfig,
551
+ label: c.cyan('[wiki]'),
552
+ streamJson: true,
553
+ onActivity,
554
+ });
555
+ if (sawSentinel(out, 'REGROUND_DONE'))
556
+ ok(`${c.cyan('wiki')} ${c.dim(`— wiki updated for "${task.title}".`)}`);
557
+ else warn(`wiki re-ground for "${task.title}" ended without REGROUND_DONE.`);
558
+ }
559
+ // Consume the durable job: attempted = done (success or not — emits
560
+ // are idempotent and a failed turn heals on the next full sweep), so
561
+ // a failing re-ground can't loop-burn quota. Only a crash BEFORE
562
+ // this line leaves the job listed for a retry after restart.
563
+ await reportMergeOutcome(REGROUND_DONE_URL, { intentId: task.intentId });
564
+ }
565
+ } catch (e) {
566
+ warn(`wiki ${task.type} failed: ${e.message}`);
567
+ } finally {
568
+ // Terminal frame so the app cover clears promptly (don't wait for the
569
+ // freshness window to lapse). force-sent past the throttle.
570
+ await postWikiProgress(
571
+ {
572
+ mode,
573
+ phase,
574
+ activity: '',
575
+ filesRead,
576
+ elapsedSec: Math.round((Date.now() - startedAt) / 1000),
577
+ done: true,
578
+ },
579
+ true
580
+ );
581
+ rmSync(dir, { recursive: true, force: true });
582
+ }
583
+ }
584
+ } finally {
585
+ wikiBusy = false;
586
+ }
587
+ }
588
+
382
589
  let connected = false; // log the first successful poll once
383
590
  let rosterSig = null; // last roster membership, to log changes only
384
591
  let idleBeatAt = 0; // throttle the "still alive" idle heartbeat
@@ -511,6 +718,15 @@ export async function runFleetDaemon() {
511
718
  }
512
719
  }
513
720
 
721
+ // Living-wiki work (runs under its own minted wiki token — no agent
722
+ // needed). enqueueSweep queues a Regenerate; regroundJobs re-offers merged
723
+ // deliveries whose re-ground never ran (e.g. we restarted between merge and
724
+ // turn) until we report reground-done; the bare drain flushes anything
725
+ // whose earlier mint failed.
726
+ enqueueSweep(roster.codeMapJob);
727
+ for (const j of roster.regroundJobs ?? []) enqueueReground(j.intentId, j.prUrl, j.title);
728
+ void drainWiki();
729
+
514
730
  // Stop workers whose agent left the roster (removed in the app).
515
731
  for (const [id, w] of [...workers]) {
516
732
  if (!rosterIds.has(id)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.19.0",
3
+ "version": "0.21.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": {