flowviant 0.117.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.
Files changed (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -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
+ }
@@ -22,6 +22,8 @@ import { learnCodexLimits, recordClaudeRateLimit } from './runtimeLimits.mjs';
22
22
  import { initHarvestFor, recordMcpServers, recordSkills } from './runtimeCapabilities.mjs';
23
23
  import { codexTurnModel } from './turnModel.mjs';
24
24
  import { printableText } from './printable.mjs';
25
+ import { startTurnDiagnostics } from './turnDiagnostics.mjs';
26
+ import { createClaudeInput } from './claudeInput.mjs';
25
27
 
26
28
  /** How long a Codex turn's answer may wait on its rollout's model line. */
27
29
  const CODEX_MODEL_READ_MS = 1_500;
@@ -166,6 +168,8 @@ function runPlainTurn(opts) {
166
168
  // which posture a turn is running under. There is no precedence chain left
167
169
  // to get wrong: the caller named one profile (turnProfile.mjs).
168
170
  const args = rt.args({
171
+ streamInput: rt.id === 'claude' && Boolean(opts.liveMessages),
172
+ writableDirs: opts.writableDirs,
169
173
  personal,
170
174
  researchIsolation: opts.researchIsolation,
171
175
  prompt,
@@ -195,8 +199,8 @@ function runPlainTurn(opts) {
195
199
  // prompt hands an absolute path OUTSIDE the cwd — and a curated Claude
196
200
  // profile (the capture chat's read-only list, `FLOWVIANT_SAFE=1`) may
197
201
  // refuse a read there. `--add-dir` says the directory is one it may read.
198
- // Only Claude's adapter uses it; codex's sandboxes read the filesystem
199
- // already, and agy's `--add-dir` is spent on the wiki vault.
202
+ // Claude adds it as a directory; Codex includes only this library in
203
+ // its project read boundary; agy's `--add-dir` is spent on the vault.
200
204
  knowledgeDir,
201
205
  // Structured-output flags for the MEDIATED path. Handed to the adapter
202
206
  // rather than appended here for the same reason `mcp` is: Codex takes its
@@ -206,7 +210,16 @@ function runPlainTurn(opts) {
206
210
  // PLAN MODE REPLACES THE POSTURE, never joins it: beside
207
211
  // `--dangerously-skip-permissions` the bypass wins silently (measured —
208
212
  // see PLAN_MODE_PERM).
209
- perm: claudePermFor(turnProfile.name, knowledgeDir, vaultDir),
213
+ //
214
+ // …AND THE PROJECT SERVERS IT MAY CALL (2026-10-10): the names of the
215
+ // base `.mcp.json` servers the caller mounted, each allowed by name on a
216
+ // profile that mounts them (`projectMcp`, turnProfile.mjs) — never on
217
+ // one that does not, whatever a caller passes.
218
+ perm: claudePermFor(turnProfile.name, knowledgeDir, vaultDir, turnProfile.projectMcp ? (opts.projectMcpServers ?? []) : []),
219
+ // …AND THE MACHINE'S OWN MCP SERVERS (2026-10-11): the `.mcp.json`
220
+ // server names an agent lane disables beside them, on a profile that
221
+ // mounts project servers — never on one that does not.
222
+ machineMcp: turnProfile.projectMcp ? opts.machineMcp : undefined,
210
223
  // Handed to the adapter rather than appended here, because WHERE these go
211
224
  // is a property of the CLI: Codex reads its prompt as a trailing
212
225
  // positional, so a flag after it is a flag in the wrong place.
@@ -253,14 +266,21 @@ function runPlainTurn(opts) {
253
266
  };
254
267
  let browser = null;
255
268
  let child;
269
+ let finishDiagnostics = () => {};
256
270
  try {
257
271
  // A turn nobody is sitting at keeps its browsers in a home of its own —
258
272
  // the lane's kept one (an agent's), else this spawn's, removed when the
259
273
  // CLI closes. See `cliEnv`.
260
274
  browser = display === true ? null : openBrowserHome(browserHome);
275
+ const childEnv = cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
276
+ display: display === true,
277
+ browserHome: browser?.dir,
278
+ });
279
+ finishDiagnostics = startTurnDiagnostics({ runtime, bin: rt.bin, env: childEnv,
280
+ cwd, args, profile: turnProfile.name, planId: opts.diagnosticPlanId });
261
281
  child = spawn(rt.bin, args, {
262
282
  cwd,
263
- stdio: ['ignore', 'pipe', 'pipe'],
283
+ stdio: [rt.id === 'claude' && opts.liveMessages ? 'pipe' : 'ignore', 'pipe', 'pipe'],
264
284
  /**
265
285
  * ITS OWN PROCESS GROUP, so what the agent starts stays attributable.
266
286
  *
@@ -287,12 +307,10 @@ function runPlainTurn(opts) {
287
307
  // sitting at, every way to a window — see `cliEnv`.
288
308
  // The login's literal secrets first, so nothing of the lane's (its MCP
289
309
  // token, its CODEX_HOME) can be overwritten by one.
290
- env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
291
- display: display === true,
292
- browserHome: browser?.dir,
293
- }),
310
+ env: childEnv,
294
311
  });
295
312
  } catch (error) {
313
+ finishDiagnostics({ spawnError: typeof error?.code === 'string' ? error.code : 'SPAWN_THROW' });
296
314
  // A spawn that throws (an argument it refuses), or anything on the way
297
315
  // to it, still takes what the adapter wrote with it — and this spawn's
298
316
  // own browser home.
@@ -309,6 +327,7 @@ function runPlainTurn(opts) {
309
327
  // than this is a previous turn's (turnModel.mjs).
310
328
  const spawnedAt = Date.now();
311
329
  let out = '';
330
+ const input = rt.id === 'claude' && opts.liveMessages ? createClaudeInput(child, { onTaken: opts.liveMessages.onTaken }) : null;
312
331
  const pfx = label ? `${label} ` : '';
313
332
  /**
314
333
  * THE ONE SINK FOR WHAT A CLI OR A MODEL WROTE (2026-10-10, the audit's
@@ -378,19 +397,28 @@ function runPlainTurn(opts) {
378
397
  const harvest = initHarvestFor(rt, args);
379
398
  const onInitAll = (i) => {
380
399
  if (harvest.skills) recordSkills(i?.skills);
381
- if (harvest.mcp) recordMcpServers(i?.mcpServers);
400
+ if (harvest.mcp) recordMcpServers(i?.mcpServers, i?.tools);
382
401
  onInit?.(i);
383
402
  };
384
403
  /** One line of the child's stdout, in whichever dialect it speaks. */
404
+ let cliFailed = false;
385
405
  const onLine = (line) => {
386
- if (opts.onCliFailure) {
387
- try {
388
- const event = JSON.parse(line);
389
- if ((event.type === 'result' && event.is_error === true) ||
390
- event.type === 'error' || event.type === 'turn.failed')
391
- opts.onCliFailure();
392
- } catch { /* ordinary non-JSON CLI output */ }
393
- }
406
+ try {
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
+ }
416
+ if ((event.type === 'result' && event.is_error === true) ||
417
+ event.type === 'error' || event.type === 'turn.failed') {
418
+ cliFailed = true;
419
+ opts.onCliFailure?.();
420
+ }
421
+ } catch { /* ordinary non-JSON CLI output */ }
394
422
  if (!rt.parse)
395
423
  return handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit: onInitAll, onUsage, onRateLimit: onRate, onModel: onRanOn });
396
424
  const ev = rt.parse(line, cwd);
@@ -405,6 +433,7 @@ function runPlainTurn(opts) {
405
433
  // The runtime's own token count (codex's `turn.completed`) — the same
406
434
  // `onUsage` Claude's result event feeds, so a caller charges a turn
407
435
  // identically whichever CLI ran it.
436
+ if (ev.toolEvent) onToolEvent?.(...ev.toolEvent);
408
437
  if (ev.usage) onUsage?.(ev.usage);
409
438
  // One agent MESSAGE, alone — see parseCodexLine's `answer`. Last call
410
439
  // wins at the caller, which is what "the final answer" means.
@@ -450,6 +479,9 @@ function runPlainTurn(opts) {
450
479
  * resolves what was captured so far.
451
480
  */
452
481
  const onSpawnError = (e) => {
482
+ input?.close();
483
+ if (opts.liveMessages) void opts.liveMessages.stop();
484
+ finishDiagnostics({ pid: child.pid, spawnError: typeof e.code === 'string' ? e.code : 'SPAWN_ERROR' });
453
485
  releaseArgs();
454
486
  if (e.code === 'ENOENT') {
455
487
  console.error(`\nerror: '${rt.bin}' CLI not found on PATH. Install ${rt.label} first: ${rt.install}`);
@@ -461,10 +493,13 @@ function runPlainTurn(opts) {
461
493
  };
462
494
  child.on('error', onSpawnError);
463
495
  child.on('close', (code, signal) => {
496
+ input?.close();
464
497
  releaseArgs();
465
498
  opts.onExit?.(code, signal);
466
499
  if (buf.trim()) onLine(buf);
467
- const handBack = () => {
500
+ finishDiagnostics({ code, signal, pid: child.pid, output: out, cliFailed });
501
+ const handBack = async () => {
502
+ if (opts.liveMessages) await opts.liveMessages.stop();
468
503
  resolve(out);
469
504
  // After the answer is handed back, never before it: see `onRate`.
470
505
  if (codexThread) {
@@ -495,5 +530,10 @@ function runPlainTurn(opts) {
495
530
  }
496
531
  handBack();
497
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
+ }
498
538
  });
499
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') {