flowviant 0.114.1 → 0.115.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.
@@ -466,6 +466,23 @@ const kickoff = ({ message, askedByName, head, tail }) => {
466
466
  * · Rule 9 is the `assumed` param on the staging verbs: a guess the chat did
467
467
  * not ask about is SAID, one line under the staged row, never folded into
468
468
  * the brief where it reads as something the person asked for.
469
+ *
470
+ * STAGE, THEN ASK — A QUESTION NEVER STANDS IN FOR A CARD (2026-09-29). The
471
+ * owner: "it ignored a subsequent ask … but it worked after i tried again."
472
+ * The follow-ups were not lost; rule 5 said "ask … then wait for the answer",
473
+ * so a new ask with one open detail came back as a question and no card, and
474
+ * to a person watching the Staged rail a reply that stages nothing reads as
475
+ * ignored. Re-sending the same words got a card only because the chat judged
476
+ * the question already asked. So the order flips: the best guess is STAGED
477
+ * first, said through `assumed` (the rail shows it under the row), and the
478
+ * one question follows in chips; the answer moves that row in place
479
+ * (`edit_staged` — restaging loses what the person typed into it). Rule 7's
480
+ * two-product case is the same order. Rule 11 says the chat stays open: a
481
+ * later message is a new ask, never an occasion to recite the staged batch.
482
+ * Rule 1 lost "what you need to know" as a reply in place of a card, and now
483
+ * says the final reply is always words — a turn that ends with none settles
484
+ * as failed on the tab. Rule 4 lost "what earlier chats left": list_staged
485
+ * holds this chat's own rows too, and its tool description says so.
469
486
  */
470
487
  export const SYSTEM_CAPTURE = `You are the human's chosen CLI, in their repository, with ONE job: turn what
471
488
  they say into well-cut task cards on their Flowviant board. You are READ-ONLY
@@ -480,21 +497,29 @@ with the matching name your CLI exposes.
480
497
 
481
498
  1. NARRATE WHILE YOU WORK. Call stream_session_turn with short progress lines
482
499
  as you read and stage. Your FINAL reply is delivered automatically when the
483
- turn ends — do not repeat it through the tool. Keep replies short: what you
484
- staged, or what you need to know — one line each.
500
+ turn ends — always end with it, in words, even when it only says what you
501
+ staged; do not also send it through the tool. Keep it short: what you
502
+ staged, what you guessed, and your one question if you have one — a line
503
+ each.
485
504
  2. STAGE, NEVER FILE. stage_card proposes a new card; stage_card_edit proposes
486
505
  a change to an existing one (read_card first — never replace fields you
487
- have not seen). Everything you stage waits in an area the person reviews
488
- and lands themselves; nothing you do reaches the board directly.
506
+ have not seen); edit_staged changes a row already staged, in place.
507
+ Everything you stage waits in an area the person reviews and lands
508
+ themselves; nothing you do reaches the board directly.
489
509
  3. ONE CARD PER SHIPPABLE UNIT. Break a big ask into the units that will build
490
510
  it, in landing order. Never card-ify chatter, questions, or one unit split
491
511
  thin.
492
512
  4. DEDUPE FIRST, EVERY TIME. Before staging, call list_cards (the OPEN QUEUE —
493
513
  capped, and cards agents already hold are absent from it) and list_staged
494
- (what earlier chats left). If the work exists, say so and point at it;
495
- stage_card_edit it if the ask adds something. Never stage a twin.
496
- 5. ASK ONLY FOR WHAT SHAPES THE CARD. Before staging, ask when — and only
497
- when — the answer changes the card itself:
514
+ (everything waiting to be landed — what you staged earlier in THIS chat
515
+ included). If the work exists, say so and point at it; when the ask adds
516
+ something to it, stage_card_edit a card or edit_staged a staged row.
517
+ Never stage a twin — and never take new work for old: an ask for
518
+ something different gets its own card (rule 11).
519
+ 5. STAGE FIRST, THEN ASK ONLY FOR WHAT SHAPES THE CARD. A question never
520
+ stands in for a card: a message that asks for work ends this turn with
521
+ that work staged. A question is worth asking when — and only when — the
522
+ answer changes the card itself:
498
523
  - its KIND — what it hands back — when the words could mean more than one
499
524
  product (rule 7);
500
525
  - its SCOPE — which pages, screens or parts, when the ask names a family
@@ -503,9 +528,15 @@ with the matching name your CLI exposes.
503
528
  acceptance criteria;
504
529
  - a CONSTRAINT the repository cannot answer — a vendor, a deadline, a
505
530
  browser to support, what must not change.
531
+ Even then, stage your best guess FIRST, the guessed part named in
532
+ \`assumed\` (rule 9); say in one line what you guessed; THEN ask your
533
+ question, as rule 8 says. When they answer, change that staged row in
534
+ place with edit_staged — only the fields the answer moves, and \`assumed\`
535
+ as "" once it is settled — never unstage_card and a fresh stage_card.
506
536
  Never ask what reading the repo answers: read it. One question per
507
- message, asked as rule 8 says, then wait for the answer. A clear ask
508
- stages without ceremony — no question for the sake of one.
537
+ message; any other guess stays said in \`assumed\`, where the person can
538
+ correct it on the row. A clear ask stages without ceremony — no question
539
+ for the sake of one.
509
540
  6. A GOOD CARD: a title naming the outcome, a brief a stranger could start
510
541
  from, acceptance criteria only when the person stated (or the code shows)
511
542
  what done means. No sizes, no owners, no statuses — none of those are
@@ -526,11 +557,13 @@ with the matching name your CLI exposes.
526
557
  "a deck", "a presentation", "a pitch".
527
558
  - "research": a write-up — read the repo and the web; no code changed.
528
559
  "Find out", "research", "compare", "how does X do it", "write up".
529
- When the words fit two products, ASK before staging — rule 5 — in chips, one
530
- option per product that fits, its description its consequence. "Redesign the
560
+ When the words fit two products, STAGE the likelier one, the guess named
561
+ in \`assumed\` ("assumed a code change, not a mockup first"), then ask —
562
+ rule 5 — in chips, one option per product that fits, its description its
563
+ consequence; the answer re-files the row with edit_staged. "Redesign the
531
564
  landing page" fits code and design; "make a chair for the scene" fits code
532
565
  (commit the asset) and model (a model to look at first). Never stage a
533
- guessed kind.
566
+ guessed kind without saying it is a guess.
534
567
  8. HAND THEM THE CHOICES. A question with answers you can name ends your
535
568
  reply with a fenced block the sheet renders as options; picking one sends
536
569
  its label as their next message, so every label must read as an answer a
@@ -558,18 +591,23 @@ with the matching name your CLI exposes.
558
591
  above the block as well: a client that doesn't render the fence shows it
559
592
  as plain text, so the reply has to read as a question with its options
560
593
  either way.
561
- 9. SAY WHAT YOU ASSUMED. When you stage a card on a GUESS you did not ask
562
- about, pass \`assumed\` on stage_card (or stage_card_edit): one short
563
- sentence naming the guess — "assumed the mobile layout too", "assumed
564
- English only". It shows under the staged row, where the person can
565
- correct it before the card lands. Never bury a guess in the brief, where
566
- it reads as something they asked for, and never pass \`assumed\` for
567
- something they said.
594
+ 9. SAY WHAT YOU ASSUMED. When you stage a card on a GUESS — the one your
595
+ question is about (rule 5), or one not worth a question —
596
+ pass \`assumed\` on stage_card (or stage_card_edit): one short sentence
597
+ naming the guess — "assumed the mobile layout too", "assumed English
598
+ only". It shows under the staged row, where the person can correct it
599
+ before the card lands. Never bury a guess in the brief, where it reads as
600
+ something they asked for, and never pass \`assumed\` for something they
601
+ said.
568
602
  10. REFERENCE WHAT THE PROJECT KEPT. When the ask names a kept mockup, 3D model,
569
603
  image, deck or write-up ("implement design A"), call list_library and pass its id as
570
604
  \`references\` on the card you stage, so the agent reads it. When the person's
571
605
  message includes attached library items, pass those ids as \`references\` on
572
- the cards you stage from that message.`;
606
+ the cards you stage from that message.
607
+ 11. THE CHAT STAYS OPEN. Every message is a new ask, however many turns came
608
+ before it (an answer to your question moves its row instead — rule 5).
609
+ When they ask for more after a staged batch, stage the new cards; never
610
+ answer a new ask by restating what is already staged.`;
573
611
 
574
612
  export const WORK_TURN_KICKOFF = ({ sessionId, sessionName, message, askedByName }) =>
575
613
  kickoff({
@@ -1073,8 +1111,22 @@ ${ARTIFACT_CONTRACT_TAIL({
1073
1111
  * WHICH CONTRACT A TURN RUNS UNDER, by the card's kind. Absent or unknown is
1074
1112
  * code, and code is `SYSTEM_AGENT` — the same object, so a code turn's system
1075
1113
  * prompt is byte-for-byte what it was before kinds existed.
1114
+ *
1115
+ * …AND WHO IT IS SPOKEN TO (0.115.0, 2026-09-29). The mockup, 3D-model and
1116
+ * deck contracts open "You are the human's own Claude" because Claude ran them
1117
+ * alone; Codex runs them now (`codexDesignFence`), and a contract that calls
1118
+ * Codex Claude is the prompt telling the agent something false about itself.
1119
+ * `runtimeLabel` is the CLI's own name ("Codex"); absent, or Claude's, the
1120
+ * contract is the pinned string itself, byte for byte. Only that opening
1121
+ * names a CLI — nothing else in these contracts is Claude's (no tool names,
1122
+ * no flags), so nothing else changes.
1076
1123
  */
1077
- export const SYSTEM_AGENT_FOR = (taskKind) => AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
1124
+ const CLAUDE_OPENING = "You are the human's own Claude,";
1125
+ export const SYSTEM_AGENT_FOR = (taskKind, runtimeLabel = null) => {
1126
+ const contract = AGENT_CONTRACTS[agentTaskKindOf(taskKind)];
1127
+ if (!runtimeLabel || /^claude\b/i.test(runtimeLabel) || !contract.startsWith(CLAUDE_OPENING)) return contract;
1128
+ return `You are the human's own ${runtimeLabel},${contract.slice(CLAUDE_OPENING.length)}`;
1129
+ };
1078
1130
 
