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.
package/bin/lib/live.mjs CHANGED
@@ -29,10 +29,12 @@ import {
29
29
  FLEET_URL,
30
30
  FLEET_TOKEN,
31
31
  USER_AGENT,
32
+ ALLOW_PATCHES,
32
33
  } from './config.mjs';
33
34
  import { c, info, ok, warn } from './ui.mjs';
34
35
  import { sleep } from './claude.mjs';
35
36
  import { git, resetWorktree, isValidBranch } from './git.mjs';
37
+ import { applyPatch, fileDiffs, ownerCurrentBranch, withPatchLock } from './patch.mjs';
36
38
  import { loadPreviewConfig, startPreview } from './preview.mjs';
37
39
  import { materializeInto, scrub as envScrub } from './env.mjs';
38
40
 
@@ -146,8 +148,17 @@ This IS your handover, so make it tangible; match the evidence to what you built
146
148
  works — write an e2e/integration test that DRIVES the flow (fill form → submit
147
149
  → assert the post-success state), attach its test_output, AND screenshot the
148
150
  end state. Never let a single static screenshot stand in for a flow.
149
- When the work is done: open ONE draft PR (git push +
150
- gh pr create --draft), call attach_pr, then call complete with a plain-language
151
+ When the work is done, check the brief's "placement" FIRST.
152
+ If placement is "patch": do NOT create a branch, do NOT push, do NOT open a PR.
153
+ Commit your change in this worktree with a one-line message and stop there — the
154
+ daemon carries it into the owner's own checkout and they keep or revert it. Then
155
+ call complete with the summary + criteria self-report as normal.
156
+ Otherwise (placement "branch", the default): create the branch named in the
157
+ brief's "branchName" (git checkout -b <branchName> — use that exact name, do not
158
+ invent one), open ONE draft PR (git push +
159
+ gh pr create --draft; if the brief has a "baseBranch", target it with
160
+ --base <baseBranch> so the stack stays reviewable), call attach_pr, then call
161
+ complete with a plain-language
151
162
  summary of what you built AND a criteria self-report (index into the brief's
152
163
  "done when" list + met true/false + a short note per item). That summary +
153
164
  self-report becomes your DELIVERY CARD in the task thread — it's what the team
@@ -161,6 +172,35 @@ questions, delivery summaries, commits, or PRs — reference keys by NAME only
161
172
  (e.g. "set STRIPE_KEY"). Never screenshot a terminal or page that displays a
162
173
  credential, and never commit an env file.`;
163
174
 
175
+ /** The brief minus the parts rendered as prose below (conversations, the ask). */
176
+ function briefWithoutThread(brief) {
177
+ const {
178
+ thread: _thread,
179
+ lastMessageId: _lastMessageId,
180
+ plan: _plan,
181
+ asked: _asked,
182
+ ...rest
183
+ } = brief ?? {};
184
+ return rest;
185
+ }
186
+
187
+ /** The plan this task was carved out of, when there was one. A slice cannot
188
+ * reconstruct WHY it was cut this way from its own spec. */
189
+ function planContext(brief) {
190
+ const plan = brief?.plan;
191
+ if (!plan) return [];
192
+ const turns = (plan.recentTurns ?? [])
193
+ .map((m) => `${m.authorName || m.role}: ${m.content}`)
194
+ .join('\n');
195
+ return [
196
+ ``,
197
+ `This task is ONE SLICE of a larger plan: "${plan.title || 'untitled plan'}".`,
198
+ plan.description ? `The plan:\n${plan.description}` : '',
199
+ turns ? `How the team was talking about it, most recent last:\n${turns}` : '',
200
+ `Build only YOUR slice — the rest is there so its shape makes sense.`,
201
+ ].filter(Boolean);
202
+ }
203
+
164
204
  function seedPrompt(runId, brief, transcript, resumedInPlace) {
165
205
  return [
166
206
  `Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
@@ -168,15 +208,35 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
168
208
  ? `You are RESUMING after a daemon restart: your worktree still contains your own uncommitted work from before the interruption. Run \`git status\` and \`git diff\` first, take stock, and CONTINUE from there — do not start over.`
169
209
  : brief?.branch
170
210
  ? `This is a REVISION — your prior branch "${brief.branch}" is checked out; address the review feedback and push to the SAME branch (the PR updates in place).`
