flowviant 0.6.0 → 0.7.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.
@@ -25,13 +25,14 @@ Operate this loop:
25
25
  3. If you hit ANYTHING only a human can decide, call report_blocker with a clear
26
26
  question (and options when you can), then call get_blocker_resolution. If it is
27
27
  not yet resolved, output exactly BLOCKED:<blockerId> on its own line and STOP.
28
- 4. Before finishing: for EACH acceptance criterion, call attach_evidence with concrete
29
- proof it is met. This is what the human reviews instead of the diff.
30
- 5. Ship: on a revision, \`git push\` to the SAME existing branch (the PR updates in place)
28
+ 4. Ship: on a revision, \`git push\` to the SAME existing branch (the PR updates in place)
31
29
  and re-call attach_pr with that PR URL; otherwise open ONE draft PR (git push +
32
- \`gh pr create --draft\`) and call attach_pr. Then complete. NEVER merge — the human
33
- approves the PR in Flowviant.
34
- 6. Return to step 1.
30
+ \`gh pr create --draft\`) and call attach_pr. Then call complete with a plain-language
31
+ summary of what you built AND a criteria self-report (index into the brief's
32
+ "done when" list + met true/false + a short note) — that becomes your delivery
33
+ card in the task thread. NEVER merge — a human confirms done in the thread and
34
+ the merge runs separately.
35
+ 5. Return to step 1.
35
36
 
36
37
  Keep every change scoped to the claimed intent. If a tool errors, report_progress with
37
38
  the error, then retry or report_blocker.`;
@@ -53,12 +54,12 @@ Do EXACTLY ONE task this turn:
53
54
  3. If you hit ANYTHING only a human can decide, call report_blocker (with options when
54
55
  you can), then get_blocker_resolution. If unresolved, output exactly
55
56
  BLOCKED:<blockerId> on its own line and STOP. Do NOT guess past a real decision.
