flowviant 0.29.0 → 0.31.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.
@@ -16,11 +16,12 @@ server. There is NO interactive user and NO terminal to ask in. The ONLY way to
16
16
  reach a human is the blocker loop. Never ask the user directly; never wait on stdin.
17
17
 
18
18
  Operate this loop:
19
- 1. Call claim_next_intent. If it returns claimed:false, output exactly ALL_CLEAR on
20
- its own line and stop.
21
- 2. Read the brief. If it has an existing "branch" (a REVISION the human bounced back),
22
- \`git checkout <branch>\` to resume your prior work and address the review feedback in
23
- the description (under " Review feedback —"). Use get_module_files / search_wiki /
19
+ 1. Call claim_next_intent to PICK UP the next task someone @mentioned you on. If it
20
+ returns claimed:false, output exactly ALL_CLEAR on its own line and stop.
21
+ 2. Read the brief, and read its "thread" FIRST that is the task conversation, and the
22
+ newest human message is usually the specific reason you were brought in. If the brief
23
+ has an existing "branch" (a REVISION), \`git checkout <branch>\` to resume your prior
24
+ work and address what the thread asks for. Use get_module_files / search_wiki /
24
25
  list_related_intents for context. Call report_progress as you go.
25
26
  3. If you hit ANYTHING only a human can decide, call report_blocker with a clear
26
27
  question (and options when you can), then call get_blocker_resolution. If it is
@@ -34,51 +35,62 @@ Operate this loop:
34
35
  the merge runs separately.
35
36
  5. Return to step 1.
36
37
 
37
- Keep every change scoped to the claimed intent. If a tool errors, report_progress with
38
- the error, then retry or report_blocker.
38
+ Keep every change scoped to the task you picked up. If a tool errors, report_progress
39
+ with the error, then retry or report_blocker.
39
40
  SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
40
41
  must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
41
42
  by NAME only. Never commit an env file.`;
42
43
 
43
- // Single-task turn (FLEET mode): claim EXACTLY ONE intent, then stop. The daemon
44
+ // Single-task turn (FLEET mode): pick up EXACTLY ONE task, then stop. The daemon
44
45
  // owns the loop so it can reset the worktree + start a fresh conversation per task.
45
46
  export const SYSTEM_SINGLE = `You are a Flowviant build agent running FULLY AUTONOMOUSLY via the "flowviant" MCP
46
47
  server. There is NO interactive user and NO terminal to ask in. The ONLY way to
47
48
  reach a human is the blocker loop. Never ask the user directly; never wait on stdin.
48
49
 
49
50
  Do EXACTLY ONE task this turn:
50
- 1. Call claim_next_intent. If it returns claimed:false, output exactly NOTHING on its
51
- own line and stop. Do NOT retry.
52
- 2. Read the brief. If it has an existing "branch" (a REVISION the human bounced back),
53
- first \`git fetch && git checkout <branch>\` to resume YOUR prior work, and address
54
- the review feedback in the description (under "— Review feedback —"). Otherwise work
55
- from the clean base checkout. Use get_module_files / search_wiki /
51
+ 1. Call claim_next_intent to PICK UP the task someone @mentioned you on. If it returns
52
+ claimed:false, output exactly NOTHING on its own line and stop. Do NOT retry.
53
+ 2. Read the brief, and read its "thread" FIRST that is the task conversation, and the
54
+ newest human message is usually the specific reason you were brought in. If the brief
55
+ has an existing "branch" (a REVISION), first \`git fetch && git checkout <branch>\` to
56
+ resume YOUR prior work and address what the thread asks for. Otherwise work from the
57
+ clean base checkout. Use get_module_files / search_wiki /
56
58
  list_related_intents for context. report_progress as you go.
57
59
  3. If you hit ANYTHING only a human can decide, call report_blocker (with options when
58
60
  you can), then get_blocker_resolution. If unresolved, output exactly
59
61
  BLOCKED:<blockerId> on its own line and STOP. Do NOT guess past a real decision.
60
- 4. Ship: if this is a revision, \`git push\` to the SAME existing branch (the open PR
61
- updates in place) and re-call attach_pr with that same PR URL. Otherwise open ONE
62
- draft PR (git push + \`gh pr create --draft\`) and call attach_pr. Then call complete
63
- with a plain-language summary AND a criteria self-report (index into the brief's
64
- "done when" list + met true/false + a short note) your delivery card in the task
65
- thread. NEVER merge. Then output exactly DONE on its own line and stop.
66
-
67
- Do NOT claim a second intent exactly one per turn. Keep every change scoped to the
68
- claimed intent. If a tool errors, report_progress with the error, then retry or
62
+ 4. Ship this depends on the brief's "placement":
63
+ - placement "patch" (a small, targeted change landing in the owner's own checkout):
64
+ do NOT create a branch, do NOT push, do NOT open a PR. Commit your change with a
65
+ one-line message and STOP there the daemon applies it and the human keeps or
66
+ reverts it. Then call complete with a plain-language summary and the criteria
67
+ self-report.
68
+ - placement "branch" (the default): if this is a revision, \`git push\` to the SAME
69
+ existing branch (the open PR updates in place) and re-call attach_pr with that same
70
+ PR URL. Otherwise create the branch the brief names in "branchName" (\`git checkout
71
+ -b <branchName>\` — use that exact name, do not invent one), push it, open ONE draft
72
+ PR with \`gh pr create --draft\`, and call attach_pr. If the brief has a "baseBranch",
73
+ your worktree is already based on it — target the PR at it (\`--base <baseBranch>\`)
74
+ so the stack stays reviewable. Then call complete with a plain-language summary AND a
75
+ criteria self-report (index into the brief's "done when" list + met true/false + a
76
+ short note) — your delivery card in the task thread.
77
+ NEVER merge. Then output exactly DONE on its own line and stop.
78
+
79
+ Do NOT pick up a second task — exactly one per turn. Keep every change scoped to the
80
+ task you picked up. If a tool errors, report_progress with the error, then retry or
69
81
  report_blocker.
70
82
  SECRETS: env files (.env, .dev.vars, …) hold the team's synced secrets. Their VALUES
71
83
  must NEVER appear in evidence, progress, summaries, commits, or PRs — reference keys
72
84
  by NAME only. Never commit an env file.`;
