flowviant 0.124.0 → 0.124.1
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/README.md +6 -0
- package/bin/cli.mjs +7 -0
- package/bin/lib/agentInternalWait.mjs +2 -1
- package/bin/lib/agentPlan.mjs +8 -0
- package/bin/lib/agentRepositories.mjs +48 -0
- package/bin/lib/checkoutIdentity.mjs +25 -0
- package/bin/lib/claudeInput.mjs +32 -0
- package/bin/lib/claudeSessions.mjs +20 -0
- package/bin/lib/credentials.mjs +52 -17
- package/bin/lib/daemonLocks.mjs +21 -3
- package/bin/lib/fleet.mjs +19 -6
- package/bin/lib/fleetRoster.mjs +1 -89
- package/bin/lib/fleetStartup.mjs +11 -14
- package/bin/lib/holder.mjs +4 -37
- package/bin/lib/instance.mjs +20 -7
- package/bin/lib/landed.mjs +2 -2
- package/bin/lib/login.mjs +39 -4
- package/bin/lib/placeLock.mjs +9 -7
- package/bin/lib/prWorkflow.mjs +15 -0
- package/bin/lib/projectLanded.mjs +16 -0
- package/bin/lib/projectRepositories.mjs +60 -0
- package/bin/lib/projectTools.mjs +2 -1
- package/bin/lib/projectWorktreeHome.mjs +25 -0
- package/bin/lib/prompts.mjs +6 -1
- package/bin/lib/repositoryDelivery.mjs +64 -0
- package/bin/lib/repositoryMerge.mjs +116 -0
- package/bin/lib/repositoryPlaces.mjs +179 -0
- package/bin/lib/repositorySetup.mjs +58 -0
- package/bin/lib/repositoryTool.mjs +71 -0
- package/bin/lib/runTurn.mjs +23 -2
- package/bin/lib/runtimeAntigravity.mjs +2 -1
- package/bin/lib/runtimeClaude.mjs +5 -2
- package/bin/lib/runtimeCodex.mjs +2 -1
- package/bin/lib/shipMerge.mjs +5 -2
- package/bin/lib/startProject.mjs +4 -4
- package/bin/lib/trace.mjs +8 -3
- package/bin/lib/turnDeclaration.mjs +88 -0
- package/bin/lib/turnLock.mjs +48 -8
- package/bin/lib/work.mjs +22 -24
- package/bin/lib/workAgentCheck.mjs +6 -5
- package/bin/lib/workAgentMerges.mjs +19 -2
- package/bin/lib/workAgentPrecheck.mjs +5 -5
- package/bin/lib/workAgentPublish.mjs +18 -5
- package/bin/lib/workAgentRecovery.mjs +85 -0
- package/bin/lib/workAgentReview.mjs +5 -3
- package/bin/lib/workAgentTurnBegun.mjs +6 -4
- package/bin/lib/workAgentTurnExecution.mjs +76 -9
- package/bin/lib/workAgentTurnOutcome.mjs +9 -3
- package/bin/lib/workAgentTurns.mjs +33 -9
- package/bin/lib/workDelivery.mjs +21 -0
- package/bin/lib/workDiffs.mjs +18 -11
- package/bin/lib/workLiveMessages.mjs +129 -0
- package/bin/lib/workPlaceReports.mjs +5 -0
- package/bin/lib/workPlaces.mjs +6 -1
- package/bin/lib/workRetire.mjs +5 -0
- package/bin/lib/workSessionTurns.mjs +72 -40
- package/bin/lib/workShip.mjs +17 -16
- package/package.json +1 -1
- package/npm-shrinkwrap.json +0 -69
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** A place lands as a coordinated set. Positive landings are never undone. */
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { git, gitNet as network, gitOrNull, baseBranchName, usableBaseRef, detectBaseRef } from './git.mjs';
|
|
4
|
+
import { gitMergeIn } from './gitMergeIdentity.mjs';
|
|
5
|
+
import { mergeOutward, isRaceRejection } from './shipMerge.mjs';
|
|
6
|
+
import { ensureOpenPr, mergeAndVerifyTip, ghReady, linkRepositoryPrs, relayedFailure } from './prWorkflow.mjs';
|
|
7
|
+
|
|
8
|
+
export function repositoryMergeOrder(cards, changed) {
|
|
9
|
+
const edges = new Map(changed.map((id) => [id, new Set()]));
|
|
10
|
+
for (const card of cards) {
|
|
11
|
+
const ids = card.repos.filter((id) => changed.includes(id));
|
|
12
|
+
if (ids.length > 1 && (!card.mergeOrderReason?.trim() || card.mergeOrder.length !== card.repos.length || new Set(card.mergeOrder).size !== card.repos.length || card.repos.some((id) => !card.mergeOrder.includes(id)))) throw new Error(`Card ${card.id} needs a merge order covering every changed repository and a reason.`);
|
|
13
|
+
const order = card.mergeOrder.filter((id) => ids.includes(id));
|
|
14
|
+
for (let i = 1; i < order.length; i++) edges.get(order[i - 1]).add(order[i]);
|
|
15
|
+
}
|
|
16
|
+
const result = [];
|
|
17
|
+
while (result.length < changed.length) {
|
|
18
|
+
const next = changed.find((id) => !result.includes(id) && ![...edges].some(([from, to]) => !result.includes(from) && to.has(id)));
|
|
19
|
+
if (!next) throw new Error('The delivered cards specify conflicting merge orders. Restate a consistent order for these cards.');
|
|
20
|
+
result.push(next);
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function mergeRepositoryPlace({ job, entries, baseDir, progress, check, gitNet = network,
|
|
26
|
+
land = mergeOutward, pr = { ghReady, ensureOpenPr, mergeAndVerifyTip, linkRepositoryPrs } }) {
|
|
27
|
+
const spec = job.repositoryMerge;
|
|
28
|
+
const ids = [...new Set(spec.cards.flatMap((c) => c.repos))];
|
|
29
|
+
const rows = ids.map((id) => {
|
|
30
|
+
const row = entries.find((r) => r.id === id && r.writable);
|
|
31
|
+
if (!row?.repoRoot) throw new Error(`Repository ${spec.repositories.find((r) => r.id === id)?.name ?? id} has no writable worktree on this machine.`);
|
|
32
|
+
const configured = spec.repositories.find((r) => r.id === id);
|
|
33
|
+
return { ...row, name: configured?.name ?? row.name, configuredBase: configured?.baseBranch };
|
|
34
|
+
});
|
|
35
|
+
const states = structuredClone(spec.progress ?? {});
|
|
36
|
+
const report = async (retry = false) => progress(states, retry);
|
|
37
|
+
const ancestor = (a, b, wt) => { try { git(['merge-base', '--is-ancestor', a, b], wt); return true; } catch { return false; } };
|
|
38
|
+
const fetch = (r) => {
|
|
39
|
+
if (gitOrNull(['remote', 'get-url', 'origin'], r.repoRoot)) gitNet(['fetch', 'origin', '--quiet'], r.repoRoot, 60_000);
|
|
40
|
+
r.baseRef = r.configuredBase ? usableBaseRef(r.repoRoot, `origin/${baseBranchName(r.configuredBase)}`) : detectBaseRef(r.repoRoot);
|
|
41
|
+
r.baseTip = git(['rev-parse', r.baseRef], r.repoRoot);
|
|
42
|
+
};
|
|
43
|
+
let order;
|
|
44
|
+
try { order = repositoryMergeOrder(spec.cards, ids); }
|
|
45
|
+
catch (e) { return { ok: false, conflict: true, detail: e.message }; }
|
|
46
|
+
if (job.prMode && pr.ghReady()) return { ok: false, fix: 'person', detail: 'GitHub CLI is not installed or signed in on this machine.' };
|
|
47
|
+
// Yield the lease after bounded races, retaining approval and all observations.
|
|
48
|
+
for (let attempt = 0; attempt < 4; attempt++) {
|
|
49
|
+
const pending = [], conflicts = [];
|
|
50
|
+
for (const r of rows) {
|
|
51
|
+
fetch(r);
|
|
52
|
+
const head = git(['rev-parse', 'HEAD'], r.wt);
|
|
53
|
+
if (ancestor(head, r.baseTip, r.wt)) { states[r.id] = { ...states[r.id], head, landed: head }; continue; }
|
|
54
|
+
pending.push(r);
|
|
55
|
+
try { gitMergeIn(r.repoRoot)(['merge', '--no-edit', r.baseTip], r.wt); }
|
|
56
|
+
catch (e) {
|
|
57
|
+
const files = git(['diff', '--name-only', '--diff-filter=U'], r.wt).trim();
|
|
58
|
+
if (files || gitOrNull(['rev-parse', '--verify', 'MERGE_HEAD'], r.wt)) conflicts.push(`${r.name} (${r.wt}):\n${files || relayedFailure(e)}`);
|
|
59
|
+
else throw e;
|
|
60
|
+
}
|
|
61
|
+
states[r.id] = { ...states[r.id], head: git(['rev-parse', 'HEAD'], r.wt) };
|
|
62
|
+
}
|
|
63
|
+
await report();
|
|
64
|
+
if (conflicts.length) return { ok: false, conflict: true, detail: `Resolve and commit every conflicted repository in this place before any remaining repository lands:\n${conflicts.join('\n\n')}` };
|
|
65
|
+
// All branches have caught up. A failing repository prevents every new landing.
|
|
66
|
+
const failed = [];
|
|
67
|
+
for (const r of pending) {
|
|
68
|
+
await report();
|
|
69
|
+
const result = await check(r);
|
|
70
|
+
await report();
|
|
71
|
+
if (result?.status !== 'passed' && result?.status !== 'none') failed.push(r.name);
|
|
72
|
+
else if (git(['rev-parse', 'HEAD'], r.wt) !== states[r.id].head) failed.push(`${r.name} (branch changed during its check)`);
|
|
73
|
+
}
|
|
74
|
+
if (failed.length) return { ok: false, fix: 'person', detail: `Repository checks failed or could not be measured: ${failed.join(', ')}. Review the checks before approving again.` };
|
|
75
|
+
if (job.prMode) {
|
|
76
|
+
for (const r of pending) {
|
|
77
|
+
const branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], r.wt);
|
|
78
|
+
if (branch === baseBranchName(r.baseRef)) throw new Error(`Refusing to publish ${r.name}'s base as a PR branch.`);
|
|
79
|
+
gitNet(['push', '-u', 'origin', branch], r.wt, 120_000);
|
|
80
|
+
const opened = pr.ensureOpenPr(branch, baseBranchName(r.baseRef), { cwd: r.wt });
|
|
81
|
+
if (opened.error || opened.wrongBase || !opened.url) return { ok: false, fix: 'person', detail: opened.error || `No verified pull request for ${r.name}.` };
|
|
82
|
+
states[r.id].prUrl = opened.url;
|
|
83
|
+
}
|
|
84
|
+
await report();
|
|
85
|
+
for (const r of pending) await pr.linkRepositoryPrs(states[r.id].prUrl, { cwd: r.wt, cards: spec.cards, order, repositories: spec.repositories, states });
|
|
86
|
+
}
|
|
87
|
+
let raced = false;
|
|
88
|
+
for (const id of order) {
|
|
89
|
+
const r = pending.find((r) => r.id === id);
|
|
90
|
+
if (!r) continue;
|
|
91
|
+
// Refuse a changed base before landing. Another writer can still win the push.
|
|
92
|
+
fetch(r);
|
|
93
|
+
if (!ancestor(r.baseTip, 'HEAD', r.wt)) { raced = true; break; }
|
|
94
|
+
const head = git(['rev-parse', 'HEAD'], r.wt);
|
|
95
|
+
const branch = git(['symbolic-ref', '--quiet', '--short', 'HEAD'], r.wt);
|
|
96
|
+
try {
|
|
97
|
+
if (job.prMode) {
|
|
98
|
+
const merged = await pr.mergeAndVerifyTip(branch, { cwd: r.wt, tip: head, baseRef: () => r.baseRef, repoRoot: r.repoRoot, fetchOrigin: () => fetch(r) });
|
|
99
|
+
if (!merged.ok) { fetch(r); if (merged.notOnBase || !ancestor(r.baseTip, 'HEAD', r.wt)) { raced = true; break; } throw new Error(merged.error || `Pull request for ${r.name} has not merged.`); }
|
|
100
|
+
} else land({ tip: head, count: Number(git(['rev-list', '--count', `${r.baseRef}..HEAD`], r.wt)), branch,
|
|
101
|
+
label: job.agentName || job.placeId, git, gitMerge: gitMergeIn(r.repoRoot), repoRoot: r.repoRoot,
|
|
102
|
+
tmpDir: join(baseDir, 'ship', `${job.placeId}-${r.id}`), baseRef: () => r.baseRef, baseTip: r.baseTip, workingTree: r.wt, gitNet, retryRace: false });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (isRaceRejection(e)) { raced = true; break; }
|
|
105
|
+
return { ok: false, fix: e?.fix === 'person' || job.prMode ? 'person' : 'agent', detail: `${r.name}: ${relayedFailure(e)}` };
|
|
106
|
+
}
|
|
107
|
+
fetch(r);
|
|
108
|
+
if (!ancestor(head, r.baseTip, r.wt)) { raced = true; break; }
|
|
109
|
+
states[r.id] = { ...states[r.id], head, landed: head };
|
|
110
|
+
await report();
|
|
111
|
+
}
|
|
112
|
+
if (!raced) return { ok: true, sha: states[order.at(-1)]?.head ?? git(['rev-parse', 'HEAD'], entries.find((r) => r.writable).wt) };
|
|
113
|
+
}
|
|
114
|
+
await report(true);
|
|
115
|
+
return { retry: true };
|
|
116
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/** One durable place manifest; all targeted worktrees share session/<place>.
|
|
2
|
+
* Untargeted siblings are base snapshots, not links to the operator's checkout.
|
|
3
|
+
* Read-only file modes prevent ordinary writes; build CLIs still run as the
|
|
4
|
+
* operator and this is not a security boundary against chmod or privileged code.
|
|
5
|
+
*/
|
|
6
|
+
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { execFileSync } from 'node:child_process';
|
|
9
|
+
import { git, gitRaw, gitOrNull, isSafePathSegment, sessionBranch } from './git.mjs';
|
|
10
|
+
import { setupRepository } from './repositorySetup.mjs';
|
|
11
|
+
|
|
12
|
+
export const PLACE_MANIFEST = '.flowviant-place.json';
|
|
13
|
+
export function readRepositoryPlace(home) {
|
|
14
|
+
try {
|
|
15
|
+
const row = JSON.parse(readFileSync(join(home, PLACE_MANIFEST), 'utf8'));
|
|
16
|
+
if (row.version !== 1 || !Array.isArray(row.repositories) ||
|
|
17
|
+
row.repositories.some((r) => !isSafePathSegment(r.folder) || typeof r.id !== 'string') ||
|
|
18
|
+
!(row.preparing === true || row.repositories.some((r) => r.id === row.primary && r.writable))) return null;
|
|
19
|
+
return row;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
function modeTree(dir, writable) {
|
|
23
|
+
const st = lstatSync(dir);
|
|
24
|
+
if (st.isSymbolicLink()) return;
|
|
25
|
+
if (writable) chmodSync(dir, st.isDirectory() ? 0o700 : 0o600 | (st.mode & 0o111));
|
|
26
|
+
if (st.isDirectory()) for (const name of readdirSync(dir)) modeTree(join(dir, name), writable);
|
|
27
|
+
if (!writable) chmodSync(dir, st.isDirectory() ? 0o500 : 0o400 | (st.mode & 0o111));
|
|
28
|
+
}
|
|
29
|
+
/** The explicit `clean` command also owns these snapshots. Never chmod links. */
|
|
30
|
+
export function unlockRepositorySnapshots(root) {
|
|
31
|
+
for (const project of readdirSync(root, { withFileTypes: true })) {
|
|
32
|
+
if (!project.isDirectory()) continue;
|
|
33
|
+
const sessions = join(root, project.name, 'sessions');
|
|
34
|
+
if (!existsSync(sessions)) continue;
|
|
35
|
+
for (const place of readdirSync(sessions)) {
|
|
36
|
+
if (!isSafePathSegment(place)) continue;
|
|
37
|
+
const home = join(sessions, place), state = readRepositoryPlace(home);
|
|
38
|
+
for (const repo of state?.repositories ?? []) if (!repo.writable && existsSync(join(home, repo.folder))) modeTree(join(home, repo.folder), true);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function createRepositoryPlaces({ repoRoot, baseDir, repositories, setup = setupRepository, changed = () => {} }) {
|
|
43
|
+
const homeFor = (place) => {
|
|
44
|
+
if (!isSafePathSegment(place)) throw new Error('Invalid place.');
|
|
45
|
+
return join(baseDir, 'sessions', place);
|
|
46
|
+
};
|
|
47
|
+
const save = (home, state) => {
|
|
48
|
+
const path = join(home, PLACE_MANIFEST);
|
|
49
|
+
writeFileSync(`${path}.tmp`, JSON.stringify(state), { mode: 0o600 });
|
|
50
|
+
renameSync(`${path}.tmp`, path);
|
|
51
|
+
};
|
|
52
|
+
const stateFor = (place) => readRepositoryPlace(homeFor(place));
|
|
53
|
+
const entries = (place) => {
|
|
54
|
+
const state = stateFor(place);
|
|
55
|
+
return (state?.repositories ?? []).map((r) => ({ ...r, wt: join(homeFor(place), r.folder),
|
|
56
|
+
repoRoot: repositories.list().find((repo) => repo.id === r.id)?.repoRoot }));
|
|
57
|
+
};
|
|
58
|
+
const ensure = async (place, ids, setupOptions = {}) => {
|
|
59
|
+
const home = homeFor(place), catalog = repositories.list();
|
|
60
|
+
for (const id of ids) if (!catalog.find((r) => r.id === id)?.repoRoot)
|
|
61
|
+
throw new Error(`Repository ${catalog.find((r) => r.id === id)?.name ?? id} is not checked out on this machine.`);
|
|
62
|
+
const folders = catalog.filter((r) => r.repoRoot).map((r) => basename(r.repoRoot));
|
|
63
|
+
if (new Set(folders).size !== folders.length || folders.some((f) => !isSafePathSegment(f)))
|
|
64
|
+
throw new Error('Project checkouts need distinct directory names to create sibling worktrees.');
|
|
65
|
+
let state = stateFor(place);
|
|
66
|
+
if (!state) {
|
|
67
|
+
const primary = catalog.find((r) => r.isPrimary && ids.includes(r.id))?.id ?? catalog.find((r) => ids.includes(r.id))?.id;
|
|
68
|
+
const old = existsSync(join(home, '.git'));
|
|
69
|
+
// Upgrade a pre-existing single-repo place without resetting its branch,
|
|
70
|
+
// private git metadata or dirty files. git moves the registration too.
|
|
71
|
+
if (old || existsSync(join(`${home}.moving`, '.git'))) {
|
|
72
|
+
const first = catalog.find((r) => r.repoRoot && git(['worktree', 'list', '--porcelain'], r.repoRoot).split('\n')
|
|
73
|
+
.some((line) => line === `worktree ${home}` || line === `worktree ${home}.moving`));
|
|
74
|
+
if (!first?.repoRoot) throw new Error('The checkout owning this worktree is needed to upgrade this place.');
|
|
75
|
+
const temporary = `${home}.moving`;
|
|
76
|
+
if (old) git(['worktree', 'move', home, temporary], first.repoRoot);
|
|
77
|
+
mkdirSync(home, { recursive: true });
|
|
78
|
+
git(['worktree', 'move', temporary, join(home, basename(first.repoRoot))], first.repoRoot);
|
|
79
|
+
state = { version: 1, primary: first.id, repositories: [{ id: first.id, name: first.name, folder: basename(first.repoRoot), baseRef: first.baseRef, writable: true, setup: true }] };
|
|
80
|
+
} else {
|
|
81
|
+
mkdirSync(home, { recursive: true });
|
|
82
|
+
state = { version: 1, primary, preparing: true, repositories: [] };
|
|
83
|
+
}
|
|
84
|
+
save(home, state);
|
|
85
|
+
}
|
|
86
|
+
for (const repo of catalog.filter((r) => r.repoRoot)) {
|
|
87
|
+
setupOptions.signal?.throwIfAborted();
|
|
88
|
+
let item = state.repositories.find((r) => r.id === repo.id);
|
|
89
|
+
let writable = ids.includes(repo.id) || item?.writable === true;
|
|
90
|
+
const folder = item?.folder ?? basename(repo.repoRoot), wt = join(home, folder);
|
|
91
|
+
if (existsSync(join(wt, '.git'))) {
|
|
92
|
+
const registered = git(['worktree', 'list', '--porcelain'], repo.repoRoot).split('\n').includes(`worktree ${wt}`);
|
|
93
|
+
if (!registered) throw new Error(`The git directory at ${wt} is not this repository's worktree.`);
|
|
94
|
+
writable = true; // promotion or legacy move completed before its save
|
|
95
|
+
if (item && !item.writable) { item.writable = true; item.setup = false; }
|
|
96
|
+
}
|
|
97
|
+
if (!item) {
|
|
98
|
+
if (existsSync(wt)) {
|
|
99
|
+
// Recover the crash window after git moved a legacy place but
|
|
100
|
+
// before its manifest was saved. Never adopt an unrelated folder.
|
|
101
|
+
const registered = git(['worktree', 'list', '--porcelain'], repo.repoRoot).split('\n').includes(`worktree ${wt}`);
|
|
102
|
+
if (!registered) throw new Error(`A directory already exists at ${wt}; it is not this repository's worktree.`);
|
|
103
|
+
}
|
|
104
|
+
item = { id: repo.id, name: repo.name, folder, baseRef: repo.baseRef, writable, setup: false };
|
|
105
|
+
state.repositories.push(item);
|
|
106
|
+
save(home, state);
|
|
107
|
+
}
|
|
108
|
+
item.baseRef = repo.baseRef;
|
|
109
|
+
item.name = repo.name;
|
|
110
|
+
const head = gitOrNull(['rev-parse', repo.baseRef], repo.repoRoot);
|
|
111
|
+
if (item && item.writable === writable && existsSync(wt) && (writable ? item.setup : item.head === head)) continue;
|
|
112
|
+
if (item && !item.writable && writable && existsSync(wt)) { modeTree(wt, true); rmSync(wt, { recursive: true }); }
|
|
113
|
+
if (item && !writable && existsSync(wt)) { modeTree(wt, true); rmSync(wt, { recursive: true }); }
|
|
114
|
+
if (!existsSync(wt)) {
|
|
115
|
+
if (writable) {
|
|
116
|
+
const branch = sessionBranch(place);
|
|
117
|
+
git(['worktree', 'prune'], repo.repoRoot);
|
|
118
|
+
if (gitOrNull(['show-ref', '--verify', `refs/heads/${branch}`], repo.repoRoot))
|
|
119
|
+
git(['worktree', 'add', wt, branch], repo.repoRoot);
|
|
120
|
+
else git(['worktree', 'add', '-b', branch, wt, repo.baseRef], repo.repoRoot);
|
|
121
|
+
} else {
|
|
122
|
+
mkdirSync(wt, { recursive: true });
|
|
123
|
+
// tar extracts only git's archive, never ignored files or credentials.
|
|
124
|
+
const archive = gitRaw(['archive', '--format=tar', repo.baseRef], repo.repoRoot, { encoding: 'buffer', maxBuffer: 256 * 1024 * 1024 });
|
|
125
|
+
execFileSync('tar', ['-xf', '-', '-C', wt], { input: archive });
|
|
126
|
+
modeTree(wt, false);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
item.writable = writable;
|
|
130
|
+
item.head = head;
|
|
131
|
+
// Create every sibling before any install. Local file dependencies and
|
|
132
|
+
// setup hooks must resolve ../cli to this place, never a main checkout.
|
|
133
|
+
save(home, state);
|
|
134
|
+
}
|
|
135
|
+
for (const item of state.repositories.filter((r) => r.writable && !r.setup)) {
|
|
136
|
+
const repo = catalog.find((r) => r.id === item.id);
|
|
137
|
+
if (!repo?.repoRoot) throw new Error(`Repository ${item.name} is not checked out on this machine.`);
|
|
138
|
+
setupOptions.signal?.throwIfAborted();
|
|
139
|
+
await setup(repo.repoRoot, join(home, item.folder), setupOptions);
|
|
140
|
+
item.setup = true;
|
|
141
|
+
save(home, state);
|
|
142
|
+
}
|
|
143
|
+
state.preparing = false;
|
|
144
|
+
save(home, state);
|
|
145
|
+
changed();
|
|
146
|
+
return { wt: join(home, state.repositories.find((r) => r.id === state.primary).folder), fresh: false };
|
|
147
|
+
};
|
|
148
|
+
const remove = (place) => {
|
|
149
|
+
const rows = entries(place);
|
|
150
|
+
if (!rows.length) return false;
|
|
151
|
+
// Measure every writable repo BEFORE removing any of them.
|
|
152
|
+
if (rows.some((r) => r.writable && existsSync(r.wt) && (!r.repoRoot || git(['status', '--porcelain'], r.wt)))) return false;
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
if (!existsSync(row.wt)) continue;
|
|
155
|
+
if (row.writable) git(['worktree', 'remove', row.wt], row.repoRoot);
|
|
156
|
+
else { modeTree(row.wt, true); rmSync(row.wt, { recursive: true }); }
|
|
157
|
+
}
|
|
158
|
+
rmSync(homeFor(place), { recursive: true });
|
|
159
|
+
changed();
|
|
160
|
+
return true;
|
|
161
|
+
};
|
|
162
|
+
const receipts = (place, before) => entries(place).filter((r) => r.writable).flatMap((r) => {
|
|
163
|
+
const from = before.get(r.id);
|
|
164
|
+
if (!from) return [];
|
|
165
|
+
const out = gitOrNull(['log', '--format=%H', '--no-merges', `${from}..HEAD`, '--not', r.baseRef], r.wt);
|
|
166
|
+
return out?.split('\n').filter(Boolean).slice(0, 50).map((sha) => ({ sha, repo: r.id })) ?? [];
|
|
167
|
+
});
|
|
168
|
+
const pending = (place, taskId, repoId, clear = false) => {
|
|
169
|
+
const state = stateFor(place);
|
|
170
|
+
const rows = state.pending ?? [];
|
|
171
|
+
if (repoId) {
|
|
172
|
+
state.pending = rows.filter((r) => r.taskId !== taskId || r.repoId !== repoId);
|
|
173
|
+
if (!clear) state.pending.push({ taskId, repoId });
|
|
174
|
+
save(homeFor(place), state);
|
|
175
|
+
}
|
|
176
|
+
return (state.pending ?? []).filter((r) => r.taskId === taskId).map((r) => r.repoId);
|
|
177
|
+
};
|
|
178
|
+
return { ensure, entries, stateFor, remove, receipts, pending };
|
|
179
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Fresh multi-repository worktrees each get their own environment and install.
|
|
2
|
+
* A checked-in .flowviant/setup.sh replaces the default dependency install. */
|
|
3
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync } from 'node:fs';
|
|
4
|
+
import { join, dirname } from 'node:path';
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { killGroup } from './processGroup.mjs';
|
|
7
|
+
import { git } from './git.mjs';
|
|
8
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
9
|
+
import { workLanesStopped } from './standDownGate.mjs';
|
|
10
|
+
|
|
11
|
+
export async function setupRepository(root, wt, { signal, onSpawn = () => {}, onClose = () => {} } = {}) {
|
|
12
|
+
signal?.throwIfAborted();
|
|
13
|
+
if (workLanesStopped()) throw new Error('The daemon is stopping. Repository setup has not started.');
|
|
14
|
+
const copyEnv = (relative = '') => {
|
|
15
|
+
for (const name of readdirSync(join(root, relative))) {
|
|
16
|
+
if (['.git', 'node_modules', '.flowviant', 'dist', 'target', '.next', '.cache', '.venv'].includes(name)) continue;
|
|
17
|
+
const rel = join(relative, name), src = join(root, rel), dest = join(wt, rel);
|
|
18
|
+
const st = lstatSync(src);
|
|
19
|
+
if (st.isSymbolicLink()) continue;
|
|
20
|
+
if (st.isDirectory()) {
|
|
21
|
+
if (existsSync(dest)) copyEnv(rel);
|
|
22
|
+
} else if (/^\.env(?:\.|$)/.test(name) && !existsSync(dest)) {
|
|
23
|
+
try { git(['check-ignore', '--quiet', '--', rel], wt); } catch { continue; }
|
|
24
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
25
|
+
copyFileSync(src, dest);
|
|
26
|
+
chmodSync(dest, 0o600);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
copyEnv();
|
|
31
|
+
const command = existsSync(join(wt, '.flowviant/setup.sh')) ? ['sh', '.flowviant/setup.sh'] :
|
|
32
|
+
['bun.lock', 'bun.lockb'].some((f) => existsSync(join(wt, f))) ? ['bun', 'install', '--frozen-lockfile'] :
|
|
33
|
+
existsSync(join(wt, 'pnpm-lock.yaml')) ? ['pnpm', 'install', '--frozen-lockfile'] :
|
|
34
|
+
existsSync(join(wt, 'package-lock.json')) ? ['npm', 'ci'] :
|
|
35
|
+
existsSync(join(wt, 'yarn.lock')) ? ['yarn', 'install', '--frozen-lockfile'] : null;
|
|
36
|
+
if (!command) return;
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
const child = spawn(command[0], command.slice(1), { cwd: wt, env: unattendedEnv({ ...process.env, GIT_TERMINAL_PROMPT: '0' }), detached: true, stdio: ['ignore', 'ignore', 'pipe'] });
|
|
39
|
+
const abort = () => killGroup(child);
|
|
40
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
41
|
+
if (signal?.aborted) abort();
|
|
42
|
+
onSpawn(child);
|
|
43
|
+
let tail = '';
|
|
44
|
+
child.stderr.on('data', (s) => { tail = (tail + s).slice(-2000); });
|
|
45
|
+
const timer = setTimeout(() => { killGroup(child); }, 10 * 60_000);
|
|
46
|
+
child.once('error', (e) => { clearTimeout(timer); reject(e); });
|
|
47
|
+
child.once('close', (code) => {
|
|
48
|
+
clearTimeout(timer); signal?.removeEventListener('abort', abort); onClose(child);
|
|
49
|
+
code === 0 ? resolve() : reject(new Error(`${command.join(' ')} failed in ${wt}: ${tail}`));
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Installs are daemon children too: shutdown must drain their process groups. */
|
|
55
|
+
export const trackedRepositorySetup = (children, groups) => (root, wt, options) => setupRepository(root, wt, {
|
|
56
|
+
...options, onSpawn: (child) => { children.set(child, null); groups.add(child); },
|
|
57
|
+
onClose: (child) => { children.delete(child); groups.delete(child); },
|
|
58
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** A held turn's local capability. The CLI never receives the machine token. */
|
|
2
|
+
import { sameSecret } from './grant.mjs';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { runningCompiledBinary } from './launchCommand.mjs';
|
|
8
|
+
|
|
9
|
+
export async function createRepositoryTool(add) {
|
|
10
|
+
const capability = randomBytes(32).toString('hex');
|
|
11
|
+
let serial = Promise.resolve(), closed = false;
|
|
12
|
+
const server = createServer(async (req, res) => {
|
|
13
|
+
if (closed || req.method !== 'POST' || req.url !== '/' || !sameSecret(req.headers.authorization, `Bearer ${capability}`)) {
|
|
14
|
+
res.writeHead(403).end(); return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
let body = '';
|
|
18
|
+
for await (const data of req) { body += data; if (body.length > 1024) throw new Error('Repository request is too large.'); }
|
|
19
|
+
const { name } = JSON.parse(body);
|
|
20
|
+
if (typeof name !== 'string' || !name.length || name.length > 64) throw new Error('Name a project repository.');
|
|
21
|
+
// A current turn already owns its place's writer lock. Serialize its
|
|
22
|
+
// acquisitions here instead of recursively waiting for that same lock.
|
|
23
|
+
const pending = serial.then(() => { if (closed) throw new Error('This turn has ended.'); return add(name); });
|
|
24
|
+
serial = pending.catch(() => {});
|
|
25
|
+
const result = await pending;
|
|
26
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }).end(JSON.stringify(result));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
res.writeHead(409, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: String(error.message).slice(0, 2000) }));
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
server.requestTimeout = 15_000;
|
|
32
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
|
|
33
|
+
const env = { FLOWVIANT_REPOSITORY_URL: `http://127.0.0.1:${server.address().port}/`, FLOWVIANT_REPOSITORY_CAPABILITY: capability };
|
|
34
|
+
const args = runningCompiledBinary() ? ['__repository-mcp'] : [fileURLToPath(new URL('../cli.mjs', import.meta.url)), '__repository-mcp'];
|
|
35
|
+
return { env, config: { command: process.execPath, args, env },
|
|
36
|
+
async close() { closed = true; server.close(); server.closeIdleConnections(); await serial; server.closeAllConnections(); } };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function callRepository(name) {
|
|
40
|
+
const url = new URL(process.env.FLOWVIANT_REPOSITORY_URL);
|
|
41
|
+
if (url.hostname !== '127.0.0.1' || url.protocol !== 'http:') throw new Error('No held card repository tool is available.');
|
|
42
|
+
const res = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${process.env.FLOWVIANT_REPOSITORY_CAPABILITY}`, 'Content-Type': 'application/json' },
|
|
43
|
+
body: JSON.stringify({ name }), signal: AbortSignal.timeout(12 * 60_000) });
|
|
44
|
+
const result = await res.json();
|
|
45
|
+
if (!res.ok) throw new Error(result.error ?? 'Repository could not be added.');
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function repositoryToolMain(mcp) {
|
|
50
|
+
if (!mcp) {
|
|
51
|
+
try { process.stdout.write(`${JSON.stringify(await callRepository(process.argv[3]))}\n`); }
|
|
52
|
+
catch (error) { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
for await (const line of createInterface({ input: process.stdin })) {
|
|
56
|
+
let request;
|
|
57
|
+
try {
|
|
58
|
+
request = JSON.parse(line);
|
|
59
|
+
if (request.id === undefined) continue;
|
|
60
|
+
let result;
|
|
61
|
+
if (request.method === 'initialize') result = { protocolVersion: request.params?.protocolVersion ?? '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'card-repositories', version: '1.0.0' } };
|
|
62
|
+
else if (request.method === 'ping') result = {};
|
|
63
|
+
else if (request.method === 'tools/list') result = { tools: [{ name: 'add_repository', description: 'Add a project repository to the held card and create its sibling worktree on the same session branch. No human confirmation is needed.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], additionalProperties: false } }] };
|
|
64
|
+
else if (request.method === 'tools/call' && request.params?.name === 'add_repository') {
|
|
65
|
+
try { result = { content: [{ type: 'text', text: JSON.stringify(await callRepository(request.params.arguments?.name)) }] }; }
|
|
66
|
+
catch (error) { result = { isError: true, content: [{ type: 'text', text: error.message }] }; }
|
|
67
|
+
} else { process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32601, message: 'Unknown method' } })}\n`); continue; }
|
|
68
|
+
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\n`);
|
|
69
|
+
} catch { if (request?.id !== undefined) process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: { code: -32602, message: 'Invalid request' } })}\n`); }
|
|
70
|
+
}
|
|
71
|
+
}
|
package/bin/lib/runTurn.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabil
|
|
|
23
23
|
import { codexTurnModel } from './turnModel.mjs';
|
|
24
24
|
import { printableText } from './printable.mjs';
|
|
25
25
|
import { startTurnDiagnostics } from './turnDiagnostics.mjs';
|
|
26
|
+
import { createClaudeInput } from './claudeInput.mjs';
|
|
26
27
|
|
|
27
28
|
/** How long a Codex turn's answer may wait on its rollout's model line. */
|
|
28
29
|
const CODEX_MODEL_READ_MS = 1_500;
|
|
@@ -167,6 +168,8 @@ function runPlainTurn(opts) {
|
|
|
167
168
|
// which posture a turn is running under. There is no precedence chain left
|
|
168
169
|
// to get wrong: the caller named one profile (turnProfile.mjs).
|
|
169
170
|
const args = rt.args({
|
|
171
|
+
streamInput: rt.id === 'claude' && Boolean(opts.liveMessages),
|
|
172
|
+
writableDirs: opts.writableDirs,
|
|
170
173
|
personal,
|
|
171
174
|
researchIsolation: opts.researchIsolation,
|
|
172
175
|
prompt,
|
|
@@ -277,7 +280,7 @@ function runPlainTurn(opts) {
|
|
|
277
280
|
cwd, args, profile: turnProfile.name, planId: opts.diagnosticPlanId });
|
|
278
281
|
child = spawn(rt.bin, args, {
|
|
279
282
|
cwd,
|
|
280
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
283
|
+
stdio: [rt.id === 'claude' && opts.liveMessages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
|
|
281
284
|
/**
|
|
282
285
|
* ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
|
|
283
286
|
*
|
|
@@ -324,6 +327,7 @@ function runPlainTurn(opts) {
|
|
|
324
327
|
// than this is a previous turn's (turnModel.mjs).
|
|
325
328
|
const spawnedAt = Date.now();
|
|
326
329
|
let out = '';
|
|
330
|
+
const input = rt.id === 'claude' && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
|
|
327
331
|
const pfx = label ? `${label} ` : '';
|
|
328
332
|
/**
|
|
329
333
|
* THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
|
|
@@ -401,6 +405,14 @@ function runPlainTurn(opts) {
|
|
|
401
405
|
const onLine = (line) => {
|
|
402
406
|
try {
|
|
403
407
|
const event = JSON.parse(line);
|
|
408
|
+
if (input) {
|
|
409
|
+
input.event(event);
|
|
410
|
+
if (event.type === 'system' && event.subtype === 'init' && event.session_id) opts.liveMessages.onInit(event.session_id);
|
|
411
|
+
// A message written just before result may become a second native
|
|
412
|
+
// turn in this process. Return its final result alone, never two
|
|
413
|
+
// structured objects joined together; every result still reports spend.
|
|
414
|
+
if (event.type === 'result') out = '';
|
|
415
|
+
}
|
|
404
416
|
if ((event.type === 'result' && event.is_error === true) ||
|
|
405
417
|
event.type === 'error' || event.type === 'turn.failed') {
|
|
406
418
|
cliFailed = true;
|
|
@@ -467,6 +479,8 @@ function runPlainTurn(opts) {
|
|
|
467
479
|
* resolves what was captured so far.
|
|
468
480
|
*/
|
|
469
481
|
const onSpawnError = (e) => {
|
|
482
|
+
input?.close();
|
|
483
|
+
if (opts.liveMessages) void opts.liveMessages.stop();
|
|
470
484
|
finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
|
|
471
485
|
releaseArgs();
|
|
472
486
|
if (e.code === 'ENOENT') {
|
|
@@ -479,11 +493,13 @@ function runPlainTurn(opts) {
|
|
|
479
493
|
};
|
|
480
494
|
child.on('error', onSpawnError);
|
|
481
495
|
child.on('close', (code, signal) => {
|
|
496
|
+
input?.close();
|
|
482
497
|
releaseArgs();
|
|
483
498
|
opts.onExit?.(code, signal);
|
|
484
499
|
if (buf.trim()) onLine(buf);
|
|
485
500
|
finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed });
|
|
486
|
-
const handBack = () => {
|
|
501
|
+
const handBack = async () => {
|
|
502
|
+
if (opts.liveMessages) await opts.liveMessages.stop();
|
|
487
503
|
resolve(out);
|
|
488
504
|
// After the answer is handed back, never before it: see `onRate`.
|
|
489
505
|
if (codexThread) {
|
|
@@ -514,5 +530,10 @@ function runPlainTurn(opts) {
|
|
|
514
530
|
}
|
|
515
531
|
handBack();
|
|
516
532
|
});
|
|
533
|
+
if (input) {
|
|
534
|
+
const initial = opts.liveMessages.initialPrompt(prompt, resumeThreadId, resume, opts.liveContinuation === true);
|
|
535
|
+
input.send(opts.liveMessages.initialId || opts.liveMessages.turnId, initial);
|
|
536
|
+
opts.liveMessages.connect(input.send);
|
|
537
|
+
}
|
|
517
538
|
});
|
|
518
539
|
}
|
|
@@ -147,7 +147,7 @@ export const ANTIGRAVITY_RUNTIME = {
|
|
|
147
147
|
planTurns: false,
|
|
148
148
|
adopt: 'move',
|
|
149
149
|
mcp: null,
|
|
150
|
-
args({ prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
|
|
150
|
+
args({ writableDirs = [], prompt, system, model, effort, resume, resumeConversationId, profile = 'build', vaultDir, resultSchemaArgs = [], adoptResumeId }) {
|
|
151
151
|
const a = [];
|
|
152
152
|
// WHICH CONVERSATION IS THIS — one decision, answered one way (the same
|
|
153
153
|
// rule as Claude's --resume/--continue). By id when the caller knows:
|
|
@@ -212,6 +212,7 @@ export const ANTIGRAVITY_RUNTIME = {
|
|
|
212
212
|
// So a SAFE-mode operator gets an agy build that is not actually
|
|
213
213
|
// narrowed — which is why `mediatedSafeGap` is surfaced rather than
|
|
214
214
|
// quietly ignored.
|
|
215
|
+
for (const dir of writableDirs) a.push('--add-dir', dir);
|
|
215
216
|
a.push('--dangerously-skip-permissions');
|
|
216
217
|
a.push('--print-timeout', '60m');
|
|
217
218
|
} else if (profile === 'consult') {
|
|
@@ -101,7 +101,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
101
101
|
* strongest form of it available anywhere: `--append-system-prompt` sits
|
|
102
102
|
* above the conversation rather than inside it.
|
|
103
103
|
*/
|
|
104
|
-
args({ prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp }) {
|
|
104
|
+
args({ writableDirs = [], prompt, system, model, effort, resume, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, vaultDir, agentTools, personalSettings, machineMcp, streamInput = false }) {
|
|
105
105
|
// THE CARTOGRAPHER'S FENCE NAMES ITS VAULT (2026-10-10): its one write rule
|
|
106
106
|
// is the vault's absolute path (claudePosture.mjs `wikiPermFor`), so a turn
|
|
107
107
|
// with no vault, or one no rule can name, has no list — refused here in
|
|
@@ -138,7 +138,9 @@ export const CLAUDE_RUNTIME = {
|
|
|
138
138
|
if (adoptResumeId) a.push('--resume', adoptResumeId, '--fork-session');
|
|
139
139
|
else if (resumeThreadId) a.push('--resume', resumeThreadId);
|
|
140
140
|
else if (resume) a.push('--continue');
|
|
141
|
-
a.push('-p',
|
|
141
|
+
if (streamInput) a.push('-p', '--input-format', 'stream-json', '--replay-user-messages');
|
|
142
|
+
else a.push('-p', prompt);
|
|
143
|
+
a.push('--append-system-prompt', system);
|
|
142
144
|
/**
|
|
143
145
|
* WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
|
|
144
146
|
*
|
|
@@ -205,6 +207,7 @@ export const CLAUDE_RUNTIME = {
|
|
|
205
207
|
// worktree's cwd: named as a readable directory so a curated profile
|
|
206
208
|
// does not refuse the path the prompt just handed it. Before `perm`,
|
|
207
209
|
// because `--allowedTools` is variadic and would swallow the flag.
|
|
210
|
+
if (profile === 'build') for (const dir of writableDirs) a.push('--add-dir', dir);
|
|
208
211
|
if (knowledgeDir) a.push('--add-dir', knowledgeDir);
|
|
209
212
|
// THE VAULT, likewise (2026-10-10): it lives under ~/.flowviant/vaults,
|
|
210
213
|
// outside the wiki worktree, and its `mkdir`/`rm` are path-validated to the
|
package/bin/lib/runtimeCodex.mjs
CHANGED
|
@@ -233,7 +233,7 @@ export const CODEX_RUNTIME = {
|
|
|
233
233
|
* placed before it. Appending them after the positional is the kind of argv
|
|
234
234
|
* that parses today and stops parsing on some future clap upgrade.
|
|
235
235
|
*/
|
|
236
|
-
args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
|
|
236
|
+
args({ writableDirs = [], prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null, researchIsolation, knowledgeDir, projectReads }) {
|
|
237
237
|
// Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
|
|
238
238
|
// agy moves) — codex has no adoptable store wired yet. Reaching here
|
|
239
239
|
// with an adopt id is a wiring mistake upstream, and it fails loudly on
|
|
@@ -277,6 +277,7 @@ export const CODEX_RUNTIME = {
|
|
|
277
277
|
// `--sandbox` flag (codex-cli 0.156.1 lists only `-c`/`--enable`/…), so
|
|
278
278
|
// every resumed turn — an agent's second card, an answer — was refused
|
|
279
279
|
// before it started. `-c sandbox_mode=` means the same on both.
|
|
280
|
+
if (writableDirs.length) a.push('-c', `sandbox_workspace_write.writable_roots=${JSON.stringify(writableDirs)}`);
|
|
280
281
|
a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
|
|
281
282
|
}
|
|
282
283
|
|
package/bin/lib/shipMerge.mjs
CHANGED
|
@@ -26,12 +26,14 @@ export function mergeOutward({
|
|
|
26
26
|
repoRoot,
|
|
27
27
|
tmpDir,
|
|
28
28
|
baseRef,
|
|
29
|
+
baseTip,
|
|
29
30
|
workingTree,
|
|
30
31
|
warn,
|
|
31
32
|
// NETWORK calls only (push, fetch) — timed and non-interactive, so a
|
|
32
33
|
// stale credential or a half-open connection cannot freeze the ship
|
|
33
34
|
// path's writer lock. Injectable for tests; defaults to the real one.
|
|
34
35
|
gitNet = gitNetDefault,
|
|
36
|
+
retryRace = true,
|
|
35
37
|
}) {
|
|
36
38
|
const dropTmp = () => {
|
|
37
39
|
try {
|
|
@@ -56,6 +58,7 @@ export function mergeOutward({
|
|
|
56
58
|
const base = baseBranchName(baseRef());
|
|
57
59
|
const merged = String(git(['rev-parse', 'HEAD'], tmpDir)).trim();
|
|
58
60
|
const before = String(git(['rev-parse', `refs/heads/${base}`], repoRoot)).trim();
|
|
61
|
+
if (baseTip && before !== baseTip) throw new Error(`base moved to ${before}, but expected ${baseTip}`);
|
|
59
62
|
let current = null;
|
|
60
63
|
try { current = String(git(['symbolic-ref', '--quiet', '--short', 'HEAD'], repoRoot)).trim(); } catch { /* detached */ }
|
|
61
64
|
if (current === base) {
|
|
@@ -76,7 +79,7 @@ export function mergeOutward({
|
|
|
76
79
|
};
|
|
77
80
|
const attempt = () => {
|
|
78
81
|
dropTmp();
|
|
79
|
-
git(['worktree', 'add', '--detach', tmpDir, baseRef()], repoRoot);
|
|
82
|
+
git(['worktree', 'add', '--detach', tmpDir, baseTip ?? baseRef()], repoRoot);
|
|
80
83
|
gitMerge(
|
|
81
84
|
['merge', '--no-ff', tip, '-m', `ship(${label}): ${count} commit${count === 1 ? '' : 's'}`],
|
|
82
85
|
tmpDir
|
|
@@ -110,7 +113,7 @@ export function mergeOutward({
|
|
|
110
113
|
* re-pushing a stale merge. The TIP is untouched, so the receipts still
|
|
111
114
|
* name exactly the same commits.
|
|
112
115
|
*/
|
|
113
|
-
if (!isRaceRejection(e)) throw e;
|
|
116
|
+
if (!retryRace || !isRaceRejection(e)) throw e;
|
|
114
117
|
warn?.('ship: base moved under us — refetching and merging again');
|
|
115
118
|
try {
|
|
116
119
|
gitNet(['fetch', 'origin', '--quiet'], repoRoot, 60_000);
|
package/bin/lib/startProject.mjs
CHANGED
|
@@ -188,8 +188,8 @@ export async function resolveStartProject({ dirGiven }) {
|
|
|
188
188
|
}
|
|
189
189
|
// An answered question is consent: adopt it, and BIND it to this repo so
|
|
190
190
|
// the next start needs no prompt. Repointing is legitimate and said aloud.
|
|
191
|
-
if (repoRoot && picked.repoRoot && picked
|
|
192
|
-
console.log(`note: ${creds.projectLabel(picked)}
|
|
191
|
+
if (repoRoot && picked.repoRoot && !creds.isStoredRepo(picked, repoRoot)) {
|
|
192
|
+
console.log(`note: adding ${repoRoot} to ${creds.projectLabel(picked)}. Run ${terminalCommand('login')} here to register its repository in project settings.`);
|
|
193
193
|
}
|
|
194
194
|
adoptStoredCredential(picked);
|
|
195
195
|
// BOUND ONLY ONCE THE START HAS THE LOCK. Written here, the answer moved
|
|
@@ -266,10 +266,10 @@ export async function resolveStartProject({ dirGiven }) {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
if (process.argv.includes('--json-events') && dirGiven && CREDENTIAL?.entry) {
|
|
269
|
-
const { detectRepoRoot, listStoredProjects, boundElsewhere } = await import('./credentials.mjs');
|
|
269
|
+
const { detectRepoRoot, listStoredProjects, boundElsewhere, isStoredRepo } = await import('./credentials.mjs');
|
|
270
270
|
const repo = detectRepoRoot();
|
|
271
271
|
const entry = CREDENTIAL.entry;
|
|
272
|
-
if (!repo || entry
|
|
272
|
+
if (!repo || !isStoredRepo(entry, repo) || boundElsewhere(listStoredProjects(), repo, entry.projectId).length) {
|
|
273
273
|
console.error('error: this project is not connected to this repository. Run `flowviant login` for the selected folder.');
|
|
274
274
|
process.exit(1);
|
|
275
275
|
}
|