flowviant 0.97.0 → 0.99.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.
Files changed (45) hide show
  1. package/bin/cli.mjs +13 -3
  2. package/bin/lib/agentPlan.mjs +159 -37
  3. package/bin/lib/artifacts.mjs +136 -26
  4. package/bin/lib/authReject.mjs +32 -0
  5. package/bin/lib/authproxy.mjs +52 -16
  6. package/bin/lib/childEnv.mjs +33 -1
  7. package/bin/lib/claude.mjs +166 -71
  8. package/bin/lib/config.mjs +24 -1
  9. package/bin/lib/credentials.mjs +27 -4
  10. package/bin/lib/deploy.mjs +364 -30
  11. package/bin/lib/env.mjs +130 -13
  12. package/bin/lib/fleet.mjs +99 -28
  13. package/bin/lib/git.mjs +131 -3
  14. package/bin/lib/hooks/readGuard.mjs +24 -0
  15. package/bin/lib/instance.mjs +115 -14
  16. package/bin/lib/knowledge.mjs +180 -60
  17. package/bin/lib/landed.mjs +38 -30
  18. package/bin/lib/listeners.mjs +211 -0
  19. package/bin/lib/localSessions.mjs +29 -4
  20. package/bin/lib/login.mjs +2 -2
  21. package/bin/lib/machines.mjs +16 -7
  22. package/bin/lib/preview.mjs +95 -6
  23. package/bin/lib/printable.mjs +42 -0
  24. package/bin/lib/procRegistry.mjs +65 -0
  25. package/bin/lib/processes.mjs +20 -7
  26. package/bin/lib/projectTools.mjs +248 -0
  27. package/bin/lib/prompts.mjs +16 -11
  28. package/bin/lib/resources.mjs +46 -4
  29. package/bin/lib/runtimeOfferings.mjs +75 -0
  30. package/bin/lib/runtimes.mjs +71 -9
  31. package/bin/lib/shipMerge.mjs +8 -4
  32. package/bin/lib/trace.mjs +16 -6
  33. package/bin/lib/update.mjs +5 -0
  34. package/bin/lib/work.mjs +1192 -4584
  35. package/bin/lib/workAgentMerges.mjs +504 -0
  36. package/bin/lib/workAgentPlans.mjs +447 -0
  37. package/bin/lib/workAgentReview.mjs +702 -0
  38. package/bin/lib/workAgentTurns.mjs +1148 -0
  39. package/bin/lib/workDiffs.mjs +107 -0
  40. package/bin/lib/workPreviews.mjs +351 -0
  41. package/bin/lib/workProcesses.mjs +236 -0
  42. package/bin/lib/workPullRequests.mjs +313 -0
  43. package/bin/lib/workShip.mjs +385 -0
  44. package/bin/lib/worktreeDiff.mjs +99 -43
  45. package/package.json +2 -2