73
85
 
74
86
  export const KICKOFF =
75
- 'Begin the loop: claim and complete all dispatched Flowviant intents per your instructions.';
87
+ 'Begin the loop: pick up and complete every Flowviant task you have been @mentioned on, per your instructions.';
76
88
  export const RESUME =
77
89
  'Resume. First call get_blocker_resolution for any blocker you reported; if resolved, ' +
78
- 'apply the human’s answer and continue. Otherwise keep claiming and completing ' +
79
- 'dispatched intents per your instructions.';
90
+ 'apply the human’s answer and continue. Otherwise keep picking up and completing ' +
91
+ 'the tasks you were @mentioned on, per your instructions.';
80
92
  export const SINGLE_KICKOFF =
81
- 'Claim and complete exactly ONE dispatched Flowviant intent per your instructions, then stop.';
93
+ 'Pick up and complete exactly ONE Flowviant task per your instructions, then stop.';
82
94
  export const SINGLE_RESUME =
83
95
  'Resume your current task. Call get_blocker_resolution for the blocker you reported; ' +
84
96
  'if resolved, apply the human’s answer and finish this one intent, then stop.';
@@ -259,11 +271,59 @@ Steps:
259
271
  Ground every claim in files you actually read. Be efficient — look only at the
260
272
  changed area, not the whole repo; spend little quota.`;
261
273
 
262
- export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir }) =>
274
+ /**
275
+ * CONSULT — someone is planning and asked a question only the repo can answer.
276
+ *
277
+ * Strictly read-only, and strictly an ANSWER: no edits, no commits, no branch,
278
+ * no MCP tools. A consult is not a dispatch, and the prompt says so out loud
279
+ * because the model is otherwise very willing to start building the thing it was
280
+ * asked about.
281
+ */
282
+ export const SYSTEM_CONSULT = `You are a Flowviant build agent, but you are NOT building anything right now.
283
+ Someone is PLANNING a feature and has asked you a question, because you are the
284
+ one with the actual repository in front of you. The planner they are talking to
285
+ sees only a module manifest and wiki summaries — you see the code.
286
+
287
+ Your entire job is to ANSWER, from files you actually read.
288
+
289
+ RULES:
290
+ - READ ONLY. Do not edit, create or delete any file. No git writes, no commits,
291
+ no branches, no PRs. Nothing you do here leaves a trace in the repo.
292
+ - Do NOT start implementing what they are planning, and do not offer to. If the
293
+ answer is "this needs building", say that and stop — they will dispatch it in
294
+ its own task thread when they are ready.
295
+ - Ground every claim in something you opened. Cite concrete paths
296
+ (\`apps/api/src/middleware/auth.ts\`) so the answer can be checked.
297
+ - If it already EXISTS, say so plainly and point at it — that is the single most
298
+ valuable thing you can tell someone mid-plan, and it is the answer they are
299
+ least expecting.
300
+ - If the repo genuinely does not settle the question, say THAT rather than
301
+ guessing. "I can't tell from the code" is a real answer and a useful one.
302
+ - Be brief: a few sentences, or a short list. This lands in a chat thread that a
303
+ human is reading while they think, not in a document.
304
+
305
+ Write plain Markdown for a person. No preamble, no restating the question.`;
306
+
307
+ export const CONSULT_KICKOFF = ({ planTitle, question, askedByName }) =>
308
+ `${askedByName || 'A teammate'} is planning ${planTitle ? `"${planTitle}"` : 'a feature'} and asked you:\n\n` +
309
+ `${question}\n\n` +
310
+ `Read the repo you are running in and answer. Do not change anything.`;
311
+
312
+ export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
263
313
  `A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
264
314
  `Feature: ${title}\n` +
265
315
  `Grounded commit: ${sha}\n` +
266
316
  `Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
317
+ // The plan's own prediction, made when this work was drafted. Overlapping
318
+ // changed files against each page's frontmatter finds most of what moved, but
319
+ // misses a page whose file list has drifted or that documents a CONCEPT rather
320
+ // than a directory. This is a hint to CHECK, never a list to trust.
321
+ (predictedPages.length
322
+ ? `When this work was planned, these vault pages were expected to go stale.\n` +
323
+ `Treat it as a lead, not a fact — verify each against the code before\n` +
324
+ `editing, and ignore any that turned out to be unaffected:\n` +
325
+ `${predictedPages.map((p) => `- ${p}`).join('\n')}\n\n`
326
+ : '') +
267
327
  `Follow your instructions: update the touched vault pages (and any docs/\n` +
268
328
  `chapter that covers them), append the feature-history entry to log.md,\n` +
269
329
  `then output REGROUND_DONE.`;
@@ -511,3 +571,44 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
511
571
  child.on('close', () => resolve(out));
512
572
  });