171
- : `Start from the clean base checkout and open a fresh draft PR when done.`,
211
+ : brief?.placement === 'patch'
212
+ ? `This is a PATCH: commit your change in this worktree and STOP — no branch, no push, no PR. The daemon lands it in the owner's checkout.`
213
+ : `Start from the clean base checkout. Create the branch named in the brief ("${brief?.branchName ?? 'flowviant/…'}") and open a fresh draft PR when done.`,
172
214
  ``,
173
215
  `Task brief:`,
174
- JSON.stringify(brief ?? {}, null, 2),
216
+ // The conversation is rendered below as readable turns, not dumped twice as
217
+ // JSON — it is the longest thing in the brief and the least useful as data.
218
+ JSON.stringify(briefWithoutThread(brief), null, 2),
219
+ ...(brief?.asked
220
+ ? [
221
+ ``,
222
+ `What the human originally asked for, in their words:`,
223
+ ` "${brief.asked}"`,
224
+ `The specification above is someone's reading of that sentence, written`,
225
+ `without access to the repo. Where the two disagree, this is the one with`,
226
+ `a person behind it — say so rather than quietly picking one.`,
227
+ ]
228
+ : []),
229
+ ...planContext(brief),
175
230
  ...(transcript
176
- ? [``, `Conversation so far (you may be resuming — pick up where this left off):`, transcript]
231
+ ? [
232
+ ``,
233
+ `The task conversation — what the team actually said, oldest first. The`,
234
+ `newest human message is usually why you were brought in:`,
235
+ transcript,
236
+ ]
177
237
  : []),
178
238
  ``,
179
- `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it — a real screenshot for UI (run the dev server, then \`flowviant shot <url> --out shot.png\`), or test output / a request-response / a data sample for backend, so it's reviewable without running anything; 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.`,
239
+ `${transcript ? 'Continue' : 'Begin'}. Post a short plan first as a Markdown list (one numbered line per step), then: report_progress as you go; attach_evidence for each "done when" criterion as you satisfy it — a real screenshot for UI (run the dev server, then \`flowviant shot <url> --out shot.png\`), or test output / a request-response / a data sample for backend, so it's reviewable without running anything; report_blocker + stop if you hit a human decision; then finish per the brief's "placement" (patch: commit only, no PR; branch: draft PR + attach_pr) and call complete (summary + criteria self-report — your delivery card).`,
180
240
  ].join('\n');
181
241
  }
182
242
 
@@ -371,12 +431,120 @@ async function parkUntilReset(resetAt, { mcpUrl, getToken, runId, isAlive }) {
371
431
  }
372
432
  }
373
433
 