56
- 4. Before finishing: for EACH acceptance criterion call attach_evidence with concrete
57
- proof it is met.
58
- 5. Ship: if this is a revision, \`git push\` to the SAME existing branch (the open PR
57
+ 4. Ship: if this is a revision, \`git push\` to the SAME existing branch (the open PR
59
58
  updates in place) and re-call attach_pr with that same PR URL. Otherwise open ONE
60
- draft PR (git push + \`gh pr create --draft\`) and call attach_pr. Then complete.
61
- NEVER merge. Then output exactly DONE on its own line and stop.
59
+ draft PR (git push + \`gh pr create --draft\`) and call attach_pr. Then call complete
60
+ with a plain-language summary AND a criteria self-report (index into the brief's
61
+ "done when" list + met true/false + a short note) — your delivery card in the task
62
+ thread. NEVER merge. Then output exactly DONE on its own line and stop.
62
63
 
63
64
  Do NOT claim a second intent — exactly one per turn. Keep every change scoped to the
64
65
  claimed intent. If a tool errors, report_progress with the error, then retry or
@@ -127,7 +128,13 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
127
128
  const args = [];
128
129
  if (resume) args.push('--continue');
129
130
  args.push('-p', prompt, '--mcp-config', mcpConfig, '--append-system-prompt', system, ...PERM);
130
- const child = spawn('claude', args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
131
+ // Force the user's Claude Code subscription never the API. A key exported in
132
+ // the shell would otherwise silently bill every poll-mode turn as raw API
133
+ // usage (same invariant live mode enforces on its SDK session env).
134
+ const env = { ...process.env };
135
+ delete env.ANTHROPIC_API_KEY;
136
+ delete env.ANTHROPIC_AUTH_TOKEN;
137
+ const child = spawn('claude', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
131
138
  onSpawn?.(child);
132
139
  let out = '';
133
140
  const pfx = label ? `${label} ` : '';
@@ -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.6.0';
7
+ export const VERSION = '0.7.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
@@ -128,8 +128,11 @@ async function runFleetWorker({ agentId, label, cwd, baseRef, getToken, getHasWo
128
128
  // don't fake a blocker or a completion.
129
129
  enter('reconnect', warn, `${c.yellow('no result')}${c.dim(' — refreshing token, retrying')}`);
130
130
  onTokenSuspect?.(agentId);
131
- resuming = false;
132
- needsReset = true;
131
+ // A no-sentinel turn while RESUMING a blocked task is a transient MCP/token
132
+ // failure, not completion — retry in place and KEEP the worktree. Resetting
133
+ // here would wipe the blocked task's uncommitted changes. Only a fresh-task
134
+ // turn (not resuming) warrants a clean slate next time.
135
+ if (!resuming) needsReset = true;
133
136
  await sleep(IDLE_SECONDS);
134
137
  }
135
138
  info(`${label} stopped`);
package/bin/lib/live.mjs CHANGED
@@ -51,7 +51,21 @@ async function registerLiveTarget(intentId, kind, url) {
51
51
  }
52
52
  }
53
53
 
54
- const SAFE_TOOLS = ['Edit', 'Write', 'Read', 'Grep', 'Glob', 'Bash', 'mcp__flowviant'];
54
+ // Safe mode's curated toolset. Bash is scoped to the specific CLIs the agent
55
+ // needs (git/gh/npm/bun) — NOT bare `Bash`, which would auto-approve arbitrary
56
+ // shell (rm -rf, curl|sh, reading ~/.ssh) and defeat the point of safe mode.
57
+ const SAFE_TOOLS = [
58
+ 'Edit',
59
+ 'Write',
60
+ 'Read',
61
+ 'Grep',
62
+ 'Glob',
63
+ 'Bash(git:*)',
64
+ 'Bash(gh:*)',
65
+ 'Bash(npm:*)',
66
+ 'Bash(bun:*)',
67
+ 'mcp__flowviant',
68
+ ];
55
69
 
56
70
  // Appended to Claude Code's preset. The reliable copy of the contract also
57
71
  // rides in the seed message below, so this degrades gracefully if the preset
@@ -64,12 +78,15 @@ answered…" or teammate line as a new instruction and adapt. There is NO termin
64
78
  and NO interactive prompt — your only channel to a human is the flowviant MCP
65
79
  tools. When you hit a decision only a human can make, call report_blocker (with
66
80
  options when you can) and then STOP your turn — do not spin or guess; you will be
67
- resumed with the answer. When the work is done: call attach_evidence for EACH
68
- acceptance criterion (this is the floor the human reviews against); open ONE draft
69
- PR (git push + gh pr create --draft), call attach_pr, then call complete. A live
70
- preview of your branch is started for you automatically for the review you do
71
- NOT need to open a tunnel or register a live target. NEVER merge — the human
72
- reviews and merging is handled separately.`;
81
+ resumed with the answer. When the work is done: open ONE draft PR (git push +
82
+ gh pr create --draft), call attach_pr, then call complete with a plain-language
83
+ summary of what you built AND a criteria self-report (index into the brief's
84
+ "done when" list + met true/false + a short note per item). That summary +
85
+ self-report becomes your DELIVERY CARD in the task thread it's what the team
86
+ reads to confirm done, so write it for them, not for a log. A live preview of
87
+ your branch is started for you automatically — you do NOT need to open a tunnel
88
+ or register a live target. NEVER merge — a human confirms done in the thread
89
+ (the merge card) and the merge runs separately.`;
73
90
 
74
91
  function seedPrompt(runId, brief, transcript) {
75
92
  return [
@@ -84,7 +101,7 @@ function seedPrompt(runId, brief, transcript) {
84
101
  ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
85
102
  : []),
86
103
  ``,
87
- `${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; attach_evidence, open a draft PR, attach_pr, then complete when done.`,
104
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first, then: report_progress as you go; report_blocker + stop if you hit a human decision; open a draft PR, attach_pr, then complete (summary + criteria self-report — your delivery card) when done.`,
88
105
  ].join('\n');
