flowviant 0.103.0 → 0.104.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.
Files changed (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -0,0 +1,138 @@
1
+ /**
2
+ * WHAT EACH CLI TAKES OF THE REPO'S TOOLS, AND HOW A TURN IS PREPARED FOR IT
3
+ * (2026-09-26, SOLID F064).
4
+ *
5
+ * One row per runtime: whether it can take MCP servers at all, which MCP
6
+ * configurations it refuses (Codex takes no SSE), whether it takes the base
7
+ * branch's skills, and the runner-specific filesystem work a turn needs
8
+ * (Claude: a reviewed plugin carrying the base skills; Codex: an isolated
9
+ * CODEX_HOME). Split out of projectTools.mjs, where a capability table sat
10
+ * beside `runner === 'codex'` / `runner === 'claude'` branches in
11
+ * prepareAgentTools and a second copy of the SSE rule in toolReadout, so a
12
+ * new runtime or a transport change needed coordinated edits in three places.
13
+ * prepareAgentTools, toolReadout and runtimeOfferings.mjs (the Codex MCP
14
+ * report) all ask this table; the base-branch snapshot reader stays in
15
+ * projectTools.mjs. projectTools.test.mjs pins every transport word to here.
16
+ */
17
+
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from 'node:fs';
19
+ import { join, resolve } from 'node:path';
20
+ import { homedir } from 'node:os';
21
+
22
+ export const toml = (value) => JSON.stringify(String(value));
23
+
24
+ /** Keep personal Codex files, but do not copy its project trust grants. An
25
+ * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
26
+ function isolatedCodexHome(dir, env, worktree) {
27
+ const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
28
+ const dest = join(dir, 'codex-home');
29
+ mkdirSync(dest, { mode: 0o700 });
30
+ if (existsSync(source)) {
31
+ for (const name of readdirSync(source)) {
32
+ if (name === 'config.toml') continue;
33
+ symlinkSync(join(source, name), join(dest, name));
34
+ }
35
+ }
36
+ const configPath = join(source, 'config.toml');
37
+ const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
38
+ const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
39
+ const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
40
+ // These sections alone grant a checkout permission to supply project
41
+ // settings. Keep every other personal setting, skill and session store.
42
+ const lines = sourceConfig.split('\n');
43
+ let inProject = false;
44
+ let config = lines.filter((line) => {
45
+ const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
46
+ if (header) {
47
+ const section = header[1] ?? header[2];
48
+ inProject = section === 'projects' || section.startsWith('projects.');
49
+ }
50
+ return !inProject;
51
+ }).join('\n');
52
+ if (worktree) {
53
+ // Codex discovers these even in an untrusted project. Disable exactly the
54
+ // worktree skill paths; the person's ~/.codex/skills and config remain.
55
+ const worktreeSkills = [];
56
+ for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
57
+ const skillsDir = join(worktree, sourceDir);
58
+ if (!existsSync(skillsDir)) continue;
59
+ for (const name of readdirSync(skillsDir)) {
60
+ const skill = join(skillsDir, name, 'SKILL.md');
61
+ if (existsSync(skill)) worktreeSkills.push(skill);
62
+ }
63
+ }
64
+ if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
65
+ throw new Error('Cannot isolate Codex project skills with inline skills.config');
66
+ for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
67
+ }
68
+ writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
69
+ return { path: dest, personalDeveloperInstructions };
70
+ }
71
+
72
+ /** Claude takes the base branch's skills as a plugin written for this turn. */
73
+ function reviewedClaudePlugin(dir, snapshot) {
74
+ if (!snapshot.skillFiles.length) return null;
75
+ const pluginDir = join(dir, 'reviewed-plugin');
76
+ mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
77
+ writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
78
+ for (const { path, body } of snapshot.skillFiles) {
79
+ const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
80
+ mkdirSync(join(dest, '..'), { recursive: true });
81
+ writeFileSync(dest, body, { mode: 0o600 });
82
+ }
83
+ return pluginDir;
84
+ }
85
+
86
+ /**
87
+ * The table. `refuseMcp(config)` names why this runtime cannot take one MCP
88
+ * configuration it otherwise could; `prepare(dir, snapshot, env, worktree)`
89
+ * returns the runner-specific part of prepareAgentTools' result.
90
+ */
91
+ const TOOL_RUNTIMES = {
92
+ claude: {
93
+ mcp: true,
94
+ skills: true,
95
+ refuseMcp: () => null,
96
+ prepare: (dir, snapshot) => ({ pluginDir: reviewedClaudePlugin(dir, snapshot) }),
97
+ },
98
+ codex: {
99
+ mcp: true,
100
+ skills: true,
101
+ refuseMcp: (config) => (config.type === 'sse' ? 'Codex cannot take SSE MCP' : null),
102
+ prepare: (dir, _snapshot, env, worktree) => {
103
+ const isolated = isolatedCodexHome(dir, env, worktree);
104
+ return { codexHome: isolated.path, personalDeveloperInstructions: isolated.personalDeveloperInstructions };
105
+ },
106
+ },
107
+ antigravity: { mcp: false, skills: false },
108
+ };
109
+
110
+ /** An unknown runner (or 'none', when no CLI is installed) takes nothing. */
111
+ const takesNothing = { mcp: false, skills: false };
112
+ const rowFor = (runner) => (Object.hasOwn(TOOL_RUNTIMES, runner) ? TOOL_RUNTIMES[runner] : takesNothing);
113
+
114
+ /** Pure mapping used before a future tool-attach gesture is offered. */
115
+ export function runnerToolCapabilities(runner) {
116
+ const { mcp, skills } = rowFor(runner);
117
+ return { mcp, skills };
118
+ }
119
+
120
+ /** Why `runner` cannot take this MCP configuration, or null when it can. The
121
+ * one answer both preparation (which drops it) and the readout (which says it)
122
+ * read. */
123
+ export function mcpRefusal(runner, config) {
124
+ const row = rowFor(runner);
125
+ if (!row.mcp) return runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`;
126
+ return row.refuseMcp?.(config) ?? null;
127
+ }
128
+
129
+ /** Why `runner` cannot take the base branch's skills, or null when it can. */
130
+ export function skillsRefusal(runner) {
131
+ if (rowFor(runner).skills) return null;
132
+ return runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`;
133
+ }
134
+
135
+ /** The runner-specific files a turn needs, written under `dir`. */
136
+ export function prepareForRunner(runner, dir, snapshot, env, worktree) {
137
+ return { pluginDir: null, codexHome: null, personalDeveloperInstructions: false, ...(rowFor(runner).prepare?.(dir, snapshot, env, worktree) ?? {}) };
138
+ }
@@ -1,15 +1,15 @@
1
1
  /** Repo-owned tool configuration, read from the base ref for every agent turn. */
2
2
  import { execFileSync } from 'node:child_process';
3
- import { accessSync, constants, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
4
- import { delimiter, isAbsolute, join, resolve } from 'node:path';
5
- import { homedir, tmpdir } from 'node:os';
3
+ import { accessSync, constants, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { delimiter, isAbsolute, join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { mcpRefusal, prepareForRunner, skillsRefusal, toml } from './projectToolRuntimes.mjs';
6
7
 
7
8
  const MAX_TOOLS = 40;
8
9
  const MAX_REPORT_TOOLS = 80; // 40 MCP servers plus 40 instruction directories
9
10
  const MAX_INSTRUCTION_FILES = 80;
10
11
  const MAX_INSTRUCTION_BYTES = 512 * 1024;
11
12
  const nameOk = (name) => /^[A-Za-z0-9_-]{1,80}$/.test(name) && name !== 'flowviant';
12
- const toml = (value) => JSON.stringify(String(value));
13
13
  const show = (root, ref, path) => {
14
14
  try {
15
15
  return execFileSync('git', ['show', `${ref}:${path}`], {
@@ -39,12 +39,6 @@ function referenced(value) {
39
39
  return [...String(value).matchAll(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]);
40
40
  }
41
41
 
42
- /** Pure mapping used before a future tool-attach gesture is offered. */
43
- export function runnerToolCapabilities(runner) {
44
- return ({ claude: { mcp: true, skills: true }, codex: { mcp: true, skills: true }, antigravity: { mcp: false, skills: false } })[runner]
45
- ?? { mcp: false, skills: false };
46
- }
47
-
48
42
  export function readBaseTools(root, ref, env = process.env) {
49
43
  // Resolve once: a base branch may advance while the turn is being prepared.
50
44
  const commit = execFileSync('git', ['rev-parse', '--verify', ref], {
@@ -105,59 +99,10 @@ export function readBaseTools(root, ref, env = process.env) {
105
99
 
106
100
  const expand = (value, env) => String(value).replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, key) => env[key] ?? '');
107
101
 
108
- /** Keep personal Codex files, but do not copy its project trust grants. An
109
- * untrusted worktree's .codex/config.toml is ignored by Codex 0.156.1. */
110
- function isolatedCodexHome(dir, env, worktree) {
111
- const source = resolve(env.CODEX_HOME || join(homedir(), '.codex'));
112
- const dest = join(dir, 'codex-home');
113
- mkdirSync(dest, { mode: 0o700 });
114
- if (existsSync(source)) {
115
- for (const name of readdirSync(source)) {
116
- if (name === 'config.toml') continue;
117
- symlinkSync(join(source, name), join(dest, name));
118
- }
119
- }
120
- const configPath = join(source, 'config.toml');
121
- const sourceConfig = existsSync(configPath) ? readFileSync(configPath, 'utf8') : '';
122
- const topLevel = sourceConfig.split(/^\s*\[/m, 1)[0];
123
- const personalDeveloperInstructions = /^\s*developer_instructions\s*=/m.test(topLevel);
124
- // These sections alone grant a checkout permission to supply project
125
- // settings. Keep every other personal setting, skill and session store.
126
- const lines = sourceConfig.split('\n');
127
- let inProject = false;
128
- let config = lines.filter((line) => {
129
- const header = /^\s*(?:\[\[([^\]]+)\]\]|\[([^\]]+)\])\s*(?:#.*)?$/.exec(line);
130
- if (header) {
131
- const section = header[1] ?? header[2];
132
- inProject = section === 'projects' || section.startsWith('projects.');
133
- }
134
- return !inProject;
135
- }).join('\n');
136
- if (worktree) {
137
- // Codex discovers these even in an untrusted project. Disable exactly the
138
- // worktree skill paths; the person's ~/.codex/skills and config remain.
139
- const worktreeSkills = [];
140
- for (const sourceDir of ['.agents/skills', '.codex/skills', '.claude/skills']) {
141
- const skillsDir = join(worktree, sourceDir);
142
- if (!existsSync(skillsDir)) continue;
143
- for (const name of readdirSync(skillsDir)) {
144
- const skill = join(skillsDir, name, 'SKILL.md');
145
- if (existsSync(skill)) worktreeSkills.push(skill);
146
- }
147
- }
148
- if (worktreeSkills.length && /^\s*skills\.config\s*=/m.test(config))
149
- throw new Error('Cannot isolate Codex project skills with inline skills.config');
150
- for (const skill of worktreeSkills) config += `\n[[skills.config]]\npath = ${toml(skill)}\nenabled = false\n`;
151
- }
152
- writeFileSync(join(dest, 'config.toml'), config, { mode: 0o600 });
153
- return { path: dest, personalDeveloperInstructions };
154
- }
155
-
156
102
  /** Temporary per-turn files are removed after the last retry. No personal CLI config is written. */
157
103
  export function prepareAgentTools(snapshot, runner, env = process.env, worktree = null) {
158
104
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-agent-tools-'));
159
- const caps = runnerToolCapabilities(runner);
160
- const usable = snapshot.tools.filter((t) => t.state === 'ready' && caps.mcp && !(runner === 'codex' && t.config.type === 'sse'));
105
+ const usable = snapshot.tools.filter((t) => t.state === 'ready' && mcpRefusal(runner, t.config) == null);
161
106
  const mcpServers = {};
162
107
  const codexArgs = [];
163
108
  for (const { name, config } of usable) {
@@ -185,7 +130,7 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
185
130
  const mcpPath = join(dir, 'mcp.json');
186
131
  writeFileSync(mcpPath, JSON.stringify({ mcpServers }), { mode: 0o600 });
187
132
  const pointers = [];
188
- for (const { path, body } of [...snapshot.instructions, ...(caps.skills ? snapshot.skillFiles : [])]) {
133
+ for (const { path, body } of [...snapshot.instructions, ...(skillsRefusal(runner) == null ? snapshot.skillFiles : [])]) {
189
134
  const dest = join(dir, path);
190
135
  mkdirSync(join(dest, '..'), { recursive: true });
191
136
  writeFileSync(dest, body, { mode: 0o600 });
@@ -195,28 +140,14 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
195
140
  const instructions = [baseInstructions,
196
141
  pointers.length ? `Base-branch instruction and skill copies (read the relevant nested files and skill files before acting):\n${pointers.join('\n')}` : '',
197
142
  ].filter(Boolean).join('\n\n');
198
- const pluginDir = runner === 'claude' && snapshot.skillFiles.length ? join(dir, 'reviewed-plugin') : null;
199
- if (pluginDir) {
200
- mkdirSync(join(pluginDir, '.claude-plugin'), { recursive: true });
201
- writeFileSync(join(pluginDir, '.claude-plugin/plugin.json'), JSON.stringify({ name: 'flowviant-reviewed', version: '1.0.0', description: 'Base-branch agent skills' }), { mode: 0o600 });
202
- for (const { path, body } of snapshot.skillFiles) {
203
- const dest = join(pluginDir, path.replace(/^\.claude\//, ''));
204
- mkdirSync(join(dest, '..'), { recursive: true });
205
- writeFileSync(dest, body, { mode: 0o600 });
206
- }
207
- }
208
- let codexHome = null;
209
- let personalDeveloperInstructions = false;
143
+ let prepared;
210
144
  try {
211
- if (runner === 'codex') {
212
- const isolated = isolatedCodexHome(dir, env, worktree);
213
- codexHome = isolated.path;
214
- personalDeveloperInstructions = isolated.personalDeveloperInstructions;
215
- }
145
+ prepared = prepareForRunner(runner, dir, snapshot, env, worktree);
216
146
  } catch (error) {
217
147
  rmSync(dir, { recursive: true, force: true });
218
148
  throw error;
219
149
  }
150
+ const { pluginDir, codexHome, personalDeveloperInstructions } = prepared;
220
151
  return {
221
152
  mcpPath,
222
153
  codexArgs,
@@ -229,20 +160,14 @@ export function prepareAgentTools(snapshot, runner, env = process.env, worktree
229
160
  }
230
161
 
231
162
  export function toolReadout(snapshot, runner) {
232
- const caps = runnerToolCapabilities(runner);
233
163
  const short = (reason) => reason == null ? null : String(reason).slice(0, 200);
234
164
  const mcp = snapshot.tools.map(({ name, config, state, reason }) => {
235
- const unsupported = runner === 'codex' && config.type === 'sse';
236
- return {
237
- name, kind: 'mcp',
238
- state: caps.mcp && !unsupported ? state : 'missing',
239
- reason: short(!caps.mcp ? runner === 'none' ? 'no installed CLI can take MCP' : `${runner} cannot take MCP`
240
- : unsupported ? 'Codex cannot take SSE MCP' : reason),
241
- };
165
+ const refused = mcpRefusal(runner, config);
166
+ return { name, kind: 'mcp', state: refused ? 'missing' : state, reason: short(refused ?? reason) };
242
167
  });
168
+ const skillsRefused = skillsRefusal(runner);
243
169
  const instructions = snapshot.skills.map(({ path }) => ({
244
- name: path.split('/')[2], kind: 'instruction', state: caps.skills ? 'ready' : 'missing',
245
- reason: short(caps.skills ? null : runner === 'none' ? 'no installed CLI can take instructions' : `${runner} cannot take instructions`),
170
+ name: path.split('/')[2], kind: 'instruction', state: skillsRefused ? 'missing' : 'ready', reason: short(skillsRefused),
246
171
  }));
247
172
  return [...mcp, ...instructions].slice(0, MAX_REPORT_TOOLS);
248
173
  }
@@ -1,42 +1,15 @@
1
1
  /**
2
2
  * The operating-contract prompts — every system prompt and kickoff the daemon
3
- * hands a coding CLI, in one place. Split out of claude.mjs (which keeps the
4
- * permission sets and the turn plumbing) purely for size; claude.mjs
5
- * re-exports everything here, so no call site changed. These are strings and
6
- * nothing else: no imports, no environment, no I/O.
3
+ * hands a coding CLI, in one place. Split out of claude.mjs purely for size
4
+ * (the permission sets are claudePosture.mjs's, the turn plumbing runTurn.mjs's);
5
+ * every call site imports them from here. These are strings and
6
+ * nothing else: no environment, no I/O, and one import — the card-kind table
7
+ * (agentTaskKinds.mjs, itself pure data), which selects among them.
7
8
  */
8
9
 
9
- /**
10
- * A CARD'S KIND, READ THE ONE WAY THE DAEMON READS IT (0.97.0).
11
- *
12
- * `code` (the default), `design` or `research` — the server sends the key only
13
- * for the last two, so ABSENT IS CODE, and an unknown string (a newer server, a
14
- * typo) degrades to code: the one kind that changes nothing about how a card is
15
- * run. Every reader in this repo — the contract selector, the kickoff, the
16
- * spec, the posture, the post-turn artifact check — goes through this, so no
17
- * two of them can disagree about what an odd value means.
18
- */
19
- export const agentTaskKindOf = (v) => (v === 'design' || v === 'research' ? v : 'code');
20
-
21
- /**
22
- * …EXCEPT WHERE A TURN IS ABOUT TO SPAWN (2026-09-23). "Unknown degrades to
23
- * code" is right for every reader that only PRINTS the kind — the planner's
24
- * kickoff, the spec, a contract heading — and wrong for the one that picks a
25
- * POSTURE: code is the build turn, permissions skipped, so a kind a newer
26
- * server invented for a card that must not touch the code (a fourth kind, a
27
- * typo, `Design`) would have run as exactly the turn it exists to prevent. The
28
- * agent lane asks this first and refuses in words, relaying the word it was
29
- * handed. Absent, null and empty stay code — that is how a code card arrives.
30
- *
31
- * @returns {string|null} the unrecognised value, or null when the kind is one
32
- * this daemon runs.
33
- */
34
- export const unknownAgentTaskKind = (v) =>
35
- v === undefined || v === null || v === '' || v === 'code' || v === 'design' || v === 'research'
36
- ? null
37
- : String(v).replace(/[\u0000-\u001f\u007f]/g, '').slice(0, 40);
38
-
39
-
10
+ // A card's kind is read through agentTaskKinds.mjs — the one table of what a
11
+ // kind is. This file keeps only the STRINGS each kind is handed.
12
+ import { agentTaskKindOf } from './agentTaskKinds.mjs';
40
13
 
41
14
  // Wiki-gen turn: the local Claude READS the repo (cwd) and writes/maintains the
42
15
  // knowledge VAULT — a plain directory of markdown files with [[wikilinks]]
@@ -450,7 +423,7 @@ Write plain Markdown for a person reading your reply in a chat tab.`;
450
423
  * bookkeeping reasons, which is the one thing it never does.
451
424
  *
452
425
  * SHAPE, NOT MEMBERSHIP. We do not check the name against the machine's skill
453
- * list: that list is only learned after a turn has run (runtimes.mjs), so
426
+ * list: that list is only learned after a turn has run (runtimeCapabilities.mjs), so
454
427
  * gating on it would make the first `/foo` of a machine's life behave
455
428
  * differently from the second. Instead this matches what a command can LOOK
456
429
  * like — one segment, no second slash — which leaves `/home/user/x.ts is
@@ -939,7 +912,7 @@ not only this card. Past tense, plain sentences, no commit shas and no card ids.
939
912
  * everything BEFORE the JSON — no commits, no trailer, one artifact.
940
913
  *
941
914
  * THE PROMPT IS THE QUALITY; THE POSTURE IS THE FENCE. The `design` posture
942
- * (claude.mjs) can write ONLY under `.flowviant/artifacts/` — "change no
915
+ * (claudePosture.mjs) can write ONLY under `.flowviant/artifacts/` — "change no
943
916
  * repository file" is enforced, not asked — and the daemon refuses to call the
944
917
  * turn delivered if no `.html` was written under it this turn.
945
918
  *
@@ -1071,10 +1044,23 @@ past tense, plain sentences, no card ids.`;
1071
1044
  * code, and code is `SYSTEM_AGENT` — the same object, so a code turn's system
1072
1045
  * prompt is byte-for-byte what it was before kinds existed.
1073
1046
  */
1074
- export const SYSTEM_AGENT_FOR = (taskKind) => {
1075
- const k = agentTaskKindOf(taskKind);
1076
- return k === 'design' ? SYSTEM_AGENT_DESIGN : k === 'research' ? SYSTEM_AGENT_RESEARCH : SYSTEM_AGENT;
1077
- };
1047
+ export const SYSTEM_AGENT_FOR = (taskKind) => AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
1048
+
1049
+ /**
1050
+ * THE STRINGS EACH KIND IS HANDED, keyed by the kinds in agentTaskKinds.mjs —
1051
+ * its contract, and (for a kind that changes nothing in the repository) the
1052
+ * clause its kickoff says it hands back. A kind added there without a line
1053
+ * here fails `agentTaskKinds.test.mjs`, never a turn.
1054
+ */
1055
+ export const AGENT_CONTRACTS = Object.freeze({
1056
+ code: SYSTEM_AGENT,
1057
+ design: SYSTEM_AGENT_DESIGN,
1058
+ research: SYSTEM_AGENT_RESEARCH,
1059
+ });
1060
+ export const AGENT_HANDS_BACK = Object.freeze({
1061
+ design: 'a DESIGN card: it hands back a mockup',
1062
+ research: 'a RESEARCH card: it hands back a write-up',
1063
+ });
1078
1064
 
1079
1065
  /**
1080
1066
  * The turn itself.
@@ -1102,7 +1088,7 @@ export const AGENT_TASK_KICKOFF = ({ agentName, task, position, total }) =>
1102
1088
  // is not said at all. The code branch above is byte-for-byte what every
1103
1089
  // turn got before kinds existed.
1104
1090
  `You are the agent "${safeName(agentName)}", working card ${position} of ${total} ` +
1105
- `in this run. This card is ${agentTaskKindOf(task?.taskKind) === 'design' ? 'a DESIGN card: it hands back a mockup' : 'a RESEARCH card: it hands back a write-up'}, ` +
1091
+ `in this run. This card is ${AGENT_HANDS_BACK[agentTaskKindOf(task?.taskKind)]}, ` +
1106
1092
  `written under .flowviant/artifacts/, and changes nothing in the repository.\n\n` +
1107
1093
  `${fence('THE CARD', taskBlock(task))}\n\n` +
1108
1094
  `Do it, and end with the JSON object.`;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * REPO IDENTITY BY PATH — whether two spellings name the same checkout. One
3
+ * home for the rule the instance lock (neighbour takeover, same-repo
4
+ * replacement) and the credential store (collisions, the login refusal, start
5
+ * resolution) both decide on. Split out (SOLID F057) because the two were
6
+ * hand-copied: a change to symlink or missing-path treatment made in one would
7
+ * let a lock and a credential disagree about which directory a daemon serves.
8
+ *
9
+ * Realpath first (symlinks and trailing slashes both resolve); a path that no
10
+ * longer exists falls back to its own spelling minus trailing slashes, so a
11
+ * deleted checkout still matches the lock or entry that names it.
12
+ */
13
+ import { realpathSync } from 'node:fs';
14
+
15
+ /** Same directory, whatever it is spelled as. A blank side matches nothing. */
16
+ export function samePath(a, b) {
17
+ if (!a || !b) return false;
18
+ const norm = (v) => {
19
+ try {
20
+ return realpathSync(v);
21
+ } catch {
22
+ return String(v).replace(/\/+$/, '');
23
+ }
24
+ };
25
+ return norm(a) === norm(b);
26
+ }
@@ -18,7 +18,8 @@
18
18
  * ── AND SINCE 2026-09-14 THERE IS A SECOND READER, WHICH IS NOT TELEMETRY ──
19
19
  *
20
20
  * The paragraph above is about the SNAPSHOT and stands unchanged. What is added
21
- * below — `memAvailableBytes` and `pressureVerdict` — is read at the moment
21
+ * below — `memAvailableBytes` and `measurePressure`, judged by `pressureVerdict`
22
+ * in pressurePolicy.mjs — is read at the moment
22
23
  * this machine is about to spawn one more CLI, and it exists because it froze
23
24
  * somebody's computer: `MAX_CONCURRENT` had been enforced nowhere since
24
25
  * dispatch was deleted, the session-turn lane had no slice at all, and nothing
@@ -49,9 +50,6 @@ import { readFileSync, readdirSync, statfsSync } from 'node:fs';
49
50
  import { cpus, freemem, loadavg, platform } from 'node:os';
50
51
  import { MACHINE } from './config.mjs';
51
52
 
52
- const MiB = 1024 * 1024;
53
- const GiB = 1024 * MiB;
54
-
55
53
  const readFile = (p) => {
56
54
  try {
57
55
  return readFileSync(p, 'utf8').trim();
@@ -162,7 +160,7 @@ export function processTreeRssBytes(pid) {
162
160
  * pages is the nearest thing it will say out loud.
163
161
  *
164
162
  * The cgroup is asked SECOND and narrows rather than replaces, for the reason
165
- * `machineLimits` gives in config.mjs: a 4GB container on a 256GB box reads the
163
+ * `deriveMachineLimits` gives in machineLimits.mjs: a 4GB container on a 256GB box reads the
166
164
  * host through the os module, and oversubscribing there ends with the OOM
167
165
  * killer picking a victim by resident size — frequently not the offender.
168
166
  *
@@ -248,26 +246,6 @@ function darwinAvailableBytes() {
248
246
  return pages > 0 ? pages * page : null;
249
247
  }
250
248
 
251
- /** The guard off entirely — an escape hatch for a box whose operator knows
252
- * better than the thresholds. Read per call, not captured at import, so it is
253
- * true of the environment the daemon is in RIGHT NOW rather than the one it
254
- * booted in. `absence` and `'0'` both mean the guard is on. */
255
- export function pressureGuardOff() {
256
- return process.env.FLOWVIANT_NO_PRESSURE_GUARD === '1';
257
- }
258
-
259
- /** An operator's override, or the default. Positive finite numbers only: a
260
- * typo'd `FLOWVIANT_MIN_FREE_MB=lots` must fall back to the default rather
261
- * than turning every comparison into NaN, which compares false and would
262
- * silently disable the guard it was trying to tune. */
263
- const envNum = (name, fallback) => {
264
- const n = Number(process.env[name]);
265
- return Number.isFinite(n) && n > 0 ? n : fallback;
266
- };
267
-
268
- const sizeWord = (n) =>
269
- n >= GiB ? `${(n / GiB).toFixed(1)} GB` : `${Math.round(n / MiB)} MB`;
270
-
271
249
  /** How long one measurement stands. The reconcile loop asks several times a
272
250
  * tick (one per admission point) and every answer would otherwise be a fresh
273
251
  * /proc read — or, on macOS, a fresh `vm_stat` spawn, which is a process per
@@ -308,50 +286,10 @@ export function measurePressure() {
308
286
  return m;
309
287
  }
310
288
 
311
- /**
312
- * IS THIS A MOMENT TO START ANOTHER CLI? — null when there is nothing to say.
313
- *
314
- * TWO LEVELS, because the two lanes are not the same promise. `churn` is the
315
- * unattended work — agent turns, a Deploy press's planner, the wiki
316
- * cartographer — which nobody is sitting in front of and which the server will
317
- * cheerfully re-offer next poll, so it yields early and generously. A session
318
- * turn is `interactive`: somebody is watching a composer they just pressed
319
- * enter in, and deferring that is a visible stall, so it holds out until the
320
- * box is genuinely about to fall over.
321
- *
322
- * THE REASON IS MEASURED WORDS AND NOTHING ELSE. No adjectives about the
323
- * machine, no advice, no "try again in a few minutes" — a sentence naming the
324
- * number that fired, which is the only thing this side actually knows.
325
- *
326
- * An unreadable measurement contributes NO verdict: memory that could not be
327
- * read cannot refuse, and a load average this platform does not publish cannot
328
- * either. Ignorance never withholds.
329
- */
330
- export function pressureVerdict(level, measured) {
331
- if (pressureGuardOff()) return null;
332
- const m = measured ?? measurePressure();
333
- const avail = Number.isFinite(m?.memAvailable) ? m.memAvailable : null;
334
- if (level === 'interactive') {
335
- const floor = envNum('FLOWVIANT_CRITICAL_FREE_MB', 400) * MiB;
336
- if (avail !== null && avail < floor)
337
- return { reason: `nearly out of memory — ${sizeWord(avail)} available` };
338
- return null;
339
- }
340
- const total = Number.isFinite(m?.memTotal) && m.memTotal > 0 ? m.memTotal : 0;
341
- // A fixed floor AND a proportional one, whichever is larger: 1 GB is the
342
- // right reserve on a laptop and is nothing on a 256 GB box, where six per
343
- // cent is the honest "the page cache is already being squeezed" line.
344
- const floor = Math.max(envNum('FLOWVIANT_MIN_FREE_MB', 1024) * MiB, Math.round(total * 0.06));
345
- if (avail !== null && avail < floor)
346
- return { reason: `low memory — ${sizeWord(avail)} of ${sizeWord(total)} available` };
347
- const cores = Number.isFinite(m?.cores) && m.cores > 0 ? m.cores : null;
348
- const load = Number.isFinite(m?.load1) ? m.load1 : null;
349
- if (load !== null && cores !== null && load > cores * envNum('FLOWVIANT_MAX_LOAD_PER_CORE', 4))
350
- return {
351
- reason: `cpu overloaded — load ${load.toFixed(1)} on ${cores} core${cores === 1 ? '' : 's'}`,
352
- };
353
- return null;
354
- }
289
+ // THE VERDICT ITSELF — "is this a moment to start another CLI?" — lives in
290
+ // pressurePolicy.mjs (2026-09-26), pure, with its thresholds passed in. This
291
+ // module measures; admission.mjs hands the measurement and the live
292
+ // environment's thresholds to it per decision.
355
293
 
356
294
  /** Free bytes on the volume holding the worktrees. */
357
295
  export function diskFreeBytes(path) {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The resume-lost rule — when a resumed CLI turn found no conversation and
3
+ * runs once more fresh. ONE home for both lanes (the Workbench tab and the
4
+ * agent turn), split out of work.mjs because the agent lane called the
5
+ * classifier without the runtime and init evidence it needs (SOLID F001): a
6
+ * successful Claude answer that mentioned "session … not found" re-ran the
7
+ * whole card in a fresh conversation after the first run had already
8
+ * committed. The evidence is now part of the call's shape, not a default.
9
+ */
10
+
11
+ /**
12
+ * A RESUME THAT FOUND NO CONVERSATION, in the CLI's own words.
13
+ *
14
+ * "Produced nothing" was the only signal the retry backstop had, and it is not
15
+ * the signal these failures give: Claude Code answers a dead `--resume` id with
16
+ * a result event carrying `errors: ['No conversation found with session ID: …']`
17
+ * AND writes the same line to stderr, so `out` is non-empty, the backstop never
18
+ * fired, and the turn SETTLED SUCCESSFULLY with the error as its answer. Every
19
+ * later message in that tab replied the same way — the marker file still held
20
+ * the dead id, nothing rewrote it (that only happens when an init event is
21
+ * seen, and there is none), and no surface offered a way to clear it. A tab
22
+ * bricked forever by its own CLI pruning its history, which it does on its own
23
+ * schedule.
24
+ *
25
+ * Matched on the CLI's phrasing rather than a code, because neither runtime
26
+ * gives one. Deliberately narrow: it must not swallow a rate limit or a
27
+ * permission refusal, both of which are real answers that should stand.
28
+ */
29
+ const RESUME_LOST = [
30
+ /no conversation found/i,
31
+ /no session found/i,
32
+ /session .{0,80}not found/i,
33
+ /conversation .{0,80}not found/i,
34
+ /thread .{0,80}not found/i,
35
+ /trajectory not found/i,
36
+ ];
37
+ /**
38
+ * …BUT ONLY WHEN THE CLI SAID IT, NOT WHEN THE REPLY DID (2026-09-24).
39
+ *
40
+ * Under `answerFromResult` a SUCCESSFUL turn's `out` is Claude's reply, and the
41
+ * patterns above were tested against all of it — so "the session cookie was not
42
+ * found because SameSite dropped it" threw away a real answer and re-ran the
43
+ * same message in a fresh, context-free conversation, whose init id then
44
+ * re-pinned the tab: the first run's edits and commits had already happened,
45
+ * the second repeated or misread them, and the tab's history was gone for good.
46
+ * Web debugging talk says "session … not found" all day.
47
+ *
48
+ * So the text alone is never the evidence. Claude Code emits `system.init` on
49
+ * EVERY turn that reached a conversation, and a dead `--resume` id fails before
50
+ * one is emitted — so for Claude, a lost conversation is the phrase AND no init
51
+ * event. Codex and agy give no such marker to this caller, so for them the
52
+ * phrase must BE the reply: a short line, the shape a CLI error takes, never a
53
+ * paragraph that happens to contain it.
54
+ */
55
+ export const RESUME_LOST_MAX_CHARS = 400;
56
+ export const resumeConversationLost = (text, { runtime, sawInit } = {}) => {
57
+ const t = String(text || '').trim();
58
+ if (!t || !RESUME_LOST.some((re) => re.test(t))) return false;
59
+ // Claude's evidence is the init event, and it must be SAID: a caller that
60
+ // passes no `sawInit` measured nothing, and an unmeasured init never
61
+ // authorises a second run of a turn that may already have committed.
62
+ if (runtime === 'claude') return sawInit === false;
63
+ // A caller that named no CLI measured nothing either.
64
+ if (typeof runtime !== 'string' || !runtime) return false;
65
+ return t.length <= RESUME_LOST_MAX_CHARS;
66
+ };
67
+
68
+ /**
69
+ * WHETHER A RESUMED TURN RUNS ONCE MORE FRESH — both lanes' whole question.
70
+ *
71
+ * A resume that came back EMPTY, or with only the CLI saying the conversation
72
+ * is gone. `sawInit` is whether THIS spawn's CLI emitted `system.init` (only
73
+ * Claude's does, and only Claude's is read). A fresh turn is never retried:
74
+ * `resume` false answers false whatever the text says.
75
+ */
76
+ export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) =>
77
+ Boolean(resume) && (!String(out || '').trim() || resumeConversationLost(out, { runtime, sawInit }));
78
+
79
+ /**
80
+ * ONE SPAWN, AND AT MOST ONE FRESH RETRY — the agent lane's whole sequence,
81
+ * with the init evidence measured HERE rather than by a flag each caller must
82
+ * remember to set and reset (the bug F001 was: the lane asked the rule
83
+ * without it). `onInit` is wrapped, so a caller's own init handler still runs;
84
+ * the retry is a fresh conversation (`resume: false`, no pinned thread) in the
85
+ * same place, and `beforeFresh` lets the caller drop what the first spawn
86
+ * taught it. `runTurn` is the caller's (runTurn.mjs); this module imports
87
+ * nothing.
88
+ *
89
+ * The Workbench tab keeps its own sequence: its evidence is the session id it
90
+ * persists, and a fresh retry must NOT clear it (last write heals the marker).
91
+ */
92
+ export async function runTurnResumingOnce(runTurn, args, { resume, runtime, beforeFresh } = {}) {
93
+ let sawInit = false;
94
+ const callerInit = args.onInit;
95
+ const measured = {
96
+ ...args,
97
+ onInit: (i) => {
98
+ sawInit = true;
99
+ callerInit?.(i);
100
+ },
101
+ };
102
+ const out = await runTurn({ ...measured, resume });
103
+ if (!resumeRetriesFresh({ resume, out, runtime, sawInit })) return out;
104
+ beforeFresh?.();
105
+ sawInit = false;
106
+ return runTurn({ ...measured, resume: false, resumeThreadId: undefined });
107
+ }