374
- export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resumeIntentId, onChild }) {
434
+ /**
435
+ * Carry a completed patch into the owner's checkout, then narrate what happened
436
+ * in the thread.
437
+ *
438
+ * Serialised through withPatchLock so two agents can never write the tree at
439
+ * once, and refused outright when the owner is editing the same files — a
440
+ * collision becomes a blocker for a human, never a silent overwrite. Failure to
441
+ * land is reported honestly rather than being folded into a successful-looking
442
+ * delivery: the human must know the change is NOT in their tree.
443
+ */
444
+ /** Where the patch base is remembered — inside the worktree's git dir, so
445
+ * `git clean` can't take it (same reasoning as the task marker). */
446
+ function patchBaseFile(cwd) {
447
+ return join(git(['rev-parse', '--absolute-git-dir'], cwd), 'flowviant-patch-base');
448
+ }
449
+ function writePatchBase(cwd, branch) {
450
+ try {
451
+ writeFileSync(patchBaseFile(cwd), branch ?? '', 'utf8');
452
+ } catch {
453
+ /* best effort */
454
+ }
455
+ }
456
+ function readPatchBase(cwd) {
457
+ try {
458
+ const v = readFileSync(patchBaseFile(cwd), 'utf8').trim();
459
+ return v || null;
460
+ } catch {
461
+ return null;
462
+ }
463
+ }
464
+
465
+ async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef }) {
466
+ // Everything here runs AFTER the agent called `complete`, which finalizes the
467
+ // run server-side. stream_turn and report_blocker are active-run gated, so
468
+ // they would be silently rejected — report_patch is deliberately not, and the
469
+ // server does the narrating.
470
+ const report = (body) =>
471
+ mcpCall(mcpUrl, token, 'report_patch', { runId, ...body }).catch(() => {});
472
+
473
+ if (!repoRoot) {
474
+ await report({ shas: [], ok: false, reason: 'this daemon has no main checkout to land it in' });
475
+ return;
476
+ }
477
+
478
+ // Computed from the AGENT's worktree, so it is the same set of hunks whether
479
+ // or not the cherry-pick lands. A declined patch still deserves to show what
480
+ // it would have done — that's what the human needs to unblock it.
481
+ // Where the agent's commits start. Normally the owner's branch we mirrored;
482
+ // when we could NOT mirror it (they were in a detached HEAD, or the fetch
483
+ // failed) the worktree was reset to base instead, so that is the honest
484
+ // starting point. Diffing against 'HEAD' here silently produced an empty range
485
+ // and a "the agent committed nothing" refusal over real work.
486
+ const commitsFrom = patchBase ?? baseRef ?? 'HEAD';
487
+ let diffs = [];
488
+ try {
489
+ diffs = fileDiffs(cwd, commitsFrom);
490
+ } catch {
491
+ /* evidence is best-effort; never block landing on it */
492
+ }
493
+
494
+ const res = await withPatchLock(() =>
495
+ Promise.resolve(applyPatch({ repoRoot, cwd, basedOnBranch: patchBase, commitsFrom }))
496
+ );
497
+
498
+ if (res.ok) {
499
+ await report({ shas: res.shas, files: res.files, diffs, ok: true });
500
+ ok(`${c.cyan('patch')} ${c.dim(`— applied ${res.files.length} file(s) in your checkout`)}`);
501
+ return;
502
+ }
503
+
504
+ const reason =
505
+ res.reason === 'conflict'
506
+ ? `you have uncommitted edits in ${res.paths.join(', ')}`
507
+ : res.reason === 'branch_moved'
508
+ ? `you switched from ${res.expected} to ${res.actual ?? 'a detached HEAD'} mid-run`
509
+ : res.reason === 'no_commits'
510
+ ? 'the agent committed nothing to apply'
511
+ : (res.error ?? 'the cherry-pick failed');
512
+
513
+ // A rollback that itself failed leaves commits in their history — never
514
+ // report that as "unchanged".
515
+ if (res.partiallyApplied) {
516
+ await report({
517
+ shas: res.appliedShas ?? [],
518
+ diffs,
519
+ ok: false,
520
+ reason: `${reason}. Some commits could not be rolled back and are still in your history`,
521
+ });
522
+ warn(`patch partially applied for intent ${intentId} — rollback failed; commits remain`);
523
+ return;
524
+ }
525
+
526
+ // Diffs go up even on a refusal: "you have uncommitted edits in X" is only
527
+ // actionable if you can see what the agent wanted to put there.
528
+ await report({ shas: [], diffs, ok: false, reason });
529
+ warn(`patch not applied: ${reason}`);
530
+ }
531
+
532
+ export async function runLiveTask({
533
+ mcpUrl,
534
+ token,
535
+ cwd,
536
+ baseRef,
537
+ repoRoot,
538
+ isAlive,
539
+ resumeIntentId,
540
+ onChild,
541
+ }) {
375
542
  const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
376
543
  if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
377
544
  const { runId, intentId } = claim;
378
545
  const brief = claim.brief ?? {};
379
546
  const title = brief.title ?? 'a task';
547
+
380
548
  // Re-claiming the SAME intent this worker was just working — either this
381
549
  // daemon's own memory (parked on a blocker, now resuming) or the persistent
382
550
  // task marker (the daemon restarted mid-task). Its worktree holds hours of
@@ -384,6 +552,41 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
384
552
  const resuming = !!resumeIntentId && intentId === resumeIntentId;
385
553
  const resumedInPlace = !resuming && readTaskMarker(cwd) === intentId;
386
554
 
555
+ // CONSENT. A patch writes commits into the working checkout of whoever runs
556
+ // this daemon — chosen by a model, and triggerable by any teammate who
557
+ // @mentions one of your agents. Whether that is allowed at all belongs to the
558
+ // person whose disk it is, so `--no-patches` turns it into an ordinary branch
559
+ // + PR. The work is never refused, only routed the long way round, and the
560
+ // thread is told so nobody is left wondering where their Keep/Revert card is.
561
+ //
562
+ // Applies at PICKUP only. A patch run already underway keeps its placement:
563
+ // its worktree is based on the owner's current branch, so converting it to a
564
+ // PR mid-flight would open one whose diff carries the owner's unrelated
565
+ // commits — and resetting to base instead would throw away the agent's work.
566
+ // The setting refuses new patches; it does not retroactively rewrite consent
567
+ // that was already given when the task was picked up.
568
+ if (!ALLOW_PATCHES && brief.placement === 'patch' && !resuming && !resumedInPlace) {
569
+ brief.placement = 'branch';
570
+ info(`${c.dim('patch declined by this machine (--no-patches) — building a PR instead')}`);
571
+ await mcpCall(mcpUrl, token, 'report_progress', {
572
+ runId,
573
+ kind: 'status',
574
+ message:
575
+ 'This machine does not accept patches, so this is going up as a branch + PR ' +
576
+ 'instead of landing in the checkout directly.',
577
+ }).catch(() => {});
578
+ }
579
+
580
+ // Placement decides where the work lands: its own branch + PR (the default),
581
+ // or a patch cherry-picked straight into the owner's checkout.
582
+ const isPatch = brief.placement === 'patch';
583
+ // Persisted next to the task marker: a patch run that PARKS (rate limit, a
584
+ // blocker) or survives a daemon restart resumes without re-entering the
585
+ // checkout branch, and an in-memory base would be null by the time the
586
+ // cherry-pick runs — applyPatch would diff HEAD..HEAD and report no_commits,
587
+ // silently dropping the work.
588
+ let patchBase = isPatch ? readPatchBase(cwd) : null;
589
+
387
590
  // Revision resumes its PR branch; a genuinely fresh task gets a clean base
388
591
  // checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
389
592
  // The branch is server-supplied — validate it's a well-formed non-base ref
@@ -396,8 +599,50 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
396
599
  } catch {
397
600
  if (!resuming && !resumedInPlace) resetWorktree(cwd, baseRef);
398
601
  }