89
106
  }
90
107
 
@@ -99,6 +116,9 @@ async function mcpCall(mcpUrl, token, name, args) {
99
116
  Authorization: `Bearer ${token}`,
100
117
  'Content-Type': 'application/json',
101
118
  Accept: 'application/json',
119
+ // Required: Node's default UA trips Cloudflare Bot Fight Mode (403) —
120
+ // without this every live MCP call fails against api.flowviant.com.
121
+ 'User-Agent': USER_AGENT,
102
122
  },
103
123
  body: JSON.stringify({
104
124
  jsonrpc: '2.0',
@@ -199,22 +219,26 @@ async function waitForMessage(mcpUrl, token, runId, afterId, isAlive) {
199
219
 
200
220
  // One task: claim → seed → stream/mirror/inject/park → complete. Returns
201
221
  // { outcome: 'nothing' | 'done' | 'blocked' | 'stalled' | 'error' }.
202
- export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
222
+ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resumeIntentId, onChild }) {
203
223
  const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
204
224
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
205
225
  const { runId, intentId } = claim;
206
226
  const brief = claim.brief ?? {};
207
227
  const title = brief.title ?? 'a task';
228
+ // Re-claiming the SAME intent this worker was just working (parked on a blocker,
229
+ // now resuming) — its worktree holds hours of uncommitted work. Do NOT reset.
230
+ const resuming = !!resumeIntentId && intentId === resumeIntentId;
208
231
 
209
- // Revision resumes its PR branch; otherwise a clean base checkout.
232
+ // Revision resumes its PR branch; a genuinely fresh task gets a clean base
233
+ // checkout; a resume keeps its dirty worktree untouched.
210
234
  if (brief.branch) {
211
235
  try {
212
236
  git(['fetch', 'origin', '--quiet'], cwd);
213
237
  git(['checkout', brief.branch], cwd);
214
238
  } catch {
215
- resetWorktree(cwd, baseRef);
239
+ if (!resuming) resetWorktree(cwd, baseRef);
216
240
  }
217
- } else {
241
+ } else if (!resuming) {
218
242
  resetWorktree(cwd, baseRef);
219
243
  }
220
244
 
@@ -241,11 +265,34 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
241
265
  ...(SAFE ? { allowedTools: SAFE_TOOLS } : {}),
242
266
  systemPrompt: { type: 'preset', preset: 'claude_code', append: SYSTEM_LIVE },
243
267
  mcpServers: {
244
- flowviant: { type: 'http', url: mcpUrl, headers: { Authorization: `Bearer ${token}` } },
268
+ flowviant: {
269
+ type: 'http',
270
+ url: mcpUrl,
271
+ headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT },
272
+ },
245
273
  },
246
274
  },
247
275
  });
248
276
 
277
+ // Mark this worker BUSY for the daemon's reconcile loop: buildHave keeps the
278
+ // worker's token while a session is live (never rotate a credential out from
279
+ // under it), and teardown/agent-removal can interrupt the SDK session via this
280
+ // marker's kill(). Cleared in finally. Mirrors poll mode's onChild(child).
281
+ onChild?.({
282
+ kill: () => {
283
+ try {
284
+ session.interrupt?.();
285
+ } catch {
286
+ /* already ending */
287
+ }
288
+ try {
289
+ session.return?.();
290
+ } catch {
291
+ /* already closed */
292
+ }
293
+ },
294
+ });
295
+
249
296
  let turnId = null;
250
297
  let turnText = '';
251
298
  let turnAt = null;