1079
1131
  /**
1080
1132
  * THE STRINGS EACH KIND IS HANDED, keyed by the kinds in agentTaskKinds.mjs —
@@ -78,9 +78,23 @@ export const resumeConversationLost = (text, { runtime, sawInit } = {}) => {
78
78
  * is gone. `sawInit` is whether THIS spawn's CLI emitted `system.init` (only
79
79
  * Claude's does, and only Claude's is read). A fresh turn is never retried:
80
80
  * `resume` false answers false whatever the text says.
81
+ *
82
+ * …AND AN EMPTY CLAUDE RESUME THAT FOUND ITS CONVERSATION IS AN EMPTY ANSWER,
83
+ * NOT A LOST CONVERSATION (2026-09-29). Empty used to retry fresh whatever the
84
+ * evidence, so a Claude that emitted init — the conversation WAS there — and
85
+ * then said nothing had the message answered again by a Claude with no memory
86
+ * of the chat, and that fresh conversation's id re-pinned the tab: the
87
+ * follow-up read as if the chat had never happened, and so did every one
88
+ * after it. With init seen, the empty answer stands and settles as what it
89
+ * is, the conversation kept for the next message. Unmeasured init (`sawInit`
90
+ * not true) and the other CLIs keep the old reading: empty is the only sign
91
+ * they give of a conversation that is gone.
81
92
  */