602
+ } else if (isPatch && !resuming && !resumedInPlace) {
603
+ // PATCH placement: base off the branch the human is ACTUALLY on, so the
604
+ // change lands on their work rather than on main. Nothing is pushed and no
605
+ // PR is opened — the daemon cherry-picks the result across at the end.
606
+ patchBase = repoRoot ? ownerCurrentBranch(repoRoot) : null;
607
+ try {
608
+ if (!patchBase) throw new Error('owner is in a detached HEAD');
609
+ git(['fetch', 'origin', '--quiet'], cwd);
610
+ git(['checkout', '--detach', patchBase], cwd);
611
+ git(['reset', '--hard', patchBase], cwd);
612
+ git(['clean', '-fd'], cwd);
613
+ } catch {
614
+ // Can't mirror the owner's tree — fall back to a normal base checkout and
615
+ // let the apply step decline rather than landing something unexpected.
616
+ patchBase = null;
617
+ resetWorktree(cwd, baseRef);
618
+ }
619
+ writePatchBase(cwd, patchBase);
399
620
  } else if (!resuming && !resumedInPlace) {
400
- resetWorktree(cwd, baseRef);
621
+ // STACKING (0.29.x): when the collision pass sequenced this intent behind
622
+ // one that shares its code, the server sends the blocker's branch as
623
+ // `baseBranch`. Basing off it means this agent sees that work immediately
624
+ // instead of waiting for a merge — the shared-checkout benefit, without a
625
+ // shared checkout. Same validation as `branch`: a server-supplied ref is
626
+ // never handed to git unchecked. Anything unusable falls back to the base,
627
+ // which is exactly the pre-stacking behaviour.
628
+ const stackOn =
629
+ brief.baseBranch && isValidBranch(brief.baseBranch, cwd, baseRef)
630
+ ? brief.baseBranch
631
+ : null;
632
+ let stacked = false;
633
+ if (stackOn) {
634
+ try {
635
+ git(['fetch', 'origin', '--quiet'], cwd);
636
+ git(['checkout', '--detach', `origin/${stackOn}`], cwd);
637
+ git(['reset', '--hard', `origin/${stackOn}`], cwd);
638
+ git(['clean', '-fd'], cwd);
639
+ stacked = true;
640
+ } catch {
641
+ // The blocker hasn't pushed yet — the wave ordering is what stops this
642
+ // being dispatched early, so falling back to base is safe, not wrong.
643
+ }
644
+ }
645
+ if (!stacked) resetWorktree(cwd, baseRef);
401
646
  }