@@ -359,6 +406,7 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive }) {
359
406
  } catch (e) {
360
407
  return { outcome: 'error', error: e?.message ?? String(e), title, intentId };
361
408
  } finally {
409
+ onChild?.(null); // no longer busy — token may rotate between tasks
362
410
  input.close();
363
411
  try {
364
412
  await session.interrupt?.();
@@ -385,7 +433,12 @@ export async function runLiveWorker({
385
433
  getMcpUrl,
386
434
  isAlive,
387
435
  onTokenSuspect,
436
+ onChild,
388
437
  }) {
438
+ // The intent this worker is holding across iterations. When a task parks on a
439
+ // blocker its worktree keeps uncommitted work; on the resume claim we must NOT
440
+ // reset it. Cleared once the task finishes or the worker goes idle.
441
+ let lastIntentId = null;
389
442
  let phase = '';
390
443
  const enter = (p, fn, msg) => {
391
444
  if (phase !== p) {
@@ -441,13 +494,28 @@ export async function runLiveWorker({
441
494
  }
442
495
  let res;
443
496
  try {
444
- res = await runLiveTask({ mcpUrl: getMcpUrl() ?? MCP_URL, token, cwd, baseRef, isAlive });
497
+ res = await runLiveTask({
498
+ mcpUrl: getMcpUrl() ?? MCP_URL,
499
+ token,
500
+ cwd,
501
+ baseRef,
502
+ isAlive,
503
+ resumeIntentId: lastIntentId,
504
+ onChild,
505
+ });
445
506
  } catch (e) {
446
507
  enter('error', warn, `${c.yellow('error')} ${c.dim(`— ${e?.message ?? e}`)}`);
447
508
  await sleep(IDLE_SECONDS);
448
509
  continue;
449
510
  }
450
511
  if (!isAlive()) break;
512
+ // Keep the held intent only while a task is genuinely in flight (parked /
513
+ // stalled / errored → same worktree resumes). Finishing or finding no work
514
+ // clears it so the next fresh task starts from a clean base.
515
+ lastIntentId =
516
+ res.outcome === 'parked' || res.outcome === 'stalled' || res.outcome === 'error'
517
+ ? res.intentId
518
+ : null;
451
519
  if (res.outcome === 'nothing') {
452
520
  enter('idle', info, 'idle — no work assigned');
453
521
  await sleep(IDLE_SECONDS);
@@ -137,14 +137,36 @@ export async function startPreview({ worktree, kind, cmd, port, log, timeoutMs =
137
137
  const cf = await ensureCloudflared(log);
138
138
  if (!cf) return null; // fall back to captured evidence
139
139
  return new Promise((resolve) => {
140
- const server = spawn(cmd, { cwd: worktree, shell: true, stdio: ['ignore', 'ignore', 'ignore'] });
140
+ // detached so each gets its own process group `bun run dev` via a shell
141
+ // spawns a grandchild dev server that would otherwise SURVIVE a kill of the
142
+ // shell, keep port bound, and get silently re-fronted by the NEXT task's
143
+ // tunnel (reviewer sees the wrong branch). We kill the whole group instead.
144
+ const server = spawn(cmd, {
145
+ cwd: worktree,
146
+ shell: true,
147
+ detached: true,
148
+ stdio: ['ignore', 'ignore', 'ignore'],
149
+ });
141
150
  const tunnel = spawn(cf, ['tunnel', '--url', `http://localhost:${port}`], {
151
+ detached: true,
142
152
  stdio: ['ignore', 'pipe', 'pipe'],
143
153
  });
144
154
  let settled = false;
155
+ const killGroup = (child) => {
156
+ if (!child.pid) return;
157
+ try {
158
+ process.kill(-child.pid, 'SIGKILL'); // negative pid = the whole group
159
+ } catch {
160
+ try {
161
+ child.kill('SIGKILL');
162
+ } catch {
163
+ /* gone */
164
+ }
165
+ }
166
+ };
145
167
  const stop = () => {
146
- try { server.kill('SIGKILL'); } catch { /* gone */ }
147
- try { tunnel.kill('SIGKILL'); } catch { /* gone */ }
168
+ killGroup(server);
169
+ killGroup(tunnel);
148
170
  };
149
171
  const finish = (val) => {
150
172
  if (settled) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.6.0",
3
+ "version": "0.7.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": {