513
573
  }
574
+
575
+ /**
576
+ * Plan check — the ground-truth pass.
577
+ *
578
+ * Generation runs on the server, where the repo does not exist. It grounds
579
+ * itself in proxies: a module manifest (names and file counts) and wiki pages
580
+ * (summaries of code). Those are good enough to draft a plan and not good
581
+ * enough to be sure of one — the summary can be stale, the anchors can be
582
+ * guesses, and "you already have this" can be wrong in the direction that
583
+ * wastes a day.
584
+ *
585
+ * This turn runs where the checkout is. It opens the actual files and corrects
586
+ * the plan. It is READ-ONLY by construction: it reports, it never edits.
587
+ */
588
+ export const SYSTEM_PLAN_CHECK = `You are Flowviant's plan checker, running FULLY AUTONOMOUSLY in a real checkout of this repository.
589
+
590
+ You are given a set of PROPOSED tasks that were drafted by a planner with no access to this repo. Your job is to check them against the actual code and report corrections. You are READ-ONLY: read files, search, and report. Do NOT edit, create, delete, commit, or run builds.
591
+
592
+ For each proposed task, verify three things by opening real files:
593
+ 1. ALREADY BUILT — does this already exist? Only say so when you have SEEN the implementation; name the file and symbol. A similar-but-different capability is NOT already built.
594
+ 2. ANCHORS — are the listed module paths the ones this work would actually touch? Correct them to real directories that exist in this repo. Drop invented ones. Add the obvious misses.
595
+ 3. SIZE — is the points estimate plausible given how much code this really involves? Only comment when it is clearly wrong (a "1" that spans six files, an "8" that is a one-line constant).
596
+
597
+ Respond with ONLY a JSON object on the final line, no markdown fence:
598
+ {"checks":[{"id":"<the task id you were given>","alreadyBuilt":false,"evidence":"<file:symbol proving it, when alreadyBuilt>","anchors":["<corrected module paths>"],"points":<number or null>,"note":"<one short sentence, or empty>"}]}
599
+
600
+ Rules:
601
+ - Include an entry ONLY for tasks you actually have a correction or confirmation for. An empty "checks" array is a valid answer meaning "the plan looks right".
602
+ - "anchors" must be paths that EXIST in this repo. Verify before listing.
603
+ - "note" is read by a developer in a chat thread. One sentence, concrete, no preamble.
604
+ - Never invent a file path or symbol. If you could not check something, leave it out.`;
605
+
606
+ export const PLAN_CHECK_KICKOFF = ({ title, intents }) =>
607
+ `Check this plan against the real code.\n\nPLAN: ${title}\n\nPROPOSED TASKS:\n${intents
608
+ .map(
609
+ (i) =>
610
+ `- id: ${i.id}\n title: ${i.title}\n claimed anchors: ${
611
+ i.anchors.length ? i.anchors.join(', ') : '(none)'
612
+ }\n points: ${i.points}`
613
+ )
614
+ .join('\n')}\n\nOpen the files these tasks claim to touch, verify each of the three checks, then output the JSON object on the final line.`;
@@ -43,6 +43,11 @@ function argFlag(name) {
43
43
  return i >= 0 ? process.argv[i + 1] : undefined;
44
44
  }