402
647
  materializeInto(cwd); // resets wipe the synced env files — rewrite them
403
648
  writeTaskMarker(cwd, intentId);
@@ -421,15 +666,22 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
421
666
  delete env.ANTHROPIC_AUTH_TOKEN;
422
667
  delete env.ANTHROPIC_BASE_URL;
423
668
 
424
- // Prior channel transcript present when resuming a parked/re-claimed task;
425
- // seed it so a fresh session picks up where the conversation left off. afterId
426
- // starts at the last existing message so we never re-inject old ones as "new".
427
- const prior = await mcpCall(mcpUrl, token, 'poll_channel', { runId }).catch(() => null);
428
- const priorMsgs = prior?.messages ?? [];
669
+ // The conversation arrives WITH the brief (0.30.0) the claim already read
670
+ // it, so asking again over poll_channel was a round-trip that told us nothing
671
+ // new. Older servers don't send it; fall back so a daemon ahead of the server
672
+ // still resumes with its transcript instead of silently starting cold.
673
+ let priorMsgs = brief.thread ?? null;
674
+ if (!priorMsgs) {
675
+ const prior = await mcpCall(mcpUrl, token, 'poll_channel', { runId }).catch(() => null);
676
+ priorMsgs = prior?.messages ?? [];
677
+ }
429
678
  const transcript = priorMsgs
430
679
  .map((m) => `${m.authorName || m.role}: ${m.content}`)
431
680
  .join('\n');
432
- let afterId = priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null;
681
+ // Where to resume polling from, so nothing already in the seed is re-injected
682
+ // as if it just arrived.
683
+ let afterId =
684
+ brief.lastMessageId ?? (priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null);
433
685
 
434
686
  const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
