flowviant 0.29.0 → 0.30.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/claude.mjs +91 -28
- package/bin/lib/config.mjs +21 -0
- package/bin/lib/fleet.mjs +207 -5
- package/bin/lib/git.mjs +41 -0
- package/bin/lib/live.mjs +241 -15
- package/bin/lib/patch.mjs +295 -0
- package/package.json +1 -1
package/bin/lib/claude.mjs
CHANGED
|
@@ -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
|
|
20
|
-
its own line and stop.
|
|
21
|
-
2. Read the brief
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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):
|
|
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
|
|
51
|
-
own line and stop. Do NOT retry.
|
|
52
|
-
2. Read the brief
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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:
|
|
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
|
|
79
|
-
'
|
|
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
|
-
'
|
|
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,21 @@ 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
|
+
export const REGROUND_KICKOFF = ({ sha, title, files, vaultDir, predictedPages = [] }) =>
|
|
263
275
|
`A feature just merged. Re-ground the knowledge vault (${vaultDir}) for it.\n\n` +
|
|
264
276
|
`Feature: ${title}\n` +
|
|
265
277
|
`Grounded commit: ${sha}\n` +
|
|
266
278
|
`Changed files:\n${files.map((f) => `- ${f}`).join('\n')}\n\n` +
|
|
279
|
+
// The plan's own prediction, made when this work was drafted. Overlapping
|
|
280
|
+
// changed files against each page's frontmatter finds most of what moved, but
|
|
281
|
+
// misses a page whose file list has drifted or that documents a CONCEPT rather
|
|
282
|
+
// than a directory. This is a hint to CHECK, never a list to trust.
|
|
283
|
+
(predictedPages.length
|
|
284
|
+
? `When this work was planned, these vault pages were expected to go stale.\n` +
|
|
285
|
+
`Treat it as a lead, not a fact — verify each against the code before\n` +
|
|
286
|
+
`editing, and ignore any that turned out to be unaffected:\n` +
|
|
287
|
+
`${predictedPages.map((p) => `- ${p}`).join('\n')}\n\n`
|
|
288
|
+
: '') +
|
|
267
289
|
`Follow your instructions: update the touched vault pages (and any docs/\n` +
|
|
268
290
|
`chapter that covers them), append the feature-history entry to log.md,\n` +
|
|
269
291
|
`then output REGROUND_DONE.`;
|
|
@@ -511,3 +533,44 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, label, onSpawn
|
|
|
511
533
|
child.on('close', () => resolve(out));
|
|
512
534
|
});
|
|
513
535
|
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Plan check — the ground-truth pass.
|
|
539
|
+
*
|
|
540
|
+
* Generation runs on the server, where the repo does not exist. It grounds
|
|
541
|
+
* itself in proxies: a module manifest (names and file counts) and wiki pages
|
|
542
|
+
* (summaries of code). Those are good enough to draft a plan and not good
|
|
543
|
+
* enough to be sure of one — the summary can be stale, the anchors can be
|
|
544
|
+
* guesses, and "you already have this" can be wrong in the direction that
|
|
545
|
+
* wastes a day.
|
|
546
|
+
*
|
|
547
|
+
* This turn runs where the checkout is. It opens the actual files and corrects
|
|
548
|
+
* the plan. It is READ-ONLY by construction: it reports, it never edits.
|
|
549
|
+
*/
|
|
550
|
+
export const SYSTEM_PLAN_CHECK = `You are Flowviant's plan checker, running FULLY AUTONOMOUSLY in a real checkout of this repository.
|
|
551
|
+
|
|
552
|
+
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.
|
|
553
|
+
|
|
554
|
+
For each proposed task, verify three things by opening real files:
|
|
555
|
+
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.
|
|
556
|
+
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.
|
|
557
|
+
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).
|
|
558
|
+
|
|
559
|
+
Respond with ONLY a JSON object on the final line, no markdown fence:
|
|
560
|
+
{"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>"}]}
|
|
561
|
+
|
|
562
|
+
Rules:
|
|
563
|
+
- 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".
|
|
564
|
+
- "anchors" must be paths that EXIST in this repo. Verify before listing.
|
|
565
|
+
- "note" is read by a developer in a chat thread. One sentence, concrete, no preamble.
|
|
566
|
+
- Never invent a file path or symbol. If you could not check something, leave it out.`;
|
|
567
|
+
|
|
568
|
+
export const PLAN_CHECK_KICKOFF = ({ title, intents }) =>
|
|
569
|
+
`Check this plan against the real code.\n\nPLAN: ${title}\n\nPROPOSED TASKS:\n${intents
|
|
570
|
+
.map(
|
|
571
|
+
(i) =>
|
|
572
|
+
`- id: ${i.id}\n title: ${i.title}\n claimed anchors: ${
|
|
573
|
+
i.anchors.length ? i.anchors.join(', ') : '(none)'
|
|
574
|
+
}\n points: ${i.points}`
|
|
575
|
+
)
|
|
576
|
+
.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.`;
|
package/bin/lib/config.mjs
CHANGED
|
@@ -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,6 +51,8 @@ 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,
|
|
52
57
|
} from './claude.mjs';
|
|
53
58
|
import { runLiveWorker } from './live.mjs';
|
|
@@ -197,6 +202,13 @@ export async function runFleetDaemon() {
|
|
|
197
202
|
info(SAFE ? 'mode · safe (restricted toolset)' : 'mode · unattended (skips permission prompts)');
|
|
198
203
|
info(`repo · ${repoRoot}`);
|
|
199
204
|
info(`base · ${baseRef}`);
|
|
205
|
+
// Stated out loud because it is the one setting that lets something else write
|
|
206
|
+
// into the checkout you are sitting in.
|
|
207
|
+
info(
|
|
208
|
+
ALLOW_PATCHES
|
|
209
|
+
? 'patches· accepted — small changes land in your checkout for Keep/Revert (--no-patches to refuse)'
|
|
210
|
+
: 'patches· refused — everything arrives as a branch + PR'
|
|
211
|
+
);
|
|
200
212
|
info(`server · ${FLEET_URL}`);
|
|
201
213
|
console.log('');
|
|
202
214
|
await preflight({ needGit: true });
|
|
@@ -301,6 +313,149 @@ export async function runFleetDaemon() {
|
|
|
301
313
|
/* best-effort — the job reappears next poll if this failed */
|
|
302
314
|
}
|
|
303
315
|
};
|
|
316
|
+
// Patch reverts: a patch landed straight in this checkout, and a human took it
|
|
317
|
+
// back. The commits are HERE, not on the server, so the reverse-apply happens
|
|
318
|
+
// here too — a revert, never a reset, because the owner has almost certainly
|
|
319
|
+
// worked on top by now. Serialised through the same lock as applies.
|
|
320
|
+
const PATCH_REVERT_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/patch-revert-done');
|
|
321
|
+
const reverting = new Set();
|
|
322
|
+
const processPatchRevertJobs = (jobs) => {
|
|
323
|
+
for (const job of jobs ?? []) {
|
|
324
|
+
if (!job || typeof job.id !== 'string' || !Array.isArray(job.shas)) continue;
|
|
325
|
+
if (reverting.has(job.id)) continue;
|
|
326
|
+
reverting.add(job.id);
|
|
327
|
+
(async () => {
|
|
328
|
+
try {
|
|
329
|
+
note(`${c.cyan('revert')} ${c.dim(`— ${job.title}`)}`);
|
|
330
|
+
const res = await withPatchLock(() =>
|
|
331
|
+
Promise.resolve(revertPatch({ repoRoot, shas: job.shas }))
|
|
332
|
+
);
|
|
333
|
+
if (res.ok) ok(`${c.dim('reverted')} ${job.title}`);
|
|
334
|
+
else warn(`revert failed for "${job.title}": ${res.error}`);
|
|
335
|
+
// ALWAYS report, success or not. Without this the flag stays set, the
|
|
336
|
+
// roster re-serves the job every poll, and each pass reverts the
|
|
337
|
+
// revert — the change flapping in and out of the owner's tree forever.
|
|
338
|
+
await reportMergeOutcome(PATCH_REVERT_DONE_URL, {
|
|
339
|
+
intentId: job.id,
|
|
340
|
+
ok: res.ok,
|
|
341
|
+
error: res.ok ? undefined : String(res.error ?? 'revert failed'),
|
|
342
|
+
});
|
|
343
|
+
} finally {
|
|
344
|
+
reverting.delete(job.id);
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
}
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Plan checks: the ground-truth pass. Generation drafted these against a
|
|
351
|
+
// module manifest and wiki summaries — proxies for the repo. This runs where
|
|
352
|
+
// the checkout is, opens the real files, and reports corrections back into the
|
|
353
|
+
// thread. Read-only by construction; it never edits.
|
|
354
|
+
/**
|
|
355
|
+
* Pull the plan-check JSON off the tail of a Claude turn.
|
|
356
|
+
*
|
|
357
|
+
* The model is told to end with a bare JSON object, but a turn can trail
|
|
358
|
+
* prose, a fence, or a stray newline. Scan backwards for the last balanced
|
|
359
|
+
* object and validate it hard: anything shaped wrong is dropped rather than
|
|
360
|
+
* written into someone's plan. Returns null when nothing usable was found.
|
|
361
|
+
*/
|
|
362
|
+
const parsePlanChecks = (out, intents) => {
|
|
363
|
+
const text = String(out ?? '');
|
|
364
|
+
const known = new Set(intents.map((i) => i.id));
|
|
365
|
+
const end = text.lastIndexOf('}');
|
|
366
|
+
if (end === -1) return null;
|
|
367
|
+
// NOTE: `lastIndexOf(x, -1)` returns 0, NOT -1 — the position argument is
|
|
368
|
+
// clamped, so the obvious `start = lastIndexOf('{', start - 1)` loop spins
|
|
369
|
+
// forever once it reaches index 0 and the parse fails. That hangs the
|
|
370
|
+
// daemon's event loop, not just this job. Walk with an explicit stop, and
|
|
371
|
+
// cap the attempts so a pathological turn can't burn the poll cycle either.
|
|
372
|
+
let start = text.lastIndexOf('{', end);
|
|
373
|
+
for (let attempts = 0; start !== -1 && attempts < 200; attempts++) {
|
|
374
|
+
let parsed = null;
|
|
375
|
+
try {
|
|
376
|
+
parsed = JSON.parse(text.slice(start, end + 1));
|
|
377
|
+
} catch {
|
|
378
|
+
/* not a complete object at this offset — step back and retry */
|
|
379
|
+
}
|
|
380
|
+
if (!parsed || !Array.isArray(parsed.checks)) {
|
|
381
|
+
if (start === 0) break;
|
|
382
|
+
start = text.lastIndexOf('{', start - 1);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
return parsed.checks
|
|
386
|
+
.filter((ch) => ch && typeof ch.id === 'string' && known.has(ch.id))
|
|
387
|
+
.map((ch) => ({
|
|
388
|
+
id: ch.id,
|
|
389
|
+
alreadyBuilt: ch.alreadyBuilt === true,
|
|
390
|
+
evidence: typeof ch.evidence === 'string' ? ch.evidence.slice(0, 300) : '',
|
|
391
|
+
anchors: Array.isArray(ch.anchors)
|
|
392
|
+
? ch.anchors.filter((a) => typeof a === 'string' && a.length < 200).slice(0, 6)
|
|
393
|
+
: [],
|
|
394
|
+
points:
|
|
395
|
+
typeof ch.points === 'number' && Number.isFinite(ch.points)
|
|
396
|
+
? Math.max(0, Math.min(13, Math.round(ch.points)))
|
|
397
|
+
: null,
|
|
398
|
+
note: typeof ch.note === 'string' ? ch.note.slice(0, 400) : '',
|
|
399
|
+
}))
|
|
400
|
+
.slice(0, 30);
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const PLAN_CHECK_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/plan-check-done');
|
|
406
|
+
const checkingPlans = new Set();
|
|
407
|
+
const processPlanCheckJobs = (jobs) => {
|
|
408
|
+
for (const job of jobs ?? []) {
|
|
409
|
+
if (!job || typeof job.id !== 'string' || !Array.isArray(job.intents)) continue;
|
|
410
|
+
if (checkingPlans.has(job.id)) continue;
|
|
411
|
+
if (job.intents.length === 0) continue;
|
|
412
|
+
checkingPlans.add(job.id);
|
|
413
|
+
(async () => {
|
|
414
|
+
try {
|
|
415
|
+
note(`${c.cyan('plan')} ${c.dim(`— checking "${job.title}" against your code…`)}`);
|
|
416
|
+
// Reuse the wiki worktree: a clean detached checkout at base, which is
|
|
417
|
+
// what "the real code" should mean here — not whatever half-finished
|
|
418
|
+
// state an agent worktree happens to be in.
|
|
419
|
+
if (!existsSync(wikiWt)) {
|
|
420
|
+
try {
|
|
421
|
+
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
422
|
+
} catch {
|
|
423
|
+
git(['worktree', 'prune'], repoRoot);
|
|
424
|
+
git(['worktree', 'add', '--detach', wikiWt, baseRef], repoRoot);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const out = await runTurn({
|
|
428
|
+
prompt: PLAN_CHECK_KICKOFF({ title: job.title, intents: job.intents }),
|
|
429
|
+
resume: false,
|
|
430
|
+
system: SYSTEM_PLAN_CHECK,
|
|
431
|
+
cwd: wikiWt,
|
|
432
|
+
wikiPerm: true, // read-only file perms — no MCP, no shell writes
|
|
433
|
+
label: c.cyan('[plan]'),
|
|
434
|
+
});
|
|
435
|
+
const checks = parsePlanChecks(out, job.intents);
|
|
436
|
+
if (checks === null) {
|
|
437
|
+
warn(`plan check for "${job.title}": no usable JSON — leaving the plan as drafted`);
|
|
438
|
+
}
|
|
439
|
+
await reportMergeOutcome(PLAN_CHECK_DONE_URL, {
|
|
440
|
+
intentId: job.id,
|
|
441
|
+
checks: checks ?? [],
|
|
442
|
+
});
|
|
443
|
+
if (checks?.length) {
|
|
444
|
+
ok(`${c.cyan('plan')} ${c.dim(`— ${checks.length} correction${checks.length === 1 ? '' : 's'} for "${job.title}"`)}`);
|
|
445
|
+
} else {
|
|
446
|
+
ok(`${c.cyan('plan')} ${c.dim(`— "${job.title}" checks out against your code`)}`);
|
|
447
|
+
}
|
|
448
|
+
} catch (e) {
|
|
449
|
+
warn(`plan check failed for "${job.title}": ${e?.message ?? e}`);
|
|
450
|
+
// Clear the flag anyway — a stuck job would re-run every poll forever.
|
|
451
|
+
await reportMergeOutcome(PLAN_CHECK_DONE_URL, { intentId: job.id, checks: [] });
|
|
452
|
+
} finally {
|
|
453
|
+
checkingPlans.delete(job.id);
|
|
454
|
+
}
|
|
455
|
+
})();
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
304
459
|
const processMergeJobs = (jobs) => {
|
|
305
460
|
for (const job of jobs ?? []) {
|
|
306
461
|
if (!job || typeof job.id !== 'string') continue; // a null element would wedge the loop
|
|
@@ -323,6 +478,35 @@ export async function runFleetDaemon() {
|
|
|
323
478
|
warn(`merge REFUSED for "${job.title}": untrusted PR URL ${String(job.prUrl)}`);
|
|
324
479
|
return;
|
|
325
480
|
}
|
|
481
|
+
// STACKED PR: it targets its blocker's branch so the review shows only
|
|
482
|
+
// its own diff. The server holds this job until that blocker merged, so
|
|
483
|
+
// by now the blocker's commits are in the base ref — re-point before
|
|
484
|
+
// squashing, or the change lands in the blocker's branch and never
|
|
485
|
+
// reaches the trunk while the card cheerfully says "Merged".
|
|
486
|
+
if (job.retargetToBase) {
|
|
487
|
+
try {
|
|
488
|
+
// baseBranchName, not baseRef: `gh pr edit --base` needs a branch
|
|
489
|
+
// that exists in the repo, and detectBaseRef hands back a
|
|
490
|
+
// remote-tracking ref (origin/main) that GitHub 422s on.
|
|
491
|
+
execFileSync('gh', ['pr', 'edit', job.prUrl, '--base', baseBranchName(baseRef)], {
|
|
492
|
+
cwd: repoRoot,
|
|
493
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
494
|
+
});
|
|
495
|
+
} catch (e) {
|
|
496
|
+
// Already targeting base is the common no-op; anything else is
|
|
497
|
+
// reported rather than merged into the wrong place.
|
|
498
|
+
const err = e.stderr?.toString?.() || e.message || '';
|
|
499
|
+
if (!/no changes|already/i.test(err)) {
|
|
500
|
+
mergeAttempts.delete(job.id);
|
|
501
|
+
await reportMergeOutcome(MERGE_FAILED_URL, {
|
|
502
|
+
intentId: job.id,
|
|
503
|
+
message: `could not retarget the stacked PR onto ${baseBranchName(baseRef)} — merging it now would land in the branch below it, not ${baseBranchName(baseRef)}`,
|
|
504
|
+
});
|
|
505
|
+
warn(`merge held for "${job.title}": retarget failed — ${err.split('\n')[0]}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
326
510
|
try {
|
|
327
511
|
execFileSync('gh', ['pr', 'merge', job.prUrl, '--squash', '--delete-branch'], {
|
|
328
512
|
cwd: repoRoot,
|
|
@@ -359,7 +543,7 @@ export async function runFleetDaemon() {
|
|
|
359
543
|
// Direct enqueue = immediacy; the server's durable regroundJobs list
|
|
360
544
|
// (created by merge-done above, cleared by our reground-done report)
|
|
361
545
|
// is the restart-safe backstop — dedup'd here by groundedIntents.
|
|
362
|
-
enqueueReground(job.id, job.prUrl, job.title);
|
|
546
|
+
enqueueReground(job.id, job.prUrl, job.title, job.dirtiesPages);
|
|
363
547
|
} else if (failedReason) {
|
|
364
548
|
// Report into the thread (server narrates + re-arms the merge
|
|
365
549
|
// button + notifies) — the job disappears from the roster.
|
|
@@ -503,10 +687,20 @@ export async function runFleetDaemon() {
|
|
|
503
687
|
wikiQueue.push({ type: 'sweep' });
|
|
504
688
|
void drainWiki();
|
|
505
689
|
};
|
|
506
|
-
const enqueueReground = (intentId, prUrl, title) => {
|
|
690
|
+
const enqueueReground = (intentId, prUrl, title, dirtiesPages) => {
|
|
507
691
|
if (!intentId || groundedIntents.has(intentId)) return;
|
|
508
692
|
groundedIntents.add(intentId);
|
|
509
|
-
wikiQueue.push({
|
|
693
|
+
wikiQueue.push({
|
|
694
|
+
type: 'reground',
|
|
695
|
+
intentId,
|
|
696
|
+
prUrl,
|
|
697
|
+
title: title || 'a delivered task',
|
|
698
|
+
// What the PLAN thought this would invalidate. A hint, not the truth —
|
|
699
|
+
// the turn still reads the real changed files; this catches pages whose
|
|
700
|
+
// frontmatter file list has drifted, or that document a concept rather
|
|
701
|
+
// than a directory.
|
|
702
|
+
dirtiesPages: Array.isArray(dirtiesPages) ? dirtiesPages : [],
|
|
703
|
+
});
|
|
510
704
|
void drainWiki();
|
|
511
705
|
};
|
|
512
706
|
|
|
@@ -674,7 +868,13 @@ export async function runFleetDaemon() {
|
|
|
674
868
|
} else {
|
|
675
869
|
note(`${c.cyan('wiki')} ${c.dim(`— re-grounding after "${task.title}"…`)}`);
|
|
676
870
|
const out = await runTurn({
|
|
677
|
-
prompt: REGROUND_KICKOFF({
|
|
871
|
+
prompt: REGROUND_KICKOFF({
|
|
872
|
+
sha,
|
|
873
|
+
title: task.title,
|
|
874
|
+
files,
|
|
875
|
+
vaultDir,
|
|
876
|
+
predictedPages: task.dirtiesPages ?? [],
|
|
877
|
+
}),
|
|
678
878
|
resume: false,
|
|
679
879
|
system: SYSTEM_REGROUND(vaultDir),
|
|
680
880
|
cwd: wikiWt,
|
|
@@ -820,6 +1020,8 @@ export async function runFleetDaemon() {
|
|
|
820
1020
|
if (updating) return;
|
|
821
1021
|
}
|
|
822
1022
|
processMergeJobs(roster.mergeJobs);
|
|
1023
|
+
processPatchRevertJobs(roster.patchRevertJobs);
|
|
1024
|
+
processPlanCheckJobs(roster.planCheckJobs);
|
|
823
1025
|
processCleanupJobs(roster.cleanupJobs);
|
|
824
1026
|
const rosterIds = new Set(roster.agents.map((a) => a.agentId));
|
|
825
1027
|
|
|
@@ -912,7 +1114,7 @@ export async function runFleetDaemon() {
|
|
|
912
1114
|
enqueueSweep(roster.codeMapJob);
|
|
913
1115
|
for (const j of roster.regroundJobs ?? []) {
|
|
914
1116
|
if (!j || typeof j.intentId !== 'string') continue; // a null element would throw + wedge the loop
|
|
915
|
-
enqueueReground(j.intentId, j.prUrl, j.title);
|
|
1117
|
+
enqueueReground(j.intentId, j.prUrl, j.title, j.dirtiesPages);
|
|
916
1118
|
}
|
|
917
1119
|
void drainWiki();
|
|
918
1120
|
|
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);
|
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
|
|
150
|
-
|
|
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,12 @@ 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 conversation — that is rendered as prose, not JSON. */
|
|
176
|
+
function briefWithoutThread(brief) {
|
|
177
|
+
const { thread: _thread, lastMessageId: _lastMessageId, ...rest } = brief ?? {};
|
|
178
|
+
return rest;
|
|
179
|
+
}
|
|
180
|
+
|
|
164
181
|
function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
165
182
|
return [
|
|
166
183
|
`Your run id is ${runId}. Use it for every flowviant MCP tool call.`,
|
|
@@ -168,15 +185,24 @@ function seedPrompt(runId, brief, transcript, resumedInPlace) {
|
|
|
168
185
|
? `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
186
|
: brief?.branch
|
|
170
187
|
? `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
|
-
:
|
|
188
|
+
: brief?.placement === 'patch'
|
|
189
|
+
? `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.`
|
|
190
|
+
: `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
191
|
``,
|
|
173
192
|
`Task brief:`,
|
|
174
|
-
|
|
193
|
+
// The conversation is rendered below as readable turns, not dumped twice as
|
|
194
|
+
// JSON — it is the longest thing in the brief and the least useful as data.
|
|
195
|
+
JSON.stringify(briefWithoutThread(brief), null, 2),
|
|
175
196
|
...(transcript
|
|
176
|
-
? [
|
|
197
|
+
? [
|
|
198
|
+
``,
|
|
199
|
+
`The task conversation — what the team actually said, oldest first. The`,
|
|
200
|
+
`newest human message is usually why you were brought in:`,
|
|
201
|
+
transcript,
|
|
202
|
+
]
|
|
177
203
|
: []),
|
|
178
204
|
``,
|
|
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;
|
|
205
|
+
`${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
206
|
].join('\n');
|
|
181
207
|
}
|
|
182
208
|
|
|
@@ -371,12 +397,120 @@ async function parkUntilReset(resetAt, { mcpUrl, getToken, runId, isAlive }) {
|
|
|
371
397
|
}
|
|
372
398
|
}
|
|
373
399
|
|
|
374
|
-
|
|
400
|
+
/**
|
|
401
|
+
* Carry a completed patch into the owner's checkout, then narrate what happened
|
|
402
|
+
* in the thread.
|
|
403
|
+
*
|
|
404
|
+
* Serialised through withPatchLock so two agents can never write the tree at
|
|
405
|
+
* once, and refused outright when the owner is editing the same files — a
|
|
406
|
+
* collision becomes a blocker for a human, never a silent overwrite. Failure to
|
|
407
|
+
* land is reported honestly rather than being folded into a successful-looking
|
|
408
|
+
* delivery: the human must know the change is NOT in their tree.
|
|
409
|
+
*/
|
|
410
|
+
/** Where the patch base is remembered — inside the worktree's git dir, so
|
|
411
|
+
* `git clean` can't take it (same reasoning as the task marker). */
|
|
412
|
+
function patchBaseFile(cwd) {
|
|
413
|
+
return join(git(['rev-parse', '--absolute-git-dir'], cwd), 'flowviant-patch-base');
|
|
414
|
+
}
|
|
415
|
+
function writePatchBase(cwd, branch) {
|
|
416
|
+
try {
|
|
417
|
+
writeFileSync(patchBaseFile(cwd), branch ?? '', 'utf8');
|
|
418
|
+
} catch {
|
|
419
|
+
/* best effort */
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
function readPatchBase(cwd) {
|
|
423
|
+
try {
|
|
424
|
+
const v = readFileSync(patchBaseFile(cwd), 'utf8').trim();
|
|
425
|
+
return v || null;
|
|
426
|
+
} catch {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef }) {
|
|
432
|
+
// Everything here runs AFTER the agent called `complete`, which finalizes the
|
|
433
|
+
// run server-side. stream_turn and report_blocker are active-run gated, so
|
|
434
|
+
// they would be silently rejected — report_patch is deliberately not, and the
|
|
435
|
+
// server does the narrating.
|
|
436
|
+
const report = (body) =>
|
|
437
|
+
mcpCall(mcpUrl, token, 'report_patch', { runId, ...body }).catch(() => {});
|
|
438
|
+
|
|
439
|
+
if (!repoRoot) {
|
|
440
|
+
await report({ shas: [], ok: false, reason: 'this daemon has no main checkout to land it in' });
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Computed from the AGENT's worktree, so it is the same set of hunks whether
|
|
445
|
+
// or not the cherry-pick lands. A declined patch still deserves to show what
|
|
446
|
+
// it would have done — that's what the human needs to unblock it.
|
|
447
|
+
// Where the agent's commits start. Normally the owner's branch we mirrored;
|
|
448
|
+
// when we could NOT mirror it (they were in a detached HEAD, or the fetch
|
|
449
|
+
// failed) the worktree was reset to base instead, so that is the honest
|
|
450
|
+
// starting point. Diffing against 'HEAD' here silently produced an empty range
|
|
451
|
+
// and a "the agent committed nothing" refusal over real work.
|
|
452
|
+
const commitsFrom = patchBase ?? baseRef ?? 'HEAD';
|
|
453
|
+
let diffs = [];
|
|
454
|
+
try {
|
|
455
|
+
diffs = fileDiffs(cwd, commitsFrom);
|
|
456
|
+
} catch {
|
|
457
|
+
/* evidence is best-effort; never block landing on it */
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const res = await withPatchLock(() =>
|
|
461
|
+
Promise.resolve(applyPatch({ repoRoot, cwd, basedOnBranch: patchBase, commitsFrom }))
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
if (res.ok) {
|
|
465
|
+
await report({ shas: res.shas, files: res.files, diffs, ok: true });
|
|
466
|
+
ok(`${c.cyan('patch')} ${c.dim(`— applied ${res.files.length} file(s) in your checkout`)}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const reason =
|
|
471
|
+
res.reason === 'conflict'
|
|
472
|
+
? `you have uncommitted edits in ${res.paths.join(', ')}`
|
|
473
|
+
: res.reason === 'branch_moved'
|
|
474
|
+
? `you switched from ${res.expected} to ${res.actual ?? 'a detached HEAD'} mid-run`
|
|
475
|
+
: res.reason === 'no_commits'
|
|
476
|
+
? 'the agent committed nothing to apply'
|
|
477
|
+
: (res.error ?? 'the cherry-pick failed');
|
|
478
|
+
|
|
479
|
+
// A rollback that itself failed leaves commits in their history — never
|
|
480
|
+
// report that as "unchanged".
|
|
481
|
+
if (res.partiallyApplied) {
|
|
482
|
+
await report({
|
|
483
|
+
shas: res.appliedShas ?? [],
|
|
484
|
+
diffs,
|
|
485
|
+
ok: false,
|
|
486
|
+
reason: `${reason}. Some commits could not be rolled back and are still in your history`,
|
|
487
|
+
});
|
|
488
|
+
warn(`patch partially applied for intent ${intentId} — rollback failed; commits remain`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Diffs go up even on a refusal: "you have uncommitted edits in X" is only
|
|
493
|
+
// actionable if you can see what the agent wanted to put there.
|
|
494
|
+
await report({ shas: [], diffs, ok: false, reason });
|
|
495
|
+
warn(`patch not applied: ${reason}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export async function runLiveTask({
|
|
499
|
+
mcpUrl,
|
|
500
|
+
token,
|
|
501
|
+
cwd,
|
|
502
|
+
baseRef,
|
|
503
|
+
repoRoot,
|
|
504
|
+
isAlive,
|
|
505
|
+
resumeIntentId,
|
|
506
|
+
onChild,
|
|
507
|
+
}) {
|
|
375
508
|
const claim = await mcpCall(mcpUrl, token, 'claim_next_intent', {}).catch(() => null);
|
|
376
509
|
if (!claim || claim.claimed !== true) return { outcome: 'nothing' };
|
|
377
510
|
const { runId, intentId } = claim;
|
|
378
511
|
const brief = claim.brief ?? {};
|
|
379
512
|
const title = brief.title ?? 'a task';
|
|
513
|
+
|
|
380
514
|
// Re-claiming the SAME intent this worker was just working — either this
|
|
381
515
|
// daemon's own memory (parked on a blocker, now resuming) or the persistent
|
|
382
516
|
// task marker (the daemon restarted mid-task). Its worktree holds hours of
|
|
@@ -384,6 +518,41 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
384
518
|
const resuming = !!resumeIntentId && intentId === resumeIntentId;
|
|
385
519
|
const resumedInPlace = !resuming && readTaskMarker(cwd) === intentId;
|
|
386
520
|
|
|
521
|
+
// CONSENT. A patch writes commits into the working checkout of whoever runs
|
|
522
|
+
// this daemon — chosen by a model, and triggerable by any teammate who
|
|
523
|
+
// @mentions one of your agents. Whether that is allowed at all belongs to the
|
|
524
|
+
// person whose disk it is, so `--no-patches` turns it into an ordinary branch
|
|
525
|
+
// + PR. The work is never refused, only routed the long way round, and the
|
|
526
|
+
// thread is told so nobody is left wondering where their Keep/Revert card is.
|
|
527
|
+
//
|
|
528
|
+
// Applies at PICKUP only. A patch run already underway keeps its placement:
|
|
529
|
+
// its worktree is based on the owner's current branch, so converting it to a
|
|
530
|
+
// PR mid-flight would open one whose diff carries the owner's unrelated
|
|
531
|
+
// commits — and resetting to base instead would throw away the agent's work.
|
|
532
|
+
// The setting refuses new patches; it does not retroactively rewrite consent
|
|
533
|
+
// that was already given when the task was picked up.
|
|
534
|
+
if (!ALLOW_PATCHES && brief.placement === 'patch' && !resuming && !resumedInPlace) {
|
|
535
|
+
brief.placement = 'branch';
|
|
536
|
+
info(`${c.dim('patch declined by this machine (--no-patches) — building a PR instead')}`);
|
|
537
|
+
await mcpCall(mcpUrl, token, 'report_progress', {
|
|
538
|
+
runId,
|
|
539
|
+
kind: 'status',
|
|
540
|
+
message:
|
|
541
|
+
'This machine does not accept patches, so this is going up as a branch + PR ' +
|
|
542
|
+
'instead of landing in the checkout directly.',
|
|
543
|
+
}).catch(() => {});
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Placement decides where the work lands: its own branch + PR (the default),
|
|
547
|
+
// or a patch cherry-picked straight into the owner's checkout.
|
|
548
|
+
const isPatch = brief.placement === 'patch';
|
|
549
|
+
// Persisted next to the task marker: a patch run that PARKS (rate limit, a
|
|
550
|
+
// blocker) or survives a daemon restart resumes without re-entering the
|
|
551
|
+
// checkout branch, and an in-memory base would be null by the time the
|
|
552
|
+
// cherry-pick runs — applyPatch would diff HEAD..HEAD and report no_commits,
|
|
553
|
+
// silently dropping the work.
|
|
554
|
+
let patchBase = isPatch ? readPatchBase(cwd) : null;
|
|
555
|
+
|
|
387
556
|
// Revision resumes its PR branch; a genuinely fresh task gets a clean base
|
|
388
557
|
// checkout; a resume (in-memory or marker) keeps its dirty worktree untouched.
|
|
389
558
|
// The branch is server-supplied — validate it's a well-formed non-base ref
|
|
@@ -396,8 +565,50 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
396
565
|
} catch {
|
|
397
566
|
if (!resuming && !resumedInPlace) resetWorktree(cwd, baseRef);
|
|
398
567
|
}
|
|
568
|
+
} else if (isPatch && !resuming && !resumedInPlace) {
|
|
569
|
+
// PATCH placement: base off the branch the human is ACTUALLY on, so the
|
|
570
|
+
// change lands on their work rather than on main. Nothing is pushed and no
|
|
571
|
+
// PR is opened — the daemon cherry-picks the result across at the end.
|
|
572
|
+
patchBase = repoRoot ? ownerCurrentBranch(repoRoot) : null;
|
|
573
|
+
try {
|
|
574
|
+
if (!patchBase) throw new Error('owner is in a detached HEAD');
|
|
575
|
+
git(['fetch', 'origin', '--quiet'], cwd);
|
|
576
|
+
git(['checkout', '--detach', patchBase], cwd);
|
|
577
|
+
git(['reset', '--hard', patchBase], cwd);
|
|
578
|
+
git(['clean', '-fd'], cwd);
|
|
579
|
+
} catch {
|
|
580
|
+
// Can't mirror the owner's tree — fall back to a normal base checkout and
|
|
581
|
+
// let the apply step decline rather than landing something unexpected.
|
|
582
|
+
patchBase = null;
|
|
583
|
+
resetWorktree(cwd, baseRef);
|
|
584
|
+
}
|
|
585
|
+
writePatchBase(cwd, patchBase);
|
|
399
586
|
} else if (!resuming && !resumedInPlace) {
|
|
400
|
-
|
|
587
|
+
// STACKING (0.29.x): when the collision pass sequenced this intent behind
|
|
588
|
+
// one that shares its code, the server sends the blocker's branch as
|
|
589
|
+
// `baseBranch`. Basing off it means this agent sees that work immediately
|
|
590
|
+
// instead of waiting for a merge — the shared-checkout benefit, without a
|
|
591
|
+
// shared checkout. Same validation as `branch`: a server-supplied ref is
|
|
592
|
+
// never handed to git unchecked. Anything unusable falls back to the base,
|
|
593
|
+
// which is exactly the pre-stacking behaviour.
|
|
594
|
+
const stackOn =
|
|
595
|
+
brief.baseBranch && isValidBranch(brief.baseBranch, cwd, baseRef)
|
|
596
|
+
? brief.baseBranch
|
|
597
|
+
: null;
|
|
598
|
+
let stacked = false;
|
|
599
|
+
if (stackOn) {
|
|
600
|
+
try {
|
|
601
|
+
git(['fetch', 'origin', '--quiet'], cwd);
|
|
602
|
+
git(['checkout', '--detach', `origin/${stackOn}`], cwd);
|
|
603
|
+
git(['reset', '--hard', `origin/${stackOn}`], cwd);
|
|
604
|
+
git(['clean', '-fd'], cwd);
|
|
605
|
+
stacked = true;
|
|
606
|
+
} catch {
|
|
607
|
+
// The blocker hasn't pushed yet — the wave ordering is what stops this
|
|
608
|
+
// being dispatched early, so falling back to base is safe, not wrong.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!stacked) resetWorktree(cwd, baseRef);
|
|
401
612
|
}
|
|
402
613
|
materializeInto(cwd); // resets wipe the synced env files — rewrite them
|
|
403
614
|
writeTaskMarker(cwd, intentId);
|
|
@@ -421,15 +632,22 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
421
632
|
delete env.ANTHROPIC_AUTH_TOKEN;
|
|
422
633
|
delete env.ANTHROPIC_BASE_URL;
|
|
423
634
|
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
635
|
+
// The conversation arrives WITH the brief (0.30.0) — the claim already read
|
|
636
|
+
// it, so asking again over poll_channel was a round-trip that told us nothing
|
|
637
|
+
// new. Older servers don't send it; fall back so a daemon ahead of the server
|
|
638
|
+
// still resumes with its transcript instead of silently starting cold.
|
|
639
|
+
let priorMsgs = brief.thread ?? null;
|
|
640
|
+
if (!priorMsgs) {
|
|
641
|
+
const prior = await mcpCall(mcpUrl, token, 'poll_channel', { runId }).catch(() => null);
|
|
642
|
+
priorMsgs = prior?.messages ?? [];
|
|
643
|
+
}
|
|
429
644
|
const transcript = priorMsgs
|
|
430
645
|
.map((m) => `${m.authorName || m.role}: ${m.content}`)
|
|
431
646
|
.join('\n');
|
|
432
|
-
|
|
647
|
+
// Where to resume polling from, so nothing already in the seed is re-injected
|
|
648
|
+
// as if it just arrived.
|
|
649
|
+
let afterId =
|
|
650
|
+
brief.lastMessageId ?? (priorMsgs.length ? priorMsgs[priorMsgs.length - 1].id : null);
|
|
433
651
|
|
|
434
652
|
const input = makeInput(seedPrompt(runId, brief, transcript, resumedInPlace));
|
|
435
653
|
const session = query({
|
|
@@ -547,6 +765,9 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
547
765
|
// attempt's dirty files).
|
|
548
766
|
if (completed) {
|
|
549
767
|
clearTaskMarker(cwd);
|
|
768
|
+
if (isPatch) {
|
|
769
|
+
await landPatch({ mcpUrl, token, runId, intentId, repoRoot, cwd, patchBase, baseRef });
|
|
770
|
+
}
|
|
550
771
|
return { outcome: 'done', title, intentId };
|
|
551
772
|
}
|
|
552
773
|
|
|
@@ -607,7 +828,11 @@ export async function runLiveTask({ mcpUrl, token, cwd, baseRef, isAlive, resume
|
|
|
607
828
|
// Idle turn with no completion — nudge a couple of times, then stop.
|
|
608
829
|
if (nudges < 2) {
|
|
609
830
|
nudges++;
|
|
610
|
-
input.push(
|
|
831
|
+
input.push(
|
|
832
|
+
isPatch
|
|
833
|
+
? 'Continue until the task is complete: commit your change (no branch, no push, no PR) and call complete, or report a blocker.'
|
|
834
|
+
: 'Continue until the task is complete: open a draft PR and call complete, or report a blocker.'
|
|
835
|
+
);
|
|
611
836
|
continue;
|
|
612
837
|
}
|
|
613
838
|
return { outcome: 'stalled', title, intentId };
|
|
@@ -823,6 +1048,7 @@ export async function runLiveWorker({
|
|
|
823
1048
|
token,
|
|
824
1049
|
cwd,
|
|
825
1050
|
baseRef,
|
|
1051
|
+
repoRoot,
|
|
826
1052
|
isAlive,
|
|
827
1053
|
resumeIntentId: lastIntentId,
|
|
828
1054
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowviant",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.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": {
|