@@ -0,0 +1,447 @@
1
+ /** Agent plan claims, read-only planning turns, and proposal reports. */
2
+ import { existsSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
5
+ import { git, isSafePathSegment } from './git.mjs';
6
+ import { parseProposal } from './agentPlan.mjs';
7
+ import { c, note } from './ui.mjs';
8
+ import { runTurn } from './claude.mjs';
9
+ import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
10
+ import { scrub as envScrub } from './env.mjs';
11
+ import { pickRuntimeFor, removeProbeTranscript, RUNTIMES } from './runtimes.mjs';
12
+
13
+ export function createWorkAgentPlans({
14
+ postBestEffort,
15
+ baseDir,
16
+ baseRef,
17
+ REPO_PLACE,
18
+ inPlace,
19
+ repoRoot,
20
+ admit,
21
+ state,
22
+ }) {
23
+ const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
24
+ const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
25
+ const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
26
+
27
+ const { placeLocks, workChildren } = state;
28
+
29
+ // ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
30
+ //
31
+ // Somebody selected cards and pressed Deploy. This turn works out HOW THE
32
+ // WORK SHOULD BE SPLIT across agents and stops — a person edits what it
33
+ // proposes on the board, and ACCEPTING is what spawns anything. Nothing here
34
+ // creates a worktree, a branch or a card.
35
+ //
36
+ // READ-ONLY IN THE CHECKOUT. `readOnly: true` selects `consultPermFor` (the
37
+ // fenced Read/Glob rules plus a few path-validated `git`/`ls`/`cat` reads —
38
+ // no Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
39
+ // turn has no control plane to reach even if the repository it reads tries
40
+ // to steer it. The proposal comes back as the turn's final message rather
41
+ // than through a tool, which is exactly what lets that permission set be
42
+ // this narrow.
43
+ //
44
+ // It takes the checkout's place lock as a READER, beside the operator's own
45
+ // tabs. It writes nothing, so a writer lock would only starve real work.
46
+ const planning = new Set(); // press ids in flight on this tick
47
+
48
+ const postAgentPlan = async (body) => {
49
+ // Bounded retry on 408/429/5xx, an other-4xx counted as delivered — see
50
+ // postBestEffort's own docblock. Previously this swallowed every
51
+ // response including ordinary 5xx and rate limits, so a transient
52
+ // failure looked identical to a hostile refusal: nothing retried, and
53
+ // the proposal sat unsettled until the server's own expiry.
54
+ await postBestEffort(AGENT_PLAN_DONE_URL, { ...body, instance: DAEMON_INSTANCE });
55
+ };
56
+
57
+ /**
58
+ * WHAT THE PLANNER IS DOING, RELAYED — the agent turn's narration channel,
59
+ * on a PRESS instead of an agent.
60
+ *
61
+ * This turn was already streaming and the daemon was already reading it:
62
+ * `streamJson` humanizes every read, grep, command and thought and prints
63
+ * it behind `[plan]` on the operator's terminal. Every one of those lines
64
+ * was then thrown away, so a Deploy press said "planning" on the board and
65
+ * nothing else for as long as it took — which on an overnight queue is the
66
+ * whole night. Forwarding the CLI's own tail costs nothing that is not
67
+ * already being computed.
68
+ *
69
+ * IT IS THE CLI'S WORDS, OR THE MACHINE'S — never a stage, a percentage or
70
+ * an estimate of how far along a model is. Flowviant relays.
71
+ *
72
+ * SCRUBBED AND CAPPED HERE rather than at each call site, so a phase marker
73
+ * added later cannot skip either. Fire-and-forget with every failure
74
+ * swallowed: narration is a readout, and it must never fail or delay the
75
+ * turn it describes. A daemon that never calls this is a machine that has
76
+ * not narrated, which is the only thing an absent line can mean.
77
+ */
78
+ const postAgentPlanActivity = async (planId, line) => {
79
+ const text = envScrub(String(line ?? '')).slice(0, 400);
80
+ if (!text) return;
81
+ try {
82
+ await fetch(AGENT_PLAN_ACTIVITY_URL, {
83
+ method: 'POST',
84
+ headers: {
85
+ Authorization: `Bearer ${FLEET_TOKEN}`,
86
+ 'User-Agent': USER_AGENT,
87
+ 'Content-Type': 'application/json',
88
+ },
89
+ signal: AbortSignal.timeout(15_000),
90
+ body: JSON.stringify({ planId, line: text }),
91
+ });
92
+ } catch {
93
+ /* a readout — losing a line costs nothing */
94
+ }
95
+ };
96
+
97
+ /**
98
+ * A WEDGED PLANNING CLI IS STOPPED AT FIFTEEN MINUTES.
99
+ *
100
+ * `runTurn` has no timer of its own. A CLI that hangs — a login prompt
101
+ * nobody is there to answer, a model client stalled on a socket — therefore
102
+ * runs until something else kills it, holding `planning`, which is what
103
+ * blocks every auto-update on this machine, and spending whatever the
104
+ * operator's account is charged for a live session.
105
+ *
106
+ * FIFTEEN because the server fails a claimed press at THIRTY (its
107
+ * `PLAN_JOB_TTL_MS`) past thirty minutes with no renewal: half of that
108
+ * leaves this side — the only side that knows the CLI is still running —
109
+ * time to stop it and have its settle land, instead of the press expiring
110
+ * into a sentence that blames a machine which never spoke. Nothing
111
+ * legitimate is cut off either way: this turn reads a card selection and
112
+ * writes nothing, and the shape of it is minutes.
113
+ *
114
+ * `claimedAt` IS NO LONGER A ONE-SHOT STAMP (2026-09-24): the narration
115
+ * relay below (`postAgentPlanActivity`, `/fleet/agent-plan-activity`)
116
+ * renews it server-side as its own heartbeat, so a genuinely WORKING plan
117
+ * turn keeps its claim alive well past thirty minutes on its own. This
118
+ * timer exists for the one case narration cannot cover — a WEDGED CLI (a
119
+ * login prompt nobody answers, a stalled socket) emits nothing, renews
120
+ * nothing, and is exactly the silence the server's thirty-minute clock was
121
+ * always measuring.
122
+ */
123
+ const PLAN_TURN_TIMEOUT_MS = 15 * 60_000;
124
+
125
+ const claimAgentPlan = async (id) => {
126
+ try {
127
+ const res = await fetch(AGENT_PLAN_CLAIM_URL, {
128
+ method: 'POST',
129
+ headers: {
130
+ Authorization: `Bearer ${FLEET_TOKEN}`,
131
+ 'User-Agent': USER_AGENT,
132
+ 'Content-Type': 'application/json',
133
+ },
134
+ signal: AbortSignal.timeout(15_000),
135
+ body: JSON.stringify({ id, instance: DAEMON_INSTANCE }),
136
+ });
137
+ const j = await res.json().catch(() => null);
138
+ return j?.data?.claimed === true;
139
+ } catch {
140
+ return false; // the peer may hold it; doing nothing is the safe answer
141
+ }
142
+ };
143
+
144
+ /**
145
+ * What a live agent has already TOUCHED, measured here rather than sent.
146
+ *
147
+ * The server could have shipped a copy of this, and deliberately does not:
148
+ * these are directories on THIS machine, and a server-side copy would be a
149
+ * second, staler source of truth for a fact the daemon is standing on top of.
150
+ *
151
+ * Uncommitted work AND commits against base, because a planner asking "will
152
+ * these collide" cares about both — a file this agent has already rewritten
153
+ * and merged into its own branch collides exactly as hard as one it is
154
+ * editing now.
155
+ */
156
+ const agentChangedFiles = (placeId) => {
157
+ if (!placeId || !isSafePathSegment(placeId)) return [];
158
+ const wt = join(baseDir, 'sessions', placeId);
159
+ if (!existsSync(wt)) return [];
160
+ const out = new Set();
161
+ for (const args of [
162
+ ['diff', '--name-only', 'HEAD'],
163
+ ['diff', '--name-only', `${baseRef()}...HEAD`],
164
+ ['ls-files', '--others', '--exclude-standard'],
165
+ ]) {
166
+ /**
167
+ * `git()` THROWS; it does not return a non-string.
168
+ *
169
+ * The guard below was `if (typeof r !== 'string') continue`, which is a
170
+ * value `execFileSync` can never produce — so every one of these calls
171
+ * was effectively unguarded. And this runs over the LIVE AGENTS a plan
172
+ * job carries, which include agents still in `planning`: they have no
173
+ * worktree yet, so `git diff` in a directory that does not exist exits
174
+ * non-zero and throws straight out of `runAgentPlan` — after the press
175
+ * has been claimed and before the try/finally below. The press then sat
176
+ * `planning` until its expiry, having spent nothing and explained
177
+ * nothing, on what would be an ordinary second Deploy.
178
+ *
179
+ * An agent whose files cannot be read contributes none. That is the
180
+ * honest answer and it is what the planner should be told.
181
+ */
182
+ let r;
183
+ try {
184
+ r = git(args, wt);
185
+ } catch {
186
+ continue;
187
+ }
188
+ if (typeof r !== 'string') continue;
189
+ for (const line of r.split('\n')) {
190
+ const f = line.trim();
191
+ if (f) out.add(f);
192
+ if (out.size >= 60) return [...out];
193
+ }
194
+ }
195
+ return [...out];
196
+ };
197
+
198
+ /** See `runAgentTurn` — the admission reservation, released the moment the
199
+ * planner's CLI exists. */
200
+ const runAgentPlan = async (job, releaseSlot = () => {}) => {
201
+ const id = String(job.id);
202
+ const tasks = Array.isArray(job.tasks) ? job.tasks : [];
203
+ // CLAIM BEFORE ANYTHING — including before the cheap refusal below.
204
+ //
205
+ // The settle is rejected unless the caller HOLDS the lease, so posting an
206
+ // error first meant the server discarded it and the press sat open forever,
207
+ // holding its cards out of Deploy. That is the opposite of the ordering a
208
+ // kill keeps (refuse cheaply, then claim) and it is because these two
209
+ // lanes settle differently: a kill's settle does not check a lease for the
210
+ // not-found case, and a plan's always does.
211
+ if (!(await claimAgentPlan(id))) return;
212
+ if (tasks.length === 0) {
213
+ // The press named cards the doc no longer has. A real answer, and one the
214
+ // board can explain, rather than a turn that plans nothing.
215
+ await postAgentPlan({ id, error: 'those cards are no longer on the board' });
216
+ return;
217
+ }
218
+
219
+ /**
220
+ * WHICH CLI PLANS. `pickRuntimeFor('consult')`, the same picker every other
221
+ * turn nobody @mentioned already uses — Claude when it is here (the prompts
222
+ * were written against it), otherwise whatever can express the profile.
223
+ *
224
+ * Deliberately NOT the project's runtime order: that order is about AGENTS,
225
+ * which hold a conversation across many turns and whose value is that held
226
+ * context. A planner runs once and reads; it takes what the machine has.
227
+ */
228
+ const rt = pickRuntimeFor('consult');
229
+ if (!rt) {
230
+ await postAgentPlan({ id, error: 'no CLI on this machine can run a read-only turn' });
231
+ return;
232
+ }
233
+
234
+ /**
235
+ * ONE LINE AT A TIME, AT MOST ONE EVERY TWO SECONDS — the throttle the
236
+ * agent turn's narration keeps, for the same reason: a turn emits hundreds
237
+ * of lines and only the latest is ever rendered. Held per RUN rather than
238
+ * in a map keyed by press, because one press is one turn and there is
239
+ * nothing for the clock to outlive.
240
+ */
241
+ let lastPlanBeat = 0;
242
+ const narrate = (line) => {
243
+ const now = Date.now();
244
+ if (now - lastPlanBeat < 2_000) return;
245
+ lastPlanBeat = now;
246
+ void postAgentPlanActivity(id, line);
247
+ };
248
+ /**
249
+ * THE MACHINE'S OWN VOICE, for the moments only this side can see — the
250
+ * CLI process actually starting, and this press waiting its turn for the
251
+ * checkout. Both are FACTS measured here, not a reading of the model's
252
+ * progress, and both are invisible from a browser: a press held behind a
253
+ * ship's writer lock looks exactly like a press whose CLI is thinking.
254
+ *
255
+ * Never dropped by the throttle above — these are moments, not a stream —
256
+ * and they stamp its clock so the next model line does not overwrite one
257
+ * the instant it lands.
258
+ */
259
+ const say = (line) => {
260
+ lastPlanBeat = Date.now();
261
+ void postAgentPlanActivity(id, line);
262
+ };
263
+ const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
264
+ id: String(a?.id ?? ''),
265
+ name: String(a?.name ?? ''),
266
+ status: String(a?.status ?? ''),
267
+ // Belt: `agentChangedFiles` is defensive internally, but it resolves a
268
+ // place first and this whole block sits outside the try below.
269
+ changedFiles: (() => {
270
+ try {
271
+ return agentChangedFiles(a?.placeId);
272
+ } catch {
273
+ return [];
274
+ }
275
+ })(),
276
+ }));
277
+
278
+ let out = '';
279
+ /** REMOVED IN A `finally`. `workChildren` is what `workBusy()` counts, and
280
+ * a leaked entry keeps the daemon permanently "busy" — which blocks every
281
+ * auto-update from that moment on, silently, until a restart. */
282
+ let planChild = null;
283
+ let planTimer = null;
284
+ /**
285
+ * THE PLANNER'S OWN CONVERSATION, so its transcript can be removed.
286
+ *
287
+ * `claude -p` in the CHECKOUT writes `~/.claude/projects/<checkout>/<id>.jsonl`,
288
+ * and the checkout is exactly where the operator runs their own terminal
289
+ * Claude. Left behind, every Deploy press became the newest ENDED session
290
+ * there: it displaced the operator's real conversation from the `+` adopt
291
+ * menu (one row per directory), offered to fork a read-only planner into a
292
+ * build tab, and was what their own `claude --continue` resumed. The
293
+ * pre-review and the skills probe delete theirs for the same reason.
294
+ */
295
+ let planSession = null;
296
+ /** The cap fired: the CLI was still running when this machine stopped it. */
297
+ let wedged = false;
298
+ // Said BEFORE the lock is asked for, because a reader only waits when a
299
+ // writer holds the checkout or is queued ahead of it — which is exactly
300
+ // this condition, read one line before we join the queue.
301
+ const busy = placeLocks.get(REPO_PLACE);
302
+ if (busy && (busy.writing || busy.waiters.some((w) => w.write)))
303
+ say('waiting for the checkout — a ship or another turn on this machine is holding it');
304
+ try {
305
+ await inPlace(REPO_PLACE, false, async () => {
306
+ /**
307
+ * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close`
308
+ * after the kill — the shape the project check's timeout already
309
+ * keeps. A SIGKILLed process whose stdio a grandchild still holds can
310
+ * be slow to emit `close`, or never emit it, and this promise is what
311
+ * holds the claimed press open.
312
+ */
313
+ let stopWaiting = () => {};
314
+ const capped = new Promise((r) => {
315
+ stopWaiting = r;
316
+ });
317
+ const turn = runTurn({
318
+ prompt: AGENT_PLAN_KICKOFF({
319
+ tasks,
320
+ liveAgents,
321
+ agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
322
+ }),
323
+ system: SYSTEM_PLAN,
324
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
325
+ // passed empty, so there is no control plane on this turn at all.
326
+ readOnly: true,
327
+ cwd: repoRoot,
328
+ runtime: rt,
329
+ streamJson: true,
330
+ answerFromResult: true,
331
+ label: c.cyan('[plan]'),
332
+ // The CLI's own tail, forwarded to the press. Same rule as an agent
333
+ // turn's: throttled, overwritten rather than appended, and never
334
+ // awaited by the turn.
335
+ onActivity: (a) => {
336
+ if (a?.label) narrate(String(a.label));
337
+ },
338
+ onInit: (i) => {
339
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
340
+ planSession = i.sessionId.trim();
341
+ },
342
+ onSpawn: (ch) => {
343
+ planChild = ch;
344
+ // No id: a Deploy press is not a task, and the snapshot's per-task
345
+ // rows must not invent one. It still COUNTS against the machine's
346
+ // ceiling — see liveTurnCount.
347
+ workChildren.set(ch, null);
348
+ releaseSlot();
349
+ say(`${RUNTIMES[rt]?.label ?? rt} started on this machine`);
350
+ /**
351
+ * ARMED AT THE SPAWN, not at the claim: time spent waiting for the
352
+ * checkout is not a wedged CLI, and a press that never got to run
353
+ * is what the server's own clock is for.
354
+ *
355
+ * The CHILD, never its group — the rule teardown keeps. A
356
+ * read-only planning turn starts no dev server, so there is
357
+ * nothing behind it worth signalling and everything to lose by
358
+ * signalling somebody else's.
359
+ */
360
+ planTimer = setTimeout(() => {
361
+ wedged = true;
362
+ try {
363
+ ch.kill('SIGKILL');
364
+ } catch {
365
+ /* already gone */
366
+ }
367
+ stopWaiting('');
368
+ }, PLAN_TURN_TIMEOUT_MS);
369
+ planTimer.unref?.();
370
+ },
371
+ });
372
+ out = await Promise.race([turn, capped]);
373
+ });
374
+ } catch (e) {
375
+ await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500) });
376
+ return;
377
+ } finally {
378
+ if (planTimer) clearTimeout(planTimer);
379
+ if (planChild) workChildren.delete(planChild);
380
+ // On EVERY exit, after the kill and on a delay — the transcript is the
381
+ // child's file, and removing it while the child is still dying races a
382
+ // recreate. The pre-review's own shape.
383
+ if (planSession) setTimeout(() => removeProbeTranscript(repoRoot, planSession), 750).unref?.();
384
+ }
385
+
386
+ if (wedged) {
387
+ // NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
388
+ // beneath this one. The machine's own words, because the machine is the
389
+ // only side that knows: the server would have expired this press in
390
+ // another fifteen minutes with a sentence blaming a daemon that had in
391
+ // fact answered.
392
+ await postAgentPlan({
393
+ id,
394
+ error: 'the planning turn ran past fifteen minutes on this machine and was stopped',
395
+ });
396
+ return;
397
+ }
398
+
399
+ const proposal = parseProposal(out);
400
+ if (!proposal) {
401
+ await postAgentPlan({
402
+ id,
403
+ // The CLI's own words when it has any — a turn that explained why it
404
+ // could not plan is far more use than "planning failed".
405
+ error: out.trim()
406
+ ? `the plan did not come back as JSON: ${envScrub(out).slice(0, 300)}`
407
+ : 'the planning turn produced no output — the CLI may be signed out',
408
+ });
409
+ return;
410
+ }
411
+ await postAgentPlan({ id, proposal, sessionRef: repoRoot });
412
+ };
413
+
414
+ const processAgentPlanJobs = (jobs) => {
415
+ if (!Array.isArray(jobs) || jobs.length === 0) return;
416
+ // ONE AT A TIME, and the cap is 1 rather than 5: a planning turn is a real
417
+ // model call, and a machine handed three at once would run three CLIs to
418
+ // answer questions nobody asked in that order.
419
+ for (const job of jobs.slice(0, 1)) {
420
+ const id = String(job?.id || '');
421
+ if (!id || planning.has(id)) continue;
422
+ /**
423
+ * BEFORE THE CLAIM, and that ordering is the whole point: `runAgentPlan`
424
+ * claims the press as its first act, and a claimed press must be settled
425
+ * or it sits open holding its cards out of Deploy. NOT claiming is how
426
+ * this lane declines — the press stays queued, the server offers it
427
+ * again next poll, and nobody is told their Deploy failed.
428
+ */
429
+ const hold = admit('churn');
430
+ if (hold) {
431
+ note(`${c.cyan('plan')} ${c.dim(`— holding off: ${hold.reason}`)}`);
432
+ continue;
433
+ }
434
+ // One press a tick, so this lane cannot burst on its own — but the slot
435
+ // it is about to take has to be visible to the agent-turn lane that runs
436
+ // moments later in the same reconcile. See admission.mjs.
437
+ const releaseSlot = admit.reserve();
438
+ planning.add(id);
439
+ void runAgentPlan(job, releaseSlot).finally(() => {
440
+ releaseSlot();
441
+ planning.delete(id);
442
+ });
443
+ }
444
+ };
445
+
446
+ return { processAgentPlanJobs, planning };
447
+ }