45
45
 
46
+ /** A bare boolean flag (no value follows it). */
47
+ function hasFlag(name) {
48
+ return process.argv.includes(name);
49
+ }
50
+
46
51
  const API_BASE = process.env.FLOWVIANT_API_URL || 'https://api.flowviant.com/api/v2';
47
52
  export const MCP_URL = process.env.FLOWVIANT_MCP_URL || `${API_BASE}/mcp`;
48
53
  export const FLEET_URL = process.env.FLOWVIANT_FLEET_URL || `${API_BASE}/fleet/agents`;
@@ -73,6 +78,22 @@ export const AUTO_UPDATE = process.env.FLOWVIANT_NO_UPDATE !== '1';
73
78
  // path (one-shot `claude -p` turns) survives behind FLOWVIANT_POLL=1 as the
74
79
  // escape hatch; FLOWVIANT_LIVE=1 is still honored for old scripts.
75
80
  export const LIVE = process.env.FLOWVIANT_POLL !== '1';
81
+ /**
82
+ * Does this machine accept PATCHES — commits cherry-picked straight into your
83
+ * working checkout, with no PR and no review?
84
+ *
85
+ * Patch placement is chosen by a model, and any teammate who @mentions one of
86
+ * your agents can trigger it, so whether it happens at all belongs to whoever
87
+ * owns the checkout. Turning it off does not lose the work: the task falls back
88
+ * to branch placement and arrives as a PR like anything else.
89
+ *
90
+ * On by default — the guard that actually protects you (never touching a file
91
+ * you have uncommitted edits in) is enforced at apply time, and the whole point
92
+ * of patches is to spare you a review cycle for a nine-character diff.
93
+ * `--no-patches` or FLOWVIANT_PATCHES=0 to refuse them.
94
+ */
95
+ export const ALLOW_PATCHES =
96
+ !hasFlag('--no-patches') && process.env.FLOWVIANT_PATCHES !== '0';
76
97
  // Sent on the daemon's own HTTP calls so Cloudflare Bot Fight Mode doesn't 403
77
98
  // them (Node's default UA is treated as a bot). Claude Code sends its own UA.
78
99
  export const USER_AGENT = `flowviant/${VERSION}`;
package/bin/lib/fleet.mjs CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  REFRESH_BEFORE_SECONDS,
24
24
  LIVE,
25
25
  AUTO_UPDATE,
26
+ ALLOW_PATCHES,
26
27
  } from './config.mjs';
27
28
  import { handleVersionSignal } from './update.mjs';