82
- export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) =>
83
- Boolean(resume) && (!String(out || '').trim() || resumeConversationLost(out, { runtime, sawInit }));
93
+ export const resumeRetriesFresh = ({ resume, out, runtime, sawInit }) => {
94
+ if (!resume) return false;
95
+ if (!String(out || '').trim()) return !(runtime === 'claude' && sawInit === true);
96
+ return resumeConversationLost(out, { runtime, sawInit });
97
+ };
84
98
 
85
99
  /**
86
100
  * ONE SPAWN, AND AT MOST ONE FRESH RETRY — the agent lane's whole sequence,
@@ -123,6 +123,23 @@ export function runTurn(opts) {
123
123
  resolve('');
124
124
  return;
125
125
  }
126
+ /**
127
+ * THE PERSON'S LOGIN, FOR A POSTURE THAT READS NONE OF THEIR CONFIG
128
+ * (2026-09-29, codexPersonal.mjs). Asked of the runtime that has one
129
+ * (Codex; Claude reads its own inside its adapter), with the environment
130
+ * this turn's CLI will run in — its CODEX_HOME is the agent's own when the
131
+ * lane keeps one — so the file read is the file the CLI would have read.
132
+ * Its `args` go to the adapter; its `env`, the literal secrets those name,
133
+ * to the child below and never to argv.
134
+ */
135
+ const personal =
136
+ rt.personal?.({
137
+ profile: turnProfile.adapterProfile,
138
+ vaultDir,
139
+ model,
140
+ effort,
141
+ env: { ...process.env, ...(mcpEnv ?? {}) },
142
+ }) ?? null;
126
143
  // Pin the model — never inherit the user's global default (which for Claude
127
144
  // may be a 1M/long-context tier their subscription can't bill autonomous
128
145
  // work on). A per-task override (chosen in the app, validated server-side
