flowviant 0.116.0 → 0.117.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 +31 -12
- package/bin/cli.mjs +13 -17
- package/bin/lib/admission.mjs +7 -14
- package/bin/lib/agentInternalWait.mjs +83 -0
- package/bin/lib/agentPlan.mjs +56 -24
- package/bin/lib/agentPublish.mjs +12 -13
- package/bin/lib/agentTaskKinds.mjs +4 -7
- package/bin/lib/agySessions.mjs +3 -3
- package/bin/lib/artifactPreview.mjs +92 -18
- package/bin/lib/artifactReporter.mjs +16 -8
- package/bin/lib/artifacts.mjs +64 -60
- package/bin/lib/authproxy.mjs +2 -1
- package/bin/lib/childEnv.mjs +9 -4
- package/bin/lib/claude.mjs +10 -20
- package/bin/lib/claudePersonal.mjs +71 -6
- package/bin/lib/claudePosture.mjs +119 -42
- package/bin/lib/claudeSessions.mjs +7 -7
- package/bin/lib/claudeStream.mjs +7 -11
- package/bin/lib/cliLimit.mjs +2 -2
- package/bin/lib/codexHome.mjs +17 -0
- package/bin/lib/codexPersonal.mjs +44 -3
- package/bin/lib/codexRelay.mjs +21 -5
- package/bin/lib/codexResearch.mjs +180 -0
- package/bin/lib/config.mjs +37 -24
- package/bin/lib/conversationIds.mjs +51 -0
- package/bin/lib/credentials.mjs +2 -7
- package/bin/lib/daemonLocks.mjs +88 -0
- package/bin/lib/daemonState.mjs +5 -5
- package/bin/lib/deploy.mjs +6 -9
- package/bin/lib/deployCheckout.mjs +26 -22
- package/bin/lib/deployRunner.mjs +54 -18
- package/bin/lib/deployWire.mjs +32 -23
- package/bin/lib/desktopContract.mjs +3 -5
- package/bin/lib/env.mjs +104 -35
- package/bin/lib/fleet.mjs +7 -9
- package/bin/lib/fleetPost.mjs +172 -25
- package/bin/lib/fleetReports.mjs +130 -170
- package/bin/lib/fleetStartup.mjs +6 -6
- package/bin/lib/fleetWire.mjs +42 -0
- package/bin/lib/git.mjs +111 -61
- package/bin/lib/gitMergeIdentity.mjs +2 -8
- package/bin/lib/grant.mjs +23 -7
- package/bin/lib/holder.mjs +108 -81
- package/bin/lib/hooks/readGuard.mjs +41 -7
- package/bin/lib/install.mjs +80 -33
- package/bin/lib/instance.mjs +36 -83
- package/bin/lib/intakeDraft.mjs +4 -1
- package/bin/lib/knowledgeFileSync.mjs +31 -0
- package/bin/lib/knowledgeFiles.mjs +28 -83
- package/bin/lib/landed.mjs +22 -29
- package/bin/lib/launchCommand.mjs +91 -0
- package/bin/lib/listeners.mjs +0 -5
- package/bin/lib/localSessions.mjs +52 -39
- package/bin/lib/login.mjs +22 -12
- package/bin/lib/loginPath.mjs +22 -17
- package/bin/lib/machineListing.mjs +6 -3
- package/bin/lib/mcp-cli.mjs +9 -10
- package/bin/lib/noWindowEnv.mjs +23 -3
- package/bin/lib/ownedDirs.mjs +99 -0
- package/bin/lib/pinnedCapture.mjs +383 -0
- package/bin/lib/prWorkflow.mjs +94 -17
- package/bin/lib/preflight.mjs +8 -25
- package/bin/lib/preview.mjs +19 -25
- package/bin/lib/previewGatePolicy.mjs +34 -9
- package/bin/lib/previewRegistry.mjs +4 -11
- package/bin/lib/printable.mjs +70 -7
- package/bin/lib/procRegistry.mjs +33 -13
- package/bin/lib/procScan.mjs +2 -3
- package/bin/lib/procStat.mjs +76 -0
- package/bin/lib/processGroup.mjs +48 -0
- package/bin/lib/processes.mjs +10 -24
- package/bin/lib/projectToolRuntimes.mjs +101 -16
- package/bin/lib/projectTools.mjs +50 -32
- package/bin/lib/prompts.mjs +41 -19
- package/bin/lib/repoPath.mjs +19 -0
- package/bin/lib/repoState.mjs +17 -19
- package/bin/lib/runTurn.mjs +268 -210
- package/bin/lib/runtimeAntigravity.mjs +16 -5
- package/bin/lib/runtimeCapabilities.mjs +140 -20
- package/bin/lib/runtimeClaude.mjs +55 -5
- package/bin/lib/runtimeCodex.mjs +245 -205
- package/bin/lib/runtimeDetection.mjs +2 -2
- package/bin/lib/runtimeEvents.mjs +16 -10
- package/bin/lib/runtimeLimits.mjs +5 -6
- package/bin/lib/runtimeOfferings.mjs +24 -11
- package/bin/lib/runtimes.mjs +10 -0
- package/bin/lib/shipSweep.mjs +5 -3
- package/bin/lib/shot.mjs +91 -17
- package/bin/lib/sleep.mjs +11 -0
- package/bin/lib/startProject.mjs +5 -5
- package/bin/lib/statePaths.mjs +40 -0
- package/bin/lib/tty.mjs +25 -15
- package/bin/lib/turnLock.mjs +7 -14
- package/bin/lib/turnProfile.mjs +1 -1
- package/bin/lib/ui.mjs +0 -3
- package/bin/lib/uninstall.mjs +30 -26
- package/bin/lib/update.mjs +79 -27
- package/bin/lib/uplinkScrub.mjs +296 -31
- package/bin/lib/vault.mjs +78 -10
- package/bin/lib/vaultDiff.mjs +5 -2
- package/bin/lib/views.mjs +37 -12
- package/bin/lib/wikiRunner.mjs +40 -61
- package/bin/lib/work.mjs +17 -7
- package/bin/lib/workAdoptCarry.mjs +2 -4
- package/bin/lib/workAgentCheck.mjs +104 -57
- package/bin/lib/workAgentMerges.mjs +62 -57
- package/bin/lib/workAgentPlans.mjs +71 -139
- package/bin/lib/workAgentPrecheck.mjs +71 -138
- package/bin/lib/workAgentPublish.mjs +6 -8
- package/bin/lib/workAgentTurnBegun.mjs +15 -11
- package/bin/lib/workAgentTurnExecution.mjs +46 -53
- package/bin/lib/workAgentTurnReports.mjs +9 -37
- package/bin/lib/workAgentTurns.mjs +4 -3
- package/bin/lib/workAttachments.mjs +66 -11
- package/bin/lib/workBrain.mjs +9 -4
- package/bin/lib/workCappedTurn.mjs +109 -0
- package/bin/lib/workChildren.mjs +5 -5
- package/bin/lib/workCommandAudit.mjs +11 -2
- package/bin/lib/workDiffs.mjs +52 -6
- package/bin/lib/workIntake.mjs +35 -60
- package/bin/lib/workPlaceReports.mjs +7 -4
- package/bin/lib/workPlaces.mjs +96 -30
- package/bin/lib/workPreviews.mjs +47 -112
- package/bin/lib/workProcesses.mjs +8 -35
- package/bin/lib/workPullRequests.mjs +37 -56
- package/bin/lib/workReportQueue.mjs +100 -148
- package/bin/lib/workRetire.mjs +2 -1
- package/bin/lib/workSessionRuntime.mjs +38 -37
- package/bin/lib/workSessionTurns.mjs +111 -189
- package/bin/lib/workShip.mjs +43 -23
- package/bin/lib/workTurnShape.mjs +163 -0
- package/npm-shrinkwrap.json +59 -0
- package/package.json +3 -2
- package/bin/lib/fleetJobs.mjs +0 -120
- package/bin/lib/patchRevert.mjs +0 -60
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE READER OF `/proc/<pid>/stat` (audit 2026-10-10, F050/F089).
|
|
3
|
+
*
|
|
4
|
+
* Six modules used to parse this line by hand, each counting its own field
|
|
5
|
+
* index from "rest[0] is field 3" in a comment. The parse is the whole trap:
|
|
6
|
+
* field 2 is `comm`, which the kernel wraps in parens and which may contain
|
|
7
|
+
* BOTH spaces and parens — it is the first 15 bytes of the executable's name,
|
|
8
|
+
* and a name is not a token. A naive whitespace split taking $22 lands on
|
|
9
|
+
* `nice` for any such process, and `nice` is a small integer, so the answer
|
|
10
|
+
* is not a parse error but a plausible-looking "started at boot" that sails
|
|
11
|
+
* past any isFinite guard. Measured: a binary named `ev (i l) x` read 28308s
|
|
12
|
+
* too old that way (instance.mjs, which gates a SIGTERM and then a SIGKILL on
|
|
13
|
+
* this read).
|
|
14
|
+
*
|
|
15
|
+
* Every field after `comm` is a number or a single-character state, so no ')'
|
|
16
|
+
* can appear later: the LAST ')' in the line is always the kernel's own
|
|
17
|
+
* closing paren, even when comm itself ends in one. Fields are NAMED here so
|
|
18
|
+
* no caller counts again.
|
|
19
|
+
*
|
|
20
|
+
* Linux only, and a leaf: node builtins, nothing of ours. Null on any read or
|
|
21
|
+
* shape failure — which every caller already read as "cannot tell".
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { readFileSync } from 'node:fs';
|
|
25
|
+
|
|
26
|
+
/** Index of each named field in the list AFTER `comm` (index 0 is field 3). */
|
|
27
|
+
const FIELD = Object.freeze({ state: 0, ppid: 1, pgrp: 2, session: 3, tpgid: 5, starttime: 19 });
|
|
28
|
+
|
|
29
|
+
/** A kernel integer token: digits, with tpgid's `-1` for "no terminal". */
|
|
30
|
+
const int = (token) => (/^-?\d+$/.test(token ?? '') ? Number(token) : null);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The named fields of one `/proc/<pid>/stat` line, or null when the line has
|
|
34
|
+
* no `comm` close or is too short to carry `starttime` (field 22).
|
|
35
|
+
*/
|
|
36
|
+
export function parseProcStat(text) {
|
|
37
|
+
if (typeof text !== 'string') return null;
|
|
38
|
+
const close = text.lastIndexOf(')');
|
|
39
|
+
if (close < 0) return null;
|
|
40
|
+
const rest = text.slice(close + 1).trim().split(/\s+/);
|
|
41
|
+
if (rest.length <= FIELD.starttime) return null;
|
|
42
|
+
const out = { state: rest[FIELD.state] };
|
|
43
|
+
for (const name of ['ppid', 'pgrp', 'session', 'tpgid', 'starttime']) {
|
|
44
|
+
const value = int(rest[FIELD[name]]);
|
|
45
|
+
if (value === null) return null;
|
|
46
|
+
out[name] = value;
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A pid this reader will put in a path: a positive integer, its digit
|
|
52
|
+
* string (what a /proc listing hands back), or 'self'. */
|
|
53
|
+
function procName(pid) {
|
|
54
|
+
if (pid === 'self') return 'self';
|
|
55
|
+
if (Number.isSafeInteger(pid) && pid > 0) return String(pid);
|
|
56
|
+
if (typeof pid === 'string' && /^\d+$/.test(pid) && Number(pid) > 0) return pid;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* `{ state, ppid, pgrp, session, tpgid, starttime }` for `pid` (or 'self'),
|
|
62
|
+
* or null: no such process, no /proc (macOS, hidepid=2, a pid namespace), or
|
|
63
|
+
* a line this parser does not recognise. `starttime` is in clock ticks since
|
|
64
|
+
* boot (USER_HZ); `tpgid` is -1 without a controlling terminal.
|
|
65
|
+
*/
|
|
66
|
+
export function procStat(pid, { read = readFileSync } = {}) {
|
|
67
|
+
const name = procName(pid);
|
|
68
|
+
if (!name) return null;
|
|
69
|
+
let text;
|
|
70
|
+
try {
|
|
71
|
+
text = read(`/proc/${name}/stat`, 'utf8');
|
|
72
|
+
} catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
return parseProcStat(String(text));
|
|
76
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SIGNAL A DETACHED CHILD'S WHOLE PROCESS GROUP, FALLING BACK TO THE CHILD
|
|
3
|
+
* (2026-10-10, the audit's F086 / F145) — the one home of a block that was
|
|
4
|
+
* hand-written six times: the project check's timeout, the teardown of a
|
|
5
|
+
* check, the deploy command's timeout, the async network git call's timeout,
|
|
6
|
+
* a preview tunnel's stop and the reap of an orphaned tunnel.
|
|
7
|
+
*
|
|
8
|
+
* Why the GROUP: the daemon spawns these `detached`, so the child's pid is its
|
|
9
|
+
* process group, and the child is usually a shell (`shell: true`) or a tool
|
|
10
|
+
* that starts helpers (git's ssh, cloudflared). Signalling the child alone
|
|
11
|
+
* leaves what it started running — holding the worktree, its writer lock, or
|
|
12
|
+
* a pipe the caller is waiting on (workChildren.mjs tells the incident).
|
|
13
|
+
*
|
|
14
|
+
* Why the FALLBACK: a group that has already gone, a pid that is not a group
|
|
15
|
+
* leader, and a platform with no process groups (Windows throws on a negative
|
|
16
|
+
* pid) all throw on the group signal; the child itself is then signalled.
|
|
17
|
+
*
|
|
18
|
+
* NEVER A GROUP THE DAEMON DID NOT START: a pid that is not an integer above 1
|
|
19
|
+
* signals nothing by number — `kill(-1)` is every process the user owns, and
|
|
20
|
+
* `kill(0)` is the daemon's own group. Import-free, so git.mjs can use it.
|
|
21
|
+
*
|
|
22
|
+
* @param {import('node:child_process').ChildProcess | number} target
|
|
23
|
+
* @param {NodeJS.Signals} [signal]
|
|
24
|
+
* @returns {boolean} whether a signal was delivered
|
|
25
|
+
*/
|
|
26
|
+
export function killGroup(target, signal = 'SIGKILL') {
|
|
27
|
+
const child = target !== null && typeof target === 'object' ? target : null;
|
|
28
|
+
const pid = child ? child.pid : target;
|
|
29
|
+
const leader = Number.isInteger(pid) && pid > 1;
|
|
30
|
+
if (leader) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(-pid, signal);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
/* gone, not a leader, or no groups here — the process itself next */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
if (child) return child.kill(signal) === true;
|
|
40
|
+
if (leader) {
|
|
41
|
+
process.kill(pid, signal);
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* already gone */
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/bin/lib/processes.mjs
CHANGED
|
@@ -47,6 +47,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
47
47
|
import { readFileSync } from 'node:fs';
|
|
48
48
|
import { platform } from 'node:os';
|
|
49
49
|
import { procPids, scanProcs } from './procScan.mjs';
|
|
50
|
+
import { procStat } from './procStat.mjs';
|
|
50
51
|
|
|
51
52
|
// THE WALK'S BOUND is `procScan.mjs`'s: matched rows collected before the walk
|
|
52
53
|
// stops early — a bound on the WORK done for one pathological group, never on
|
|
@@ -69,21 +70,13 @@ export function processesSupported() {
|
|
|
69
70
|
|
|
70
71
|
/**
|
|
71
72
|
* `pid (comm) state ppid pgrp …` — comm is arbitrary text and CAN contain a
|
|
72
|
-
* `)`,
|
|
73
|
-
*
|
|
74
|
-
* anything that sets its own title can produce.
|
|
73
|
+
* `)`, which is why the line is read by procStat.mjs, the one parser that
|
|
74
|
+
* knows where comm ends. A process named `foo) bar` is not hypothetical; it is
|
|
75
|
+
* what anything that sets its own title can produce. Null when the pid is gone
|
|
76
|
+
* between readdir and read — ordinary.
|
|
75
77
|
*/
|
|
76
78
|
function pgrpOf(pid) {
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
stat = readFileSync(`/proc/${pid}/stat`, 'utf8');
|
|
80
|
-
} catch {
|
|
81
|
-
return null; // gone between readdir and read — ordinary
|
|
82
|
-
}
|
|
83
|
-
const close = stat.lastIndexOf(')');
|
|
84
|
-
if (close < 0) return null;
|
|
85
|
-
const rest = stat.slice(close + 1).trim().split(/\s+/);
|
|
86
|
-
const pgrp = Number(rest[2]);
|
|
79
|
+
const pgrp = procStat(pid)?.pgrp;
|
|
87
80
|
return Number.isInteger(pgrp) && pgrp > 0 ? pgrp : null;
|
|
88
81
|
}
|
|
89
82
|
|
|
@@ -172,18 +165,11 @@ function scanDarwin(pgids) {
|
|
|
172
165
|
}
|
|
173
166
|
|
|
174
167
|
/**
|
|
175
|
-
* Live processes in any of `pgids
|
|
168
|
+
* Live processes in any of `pgids`, and the TOTAL the cap was applied to.
|
|
176
169
|
*
|
|
177
|
-
* Returns null where the platform cannot answer — never
|
|
178
|
-
* "looked, found none" and is a different fact a surface renders
|
|
179
|
-
|
|
180
|
-
export function processesInGroups(pgids, { scrub } = {}) {
|
|
181
|
-
const m = measureProcesses(pgids, { scrub });
|
|
182
|
-
return m === null ? null : m.rows;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* …and the TOTAL the cap was applied to.
|
|
170
|
+
* Returns null where the platform cannot answer — never an empty measurement,
|
|
171
|
+
* which means "looked, found none" and is a different fact a surface renders
|
|
172
|
+
* differently.
|
|
187
173
|
*
|
|
188
174
|
* The rows alone are a lie about scale the moment a group has more than twelve
|
|
189
175
|
* members: a dashboard summing them states an incomplete figure as a complete
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
19
19
|
import { join, resolve } from 'node:path';
|
|
20
|
-
import {
|
|
20
|
+
import { codexHome } from './codexHome.mjs';
|
|
21
21
|
|
|
22
22
|
export const toml = (value) => JSON.stringify(String(value));
|
|
23
23
|
|
|
@@ -43,6 +43,72 @@ function linkPersonalEntry(target, link) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/** A table header's dotted keys, unquoted — null for one this reader cannot
|
|
47
|
+
* split, which is then nobody's to strip. */
|
|
48
|
+
function headerKeys(section) {
|
|
49
|
+
const key = /\s*(?:"((?:[^"\\]|\\.)*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*(?:\.|$)/y;
|
|
50
|
+
const keys = [];
|
|
51
|
+
for (let i = 0; i < section.length; ) {
|
|
52
|
+
key.lastIndex = i;
|
|
53
|
+
const m = key.exec(section);
|
|
54
|
+
if (!m || key.lastIndex === i) return null;
|
|
55
|
+
try {
|
|
56
|
+
keys.push(m[1] !== undefined ? JSON.parse(`"${m[1]}"`) : m[2] ?? m[3]);
|
|
57
|
+
} catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
i = key.lastIndex;
|
|
61
|
+
}
|
|
62
|
+
return keys;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The key a `key = …` / `key.sub = …` line defines, unquoted, or null. */
|
|
66
|
+
const lineKey = (line) => {
|
|
67
|
+
const m = /^\s*(?:"([^"\\]*)"|'([^']*)'|([A-Za-z0-9_-]+))\s*[.=]/.exec(line);
|
|
68
|
+
return m ? (m[1] ?? m[2] ?? m[3]) : null;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* THE REPO'S MCP SERVERS, AS THE AGENT'S OWN CONFIG (2026-10-10, the audit).
|
|
73
|
+
* A Codex build turn was handed the base branch's servers as `-c` pairs —
|
|
74
|
+
* `mcp_servers.<n>.env.<K>=<value>`, `…http_headers={…}`, `…url=…` — with
|
|
75
|
+
* every `${VAR}` already expanded, so a token the repo's `.mcp.json` names
|
|
76
|
+
* sat in /proc/<pid>/cmdline, every local user's to read, against this
|
|
77
|
+
* daemon's own rule (codexPersonal.mjs: "A LITERAL SECRET NEVER RIDES
|
|
78
|
+
* ARGV"). They are written here instead, as tables of the agent's private
|
|
79
|
+
* CODEX_HOME config.toml (0600, in a 0700 home), which a build turn reads and
|
|
80
|
+
* every fenced posture ignores (`--ignore-user-config`) — the same turns the
|
|
81
|
+
* `-c` pairs reached. Written in the shape codexPersonal.mjs's bounded reader
|
|
82
|
+
* parses, since a fenced turn reads this file for its login.
|
|
83
|
+
*
|
|
84
|
+
* SO THE EXPANDED VALUES NOW SIT ON DISK, AND THAT IS STATED (2026-10-10). The
|
|
85
|
+
* `-c` pairs died with the turn; these tables live as long as the home does.
|
|
86
|
+
* An agent's home is kept between its turns in the worktree's private git dir
|
|
87
|
+
* (workAgentTurnExecution.mjs `codexAgentHome`) and goes only with the
|
|
88
|
+
* worktree, when the agent retires — until then this 0600 file holds every
|
|
89
|
+
* secret the repo's `.mcp.json` named. Each turn rewrites it whole, so a
|
|
90
|
+
* server the base branch dropped, or a variable since unset, leaves with the
|
|
91
|
+
* next turn; a home with no git dir to hold it dies with its turn. NOT DONE:
|
|
92
|
+
* a URL that would carry an expanded secret is written as any other — refusing
|
|
93
|
+
* it ('missing') is an open follow-up from the same audit finding (F660).
|
|
94
|
+
*/
|
|
95
|
+
function codexServerTables(servers) {
|
|
96
|
+
let out = '';
|
|
97
|
+
for (const [name, server] of Object.entries(servers ?? {})) {
|
|
98
|
+
out += `\n[mcp_servers.${name}]\n`;
|
|
99
|
+
if (typeof server.command === 'string') {
|
|
100
|
+
out += `command = ${toml(server.command)}\nargs = [${(server.args ?? []).map(toml).join(', ')}]\n`;
|
|
101
|
+
const env = Object.entries(server.env ?? {}).filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key));
|
|
102
|
+
if (env.length) out += `[mcp_servers.${name}.env]\n${env.map(([key, value]) => `${key} = ${toml(value)}`).join('\n')}\n`;
|
|
103
|
+
} else {
|
|
104
|
+
out += `url = ${toml(server.url)}\n`;
|
|
105
|
+
const headers = Object.entries(server.headers ?? {});
|
|
106
|
+
if (headers.length) out += `[mcp_servers.${name}.http_headers]\n${headers.map(([key, value]) => `${toml(key)} = ${toml(value)}`).join('\n')}\n`;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
46
112
|
/**
|
|
47
113
|
* Keep personal Codex files, but do not copy its project trust grants. An
|
|
48
114
|
* untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1.
|
|
@@ -58,23 +124,39 @@ function linkPersonalEntry(target, link) {
|
|
|
58
124
|
* worktree's skills can change between turns), and what Codex made here
|
|
59
125
|
* itself under a name the person has not got is kept.
|
|
60
126
|
*/
|
|
61
|
-
function isolatedCodexHome(dest, env, worktree) {
|
|
62
|
-
const source = resolve(env
|
|
127
|
+
function isolatedCodexHome(dest, env, worktree, mcpServers = {}) {
|
|
128
|
+
const source = resolve(codexHome(env));
|
|
63
129
|
const configPath = join(source, 'config.toml');
|
|
64
130
|
const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
|
|
65
131
|
const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
|
|
66
132
|
const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
|
|
67
|
-
//
|
|
68
|
-
//
|
|
133
|
+
// The repo's servers replace a personal server of the same name whole —
|
|
134
|
+
// its `[mcp_servers.<n>…]` tables are dropped below. A personal
|
|
135
|
+
// `mcp_servers` written inline cannot be told apart line by line, so it
|
|
136
|
+
// fails closed, as an inline `skills.config` does below.
|
|
137
|
+
const repoServers = new Set(Object.keys(mcpServers ?? {}));
|
|
138
|
+
const inline = () => new Error('Cannot isolate Codex project MCP servers beside an inline mcp_servers entry in config.toml');
|
|
139
|
+
if (repoServers.size && /^\s*["']?mcp_servers["']?\s*[.=]/m.test(topLevel)) throw inline();
|
|
140
|
+
// The `projects` sections alone grant a checkout permission to supply
|
|
141
|
+
// project settings. Keep every other personal setting, skill and session
|
|
142
|
+
// store.
|
|
69
143
|
const lines = sourceConfig.split('\n');
|
|
70
|
-
let
|
|
144
|
+
let dropping = false;
|
|
145
|
+
let inServers = false;
|
|
71
146
|
let config = lines.filter((line) => {
|
|
72
147
|
const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
|
|
73
148
|
if (header) {
|
|
74
149
|
const section = header[1] ?? header[2];
|
|
75
|
-
|
|
150
|
+
const keys = headerKeys(section);
|
|
151
|
+
inServers = keys?.length === 1 && keys[0] === 'mcp_servers';
|
|
152
|
+
dropping =
|
|
153
|
+
section === 'projects' ||
|
|
154
|
+
section.startsWith('projects.') ||
|
|
155
|
+
(keys?.[0] === 'mcp_servers' && keys.length > 1 && repoServers.has(keys[1]));
|
|
156
|
+
} else if (inServers && repoServers.has(lineKey(line))) {
|
|
157
|
+
throw inline();
|
|
76
158
|
}
|
|
77
|
-
return !
|
|
159
|
+
return !dropping;
|
|
78
160
|
}).join('\n');
|
|
79
161
|
if (worktree) {
|
|
80
162
|
// Codex discovers these even in an untrusted project. Disable exactly the
|
|
@@ -92,6 +174,7 @@ function isolatedCodexHome(dest, env, worktree) {
|
|
|
92
174
|
throw new Error('Cannot isolate Codex project skills with inline skills.config');
|
|
93
175
|
for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
|
|
94
176
|
}
|
|
177
|
+
config += codexServerTables(mcpServers);
|
|
95
178
|
|
|
96
179
|
// The home itself, only once the config is decided: a refused turn touches
|
|
97
180
|
// nothing here. A home that is not a real directory is not ours to write
|
|
@@ -139,8 +222,10 @@ function reviewedClaudePlugin(dir, snapshot) {
|
|
|
139
222
|
/**
|
|
140
223
|
* The table. `refuseMcp(config)` names why this runtime cannot take one MCP
|
|
141
224
|
* configuration it otherwise could; `prepare(dir, snapshot, env, worktree,
|
|
142
|
-
*
|
|
143
|
-
* `dir`, which dies with the turn, except what `
|
|
225
|
+
* turn)` returns the runner-specific part of prepareAgentTools' result — under
|
|
226
|
+
* `dir`, which dies with the turn, except what `turn.codexHome` names a
|
|
227
|
+
* lasting place for. `turn.mcpServers` is the base branch's servers this
|
|
228
|
+
* runtime takes, expanded.
|
|
144
229
|
*/
|
|
145
230
|
const TOOL_RUNTIMES = {
|
|
146
231
|
claude: {
|
|
@@ -156,8 +241,8 @@ const TOOL_RUNTIMES = {
|
|
|
156
241
|
// The agent's own lasting home when the caller has one (2026-09-29: its
|
|
157
242
|
// threads are indexed under it — see isolatedCodexHome), else one that
|
|
158
243
|
// dies with the turn, as every home did before.
|
|
159
|
-
prepare: (dir, _snapshot, env, worktree,
|
|
160
|
-
const isolated = isolatedCodexHome(
|
|
244
|
+
prepare: (dir, _snapshot, env, worktree, turn) => {
|
|
245
|
+
const isolated = isolatedCodexHome(turn?.codexHome ?? join(dir, 'codex-home'), env, worktree, turn?.mcpServers);
|
|
161
246
|
return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
|
|
162
247
|
},
|
|
163
248
|
},
|
|
@@ -189,8 +274,8 @@ export function skillsRefusal(runner) {
|
|
|
189
274
|
return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
|
|
190
275
|
}
|
|
191
276
|
|
|
192
|
-
/** The runner-specific files a turn needs, written under `dir` — or, for
|
|
193
|
-
* `
|
|
194
|
-
export function prepareForRunner(runner, dir, snapshot, env, worktree,
|
|
195
|
-
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree,
|
|
277
|
+
/** The runner-specific files a turn needs, written under `dir` — or, for the
|
|
278
|
+
* `codexHome` it names, where the caller keeps it between turns. */
|
|
279
|
+
export function prepareForRunner(runner, dir, snapshot, env, worktree, turn = {}) {
|
|
280
|
+
return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree, turn) ?? {}) };
|
|
196
281
|
}
|
package/bin/lib/projectTools.mjs
CHANGED
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
/** Repo-owned tool configuration, read from the base ref for every agent turn. */
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
2
|
import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
3
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
5
4
|
import { tmpdir } from 'node:os';
|
|
6
|
-
import {
|
|
5
|
+
import { git, gitRaw, splitNul } from './git.mjs';
|
|
6
|
+
import { withoutMachineCredentials } from './machineEnv.mjs';
|
|
7
|
+
import { mcpRefusal, prepareForRunner, skillsRefusal } from './projectToolRuntimes.mjs';
|
|
7
8
|
|
|
8
9
|
const MAX_TOOLS = 40;
|
|
9
10
|
const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
|
|
10
11
|
const MAX_INSTRUCTION_FILES = 80;
|
|
11
12
|
const MAX_INSTRUCTION_BYTES = 512 * 1024;
|
|
12
13
|
const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
|
|
14
|
+
/** Base-ref reads are QUIET (git's "does not exist in" for an absent file is
|
|
15
|
+
* an answer here, not a message for the daemon's terminal) and keep a 1 MiB
|
|
16
|
+
* cap per file. */
|
|
17
|
+
const SHOW = Object.freeze({ stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024 });
|
|
13
18
|
const show = (root, ref, path) => {
|
|
14
19
|
try {
|
|
15
|
-
return
|
|
16
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
|
|
17
|
-
});
|
|
20
|
+
return gitRaw(['show', `${ref}:${path}`], root, SHOW);
|
|
18
21
|
} catch { return null; }
|
|
19
22
|
};
|
|
20
23
|
const showBytes = (root, ref, path) => {
|
|
21
24
|
try {
|
|
22
|
-
return
|
|
23
|
-
cwd: root, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
|
|
24
|
-
});
|
|
25
|
+
return gitRaw(['show', `${ref}:${path}`], root, { ...SHOW, encoding: 'buffer' });
|
|
25
26
|
} catch { return null; }
|
|
26
27
|
};
|
|
27
28
|
|
|
@@ -39,11 +40,39 @@ function referenced(value) {
|
|
|
39
40
|
return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* THE ENVIRONMENT A `${VAR}` IN THE REPO'S `.mcp.json` IS READ FROM: the
|
|
45
|
+
* caller's, MINUS THE MACHINE CREDENTIAL AND EVERY OTHER `FLOWVIANT_*` NAME,
|
|
46
|
+
* whatever the caller passed (2026-10-10, the audit). A base-branch
|
|
47
|
+
* `"url": "https://x/mcp?k=${FLOWVIANT_FLEET}"` delivered the credential this
|
|
48
|
+
* daemon authenticates with to that host on every agent turn — the
|
|
49
|
+
* inherited-credential leak machineEnv.mjs exists to close. The daemon's own
|
|
50
|
+
* names are nobody's repo config to read, so such a server reads as
|
|
51
|
+
* `missing` ("env FLOWVIANT_FLEET unset").
|
|
52
|
+
*/
|
|
53
|
+
const expansionEnv = (env) =>
|
|
54
|
+
Object.fromEntries(Object.entries(withoutMachineCredentials(env ?? process.env)).filter(([key]) => !key.startsWith('FLOWVIANT_')));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A TREE LISTING IS NUL-SEPARATED AND SIZED FOR A REAL REPOSITORY (2026-10-10,
|
|
58
|
+
* the audit). The whole-tree listing that finds nested CLAUDE.md/AGENTS.md ran
|
|
59
|
+
* under a 1 MiB buffer — about 20k paths — and nothing caught the ENOBUFS, so
|
|
60
|
+
* EVERY agent turn on a larger repository settled `nothing` before its CLI
|
|
61
|
+
* started. 64 MiB is workAdoptCarry.mjs's precedent; MAX_INSTRUCTION_FILES and
|
|
62
|
+
* MAX_INSTRUCTION_BYTES stay the real bounds. `-z` because git C-quotes a path
|
|
63
|
+
* that is not plain ASCII (`"d\303\251j\303\240/CLAUDE.md"`), which the name
|
|
64
|
+
* filter never matched (git.mjs's `-z` rule).
|
|
65
|
+
*/
|
|
66
|
+
const LIST_MAX_BUFFER = 64 * 1024 * 1024;
|
|
67
|
+
const listTree = (root, args) =>
|
|
68
|
+
splitNul(gitRaw(['ls-tree', '-r', '-z', '--name-only', ...args], root, {
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: LIST_MAX_BUFFER,
|
|
70
|
+
}));
|
|
71
|
+
|
|
72
|
+
export function readBaseTools(root, ref, callerEnv = process.env) {
|
|
73
|
+
const env = expansionEnv(callerEnv);
|
|
43
74
|
// Resolve once: a base branch may advance while the turn is being prepared.
|
|
44
|
-
const commit =
|
|
45
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
46
|
-
}).trim();
|
|
75
|
+
const commit = git(['rev-parse', '--verify', ref], root, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
47
76
|
const raw = show(root, commit, '.mcp.json');
|
|
48
77
|
let servers = {};
|
|
49
78
|
if (raw != null) {
|
|
@@ -53,9 +82,7 @@ export function readBaseTools(root, ref, env = process.env) {
|
|
|
53
82
|
const skills = [];
|
|
54
83
|
const skillFiles = [];
|
|
55
84
|
try {
|
|
56
|
-
const paths =
|
|
57
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
|
|
58
|
-
}).split('\n');
|
|
85
|
+
const paths = listTree(root, [commit, '--', '.claude/skills']);
|
|
59
86
|
let bytes = 0;
|
|
60
87
|
for (const path of paths) {
|
|
61
88
|
if (!/^\.claude\/skills\/[A-Za-z0-9_-]+\/(?:[A-Za-z0-9_.-]+\/)*[A-Za-z0-9_.-]+$/.test(path)) continue;
|
|
@@ -82,9 +109,7 @@ export function readBaseTools(root, ref, env = process.env) {
|
|
|
82
109
|
// Nested instructions are normally discovered when a CLI enters that path.
|
|
83
110
|
// Their worktree copies must not regain authority through that discovery.
|
|
84
111
|
const instructions = [];
|
|
85
|
-
const paths =
|
|
86
|
-
cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 1024 * 1024,
|
|
87
|
-
}).split('\n').filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
|
|
112
|
+
const paths = listTree(root, [commit]).filter((path) => /(^|\/)(CLAUDE|AGENTS)\.md$/.test(path));
|
|
88
113
|
if (paths.length > MAX_INSTRUCTION_FILES) throw new Error('Base branch has too many agent instruction files');
|
|
89
114
|
let instructionBytes = 0;
|
|
90
115
|
for (const path of paths) {
|
|
@@ -107,32 +132,26 @@ const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]
|
|
|
107
132
|
* removed by `cleanup`, because Codex indexes a thread under the home it ran
|
|
108
133
|
* in and the next card resumes that thread. Absent, a Codex turn gets a home
|
|
109
134
|
* that dies with it.
|
|
135
|
+
*
|
|
136
|
+
* NO EXPANDED VALUE RIDES ARGV (2026-10-10, the audit): Claude's servers go
|
|
137
|
+
* to this turn's 0600 mcp.json, Codex's into the isolated home's config.toml
|
|
138
|
+
* (projectToolRuntimes.mjs) — never `-c` pairs on the command line.
|
|
110
139
|
*/
|
|
111
|
-
export function prepareAgentTools(snapshot, runner,
|
|
140
|
+
export function prepareAgentTools(snapshot, runner, callerEnv = process.env, worktree = null, { codexHome: keptCodexHome = null } = {}) {
|
|
141
|
+
const env = expansionEnv(callerEnv);
|
|
112
142
|
const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
|
|
113
143
|
const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
|
|
114
144
|
const mcpServers = {};
|
|
115
|
-
const codexArgs = [];
|
|
116
145
|
for (const { name, config } of usable) {
|
|
117
146
|
if (typeof config.command === 'string') {
|
|
118
147
|
const command = expand(config.command, env);
|
|
119
148
|
const args = Array.isArray(config.args) ? config.args.map((v) => expand(v, env)) : [];
|
|
120
149
|
const vars = Object.fromEntries(Object.entries(config.env ?? {}).map(([k, v]) => [k, expand(v, env)]));
|
|
121
150
|
mcpServers[name] = { command, args, env: vars };
|
|
122
|
-
codexArgs.push('-c', `mcp_servers.${name}.command=${toml(command)}`);
|
|
123
|
-
codexArgs.push('-c', `mcp_servers.${name}.args=${JSON.stringify(args)}`);
|
|
124
|
-
for (const [key, value] of Object.entries(vars)) {
|
|
125
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) codexArgs.push('-c', `mcp_servers.${name}.env.${key}=${toml(value)}`);
|
|
126
|
-
}
|
|
127
151
|
} else if (typeof config.url === 'string') {
|
|
128
152
|
const url = expand(config.url, env);
|
|
129
153
|
const headers = Object.fromEntries(Object.entries(config.headers ?? {}).map(([k, v]) => [k, expand(v, env)]));
|
|
130
154
|
mcpServers[name] = { type: config.type ?? 'http', url, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
131
|
-
codexArgs.push('-c', `mcp_servers.${name}.url=${toml(url)}`);
|
|
132
|
-
if (Object.keys(headers).length) {
|
|
133
|
-
const inline = Object.entries(headers).map(([key, value]) => `${toml(key)}=${toml(value)}`).join(',');
|
|
134
|
-
codexArgs.push('-c', `mcp_servers.${name}.http_headers={${inline}}`);
|
|
135
|
-
}
|
|
136
155
|
}
|
|
137
156
|
}
|
|
138
157
|
const mcpPath = join(dir, 'mcp.json');
|
|
@@ -150,7 +169,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
|
|
|
150
169
|
].filter(Boolean).join('\n\n');
|
|
151
170
|
let prepared;
|
|
152
171
|
try {
|
|
153
|
-
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome });
|
|
172
|
+
prepared = prepareForRunner(runner, dir, snapshot, env, worktree, { codexHome: keptCodexHome, mcpServers });
|
|
154
173
|
} catch (error) {
|
|
155
174
|
rmSync(dir, { recursive: true, force: true });
|
|
156
175
|
throw error;
|
|
@@ -158,7 +177,6 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
|
|
|
158
177
|
const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
|
|
159
178
|
return {
|
|
160
179
|
mcpPath,
|
|
161
|
-
codexArgs,
|
|
162
180
|
instructions,
|
|
163
181
|
pluginDir,
|
|
164
182
|
codexHome,
|
package/bin/lib/prompts.mjs
CHANGED
|
@@ -209,6 +209,23 @@ const fence = (label, content) =>
|
|
|
209
209
|
`${String(content ?? '').replace(/<<<|>>>/g, (m) => m.split('').join('\u200b'))}\n` +
|
|
210
210
|
`<<<END ${label}>>>`;
|
|
211
211
|
|
|
212
|
+
/** One line, capped — a title or name is a server string headed into a
|
|
213
|
+
* prompt, and a line break would forge a second entry (a reference, a
|
|
214
|
+
* `- id:` row, a `brief:` line). Above every use (2026-10-10): it was declared
|
|
215
|
+
* far below the builders that call it. Every line break Unicode names, not
|
|
216
|
+
* only `\r` and `\n`: a U+2028 starts a line as surely as `\n` does. */
|
|
217
|
+
const oneLine = (v, max) => String(v ?? '').replace(/[\r\n\v\f\u0085\u2028\u2029]+/g, ' ').trim().slice(0, max);
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A CARD ID FIT TO PRINT OUTSIDE THE FENCE (2026-10-10, the audit). The commit
|
|
221
|
+
* trailer is OUR instruction, after the card's fence closes, and a card id is
|
|
222
|
+
* a doc key a member can write — the server checks only its length, so a
|
|
223
|
+
* newline and a sentence ride in it. A plain token (what the server mints is
|
|
224
|
+
* a UUID) prints as it is; anything else prints nothing, and the trailer
|
|
225
|
+
* paragraph is not said, as it is not on a card with nothing to commit.
|
|
226
|
+
*/
|
|
227
|
+
const trailerId = (id) => (typeof id === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(id) ? id : '');
|
|
228
|
+
|
|
212
229
|
|
|
213
230
|
|
|
214
231
|
|
|
@@ -373,11 +390,14 @@ MECHANICS OF THIS TAB:
|
|
|
373
390
|
\`\`\`
|
|
374
391
|
|
|
375
392
|
ONE block per reply, and always the LAST thing in it. Two to eight options,
|
|
376
|
-
each label short enough to sit on a button
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
393
|
+
each label short enough to sit on a button — a few words, never a comma
|
|
394
|
+
(multi-select answers arrive as the chosen labels comma-joined, in the order
|
|
395
|
+
listed). Do NOT add an "Other" option — the card offers a free-text path
|
|
396
|
+
itself. multiSelect true only for a genuine check-several-of-these case.
|
|
397
|
+
NEVER for an open question — ask those in prose, like anyone would. And ask
|
|
398
|
+
the question in prose above the block as well: a client that doesn't render
|
|
399
|
+
the fence shows it as plain text, so the reply has to read as a question
|
|
400
|
+
with its options either way.
|
|
381
401
|
|
|
382
402
|
THERE IS NO LATER. Your turn ends when you stop writing, and nothing of yours
|
|
383
403
|
runs after that — so never promise to report back, keep watching, or tell them
|
|
@@ -651,11 +671,12 @@ export const WORK_TURN_KICKOFF_PLAIN = ({ sessionName, message, askedByName }) =
|
|
|
651
671
|
* REPORTER's ticket title verbatim, so a stranger can put words in it. That
|
|
652
672
|
* string rides the ship report into `code_map_reground_jobs`, comes back on the
|
|
653
673
|
* roster, and landed here as a bare `Feature: <text>` line — no delimiter, no
|
|
654
|
-
* instruction not to obey it. This turn runs UNATTENDED
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
674
|
+
* instruction not to obey it. This turn runs UNATTENDED, with `Bash(mkdir:*)`
|
|
675
|
+
* and `Bash(rm:*)` and a write to the vault — every page of which is uploaded
|
|
676
|
+
* to the project's wiki. Since 2026-10-10 Claude's list scopes that write to
|
|
677
|
+
* the vault and fences its reads (claudePosture.mjs `wikiPermFor`), but the
|
|
678
|
+
* fence here is still the prompt's half: what the turn WRITES into the vault
|
|
679
|
+
* is what members read.
|
|
659
680
|
*
|
|
660
681
|
* The predicted pages come off the roster exactly as the title does — a
|
|
661
682
|
* planner wrote them from card text, and card text is member-authored — so an
|
|
@@ -886,7 +907,9 @@ export const AGENT_PLAN_KICKOFF = ({ tasks, liveAgents, agentCap }) => {
|
|
|
886
907
|
const cards = tasks
|
|
887
908
|
.map(
|
|
888
909
|
(t) =>
|
|
889
|
-
|
|
910
|
+
// THE TITLE ON ONE LINE (2026-10-10): a member writes it, and a line
|
|
911
|
+
// break in it forged a second `- id:` row inside the fence.
|
|
912
|
+
`- id: ${t.id}\n title: ${oneLine(t.title, 300)}\n` +
|
|
890
913
|
// EVERY card says its kind, code included — the planner is splitting
|
|
891
914
|
// by it (SYSTEM_PLAN rule 8), and an absent line would read as "not
|
|
892
915
|
// stated" rather than as the default. Absent on the wire IS code.
|
|
@@ -1170,9 +1193,11 @@ export const AGENT_TASK_KICKOFF = ({ agentName, task, cardThread, cardFiles, pos
|
|
|
1170
1193
|
`on this branch. Everything you commit here is reviewed and merged TOGETHER with ` +
|
|
1171
1194
|
`the other cards in this run.\n\n` +
|
|
1172
1195
|
`${fence('THE CARD', taskBlock(task, cardThread, cardFiles))}\n\n` +
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1196
|
+
(trailerId(task?.id)
|
|
1197
|
+
? `When you commit, put this trailer on the LAST line of each commit message so ` +
|
|
1198
|
+
`the card can find its own commits:\n` +
|
|
1199
|
+
`Flowviant-Task: ${trailerId(task?.id)}\n\n`
|
|
1200
|
+
: '') +
|
|
1176
1201
|
`Do it, commit it, and end with the JSON object.`
|
|
1177
1202
|
: // A NON-CODE CARD (0.97.0; 3D model and presentation 0.105.0; image 0.114.0) has nothing to commit, so the trailer
|
|
1178
1203
|
// paragraph — an instruction to do the one thing its contract forbids —
|
|
@@ -1211,7 +1236,8 @@ const safeName = (n) =>
|
|
|
1211
1236
|
*/
|
|
1212
1237
|
export const AGENT_TASK_SPEC = (task, cardThread, cardFiles) =>
|
|
1213
1238
|
`id: ${task?.id ?? ''}\n` +
|
|
1214
|
-
|
|
1239
|
+
// On one line (2026-10-10): a line break forged a `brief:` or `done when:`.
|
|
1240
|
+
`title: ${oneLine(task?.title, 300)}\n` +
|
|
1215
1241
|
// THE KIND, when it is news (0.97.0). A code card's spec stays byte-for-byte
|
|
1216
1242
|
// what it was — the reviewer's stash and the agent's prompt both read this,
|
|
1217
1243
|
// and a line saying "kind: code" on every card is a change to every prompt
|
|
@@ -1324,10 +1350,6 @@ const cardFilesText = (files) => {
|
|
|
1324
1350
|
* The server has sent `designs/<slug>-vN/` since 0.99.0, and the folder list
|
|
1325
1351
|
* this file used to spell for itself dropped every one until 0.105.0. */
|
|
1326
1352
|
const isReference = (r) => !!r && isLibraryReference(r.name) && typeof r.title === 'string';
|
|
1327
|
-
/** One line, capped — a reference title is a server string headed into a
|
|
1328
|
-
* prompt, and a line break would forge a second entry. */
|
|
1329
|
-
const oneLine = (v, max) => String(v ?? '').replace(/[\r\n]+/g, ' ').trim().slice(0, max);
|
|
1330
|
-
|
|
1331
1353
|
const taskBlock = AGENT_TASK_SPEC;
|
|
1332
1354
|
|
|
1333
1355
|
/**
|
package/bin/lib/repoPath.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* deleted checkout still matches the lock or entry that names it.
|
|
12
12
|
*/
|
|
13
13
|
import { realpathSync } from 'node:fs';
|
|
14
|
+
import { sep } from 'node:path';
|
|
14
15
|
|
|
15
16
|
/** Same directory, whatever it is spelled as. A blank side matches nothing. */
|
|
16
17
|
export function samePath(a, b) {
|
|
@@ -24,3 +25,21 @@ export function samePath(a, b) {
|
|
|
24
25
|
};
|
|
25
26
|
return norm(a) === norm(b);
|
|
26
27
|
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* `p` IS `root` OR INSIDE IT — a pure prefix test on already-RESOLVED absolute
|
|
31
|
+
* paths, never a sibling that merely shares the prefix (`/repo-other` is not
|
|
32
|
+
* inside `/repo`). No filesystem: each caller resolves (or deliberately keeps
|
|
33
|
+
* a literal) before asking.
|
|
34
|
+
*
|
|
35
|
+
* One predicate for the fences that decide on it (audit 2026-10-10, F106 /
|
|
36
|
+
* F161): the adopt fence offered and accepted on both sides (localSessions.mjs)
|
|
37
|
+
* and the listener attribution behind a public preview tunnel (procScan.mjs's
|
|
38
|
+
* insideDir). Three hand copies meant a containment fix — a root of `/`, a
|
|
39
|
+
* case-insensitive filesystem — had to land three times or the fences would
|
|
40
|
+
* disagree about the same path.
|
|
41
|
+
*/
|
|
42
|
+
export function isPathInside(p, root) {
|
|
43
|
+
if (typeof p !== 'string' || typeof root !== 'string') return false;
|
|
44
|
+
return p === root || p.startsWith(root.endsWith(sep) ? root : root + sep);
|
|
45
|
+
}
|