flowviant 0.117.0 → 0.124.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/README.md +35 -0
- package/bin/cli.mjs +37 -0
- package/bin/lib/agentAsk.mjs +123 -0
- package/bin/lib/agentBrief.mjs +36 -0
- package/bin/lib/agentFootprint.mjs +76 -0
- package/bin/lib/agentInternalWait.mjs +2 -2
- package/bin/lib/agentPlan.mjs +78 -7
- package/bin/lib/agentPrerequisiteContract.mjs +43 -0
- package/bin/lib/agentTaskKinds.mjs +1 -1
- package/bin/lib/artifactContracts.mjs +91 -20
- package/bin/lib/artifactIteration.mjs +182 -0
- package/bin/lib/artifactRecovery.mjs +36 -0
- package/bin/lib/artifactReporter.mjs +183 -19
- package/bin/lib/artifacts.mjs +6 -3
- package/bin/lib/claudeAuth.mjs +15 -5
- package/bin/lib/claudePosture.mjs +110 -14
- package/bin/lib/claudeStream.mjs +20 -2
- package/bin/lib/codexProjectRead.mjs +63 -0
- package/bin/lib/codexResearch.mjs +16 -3
- package/bin/lib/commandSpecs.mjs +6 -0
- package/bin/lib/daemonLocks.mjs +5 -3
- package/bin/lib/desktopContract.mjs +3 -0
- package/bin/lib/discoveryContract.mjs +19 -0
- package/bin/lib/fleet.mjs +46 -4
- package/bin/lib/fleetReports.mjs +44 -3
- package/bin/lib/hooks/mcpAllow.mjs +60 -0
- package/bin/lib/hooks/readGuard.mjs +13 -4
- package/bin/lib/imageArtifact.mjs +74 -0
- package/bin/lib/maintenance.mjs +47 -0
- package/bin/lib/maintenanceInventory.mjs +39 -0
- package/bin/lib/mcpInstall.mjs +163 -0
- package/bin/lib/mcpInventory.mjs +119 -0
- package/bin/lib/planDependencies.mjs +70 -0
- package/bin/lib/postureTools.mjs +85 -0
- package/bin/lib/projectToolRuntimes.mjs +227 -3
- package/bin/lib/projectTools.mjs +61 -4
- package/bin/lib/prompts.mjs +164 -41
- package/bin/lib/repositoryBaseline.mjs +68 -0
- package/bin/lib/runTurn.mjs +35 -16
- package/bin/lib/runtimeCapabilities.mjs +77 -3
- package/bin/lib/runtimeClaude.mjs +27 -9
- package/bin/lib/runtimeCodex.mjs +27 -59
- package/bin/lib/runtimeDetection.mjs +6 -0
- package/bin/lib/runtimeEvents.mjs +12 -1
- package/bin/lib/runtimeOfferings.mjs +75 -3
- package/bin/lib/turnDiagnostics.mjs +93 -0
- package/bin/lib/turnProfile.mjs +15 -3
- package/bin/lib/work.mjs +28 -7
- package/bin/lib/workAgentBrief.mjs +92 -0
- package/bin/lib/workAgentBriefRunner.mjs +59 -0
- package/bin/lib/workAgentPlans.mjs +21 -70
- package/bin/lib/workAgentPrecheck.mjs +5 -0
- package/bin/lib/workAgentTurnExecution.mjs +93 -22
- package/bin/lib/workAgentTurnOutcome.mjs +11 -2
- package/bin/lib/workAgentTurns.mjs +60 -4
- package/bin/lib/workArtifactReconcile.mjs +79 -0
- package/bin/lib/workCappedTurn.mjs +10 -0
- package/bin/lib/workCommandAudit.mjs +8 -1
- package/bin/lib/workPlaces.mjs +18 -2
- package/bin/lib/workSessionTurns.mjs +148 -13
- package/bin/lib/workToolLog.mjs +70 -3
- package/bin/lib/workTurnShape.mjs +6 -4
- package/npm-shrinkwrap.json +12 -2
- package/package.json +7 -3
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** The restart must also see live locks not backed by a stored project, and
|
|
2
|
+
* unguarded processes recorded in state. Neither is an empty machine. */
|
|
3
|
+
import { instanceLockPath, listDaemonLocks } from './daemonLocks.mjs';
|
|
4
|
+
import { daemonLockPid, readDaemonState } from './daemonState.mjs';
|
|
5
|
+
import { listStoredProjects } from './credentials.mjs';
|
|
6
|
+
import { processAlive } from './procRegistry.mjs';
|
|
7
|
+
|
|
8
|
+
export function unknownMaintenanceDaemons({ entries = listStoredProjects(), locks = listDaemonLocks(),
|
|
9
|
+
stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
|
|
10
|
+
const known = new Set();
|
|
11
|
+
const unknown = new Set();
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
const state = stateFor(entry.projectId);
|
|
14
|
+
const pid = pidFor(entry.fleetToken);
|
|
15
|
+
if (pid != null && pid === state.pid) known.add(pid);
|
|
16
|
+
if (Number.isInteger(state.pid) && state.pid > 0 && state.pid !== pid && alive(state.pid)) unknown.add(state.pid);
|
|
17
|
+
}
|
|
18
|
+
for (const { path, holder } of locks) {
|
|
19
|
+
if (!holder) unknown.add(path); // unreadable is not absent
|
|
20
|
+
else if (alive(holder.pid) && !known.has(holder.pid)) unknown.add(holder.pid);
|
|
21
|
+
}
|
|
22
|
+
return unknown.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The ordinary tray status reserves null for an absent lock. For a global
|
|
26
|
+
* restart we additionally measure the whole lock directory: no lock and no
|
|
27
|
+
* live state PID means this stored project's daemon is stopped. Unreadable
|
|
28
|
+
* directories throw rather than becoming a fabricated empty machine. */
|
|
29
|
+
export function maintenanceInventory(status, { entries = listStoredProjects(), locks = listDaemonLocks({ strict: true }),
|
|
30
|
+
stateFor = readDaemonState, pidFor = daemonLockPid, alive = processAlive } = {}) {
|
|
31
|
+
const paths = new Set(locks.map(lock => lock.path));
|
|
32
|
+
const projects = status.projects.map(row => {
|
|
33
|
+
const entry = entries.find(entry => entry.projectId === row.id);
|
|
34
|
+
if (row.running !== null || !entry || paths.has(instanceLockPath(entry.fleetToken))) return row;
|
|
35
|
+
const pid = stateFor(row.id).pid;
|
|
36
|
+
return Number.isInteger(pid) && pid > 0 && alive(pid) ? row : { ...row, running: false };
|
|
37
|
+
});
|
|
38
|
+
return { projects, unknownDaemons: unknownMaintenanceDaemons({ entries, locks, stateFor, pidFor, alive }) };
|
|
39
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A CONFIRMED MCP INSTALL (2026-10-13, the owner; the app's half is
|
|
3
|
+
* packages/shared `mcpSetup.ts`). A project owner pressed "Install on <this
|
|
4
|
+
* machine>" on the Machines page and confirmed the exact command; the roster
|
|
5
|
+
* hands it to THIS box as `mcpInstallJobs`.
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS DOES, AND ALL IT DOES:
|
|
8
|
+
* · claims the job (single-flight across processes, bound to this box);
|
|
9
|
+
* · refuses, without running anything, a command its own copy of the rule
|
|
10
|
+
* refuses (`mcpInstallCommandRefusal` — a privilege word, more than one
|
|
11
|
+
* line, over the cap; parity rule 36 holds it to the server's);
|
|
12
|
+
* · runs EXACTLY the text it was handed through `/bin/sh -c`, in the
|
|
13
|
+
* project's checkout, as this daemon's own user, never with sudo, stdin
|
|
14
|
+
* closed, as an unattended child (no machine credential, no display), in
|
|
15
|
+
* its own process group killed whole at the timeout;
|
|
16
|
+
* · streams the output's tail back about once a second, and at the end the
|
|
17
|
+
* exit code;
|
|
18
|
+
* · then looks again (`recheck`): the tool report is rescanned and posted,
|
|
19
|
+
* and Claude Code is probed with the base branch's servers mounted, so the
|
|
20
|
+
* page's checklist clears when the server starts.
|
|
21
|
+
*
|
|
22
|
+
* The command is never rewritten, prefixed or appended to.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from 'node:child_process';
|
|
25
|
+
import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
|
|
26
|
+
import { fleetEndpoint } from './fleetWire.mjs';
|
|
27
|
+
import { claimFleetLease, postBestEffort, settleLease } from './fleetPost.mjs';
|
|
28
|
+
import { killGroup } from './processGroup.mjs';
|
|
29
|
+
import { unattendedEnv } from './noWindowEnv.mjs';
|
|
30
|
+
import { scrubTail } from './uplinkScrub.mjs';
|
|
31
|
+
import { c, note, ok, warn } from './ui.mjs';
|
|
32
|
+
import { printableLine } from './printable.mjs';
|
|
33
|
+
|
|
34
|
+
/** The server's bounds (shared `mcpSetup.ts`; parity rule 36). */
|
|
35
|
+
export const MCP_INSTALL_COMMAND_MAX = 500;
|
|
36
|
+
export const MCP_INSTALL_OUTPUT_MAX = 16_000;
|
|
37
|
+
export const MCP_INSTALL_TIMEOUT_MS = 10 * 60_000;
|
|
38
|
+
export const MCP_INSTALL_PRIVILEGE_WORDS = Object.freeze(['sudo', 'doas', 'su', 'pkexec', 'run0']);
|
|
39
|
+
|
|
40
|
+
/** Why this command cannot run, or null — the shared rule, word for word. */
|
|
41
|
+
export function mcpInstallCommandRefusal(command) {
|
|
42
|
+
if (typeof command !== 'string' || !command.trim()) return 'The install command is empty.';
|
|
43
|
+
if (command.length > MCP_INSTALL_COMMAND_MAX) return `The install command is over ${MCP_INSTALL_COMMAND_MAX} characters.`;
|
|
44
|
+
if (/[\r\n\0]/.test(command)) return 'The install command must be one line.';
|
|
45
|
+
const words = command.split(/[^A-Za-z0-9_-]+/);
|
|
46
|
+
const word = MCP_INSTALL_PRIVILEGE_WORDS.find((w) => words.includes(w));
|
|
47
|
+
if (word) return `Flowviant never installs with ${word}. Use a command that installs as the machine's own user.`;
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = (tail) => fleetEndpoint(tail, FLEET_URL);
|
|
52
|
+
const tail = (text) => (text.length > MCP_INSTALL_OUTPUT_MAX ? text.slice(text.length - MCP_INSTALL_OUTPUT_MAX) : text);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Run one command and hand back `{ code, out }`. `onOutput(out)` hears the
|
|
56
|
+
* tail as it grows. `spawnChild` is a test's.
|
|
57
|
+
*/
|
|
58
|
+
export function runInstallCommand(command, { cwd, timeoutMs = MCP_INSTALL_TIMEOUT_MS, env = unattendedEnv(), onOutput = () => {}, spawnChild = spawn } = {}) {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let child;
|
|
61
|
+
try {
|
|
62
|
+
child = spawnChild('/bin/sh', ['-c', command], { cwd, env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
resolve({ code: -1, out: `[flowviant] could not start the command: ${error?.message ?? error}` });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
let buf = '';
|
|
68
|
+
let settled = false;
|
|
69
|
+
const settle = (v) => {
|
|
70
|
+
if (settled) return;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
resolve(v);
|
|
74
|
+
};
|
|
75
|
+
const take = (d) => {
|
|
76
|
+
buf += d;
|
|
77
|
+
if (buf.length > 2 * MCP_INSTALL_OUTPUT_MAX) buf = scrubTail(buf, MCP_INSTALL_OUTPUT_MAX);
|
|
78
|
+
onOutput(scrubTail(buf, MCP_INSTALL_OUTPUT_MAX));
|
|
79
|
+
};
|
|
80
|
+
child.stdout?.setEncoding('utf8');
|
|
81
|
+
child.stderr?.setEncoding('utf8');
|
|
82
|
+
child.stdout?.on('data', take);
|
|
83
|
+
child.stderr?.on('data', take);
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
killGroup(child, 'SIGKILL');
|
|
86
|
+
const minutes = Math.round(timeoutMs / 60_000);
|
|
87
|
+
settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] the command ran past ${minutes >= 1 ? `${minutes} minutes` : `${timeoutMs}ms`} and was stopped`, MCP_INSTALL_OUTPUT_MAX) });
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
timer.unref?.();
|
|
90
|
+
child.on('error', (error) => settle({ code: -1, out: scrubTail(`${buf}\n[flowviant] ${error?.message ?? error}`, MCP_INSTALL_OUTPUT_MAX) }));
|
|
91
|
+
child.on('close', (code) => settle({ code: code ?? -1, out: scrubTail(buf, MCP_INSTALL_OUTPUT_MAX) }));
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Job ids this process has taken — never run twice in one process. */
|
|
96
|
+
const seen = new Set();
|
|
97
|
+
let running = 0;
|
|
98
|
+
/** Installs this process is running — the machine is busy while one is. */
|
|
99
|
+
export const mcpInstallsInFlight = () => running;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The roster's `mcpInstallJobs` for this box. `ctx` = { repoRoot, myPubB64,
|
|
103
|
+
* recheck }, and for tests `run`, `claim`, `report` and `final`.
|
|
104
|
+
*/
|
|
105
|
+
export function processMcpInstallJobs(jobs, ctx) {
|
|
106
|
+
if (!Array.isArray(jobs)) return;
|
|
107
|
+
for (const job of jobs) {
|
|
108
|
+
if (!job || typeof job.id !== 'string' || typeof job.command !== 'string' || seen.has(job.id)) continue;
|
|
109
|
+
seen.add(job.id);
|
|
110
|
+
void runJob(job, ctx);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function runJob(job, ctx) {
|
|
115
|
+
const pubkey = ctx.myPubB64?.();
|
|
116
|
+
if (!pubkey) return;
|
|
117
|
+
const claim = ctx.claim ?? ((fields) => claimFleetLease(url('mcp-install-claim'), fields));
|
|
118
|
+
const report = ctx.report ?? ((body) => settleLease(url('mcp-install-report'), body, { timeoutMs: 15_000 }));
|
|
119
|
+
const final = ctx.final ?? ((body) => postBestEffort(url('mcp-install-report'), { ...body, instance: DAEMON_INSTANCE }));
|
|
120
|
+
const run = ctx.run ?? runInstallCommand;
|
|
121
|
+
if (!(await claim({ jobId: job.id, pubkey }))) return;
|
|
122
|
+
const name = printableLine(String(job.serverName ?? 'MCP server'), 60);
|
|
123
|
+
const refusal = mcpInstallCommandRefusal(job.command);
|
|
124
|
+
if (refusal) {
|
|
125
|
+
warn(`${c.cyan('mcp')} ${c.dim(`— not installing ${name}: ${refusal}`)}`);
|
|
126
|
+
await final({ jobId: job.id, pubkey, output: `[flowviant] ${refusal}`, done: true, exitCode: -1 });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
running += 1;
|
|
130
|
+
note(`${c.cyan('mcp')} ${c.dim(`— installing ${name}, as confirmed in the app: ${printableLine(job.command, 200)}`)}`);
|
|
131
|
+
let latest = '';
|
|
132
|
+
let sentAt = 0;
|
|
133
|
+
let sending = false;
|
|
134
|
+
const beat = () => {
|
|
135
|
+
if (sending || latest === '' || Date.now() - sentAt < 1000) return;
|
|
136
|
+
sending = true;
|
|
137
|
+
sentAt = Date.now();
|
|
138
|
+
const output = latest;
|
|
139
|
+
void Promise.resolve(report({ jobId: job.id, pubkey, output, done: false, exitCode: null })).catch(() => {}).finally(() => { sending = false; });
|
|
140
|
+
};
|
|
141
|
+
const timer = setInterval(beat, 1000);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
try {
|
|
144
|
+
const timeoutMs = Number.isInteger(job.timeoutMs) && job.timeoutMs > 0 ? Math.min(job.timeoutMs, MCP_INSTALL_TIMEOUT_MS) : MCP_INSTALL_TIMEOUT_MS;
|
|
145
|
+
const { code, out } = await run(job.command, { cwd: ctx.repoRoot, timeoutMs, onOutput: (o) => { latest = tail(o); } });
|
|
146
|
+
clearInterval(timer);
|
|
147
|
+
await final({ jobId: job.id, pubkey, output: tail(out), done: true, exitCode: code });
|
|
148
|
+
if (code === 0) ok(`${c.cyan('mcp')} ${c.dim(`— installed ${name}; checking it again`)}`);
|
|
149
|
+
else warn(`${c.cyan('mcp')} ${c.dim(`— installing ${name} exited ${code}`)}`);
|
|
150
|
+
} finally {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
running -= 1;
|
|
153
|
+
try {
|
|
154
|
+
await ctx.recheck?.();
|
|
155
|
+
} catch { /* a re-check is best-effort; the next beat looks anyway */ }
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Tests only. */
|
|
160
|
+
export function resetMcpInstallsForTest() {
|
|
161
|
+
seen.clear();
|
|
162
|
+
running = 0;
|
|
163
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EVERY MCP SERVER EACH CLI ON THIS BOX HAS, FOR THE MACHINES PAGE (2026-10-13,
|
|
3
|
+
* the owner: "see at a glance what Flowviant can do on this project, from what
|
|
4
|
+
* the serving machine's CLIs actually have").
|
|
5
|
+
*
|
|
6
|
+
* Per installed CLI, one list: the servers the CLI itself reported (Claude
|
|
7
|
+
* Code's init, harvested in runtimeCapabilities.mjs; Codex's `mcp list`, read
|
|
8
|
+
* in runtimeOfferings.mjs) and every server the base branch's `.mcp.json`
|
|
9
|
+
* declares (projectTools.mjs). Rides this box's own `tools-report`
|
|
10
|
+
* (fleetReports.mjs), so its 60-second beat and write-on-change dedup are the
|
|
11
|
+
* lane's; the server stores it per box (packages/shared `mcpInventory.ts`,
|
|
12
|
+
* the shape and the bounds, held equal by parity rule 35).
|
|
13
|
+
*
|
|
14
|
+
* DECLARED IS NOT PRESENT. A base `.mcp.json` server the CLI did not report
|
|
15
|
+
* running is `declared`, with the reason this daemon would leave it unmounted
|
|
16
|
+
* (`env X unset`, `command y not on PATH`, a transport the CLI refuses) when
|
|
17
|
+
* there is one — never shown as available because the file lists it.
|
|
18
|
+
*
|
|
19
|
+
* Names, statuses, sources, reasons and tool names only: no env value, header,
|
|
20
|
+
* token, URL or path leaves the box.
|
|
21
|
+
*/
|
|
22
|
+
import { toolReadout } from './projectTools.mjs';
|
|
23
|
+
import { MCP_NAME_MAX } from './runtimeCapabilities.mjs';
|
|
24
|
+
|
|
25
|
+
/** The server's bounds (shared `mcpInventory.ts`; parity rule 35). */
|
|
26
|
+
export const MCP_INVENTORY_MAX_SERVERS = 80;
|
|
27
|
+
export const MCP_INVENTORY_NAME_MAX = MCP_NAME_MAX;
|
|
28
|
+
export const MCP_INVENTORY_TOOLS_PER_SERVER = 100;
|
|
29
|
+
export const MCP_INVENTORY_TOOLS_PER_CLI = 1000;
|
|
30
|
+
export const MCP_INVENTORY_TOOL_NAME_MAX = 64;
|
|
31
|
+
export const MCP_INVENTORY_REASON_MAX = 200;
|
|
32
|
+
|
|
33
|
+
const SOURCE_ORDER = { project: 0, personal: 1, claudeai: 2 };
|
|
34
|
+
const TOOL_NAME = /^[A-Za-z0-9_.:-]+$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Claude Code's scope word → the page's source. `project` is a checkout's
|
|
38
|
+
* `.mcp.json`; `claudeai` an account connector; `dynamic` is a server named on
|
|
39
|
+
* the command line, which on a turn the harvest records is the base copy when
|
|
40
|
+
* the name is declared and otherwise this daemon's own — not the person's.
|
|
41
|
+
* Every other scope (user, local, plugin, enterprise, managed, agent) is the
|
|
42
|
+
* CLI's own config on this box.
|
|
43
|
+
*/
|
|
44
|
+
function claudeSource(scope, name, declared) {
|
|
45
|
+
if (scope === 'claudeai' || name.startsWith('claude.ai ')) return 'claudeai';
|
|
46
|
+
if (scope === 'project') return 'project';
|
|
47
|
+
if (scope === 'dynamic') return declared.has(name) ? 'project' : null;
|
|
48
|
+
return 'personal';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const declaredRows = (snapshot, runner) =>
|
|
52
|
+
snapshot ? toolReadout(snapshot, runner).filter((t) => t.kind === 'mcp') : [];
|
|
53
|
+
|
|
54
|
+
/** One CLI's list, bounded the way the server re-applies it. */
|
|
55
|
+
function bound(servers) {
|
|
56
|
+
let budget = MCP_INVENTORY_TOOLS_PER_CLI;
|
|
57
|
+
const seen = new Set();
|
|
58
|
+
return servers
|
|
59
|
+
.map((s) => ({ ...s, name: s.name.trim().slice(0, MCP_INVENTORY_NAME_MAX).trim() }))
|
|
60
|
+
.filter((s) => s.name && s.name !== 'flowviant' && !seen.has(s.name) && seen.add(s.name))
|
|
61
|
+
.sort((a, b) => SOURCE_ORDER[a.source] - SOURCE_ORDER[b.source] || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
62
|
+
.slice(0, MCP_INVENTORY_MAX_SERVERS)
|
|
63
|
+
.map((s) => {
|
|
64
|
+
let tools = null;
|
|
65
|
+
if (Array.isArray(s.tools)) {
|
|
66
|
+
tools = [...new Set(s.tools.filter((t) => typeof t === 'string' && t.length <= MCP_INVENTORY_TOOL_NAME_MAX && TOOL_NAME.test(t)))]
|
|
67
|
+
.sort()
|
|
68
|
+
.slice(0, Math.min(MCP_INVENTORY_TOOLS_PER_SERVER, budget));
|
|
69
|
+
budget -= tools.length;
|
|
70
|
+
}
|
|
71
|
+
return { name: s.name, source: s.source, status: s.status, reason: s.reason == null ? null : String(s.reason).slice(0, MCP_INVENTORY_REASON_MAX), tools };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Claude Code: what its init reported, then each declaration it did not. */
|
|
76
|
+
export function claudeInventory(harvested, snapshot) {
|
|
77
|
+
const declared = new Map(declaredRows(snapshot, 'claude').map((t) => [t.name, t]));
|
|
78
|
+
const servers = [];
|
|
79
|
+
for (const h of harvested ?? []) {
|
|
80
|
+
const source = claudeSource(h.scope, h.name, declared);
|
|
81
|
+
if (!source) continue;
|
|
82
|
+
const why = source === 'project' && h.status !== 'connected' ? declared.get(h.name)?.reason ?? null : null;
|
|
83
|
+
servers.push({ name: h.name, source, status: h.status, reason: why, tools: h.status === 'connected' ? h.tools ?? [] : null });
|
|
84
|
+
}
|
|
85
|
+
const running = new Set(servers.filter((s) => s.source === 'project').map((s) => s.name));
|
|
86
|
+
for (const [name, d] of declared) {
|
|
87
|
+
if (!running.has(name)) servers.push({ name, source: 'project', status: 'declared', reason: d.reason, tools: null });
|
|
88
|
+
}
|
|
89
|
+
return { reported: harvested != null, servers: bound(servers) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Codex: its own config's servers, then each declaration. A declared server
|
|
94
|
+
* replaces a personal one of the same name on a card's turn
|
|
95
|
+
* (projectToolRuntimes.mjs `isolatedCodexHome`), so the declaration is the
|
|
96
|
+
* row. Codex names no tools without starting a server, so `tools` is null.
|
|
97
|
+
*/
|
|
98
|
+
export function codexInventory(rows, snapshot) {
|
|
99
|
+
const declared = declaredRows(snapshot, 'codex');
|
|
100
|
+
const names = new Set(declared.map((d) => d.name));
|
|
101
|
+
const servers = [
|
|
102
|
+
...declared.map((d) => ({ name: d.name, source: 'project', status: 'declared', reason: d.reason, tools: null })),
|
|
103
|
+
...(rows ?? []).filter((r) => !names.has(r.name)).map((r) => ({ name: r.name, source: 'personal', status: r.status, reason: r.reason, tools: null })),
|
|
104
|
+
];
|
|
105
|
+
return { reported: rows != null, servers: bound(servers) };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The `mcp` key of the tools report: `{ claude?, codex? }`, a CLI only when it
|
|
110
|
+
* is installed here. `snapshot` is the base-branch read (null when it could
|
|
111
|
+
* not be read — the declarations are then unknown, and only the CLI's own
|
|
112
|
+
* list is said).
|
|
113
|
+
*/
|
|
114
|
+
export function buildMcpInventory({ installed, snapshot, claude, codex }) {
|
|
115
|
+
const out = {};
|
|
116
|
+
if (installed.includes('claude')) out.claude = claudeInventory(claude, snapshot);
|
|
117
|
+
if (installed.includes('codex')) out.codex = codexInventory(codex, snapshot);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/** The planner declares prerequisites; the daemon enforces them without reading
|
|
2
|
+
* human prose. Declared collisions use the same compatible groups; evidence stays on the wire. */
|
|
3
|
+
export function groupDependencies(agents, dependencies, collisions) {
|
|
4
|
+
if (dependencies === undefined && collisions === undefined) return agents;
|
|
5
|
+
if (dependencies === undefined) dependencies = [];
|
|
6
|
+
if (collisions === undefined) collisions = [];
|
|
7
|
+
if (!Array.isArray(collisions) || collisions.length > 1200) return null;
|
|
8
|
+
if (!Array.isArray(dependencies) || dependencies.length > 1200) return null;
|
|
9
|
+
if (!dependencies.length && !collisions.length) return agents;
|
|
10
|
+
const owner = new Map();
|
|
11
|
+
for (const [i, group] of agents.entries()) {
|
|
12
|
+
for (const id of group.taskIds) {
|
|
13
|
+
if (owner.has(id)) return null;
|
|
14
|
+
owner.set(id, i);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
const parents = agents.map((_, i) => i);
|
|
18
|
+
const root = (i) => {
|
|
19
|
+
while (parents[i] !== i) i = parents[i];
|
|
20
|
+
return i;
|
|
21
|
+
};
|
|
22
|
+
const needs = new Map();
|
|
23
|
+
for (const edge of dependencies) {
|
|
24
|
+
if (!edge || !owner.has(edge.taskId) || !Array.isArray(edge.requires) || edge.requires.length > 60) return null;
|
|
25
|
+
for (const id of edge.requires) {
|
|
26
|
+
if (!owner.has(id) || id === edge.taskId) return null;
|
|
27
|
+
const a = root(owner.get(edge.taskId));
|
|
28
|
+
const b = root(owner.get(id));
|
|
29
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
30
|
+
if (!needs.has(edge.taskId)) needs.set(edge.taskId, new Set());
|
|
31
|
+
needs.get(edge.taskId).add(id);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
for (const collision of collisions) {
|
|
35
|
+
if (!collision || !Array.isArray(collision.taskIds) || new Set(collision.taskIds).size < 2 || collision.taskIds.length > 60) return null;
|
|
36
|
+
for (const id of collision.taskIds) {
|
|
37
|
+
if (!owner.has(id)) return null;
|
|
38
|
+
const a = root(owner.get(collision.taskIds[0]));
|
|
39
|
+
const b = root(owner.get(id));
|
|
40
|
+
parents[Math.max(a, b)] = Math.min(a, b);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
for (const [i, group] of agents.entries()) {
|
|
45
|
+
const key = root(i);
|
|
46
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
47
|
+
groups.get(key).push(group);
|
|
48
|
+
}
|
|
49
|
+
const result = [];
|
|
50
|
+
for (const members of groups.values()) {
|
|
51
|
+
const attached = [...new Set(members.map((g) => g.intoAgentId).filter(Boolean))];
|
|
52
|
+
if (attached.length > 1) return null;
|
|
53
|
+
const pending = members.flatMap((g) => g.taskIds);
|
|
54
|
+
if (pending.length > 60) return null;
|
|
55
|
+
const taskIds = [];
|
|
56
|
+
// Stable topological order: unrelated cards keep their proposed order.
|
|
57
|
+
while (pending.length) {
|
|
58
|
+
const at = pending.findIndex((id) => [...(needs.get(id) ?? [])].every((need) => taskIds.includes(need)));
|
|
59
|
+
if (at < 0) return null; // a cycle cannot become a runnable proposal
|
|
60
|
+
taskIds.push(...pending.splice(at, 1));
|
|
61
|
+
}
|
|
62
|
+
const budget = members.reduce((sum, g) => sum + (g.pointsBudget ?? 0), 0);
|
|
63
|
+
result.push({ ...members[0], taskIds,
|
|
64
|
+
...(members.some(g => g.rationale) ? { rationale: members.map(g => g.rationale).filter(Boolean).join('\n').slice(0, 1000) } : {}),
|
|
65
|
+
...(budget ? { pointsBudget: Math.min(budget, 100_000) } : {}),
|
|
66
|
+
...(attached.length ? { intoAgentId: attached[0] } : {}),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Per-turn readout of mounted tools and remaining permission boundaries.
|
|
2
|
+
* Kind selects the delivery contract; CLI/configuration and the filesystem
|
|
3
|
+
* fence determine supporting capabilities. Keep these claims beside the
|
|
4
|
+
* actual builders and the spawned-lane tests in artifactProjectMcp.test.mjs.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** What the machine's own MCP servers are, per CLI. */
|
|
8
|
+
const MACHINE = {
|
|
9
|
+
claude: 'the MCP servers and claude.ai connectors this machine’s Claude Code has (its user and local servers)',
|
|
10
|
+
codex: 'the MCP servers in this machine’s Codex config',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** Per posture and CLI: what the card has and what it lacks. */
|
|
14
|
+
const POSTURES = {
|
|
15
|
+
research: {
|
|
16
|
+
claude: {
|
|
17
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
18
|
+
lacks: 'no measured native image-generation tool, no delegation, guarded read commands only (no helper scripts or browsers), and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
19
|
+
},
|
|
20
|
+
codex: {
|
|
21
|
+
has: 'a sandboxed shell over an isolated copy of the repository (no .env, credential or git files, and no network from the shell), web search, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
22
|
+
lacks: 'no host files outside that copy except native generated images; native image generation depends on this CLI and account',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
design: {
|
|
26
|
+
claude: {
|
|
27
|
+
has: 'the repository’s files (never .env files), the project’s knowledge library, guarded read commands, web search and web fetch, and writing under .flowviant/artifacts/ and the granted scratch directory',
|
|
28
|
+
lacks: 'no measured native image-generation tool, no delegation, no helper scripts or browsers, and no plugins from this machine’s Claude Code (nor the MCP servers a plugin brings)',
|
|
29
|
+
},
|
|
30
|
+
codex: {
|
|
31
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
32
|
+
lacks: 'no native web on this broad-read fence (host credentials are readable); use a configured project web MCP server when available',
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
image: {
|
|
36
|
+
codex: {
|
|
37
|
+
has: 'a sandboxed shell that reads the repository and writes only under .flowviant/artifacts/, with no shell network, scratch writes, and native image generation when this CLI and account expose it',
|
|
38
|
+
lacks: 'no web',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Configuration supplies tools; a skill supplies instructions, not a tool. */
|
|
44
|
+
const SUPPORTING_CAPABILITIES = `The kind defines the delivered product, not which supporting capabilities
|
|
45
|
+
may be used. Use available tools needed for the person's request, including
|
|
46
|
+
native image generation for requested textures or illustrations. A listed skill
|
|
47
|
+
is not proof that its tool is installed, mounted or entitled on this account.
|
|
48
|
+
Do not re-file the kind or require a separate image card just to use a tool.
|
|
49
|
+
If a needed tool is absent or fails, relay the measured limitation and ask for
|
|
50
|
+
the missing configuration or a decision. A separately billed CLI/API fallback
|
|
51
|
+
requires the person's explicit choice first; never default to OPENAI_API_KEY.
|
|
52
|
+
Use personal connectors only for the requested work; sending messages or other
|
|
53
|
+
external writes requires explicit authorization. Never open the person's browser
|
|
54
|
+
or desktop; headless inspection must stay within the granted filesystem fence.
|
|
55
|
+
Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.`;
|
|
56
|
+
|
|
57
|
+
const list = (names) => names.join(', ');
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The note for a turn, or '' when its posture withholds nothing to say.
|
|
61
|
+
* `mounted` is the names of the base `.mcp.json` servers this turn mounted;
|
|
62
|
+
* `machine` whether the machine's own servers were mounted beside them (a
|
|
63
|
+
* Claude turn whose `.mcp.json` walk could not be bounded mounts the base copy
|
|
64
|
+
* alone); `unmounted` is `[{ name, reason, source }]` for the servers it
|
|
65
|
+
* could not mount — `source` 'project' (the base `.mcp.json`) or 'machine'.
|
|
66
|
+
*/
|
|
67
|
+
export function postureToolsNote({ posture, runtime, mounted = [], machine = true, unmounted = [] }) {
|
|
68
|
+
const row = POSTURES[posture]?.[runtime];
|
|
69
|
+
if (!row) return posture === 'build' ? `TOOLS ON THIS CARD. Use the tools actually exposed by this CLI and its configuration. ${SUPPORTING_CAPABILITIES.replace('Delegation is disabled by policy on fenced turns; tool names may still be listed. Complete the work in this turn.', 'Delegate only when the person explicitly requests it.')} ` : '';
|
|
70
|
+
const fromProject = unmounted.filter((u) => u.source !== 'machine');
|
|
71
|
+
const fromMachine = unmounted.filter((u) => u.source === 'machine');
|
|
72
|
+
const project = mounted.length
|
|
73
|
+
? `The project’s MCP servers from the base branch’s .mcp.json are mounted: ${list(mounted)}.`
|
|
74
|
+
: fromProject.length
|
|
75
|
+
? 'None of the project’s MCP servers is mounted on this machine.'
|
|
76
|
+
: 'The base branch’s .mcp.json lists no MCP server.';
|
|
77
|
+
const own = machine
|
|
78
|
+
? ` So are ${MACHINE[runtime]}.`
|
|
79
|
+
: ` ${MACHINE[runtime][0].toUpperCase()}${MACHINE[runtime].slice(1)} are not mounted on this turn.`;
|
|
80
|
+
const missing = (rows, where) => (rows.length ? ` ${where}, but not mounted here: ${list(rows.map(({ name, reason }) => `${name} (${reason})`))}.` : '');
|
|
81
|
+
return `TOOLS ON THIS CARD. You have ${row.has}. You have ${row.lacks}. ${project}${own}${missing(fromProject, 'Listed in the base branch’s .mcp.json')}${missing(fromMachine, `In ${MACHINE[runtime]}`)} A server added to .mcp.json only in this worktree is not mounted; the base branch’s file is the one that counts.
|
|
82
|
+
There is no Flowviant setting that adds a tool, so never invent one. A missing
|
|
83
|
+
server can be configured in the base branch's .mcp.json or the machine's CLI.
|
|
84
|
+
${SUPPORTING_CAPABILITIES}`;
|
|
85
|
+
}
|