435
687
  const session = query({
@@ -547,6 +799,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
547
799
  // attempt's dirty files).
548
800
  if (completed) {
549
801
  clearTaskMarker(cwd);
802
+ if (isPatch) {
803
+ await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
804
+ }
550
805
  return { outcome: 'done', title, intentId };
551
806
  }
552
807
 
@@ -607,7 +862,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
607
862
  // Idle turn with no completion — nudge a couple of times, then stop.
608
863
  if (nudges < 2) {
609
864
  nudges++;
610
- input.push('Continue until the task is complete: open a draft PR and call complete, or report a blocker.');
865
+ input.push(
866
+ isPatch
867
+ ? 'Continue until the task is complete: commit your change (no branch, no push, no PR) and call complete, or report a blocker.'
868
+ : 'Continue until the task is complete: open a draft PR and call complete, or report a blocker.'
869
+ );
611
870
  continue;
612
871
  }
613
872
  return { outcome: 'stalled', title, intentId };
@@ -823,6 +1082,7 @@ export async function runLiveWorker({
823
1082
  token,
824
1083
  cwd,
825
1084
  baseRef,
1085
+ repoRoot,
826
1086
  isAlive,
827
1087
  resumeIntentId: lastIntentId,
828
1088
  onChild,
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Patch placement — landing a small change in the OWNER's own checkout.
3
+ *
4
+ * The motivating case: a teammate wants the login button to read "Sign in"
5
+ * while you are mid-work on auth. A clone → branch → push → PR → review → merge
6
+ * → pull cycle for a nine-character diff is absurd, and it blocks THEM on YOU.
7
+ *
8
+ * The naive version of this — let their agent write into your working directory
9
+ * — is the one thing we will not do. Two writers in one directory produce silent
10
+ * lost edits: the agent reads a file, thinks for forty seconds, and writes it
11
+ * back over the change you made in between. No conflict marker, nothing to
12
+ * resolve, and you find out three tasks later.
13
+ *
14
+ * So the agent still works in its OWN worktree, branched off your current
15
+ * branch, and the daemon carries the result across with a cherry-pick:
16
+ *
17
+ * 1. the agent commits in its worktree (no push, no PR)
18
+ * 2. we check the files it touched against YOUR uncommitted edits
19
+ * 3. only if they are disjoint do we cherry-pick into your checkout
20
+ *
21
+ * Step 2 is the whole safety argument. A collision is reported as a blocker for
22
+ * a human to sort out — never resolved by guessing, and never applied anyway.
23
+ *
24
+ * One patch at a time per daemon: `withPatchLock` serialises every apply in this
25
+ * process, so two agents can't race to write your tree. (Two daemons on one repo
26
+ * would still race — nothing in the daemon guards that today.)
27
+ */
28
+
29
+ import { git, gitRaw, splitNul, isValidSha } from './git.mjs';
30
+
31
+ /** Serialises applies within this process. */
32
+ let patchChain = Promise.resolve();
33
+
34
+ export function withPatchLock(fn) {
35
+ const run = patchChain.then(fn, fn);
36
+ // Keep the chain alive regardless of outcome, but don't swallow the result.
37
+ patchChain = run.then(
38
+ () => {},
39
+ () => {}
40
+ );
41
+ return run;
42
+ }
43
+
44
+ /** The branch the human is actually on, or null in a detached head. */
45
+ export function ownerCurrentBranch(repoRoot) {
46
+ try {
47
+ const name = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot);
48
+ return name && name !== 'HEAD' ? name : null;
49
+ } catch {
50
+ return null;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Repo-relative paths with uncommitted changes in the owner's checkout.
56
+ *
57
+ * `-z` on purpose. The line-based form quotes any path that isn't plain ASCII
58
+ * ("src/caf\303\251.ts") while `git diff --name-only` renders the same file
59
+ * differently — so the collision check, which is the entire safety argument for
60
+ * patch placement, quietly stopped matching for anyone with an accent in a
61
+ * filename and let the cherry-pick land on top of their edits. NUL-separated
62
+ * output is verbatim on both sides.
63
+ */
64
+ export function dirtyPaths(repoRoot) {
65
+ let out = '';
66
+ try {
67
+ out = gitRaw(['status', '--porcelain', '-z'], repoRoot);
68
+ } catch {
69
+ return [];
70
+ }
71
+ // Porcelain -z is "XY path\0", and a RENAME is "R new\0old\0" — the extra
72
+ // entry is the source, which we skip: the destination is what gets clobbered.
73
+ const entries = splitNul(out);
74
+ const paths = [];
75
+ for (let i = 0; i < entries.length; i++) {
76
+ const entry = entries[i];
77
+ // "XY " — two status columns and a space — then the path, verbatim.
78
+ if (entry.length < 4) continue;
79
+ const code = entry.slice(0, 2);
80
+ paths.push(entry.slice(3));
81
+ if (code.includes('R') || code.includes('C')) i += 1; // consume the source
82
+ }
83
+ return paths;
84
+ }
85
+
86
+ /** Commits the agent made on top of `base`, oldest first. */
87
+ export function commitsSince(cwd, base) {
88
+ try {
89
+ const out = git(['rev-list', '--reverse', `${base}..HEAD`], cwd);
90
+ return out.split('\n').map((l) => l.trim()).filter(Boolean);
91
+ } catch {
92
+ return [];
93
+ }
94
+ }
95
+
96
+ /** Files those commits touch. `-z` to match dirtyPaths byte for byte. */
97
+ export function filesChanged(cwd, base) {
98
+ try {
99
+ return splitNul(gitRaw(['diff', '--name-only', '-z', `${base}..HEAD`], cwd));
100
+ } catch {
101
+ return [];
102
+ }
103
+ }
104
+
105
+ // A patch never reaches GitHub, so the server has no provider to read a diff
106
+ // back from — these commits exist only here. Without carrying it across, the
107
+ // delivery card asks the human to Keep or Revert on the agent's word alone,
108
+ // which is the review we skipped the PR to avoid needing.
109
+ //
110
+ // Capped because it crosses the wire and lands in a row read on every card
111
+ // render: 40 files, 24KB of hunks each. Truncation is stated in the patch text
112
+ // rather than silently trimmed — "no diff shown" is honest, a diff that lies
113
+ // about its own extent is not.
114
+ const MAX_DIFF_FILES = 40;
115
+ const MAX_PATCH_BYTES = 24_000;
116
+
117
+ const DIFF_STATUS = { A: 'added', D: 'removed', M: 'modified' };
118
+
119
+ /**
120
+ * The per-file unified diffs for the agent's commits, in the same shape the PR
121
+ * path returns so the delivery card renders both through one component.
122
+ *
123
+ * Rename detection is deliberately OFF (no -M): with it on, `--numstat` writes
124
+ * the path as `dir/{old => new}.ts`, which no longer matches anything you can
125
+ * pass back to `git diff -- <path>`. A rename showing up as a delete plus an add
126
+ * is a slightly longer diff and a correct one.
127
+ */
128
+ export function fileDiffs(cwd, base) {
129
+ let numstat = '';
130
+ let names = '';
131
+ try {
132
+ numstat = git(['diff', '--numstat', '--no-renames', `${base}..HEAD`], cwd);
133
+ names = git(['diff', '--name-status', '--no-renames', `${base}..HEAD`], cwd);
134
+ } catch {
135
+ return [];
136
+ }
137
+
138
+ const statusOf = new Map();
139
+ for (const line of names.split('\n')) {
140
+ const [letter, path] = line.split('\t');
141
+ if (!letter || !path) continue;
142
+ statusOf.set(path.trim(), letter.trim().charAt(0));
143
+ }
144
+
145
+ const out = [];
146
+ for (const line of numstat.split('\n')) {
147
+ if (out.length >= MAX_DIFF_FILES) break;
148
+ const m = /^(\d+|-)\t(\d+|-)\t(.+)$/.exec(line.replace(/\n$/, ''));
149
+ if (!m) continue;
150
+ const path = m[3].trim();
151
+ // A "-" count means binary — git has no textual hunks to give us.
152
+ const binary = m[1] === '-' || m[2] === '-';
153
+
154
+ let patch = null;
155
+ if (!binary) {
156
+ try {
157
+ const full = git(['diff', `${base}..HEAD`, '--', path], cwd);
158
+ // Drop git's own "diff --git a/… b/…" preamble; the card shows the path.
159
+ const at = full.indexOf('@@');
160
+ const hunks = at === -1 ? full : full.slice(at);
161
+ patch =
162
+ hunks.length > MAX_PATCH_BYTES
163
+ ? `${hunks.slice(0, MAX_PATCH_BYTES)}\n… diff truncated (${hunks.length} bytes)`
164
+ : hunks;
165
+ } catch {
166
+ patch = null;
167
+ }
168
+ }
169
+
170
+ out.push({
171
+ path,
172
+ status: DIFF_STATUS[statusOf.get(path)] ?? 'modified',
173
+ additions: binary ? 0 : Number(m[1]),
174
+ deletions: binary ? 0 : Number(m[2]),
175
+ patch,
176
+ });
177
+ }
178
+ return out;
179
+ }
180
+
181
+ /**
182
+ * Carry the agent's commits into the owner's checkout.
183
+ *
184
+ * Returns one of:
185
+ * { ok: true, shas, files } — applied
186
+ * { ok: false, reason: 'no_commits' } — the agent committed nothing
187
+ * { ok: false, reason: 'branch_moved' } — the owner switched branches mid-run
188
+ * { ok: false, reason: 'conflict', paths } — the owner is editing those files
189
+ * { ok: false, reason: 'apply_failed', error }
190
+ *
191
+ * Never leaves a half-applied state: a failed cherry-pick is aborted and the
192
+ * already-applied commits are rolled back, so the owner's tree is either fully
193
+ * patched or untouched.
194
+ */
195
+ export function applyPatch({ repoRoot, cwd, basedOnBranch, commitsFrom }) {
196
+ // NB: uses revertPatch (declared below — hoisted) for its rollback path.
197
+ //
198
+ // Two different refs, and conflating them lost work. `basedOnBranch` is the
199
+ // owner's branch, used ONLY to check they haven't moved since we mirrored it;
200
+ // it is null when we couldn't mirror it at all (detached HEAD, a failed
201
+ // fetch). `commitsFrom` is where the agent's commits start, which in that case
202
+ // is the base ref the worktree actually got reset to. Defaulting the diff to
203
+ // 'HEAD' meant HEAD..HEAD — an empty range reported as "the agent committed
204
+ // nothing", which is a lie about work that is sitting right there.
205
+ const base = commitsFrom ?? basedOnBranch ?? 'HEAD';
206
+ const shas = commitsSince(cwd, base);
207
+ if (shas.length === 0) return { ok: false, reason: 'no_commits' };
208
+
209
+ // The tree we branched from must still be the tree we're landing in.
210
+ const current = ownerCurrentBranch(repoRoot);
211
+ if (basedOnBranch && current !== basedOnBranch) {
212
+ return { ok: false, reason: 'branch_moved', expected: basedOnBranch, actual: current };
213
+ }
214
+
215
+ const files = filesChanged(cwd, base);
216
+ const dirty = new Set(dirtyPaths(repoRoot));
217
+ const collisions = files.filter((f) => dirty.has(f));
218
+ if (collisions.length > 0) {
219
+ return { ok: false, reason: 'conflict', paths: collisions };
220
+ }
221
+
222
+ // Make the agent's commits reachable from the main checkout. Worktrees of the
223
+ // same repo share an object store, so this needs no network and no remote.
224
+ const applied = [];
225
+ try {
226
+ for (const sha of shas) {
227
+ git(['cherry-pick', '--allow-empty', '-x', sha], repoRoot);
228
+ applied.push(sha);
229
+ }
230
+ } catch (e) {
231
+ try {
232
+ git(['cherry-pick', '--abort'], repoRoot);
233
+ } catch {
234
+ /* nothing in progress */
235
+ }
236
+ let rolledBack = true;
237
+ if (applied.length > 0) {
238
+ // Roll back the ones that did land — by REVERTING them, never by
239
+ // resetting. `git reset --hard HEAD~n` would restore every tracked file
240
+ // in the repo, silently destroying the owner's uncommitted work in files
241
+ // this patch never touched. The pre-flight guard above only clears the
242
+ // patch's OWN file set, so a reset here is unbounded damage for a bounded
243
+ // mistake. A revert touches only the files in those commits.
244
+ const rev = revertPatch({ repoRoot, shas: applied });
245
+ rolledBack = rev.ok;
246
+ }
247
+ return {
248
+ ok: false,
249
+ reason: 'apply_failed',
250
+ error: e?.message ?? String(e),
251
+ // The caller must NOT claim "your tree is untouched" when part of the
252
+ // patch is still sitting in the owner's history.
253
+ partiallyApplied: !rolledBack,
254
+ appliedShas: rolledBack ? [] : applied,
255
+ };
256
+ }
257
+
258
+ // The landed shas differ from the source shas (cherry-pick rewrites them);
259
+ // report the ones now in the owner's history, since Revert acts on those.
260
+ let landed = applied;
261
+ try {
262
+ const out = git(['rev-list', `-n${applied.length}`, 'HEAD'], repoRoot);
263
+ landed = out.split('\n').map((l) => l.trim()).filter(Boolean).reverse();
264
+ } catch {
265
+ /* keep the source shas as a best-effort record */
266
+ }
267
+ return { ok: true, shas: landed, files };
268
+ }
269
+
270
+ /**
271
+ * Undo a landed patch. `git revert` rather than `reset` on purpose: the owner
272
+ * has almost certainly committed or edited on top by now, and rewriting their
273
+ * history to take something back would be far worse than the patch was.
274
+ */
275
+ export function revertPatch({ repoRoot, shas }) {
276
+ // These arrive over the roster and go straight into git argv. Anything that
277
+ // isn't a bare object id — a revision range, a leading-dash option — is
278
+ // refused here rather than trusted because the server said so.
279
+ const clean = (shas ?? []).filter(isValidSha);
280
+ if (clean.length === 0 || clean.length !== (shas ?? []).length) {
281
+ return { ok: false, error: 'refused: patch revert carried a non-sha value' };
282
+ }
283
+ const ordered = [...clean].reverse(); // newest first
284
+ try {
285
+ for (const sha of ordered) git(['revert', '--no-edit', sha], repoRoot);
286
+ return { ok: true };
287
+ } catch (e) {
288
+ try {
289
+ git(['revert', '--abort'], repoRoot);
290
+ } catch {
291
+ /* nothing in progress */
292
+ }
293
+ return { ok: false, error: e?.message ?? String(e) };
294
+ }
295
+ }