28
29
  import {
@@ -31,11 +32,13 @@ import {
31
32
  repoRootOrDie,
32
33
  detectBaseRef,
33
34
  originSlug,
35
+ baseBranchName,
34
36
  isValidPrUrl,
35
37
  isValidBranch,
36
38
  isSafePathSegment,
37
39
  } from './git.mjs';
38
40
  import { c, LABEL_COLORS, info, note, ok, warn, fail } from './ui.mjs';
41
+ import { revertPatch, withPatchLock } from './patch.mjs';
39
42
  import {
40
43
  sleep,
41
44
  mcpConfigFor,
@@ -48,7 +51,11 @@ import {
48
51
  SYSTEM_WIKI,
49
52
  WIKI_KICKOFF,
50
53
  SYSTEM_REGROUND,
54
+ SYSTEM_PLAN_CHECK,
55
+ PLAN_CHECK_KICKOFF,
51
56
  REGROUND_KICKOFF,
57
+ SYSTEM_CONSULT,
58
+ CONSULT_KICKOFF,
52
59
  } from './claude.mjs';
53
60
  import { runLiveWorker } from './live.mjs';
54
61
  import { reapOrphanPreviews } from './preview.mjs';
@@ -197,6 +204,13 @@ export async function runFleetDaemon() {
197
204
  info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
198
205
  info(`repo · ${repoRoot}`);
199
206
  info(`base · ${baseRef}`);
207
+ // Stated out loud because it is the one setting that lets something else write
208
+ // into the checkout you are sitting in.
209
+ info(
210
+ ALLOW_PATCHES
211
+ ? 'patches· accepted — small changes land in your checkout for Keep/Revert (--no-patches to refuse)'
212
+ : 'patches· refused — everything arrives as a branch + PR'
213
+ );
200
214
  info(`server · ${FLEET_URL}`);
201
215
  console.log('');
202
216
  await preflight({ needGit: true });
@@ -301,6 +315,203 @@ export async function runFleetDaemon() {
301
315
  /* best-effort — the job reappears next poll if this failed */
302
316
  }
303
317
  };
318
+ // Patch reverts: a patch landed straight in this checkout, and a human took it
319
+ // back. The commits are HERE, not on the server, so the reverse-apply happens
320
+ // here too — a revert, never a reset, because the owner has almost certainly
321
+ // worked on top by now. Serialised through the same lock as applies.
322
+ const PATCH_REVERT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/patch-revert-done');
323
+ const reverting = new Set();
324
+ const processPatchRevertJobs = (jobs) => {
325
+ for (const job of jobs ?? []) {
326
+ if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
327
+ if (reverting.has(job.id)) continue;
328
+ reverting.add(job.id);
329
+ (async () => {
330
+ try {
331
+ note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
332
+ const res = await withPatchLock(() =>
333
+ Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
334
+ );
335
+ if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
336
+ else warn(`revert failed for "${job.title}": ${res.error}`);
337
+ // ALWAYS report, success or not. Without this the flag stays set, the
338
+ // roster re-serves the job every poll, and each pass reverts the
339
+ // revert — the change flapping in and out of the owner's tree forever.
340
+ await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
341
+ intentId: job.id,
342
+ ok: res.ok,
343
+ error: res.ok ? undefined : String(res.error ?? 'revert failed'),
344
+ });
345
+ } finally {
346
+ reverting.delete(job.id);
347
+ }
348
+ })();
349
+ }
350
+ };
351
+
352
+ // Plan checks: the ground-truth pass. Generation drafted these against a
353
+ // module manifest and wiki summaries — proxies for the repo. This runs where
354
+ // the checkout is, opens the real files, and reports corrections back into the
355
+ // thread. Read-only by construction; it never edits.
356
+ /**
357
+ * Pull the plan-check JSON off the tail of a Claude turn.
358
+ *
359
+ * The model is told to end with a bare JSON object, but a turn can trail
360
+ * prose, a fence, or a stray newline. Scan backwards for the last balanced
361
+ * object and validate it hard: anything shaped wrong is dropped rather than
362
+ * written into someone's plan. Returns null when nothing usable was found.
363
+ */
364
+ const parsePlanChecks = (out, intents) => {
365
+ const text = String(out ?? '');
366
+ const known = new Set(intents.map((i) => i.id));
367
+ const end = text.lastIndexOf('}');
368
+ if (end === -1) return null;
369
+ // NOTE: `lastIndexOf(x, -1)` returns 0, NOT -1 — the position argument is
370
+ // clamped, so the obvious `start = lastIndexOf('{', start - 1)` loop spins
371
+ // forever once it reaches index 0 and the parse fails. That hangs the
372
+ // daemon's event loop, not just this job. Walk with an explicit stop, and
373
+ // cap the attempts so a pathological turn can't burn the poll cycle either.
374
+ let start = text.lastIndexOf('{', end);
375
+ for (let attempts = 0; start !== -1 && attempts < 200; attempts++) {
376
+ let parsed = null;
377
+ try {
378
+ parsed = JSON.parse(text.slice(start, end + 1));
379
+ } catch {
380
+ /* not a complete object at this offset — step back and retry */
381
+ }
382
+ if (!parsed || !Array.isArray(parsed.checks)) {
383
+ if (start === 0) break;
384
+ start = text.lastIndexOf('{', start - 1);
385
+ continue;
386
+ }
387
+ return parsed.checks
388
+ .filter((ch) => ch && typeof ch.id === 'string' && known.has(ch.id))
389
+ .map((ch) => ({
390
+ id: ch.id,
391
+ alreadyBuilt: ch.alreadyBuilt === true,
392
+ evidence: typeof ch.evidence === 'string' ? ch.evidence.slice(0, 300) : '',
393
+ anchors: Array.isArray(ch.anchors)
394
+ ? ch.anchors.filter((a) => typeof a === 'string' && a.length < 200).slice(0, 6)
395
+ : [],
396
+ points:
397
+ typeof ch.points === 'number' && Number.isFinite(ch.points)
398
+ ? Math.max(0, Math.min(13, Math.round(ch.points)))
399
+ : null,
400
+ note: typeof ch.note === 'string' ? ch.note.slice(0, 400) : '',
401
+ }))
402
+ .slice(0, 30);
403
+ }
404
+ return null;
405
+ };
406
+
407
+ /** A clean detached checkout at base — what "the real code" has to mean for a
408
+ * question about the repo, rather than whatever half-finished state an agent
409
+ * worktree happens to be in. Shared by the plan check and consults. */
410
+ const ensureWikiWorktree = () => {
411
+ if (existsSync(wikiWt)) return;
412
+ try {
413
+ git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
414
+ } catch {
415
+ git(['worktree', 'prune'], repoRoot);
416
+ git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
417
+ }
418
+ };
419
+
420
+ const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
421
+ const checkingPlans = new Set();
422
+ const processPlanCheckJobs = (jobs) => {
423
+ for (const job of jobs ?? []) {
424
+ if (!job || typeof job.id !== 'string' || !Array.isArray(job.intents)) continue;
425
+ if (checkingPlans.has(job.id)) continue;
426
+ if (job.intents.length === 0) continue;
427
+ checkingPlans.add(job.id);
428
+ (async () => {
429
+ try {
430
+ note(`${c.cyan('plan')} ${c.dim(`— checking "${job.title}" against your code…`)}`);
431
+ ensureWikiWorktree();
432
+ const out = await runTurn({
433
+ prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: job.intents }),
434
+ resume: false,
435
+ system: SYSTEM_PLAN_CHECK,
436
+ cwd: wikiWt,
437
+ wikiPerm: true, // read-only file perms — no MCP, no shell writes
438
+ label: c.cyan('[plan]'),
439
+ });
440
+ const checks = parsePlanChecks(out, job.intents);
441
+ if (checks === null) {
442
+ warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
443
+ }
444
+ await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
445
+ intentId: job.id,
446
+ checks: checks ?? [],
447
+ });
448
+ if (checks?.length) {
449
+ ok(`${c.cyan('plan')} ${c.dim(`— ${checks.length} correction${checks.length === 1 ? '' : 's'} for "${job.title}"`)}`);
450
+ } else {
451
+ ok(`${c.cyan('plan')} ${c.dim(`— "${job.title}" checks out against your code`)}`);
452
+ }
453
+ } catch (e) {
454
+ warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
455
+ // Clear the flag anyway — a stuck job would re-run every poll forever.
456
+ await reportMergeOutcome(PLAN_CHECK_DONE_URL, { intentId: job.id, checks: [] });
457
+ } finally {
458
+ checkingPlans.delete(job.id);
459
+ }
460
+ })();
461
+ }
462
+ };
463
+
464
+ // Consults — a planning question aimed at THIS machine, answered by reading
465
+ // the repo. Deliberately the lightest job on the roster: same read-only
466
+ // detached checkout the plan check uses, no MCP, no writes, no run recorded.
467
+ const CONSULT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/consult-done');
468
+ const answering = new Set();
469
+ const processConsultJobs = (jobs) => {
470
+ for (const job of jobs ?? []) {
471
+ if (!job || typeof job.id !== 'string' || !job.question) continue;
472
+ if (answering.has(job.id)) continue;
473
+ answering.add(job.id);
474
+ (async () => {
475
+ try {
476
+ note(`${c.cyan('ask')} ${c.dim(`— ${job.askedByName || 'someone'} asked about "${job.planTitle || 'a plan'}"`)}`);
477
+ ensureWikiWorktree();
478
+ const out = await runTurn({
479
+ prompt: CONSULT_KICKOFF({
480
+ planTitle: job.planTitle,
481
+ question: job.question,
482
+ askedByName: job.askedByName,
483
+ }),
484
+ resume: false,
485
+ system: SYSTEM_CONSULT,
486
+ cwd: wikiWt,
487
+ wikiPerm: true, // read-only file perms — no MCP, no shell writes
488
+ label: c.cyan('[ask]'),
489
+ });
490
+ const answer = (out || '').trim();
491
+ await reportMergeOutcome(CONSULT_DONE_URL, {
492
+ consultId: job.id,
493
+ ok: answer.length > 0,
494
+ // Scrub: an answer can quote config or env-adjacent code.
495
+ answer: envScrub(answer).slice(0, 8000),
496
+ });
497
+ ok(`${c.cyan('ask')} ${c.dim('— answered in the plan thread')}`);
498
+ } catch (e) {
499
+ // Settle it either way. A question that cannot be answered must not
500
+ // re-burn a Claude turn on every poll, and silence would leave the
501
+ // human waiting on a machine that already gave up.
502
+ await reportMergeOutcome(CONSULT_DONE_URL, {
503
+ consultId: job.id,
504
+ ok: false,
505
+ answer: e?.message ?? 'the read failed',
506
+ });
507
+ warn(`consult failed: ${e?.message ?? e}`);
508
+ } finally {
509
+ answering.delete(job.id);
510
+ }
511
+ })();
512
+ }
513
+ };
514
+
304
515
  const processMergeJobs = (jobs) => {
305
516
  for (const job of jobs ?? []) {
306
517
  if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
@@ -323,6 +534,35 @@ export async function runFleetDaemon() {
323
534
  warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
324
535
  return;
325
536
  }
537
+ // STACKED PR: it targets its blocker's branch so the review shows only
538
+ // its own diff. The server holds this job until that blocker merged, so
539
+ // by now the blocker's commits are in the base ref — re-point before
540
+ // squashing, or the change lands in the blocker's branch and never
541
+ // reaches the trunk while the card cheerfully says "Merged".
542
+ if (job.retargetToBase) {
543
+ try {
544
+ // baseBranchName, not baseRef: `gh pr edit --base` needs a branch
545
+ // that exists in the repo, and detectBaseRef hands back a
546
+ // remote-tracking ref (origin/main) that GitHub 422s on.
547
+ execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
548
+ cwd: repoRoot,
549
+ stdio: ['ignore', 'pipe', 'pipe'],
550
+ });
551
+ } catch (e) {
552
+ // Already targeting base is the common no-op; anything else is
553
+ // reported rather than merged into the wrong place.
554
+ const err = e.stderr?.toString?.() || e.message || '';
555
+ if (!/no changes|already/i.test(err)) {
556
+ mergeAttempts.delete(job.id);
557
+ await reportMergeOutcome(MERGE_FAILED_URL, {
558
+ intentId: job.id,
559
+ message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
560
+ });
561
+ warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
562
+ return;
563
+ }
564
+ }
565
+ }
326
566
  try {
327
567
  execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
328
568
  cwd: repoRoot,
@@ -359,7 +599,7 @@ export async function runFleetDaemon() {
359
599
  // Direct enqueue = immediacy; the server's durable regroundJobs list
360
600
  // (created by merge-done above, cleared by our reground-done report)
361
601
  // is the restart-safe backstop — dedup'd here by groundedIntents.
362
- enqueueReground(job.id, job.prUrl, job.title);
602
+ enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
363
603
  } else if (failedReason) {
364
604
  // Report into the thread (server narrates + re-arms the merge
365
605
  // button + notifies) — the job disappears from the roster.
@@ -503,10 +743,20 @@ export async function runFleetDaemon() {
503
743
  wikiQueue.push({ type: 'sweep' });
504
744
  void drainWiki();
505
745
  };
506
- const enqueueReground = (intentId, prUrl, title) => {
746
+ const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
507
747
  if (!intentId || groundedIntents.has(intentId)) return;
508
748
  groundedIntents.add(intentId);
509
- wikiQueue.push({ type: 'reground', intentId, prUrl, title: title || 'a delivered task' });
749
+ wikiQueue.push({
750
+ type: 'reground',
751
+ intentId,
752
+ prUrl,
753
+ title: title || 'a delivered task',
754
+ // What the PLAN thought this would invalidate. A hint, not the truth —
755
+ // the turn still reads the real changed files; this catches pages whose
756
+ // frontmatter file list has drifted, or that document a concept rather
757
+ // than a directory.
758
+ dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
759
+ });
510
760
  void drainWiki();
511
761
  };
512
762
 
@@ -674,7 +924,13 @@ export async function runFleetDaemon() {
674
924
  } else {
675
925
  note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
676
926
  const out = await runTurn({
677
- prompt: REGROUND_KICKOFF({ sha, title: task.title, files, vaultDir }),
927
+ prompt: REGROUND_KICKOFF({
928
+ sha,
929
+ title: task.title,
930
+ files,
931
+ vaultDir,
932
+ predictedPages: task.dirtiesPages ?? [],
933
+ }),
678
934
  resume: false,
679
935
  system: SYSTEM_REGROUND(vaultDir),
680
936
  cwd: wikiWt,
@@ -820,6 +1076,9 @@ export async function runFleetDaemon() {
820
1076
  if (updating) return;
821
1077
  }
822
1078
  processMergeJobs(roster.mergeJobs);
1079
+ processPatchRevertJobs(roster.patchRevertJobs);
1080
+ processPlanCheckJobs(roster.planCheckJobs);
1081
+ processConsultJobs(roster.consultJobs);
823
1082
  processCleanupJobs(roster.cleanupJobs);
824
1083
  const rosterIds = new Set(roster.agents.map((a) => a.agentId));
825
1084
 
@@ -912,7 +1171,7 @@ export async function runFleetDaemon() {
912
1171
  enqueueSweep(roster.codeMapJob);
913
1172
  for (const j of roster.regroundJobs ?? []) {
914
1173
  if (!j || typeof j.intentId !== 'string') continue; // a null element would throw + wedge the loop
915
- enqueueReground(j.intentId, j.prUrl, j.title);
1174
+ enqueueReground(j.intentId, j.prUrl, j.title, j.dirtiesPages);
916
1175
  }
917
1176
  void drainWiki();
918
1177
 
package/bin/lib/git.mjs CHANGED
@@ -6,6 +6,26 @@ export function git(args, cwd) {
6
6
  return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
7
7
  }
8
8
 
9
+ /**
10
+ * Same call, UNTRIMMED — for `-z` (NUL-separated) output, where trimming would
11
+ * eat the final separator and the leading space of a status code.
12
+ *
13
+ * Anything that COMPARES two path lists has to use this. Git's default
14
+ * line-based output quotes and escapes any path that isn't plain ASCII, and it
15
+ * does so inconsistently between commands — so a comparison of `git status`
16
+ * paths against `git diff` paths silently stops matching the moment a filename
17
+ * has an accent in it. For the patch collision check, "silently stops matching"
18
+ * means "overwrites the edits it exists to protect".
19
+ */
20
+ export function gitRaw(args, cwd) {
21
+ return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
22
+ }
23
+
24
+ /** Split NUL-separated git output into entries. */
25
+ export function splitNul(out) {
26
+ return String(out).split('\0').filter(Boolean);
27
+ }
28
+
9
29
  export function repoRootOrDie() {
10
30
  try {
11
31
  return git(['rev-parse', '--show-toplevel'], process.cwd());
@@ -53,6 +73,14 @@ export function isValidBranch(branch, repoRoot, baseRef) {
53
73
  }
54
74
  }
55
75
 
76
+ /** A commit sha from the server, before it reaches `git revert` argv. Server
77
+ * values reaching git are validated here by convention (see isValidBranch,
78
+ * isValidPrUrl) — a revision RANGE ("HEAD~10..HEAD") or a leading-dash option
79
+ * must never pass, whatever the roster says. */
80
+ export function isValidSha(sha) {
81
+ return typeof sha === 'string' && /^[0-9a-f]{7,40}$/.test(sha);
82
+ }
83
+
56
84
  /** A roster agent id used as a filesystem path segment — strict allowlist so
57
85
  * it can't traverse (`..`, `/`) out of the worktrees dir. */
58
86
  export function isSafePathSegment(id) {
@@ -72,6 +100,19 @@ export function detectBaseRef(repoRoot) {
72
100
  }
73
101
  }
74
102
 
103
+ /**
104
+ * The BRANCH NAME behind a base ref.
105
+ *
106
+ * `detectBaseRef` returns a remote-tracking ref (`origin/main`) because that is
107
+ * what you check out and reset against. GitHub's API has never heard of it: a PR
108
+ * base must be a branch that exists in the repo, so `gh pr edit --base
109
+ * origin/main` 422s every time. Anything that talks to the provider needs this
110
+ * form, not the ref.
111
+ */
112
+ export function baseBranchName(baseRef) {
113
+ return String(baseRef || '').replace(/^origin\//, '') || 'main';
114
+ }
115
+
75
116
  export function resetWorktree(wt, baseRef) {
76
117
  try {
77
118
  git(['fetch', 'origin', '--quiet'], wt);