@@ -139,6 +156,7 @@ export function runTurn(opts) {
139
156
  // which posture a turn is running under. There is no precedence chain left
140
157
  // to get wrong: the caller named one profile (turnProfile.mjs).
141
158
  const args = rt.args({
159
+ personal,
142
160
  prompt,
143
161
  system,
144
162
  model,
@@ -237,7 +255,12 @@ export function runTurn(opts) {
237
255
  // this environment, and handing it a curated one signs it out. What it
238
256
  // REMOVES is this daemon's machine credential and, for a turn nobody is
239
257
  // sitting at, every way to a window — see `cliEnv`.
240
- env: cliEnv(mcpEnv, { display: display === true, browserHome: browser?.dir }),
258
+ // The login's literal secrets first, so nothing of the lane's (its MCP
259
+ // token, its CODEX_HOME) can be overwritten by one.
260
+ env: cliEnv(personal?.env ? { ...personal.env, ...(mcpEnv ?? {}) } : mcpEnv, {
261
+ display: display === true,
262
+ browserHome: browser?.dir,
263
+ }),
241
264
  });
242
265
  if (browser) {
243
266
  child.once('close', browser.cleanup);
@@ -14,6 +14,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs';
14
14
  import { tmpdir } from 'node:os';
15
15
  import { join } from 'node:path';
16
16
  import { MODEL, USER_AGENT } from './config.mjs';
17
+ import { NO_SETTING_SOURCES, personalClaudeSettings, withPersonalSettings } from './claudePersonal.mjs';
17
18
 
18
19
  function claudeMcp(token, mcpUrl) {
19
20
  const dir = mkdtempSync(join(tmpdir(), 'flowviant-mcp-'));
@@ -51,11 +52,14 @@ export const CLAUDE_RUNTIME = {
51
52
  * "consult" and "plan" currently mean. That is a statement about where the
52
53
  * contract was written, not a claim that only Claude could ever satisfy it.
53
54
  */
54
- // `design` and `research` (0.97.0) are Claude's alone: each is an
55
- // `--allowedTools` list with a PATH-SCOPED write (claudePosture.mjs), and neither
56
- // codex's sandbox modes nor agy's flags were measured to express "write
57
- // only this directory". A runtime that does not declare them is refused
58
- // such a card before spawn, in words — never handed a build turn instead.
55
+ // `design` and `research` (0.97.0): each is an `--allowedTools` list with a
56
+ // PATH-SCOPED write (claudePosture.mjs). `research` is Claude's alone;
57
+ // `design` is Codex's too since 0.115.0 (2026-09-29), whose permission
58
+ // profile was measured to write only the artifacts directory
59
+ // (runtimeCodex.mjs `codexDesignFence`). agy's flags cannot express "write
60
+ // only this directory" (runtimeAntigravity.mjs). A runtime that does not
61
+ // declare a posture is refused such a card before spawn, in words — never
62
+ // handed a build turn instead.
59
63
  profiles: ['build', 'wiki', 'consult', 'plan', 'design', 'research'],
60
64
  /**
61
65
  * THE EFFORTS THIS CLI SPELLS — the daemon's copy of the server's
@@ -72,7 +76,7 @@ export const CLAUDE_RUNTIME = {
72
76
  * strongest form of it available anywhere: `--append-system-prompt` sits
73
77
  * above the conversation rather than inside it.
74
78
  */
75
- args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools }) {
79
+ args({ prompt, system, model, effort, resume, streamJson, perm, profile = 'build', mcp = [], resultSchemaArgs = [], adoptResumeId, resumeThreadId, knowledgeDir, agentTools, personalSettings }) {
76
80
  const a = [];
77
81
  // THREE ANSWERS TO ONE QUESTION — "what conversation is this?" — and they
78
82
  // are mutually exclusive, never combined.
@@ -99,11 +103,38 @@ export const CLAUDE_RUNTIME = {
99
103
  else if (resumeThreadId) a.push('--resume', resumeThreadId);
100
104
  else if (resume) a.push('--continue');
101
105
  a.push('-p', prompt, '--append-system-prompt', system);
106
+ /**
107
+ * WHOSE SETTINGS A TURN READS (2026-09-29, claudePersonal.mjs).
108
+ *
109
+ * EVERY FENCED POSTURE READS NONE — every profile but `build`: an agent's
110
+ * design and research cards, the pre-review, the planner, intake, the
111
+ * wiki and the capture chat. Its permissions and hooks are its posture's
112
+ * alone; of the person's file it keeps only the login and the model
113
+ * defaults, folded into the posture's `--settings`. Keyed on `build`
114
+ * rather than on a list of fenced names, so a posture added later is
115
+ * fenced unless it is the build.
116
+ *
117
+ * THE BUILD POSTURE KEEPS THE PERSON'S OWN, on a code agent and on a
118
+ * Terminal tab alike. `--dangerously-skip-permissions` already admits
119
+ * everything, so no personal rule can widen it; what a personal file adds
120
+ * there is the operator narrowing their own box (a deny rule held a
121
+ * bypass turn, measured) and their own CLAUDE.md, skills and subagents,
122
+ * which an empty source list would take away with no flag to keep them.
123
+ * Codex's build is not hermetic either, for the same reason
124
+ * (runtimeCodex.mjs). STATED, not closed: under FLOWVIANT_SAFE=1 the
125
+ * build is a curated list, and the operator's own allow rules or
126
+ * `defaultMode` can still widen it — two choices of the same operator on
127
+ * their own box, and a personal allow is the only way a SAFE build runs a
128
+ * tool its list does not name (`pnpm`, `cargo`, `make`). A code agent names `user` so the agent-editable
129
+ * worktree's settings and hooks never load; a Terminal tab names nothing
130
+ * and is the person's Claude Code, project files included. A resumed
131
+ * agent renders the CURRENT base snapshot, not the first turn's cache.
132
+ */
133
+ const fenced = profile !== 'build';
134
+ if (fenced) a.push(...NO_SETTING_SOURCES);
135
+ else if (agentTools) a.push('--setting-sources', 'user');
102
136
  if (agentTools) {
103
- // Keep personal settings, but never load the agent-editable project's
104
- // settings, hooks, skills or CLAUDE.md discovery. A resumed agent must
105
- // render the CURRENT base snapshot, not the first turn's prompt cache.
106
- a.push('--setting-sources', 'user', '--system-prompt-snapshot', 'off');
137
+ a.push('--system-prompt-snapshot', 'off');
107
138
  if (agentTools.pluginDir) a.push('--plugin-dir', agentTools.pluginDir);
108
139
  }
109
140
  // These fenced postures have no project MCP, even if Claude discovers a
@@ -119,7 +150,9 @@ export const CLAUDE_RUNTIME = {
119
150
  // does not refuse the path the prompt just handed it. Before `perm`,
120
151
  // because `--allowedTools` is variadic and would swallow the flag.
121
152
  if (knowledgeDir) a.push('--add-dir', knowledgeDir);
122
- a.push(...perm);
153
+ // `personalSettings` is the caller's copy when it has one (a test); a
154
+ // turn reads the person's file here, at spawn, as the CLI would.
155
+ a.push(...(fenced ? withPersonalSettings(perm, personalSettings ?? personalClaudeSettings()) : perm));
123
156
  return a;
124
157
  },
125
158
  parse: null, // claudeStream.mjs owns its own stream parser (unchanged)
@@ -12,8 +12,30 @@
12
12
  import { isAbsolute, join } from 'node:path';
13
13
  import { SAFE } from './config.mjs';
14
14
  import { parseCodexLine } from './runtimeEvents.mjs';
15
- import { ARTIFACT_DIR } from './artifacts.mjs';
15
+ import { ARTIFACT_DIR, FENCE_TMP_DIR } from './artifacts.mjs';
16
+ import { codexPersonalTurn } from './codexPersonal.mjs';
16
17
 
18
+ /**
19
+ * FLOWVIANT'S OWN TOOLS NEED NO APPROVAL FROM A CHAT NOBODY CAN ANSWER
20
+ * (2026-09-29). The owner's Codex New task chat staged nothing: "flowviant
21
+ * rejected the tool calls because approval is required and this session's
22
+ * policy forbids it". MEASURED on codex-cli 0.156.1 with a fake MCP server
23
+ * whose one tool carries no annotations (Flowviant's carry none): under the
24
+ * capture chat's read-only argv the call failed "MCP tool call requires
25
+ * approval, but approval policy is never" and never reached the server; under
26
+ * the build posture (workspace-write) the same call ran unasked. With
27
+ * `mcp_servers.flowviant.default_tools_approval_mode="approve"` beside the
28
+ * read-only argv it ran — and in the same turn a shell write still failed
29
+ * "Read-only file system" and an escalation still failed "approval policy is
30
+ * Never; reject command".
31
+ *
32
+ * So the approval is this ONE server's, never a blanket `approval_policy`:
33
+ * what its tools may do is the per-turn token's scope, which the server
34
+ * enforces (a capture token stages and reads; it files nothing), and every
35
+ * other door — the shell, file writes, escalation, any other server — keeps
36
+ * the posture's own rule. Wherever the server is mounted: the capture chat
37
+ * needs it, and a Terminal tab's build posture already ran the calls.
38
+ */
17
39
  function codexMcp(token, mcpUrl) {
18
40
  return {
19
41
  dir: null, // nothing written — the token rides in the environment
@@ -22,11 +44,23 @@ function codexMcp(token, mcpUrl) {
22
44
  `mcp_servers.flowviant.url="${mcpUrl}"`,
23
45
  '-c',
24
46
  'mcp_servers.flowviant.bearer_token_env_var="FLOWVIANT_MCP_TOKEN"',
47
+ '-c',
48
+ 'mcp_servers.flowviant.default_tools_approval_mode="approve"',
25
49
  ],
26
50
  env: { FLOWVIANT_MCP_TOKEN: token },
27
51
  };
28
52
  }
29
53
 
54
+ /**
55
+ * THE POSTURES THAT READ NONE OF THE PERSON'S CONFIG — the branches of `args()`
56
+ * below that pass `--ignore-user-config`, said once so the adapter and its
57
+ * `personal` hook cannot disagree about which turns carry the person's login
58
+ * back (codexPersonal.mjs). A wiki turn with no vault is a build turn there,
59
+ * and so here.
60
+ */
61
+ export const codexIgnoresUserConfig = ({ profile = 'build', vaultDir } = {}) =>
62
+ profile === 'consult' || profile === 'plan' || profile === 'image' || profile === 'design' || (profile === 'wiki' && Boolean(vaultDir));
63
+
30
64
  /**
31
65
  * WHAT A FENCED CODEX TURN MAY NOT REACH FOR (0.114.0). codex-cli 0.156.1
32
66
  * ships `browser_use` (and its external and full-CDP forms), an in-app
@@ -71,8 +105,13 @@ export const CODEX_RUNTIME = {
71
105
  * AND `image` (0.114.0), which ONLY Codex declares: the image card's fence —
72
106
  * see the branch in `args()`. It is the one kind posture that is not
73
107
  * Claude's, because the pictures come from Codex's own image tool.
108
+ *
109
+ * AND `design` (0.115.0), shared with Claude: the mockup, 3D-model and deck
110
+ * cards' fence, expressed as `codexDesignFence` below. Research is NOT
111
+ * declared: its web and Codex's whole-box reads cannot be fenced apart
112
+ * (see that function).
74
113
  */
75
- profiles: ['build', 'consult', 'wiki', 'plan', 'image'],
114
+ profiles: ['build', 'consult', 'wiki', 'plan', 'image', 'design'],
76
115
  /** `model_reasoning_effort` tops out at `xhigh` — there is no `max`, and a
77
116
  * queued or skewed job naming it would be an argument Codex refuses. */
78
117
  efforts: ['low', 'medium', 'high', 'xhigh'],
@@ -102,7 +141,7 @@ export const CODEX_RUNTIME = {
102
141
  * placed before it. Appending them after the positional is the kind of argv
103
142
  * that parses today and stops parsing on some future clap upgrade.
104
143
  */
105
- args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools }) {
144
+ args({ prompt, system, model, effort, resume, resumeThreadId, profile = 'build', vaultDir, cwd, mcp = [], resultSchemaArgs = [], adoptResumeId, agentTools, personal = null }) {
106
145
  // Adoption resumes a conversation in ITS OWN CLI's store (claude forks,
107
146
  // agy moves) — codex has no adoptable store wired yet. Reaching here
108
147
  // with an adopt id is a wiring mistake upstream, and it fails loudly on
@@ -163,6 +202,10 @@ export const CODEX_RUNTIME = {
163
202
 
164
203
  // Sub-agents would be a second turn whose posture nobody here chose.
165
204
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
205
+ // Image generation ships ON in codex-cli 0.156.1 even under
206
+ // --ignore-user-config (measured 2026-09-29): a turn that asked for no
207
+ // picture is handed no tool that spends image credits.
208
+ a.push('--disable', 'image_generation');
166
209
 
167
210
  // HERMETIC. Without these a user's ~/.codex/config.toml, a project
168
211
  // `.rules` execpolicy file, or their own MCP servers can widen a posture
@@ -190,6 +233,10 @@ export const CODEX_RUNTIME = {
190
233
  a.push('-c', 'sandbox_mode="read-only"');
191
234
  a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
192
235
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
236
+ // Image generation ships ON in codex-cli 0.156.1 even under
237
+ // --ignore-user-config (measured 2026-09-29): a turn that asked for no
238
+ // picture is handed no tool that spends image credits.
239
+ a.push('--disable', 'image_generation');
193
240
  a.push('--ignore-user-config', '--ignore-rules');
194
241
  } else if (profile === 'wiki' && vaultDir) {
195
242
  // THE CARTOGRAPHER, AND THIS ONE IS STRICTER THAN CLAUDE'S.
@@ -217,6 +264,7 @@ export const CODEX_RUNTIME = {
217
264
  // Selected by config: `codex exec` (0.156.1) has no `-P`, so every Codex
218
265
  // wiki turn was refused before it started (0.114.0).
219
266
  a.push('-c', 'default_permissions="flowviantwiki"');
267
+ a.push('--disable', 'image_generation');
220
268
  a.push(...NO_REACH_OUT);
221
269
  a.push('--ignore-user-config', '--ignore-rules');
222
270
  } else if (profile === 'image') {
@@ -261,7 +309,12 @@ export const CODEX_RUNTIME = {
261
309
  a.push('-c', 'permissions.flowviantimage.extends=":read-only"');
262
310
  // The table WHOLE (the wiki branch's reason: a dotted key splits on the
263
311
  // dots inside the path), the path as a TOML basic string.
264
- a.push('-c', `permissions.flowviantimage.filesystem={${JSON.stringify(artifacts)}="write"}`);
312
+ // …and the fence's own scratch (2026-09-29, the design fence's
313
+ // measurement: a `cat > x <<EOF` needs a temp file, and /tmp is
314
+ // read-only in here), named for the shells through TMPDIR/TMPPREFIX.
315
+ const scratch = join(cwd, FENCE_TMP_DIR);
316
+ a.push('-c', `permissions.flowviantimage.filesystem={${JSON.stringify(artifacts)}="write",${JSON.stringify(scratch)}="write"}`);
317
+ a.push('-c', `shell_environment_policy.set={TMPDIR=${JSON.stringify(scratch)},TMPPREFIX=${JSON.stringify(join(scratch, 'zsh'))}}`);
265
318
  a.push('-c', 'default_permissions="flowviantimage"');
266
319
  a.push(...NO_REACH_OUT);
267
320
  // Nobody is on this end of the pipe to approve an escalation.
@@ -272,6 +325,10 @@ export const CODEX_RUNTIME = {
272
325
  a.push('-c', 'tools.web_search=false', '-c', 'web_search="disabled"');
273
326
  a.push('-c', 'features.multi_agent=false', '-c', 'features.goals=false', ...NO_REACH_OUT);
274
327
  a.push('--ignore-user-config', '--ignore-rules');
328
+ } else if (profile === 'design') {
329
+ // A MOCKUP, 3D-MODEL OR DECK CARD (0.115.0): the fence is
330
+ // `codexDesignFence`'s, argued and measured at its definition below.
331
+ a.push(...codexDesignFence(cwd));
275
332
  } else {
276
333
  // The daemon's build posture, mapped: SAFE keeps writes inside the
277
334
  // workspace, the default lets the agent run its own tests and git
@@ -286,13 +343,117 @@ export const CODEX_RUNTIME = {
286
343
  a.push('-c', `sandbox_mode="${SAFE ? 'workspace-write' : 'danger-full-access'}"`);
287
344
  }
288
345
 
346
+ /**
347
+ * …AND THE PERSON'S LOGIN RIDES BACK BESIDE THE FLAG (2026-09-29,
348
+ * codexPersonal.mjs): where the login lives, the provider and endpoint it
349
+ * signs into, and the model an unpinned turn runs — `-c` pairs after
350
+ * `--ignore-user-config`, never a key that widens the posture above.
351
+ * Keyed on the flag itself, so a fenced branch added later carries the
352
+ * login the day it ignores the file. `personal` is runTurn's, read where
353
+ * the turn's Codex will look (the `personal` hook below); a caller that
354
+ * hands none gets the posture as it was.
355
+ */
356
+ if (a.includes('--ignore-user-config')) a.push(...(personal?.args ?? []));
357
+
289
358
  a.push(...mcp, ...resultSchemaArgs);
290
359
  a.push(`${system}\n\n---\n\n${prompt}`);
291
360
  return a;
292
361
  },
362
+ /**
363
+ * THE PERSON'S LOGIN FOR A FENCED TURN, read once per spawn by runTurn from
364
+ * the turn's own environment (its CODEX_HOME — an agent's own home when the
365
+ * lane keeps one): `{ args, env }`, the `-c` pairs `args()` places and the
366
+ * literal secrets they name, which ride the child's environment, never argv.
367
+ * Null for a turn that reads the person's config itself.
368
+ */
369
+ personal: ({ profile, vaultDir, env, model, effort } = {}) =>
370
+ codexIgnoresUserConfig({ profile, vaultDir }) ? codexPersonalTurn({ env, model, effort }) : null,
293
371
  parse: parseCodexLine,
294
372
  /** `codex exec --output-schema <file>` constrains the FINAL message to a
295
373
  * JSON Schema. Only needed on the mediated path; the direct path reports
296
374
  * through MCP tool calls, which are already structured. */
297
375
  resultSchema: (path) => ['--output-schema', path],
298
376
  };
377
+
378
+ /**
379
+ * THE MOCKUP, 3D-MODEL AND DECK CARDS' FENCE ON CODEX (0.115.0; 2026-09-29, the
380
+ * owner: "its saying 3d models run on claude only but thats not true").
381
+ *
382
+ * The image fence's shape without the image tool: a permission profile that
383
+ * reads the repo and writes ONLY `<worktree>/.flowviant/artifacts/`, selected
384
+ * by `default_permissions` (`codex exec` and `exec resume` refuse `-P` on
385
+ * 0.156.1), no network, no web search, no sub-agents, no browser, computer or
386
+ * apps, nobody's config or rules, and no approvals to wait on. Nothing about
387
+ * the card's contract needs more: every one of the three is one HTML page, and
388
+ * a page's CDN scripts load in the viewer, never on this machine.
389
+ *
390
+ * MEASURED on codex-cli 0.156.1 (Linux, bubblewrap), with gpt-6-luna at low
391
+ * effort, in a scratch git repository (2026-09-29), with exactly this argv:
392
+ * · the WRITE FENCE holds for both of Codex's ways to write: its
393
+ * `apply_patch` of `src/escape-patch.txt` was refused by Codex itself
394
+ * ("patch rejected: writing outside of the project; rejected by user
395
+ * approval settings"), and `echo x > src/escape-shell.txt` and
396
+ * `echo x > /tmp/fv-escape.txt` each failed "read-only file system";
397
+ * `apply_patch` of `.flowviant/artifacts/ok-patch.txt` landed;
398
+ * · NO NETWORK: `curl https://example.com` failed "Could not resolve host";
399
+ * NO WEB: the model reported no search tool;
400
+ * · THE TOOL LIST was `apply_patch`, `clock__curr_time`, `exec_command`,
401
+ * `view_image`, `write_stdin` — and `image_gen__imagegen` WITHOUT
402
+ * `--disable image_generation`: image generation ships ON in 0.156.1 even
403
+ * under `--ignore-user-config`, so it is switched off here by name, and a
404
+ * mockup never spends the person's image credits;
405
+ * · THE CONTRACTS DELIVER: the real mockup, 3D-model and deck contracts each
406
+ * produced their one page under `.flowviant/artifacts/` (the model page
407
+ * with its `glb-ready`/`export-glb` handshake and GLTFExporter, the deck
408
+ * with its sections, arrow keys and print rules) and changed no repository
409
+ * file (`git status` clean but for the ignored directory);
410
+ * · THE SCRATCH IS LOAD-BEARING: a deck turn that wrote its page with a
411
+ * here-document got "can't create temp file for here document: read-only
412
+ * file system", left a 0-byte page and stopped to ask; with the scratch
413
+ * granted and named (below) the same here-document wrote the page, and a
414
+ * resumed turn (`exec resume <id>` with this argv, as `CODEX_RUNTIME.args`
415
+ * builds it) edited it in place.
416
+ *
417
+ * STATED, not closed: Codex's permission profile READS the whole box, where
418
+ * Claude's design list reads only the worktree and the knowledge library. It
419
+ * cannot be narrowed on 0.156.1 — a `"none"` entry over the home directory
420
+ * hides Codex's own sandbox helper when Codex is installed there ("bwrap:
421
+ * execvp …/codex: No such file or directory"), and masking a second FILE
422
+ * fails every command ("bwrap: Can't write data to file …: Bad file
423
+ * descriptor"). With the network and the web shut, the one road out is the
424
+ * artifact itself, which is scrubbed of the machine's known secret values and
425
+ * of any Flowviant credential by shape on the way up — the image fence's
426
+ * standing (0.114.0), and why RESEARCH is not declared here: a write-up needs
427
+ * the web, and a turn that can read `.env` and `~/.flowviant/credentials.json`
428
+ * while holding `web_search` (both measured readable under this profile with
429
+ * `web_search="live"`) is the exfiltration Claude's research list was fenced
430
+ * to prevent.
431
+ */
432
+ export function codexDesignFence(cwd) {
433
+ if (!cwd || !isAbsolute(cwd)) {
434
+ throw new Error("a design turn needs its worktree to fence — none reached codex's builder");
435
+ }
436
+ const artifacts = join(cwd, ARTIFACT_DIR);
437
+ // THE TURN'S OWN SCRATCH (artifacts.mjs `ensureFenceScratch`, made empty by
438
+ // the lane before spawn): `/tmp` is read-only in here, and a here-document
439
+ // needs a temp file — measured, a `cat > page.html <<EOF` failed "can't
440
+ // create temp file for here document: read-only file system" and the turn
441
+ // stopped to ask. Granted, and named by the two variables the shells read
442
+ // (`TMPDIR`; zsh's `TMPPREFIX`), set for every command through
443
+ // `shell_environment_policy.set` — measured to reach the sandboxed shell.
444
+ const scratch = join(cwd, FENCE_TMP_DIR);
445
+ return [
446
+ '-c', 'permissions.flowviantdesign.extends=":read-only"',
447
+ // The table WHOLE and the path a TOML basic string — the wiki branch's
448
+ // reason: a dotted key splits on the dots inside the path.
449
+ '-c', `permissions.flowviantdesign.filesystem={${JSON.stringify(artifacts)}="write",${JSON.stringify(scratch)}="write"}`,
450
+ '-c', `shell_environment_policy.set={TMPDIR=${JSON.stringify(scratch)},TMPPREFIX=${JSON.stringify(join(scratch, 'zsh'))}}`,
451
+ '-c', 'default_permissions="flowviantdesign"',
452
+ '-c', 'approval_policy="never"',
453
+ '--disable', 'image_generation',
454
+ '-c', 'tools.web_search=false', '-c', 'web_search="disabled"',
455
+ '-c', 'features.multi_agent=false', '-c', 'features.goals=false',
456
+ ...NO_REACH_OUT,
457
+ '--ignore-user-config', '--ignore-rules',
458
+ ];
459
+ }
@@ -277,8 +277,15 @@ function humanizeCodexItem(item = {}, cwd = '') {
277
277
  const verb = first.kind === 'add' ? '+' : first.kind === 'delete' ? '-' : '~';
278
278
  return { kind: 'write', path: p, label: `${verb} ${tail || 'file'}` };
279
279
  }
280
- case 'mcp_tool_call':
281
- return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}` };
280
+ case 'mcp_tool_call': {
281
+ // A FAILED CALL KEEPS ITS OWN WORDS (2026-09-29), beside a label that is
282
+ // unchanged: codex-cli 0.156.1 closes a call it would not run as
283
+ // `status: "failed"`, `error.message` "MCP tool call requires approval,
284
+ // but approval policy is never" (measured), and the tab lane says so
285
+ // in words (codexRelay.mjs) instead of leaving a chat that staged nothing.
286
+ const error = String(item.error?.message ?? '');
287
+ return { kind: 'tool', label: `${item.server ?? 'mcp'}.${item.tool ?? ''}`, ...(error ? { error } : {}) };
288
+ }
282
289
  case 'web_search':
283
290
  return { kind: 'search', label: `search ${oneLine(item.query, 60)}` };
284
291
  default:
@@ -22,8 +22,9 @@
22
22
  * - design / research / image — a non-code card's posture
23
23
  * (agentTaskKinds.mjs), refused on any runtime that does not
24
24
  * DECLARE it, because every other adapter's fallback is its
25
- * build branch. design and research are Claude Code's; image
26
- * (0.114.0) is Codex's.
25
+ * build branch. research is Claude Code's; design is
26
+ * Claude Code's and, since 0.115.0, Codex's; image (0.114.0)
27
+ * is Codex's.
27
28
  *
28
29
  * Claude's permission list for each name lives beside the lists themselves
29
30
  * (`claudePermFor`, claudePosture.mjs); this module owns the vocabulary and the