flowviant 0.95.0 → 0.97.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 CHANGED
@@ -54,7 +54,7 @@ Launch with `@latest` so each start pulls the newest published version — a bar
54
54
 
55
55
  ## Machines on this box
56
56
 
57
- One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not: every `npx flowviant` in that directory has to ask which of them to serve.
57
+ One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `npx flowviant` there refuses to start until one of them goes.
58
58
 
59
59
  On a terminal the listing is a menu: ↑/↓ over the projects, enter for what you can do about one, esc to leave. Two verbs, each acting on **this box's own connection** and nothing else:
60
60
 
@@ -63,7 +63,7 @@ On a terminal the listing is a menu: ↑/↓ over the projects, enter for what y
63
63
 
64
64
  Stopping or removing a daemon on **another** computer is done in the app (project settings → Machines), where the person pressing it can see what they are changing.
65
65
 
66
- `flowviant login` asks before it binds a second project to a repository that is already connected to one: keep both (every start there asks which to serve), replace (the repo serves the new project and this box is disconnected from the old one), or cancel.
66
+ **A directory serves one project.** `flowviant login` refuses to bind a second project to a repository that is already connected to one, and a store that already holds two projects for one repository refuses to start there — both name the projects and the remedy: delete the project you do not mean in Flowviant (project settings → General → Delete project), or disconnect this box from it with `flowviant machines`, then try again.
67
67
 
68
68
  ## Sessions
69
69
 
package/bin/cli.mjs CHANGED
@@ -46,7 +46,10 @@
46
46
  * a daemon on another computer is invisible from here; since 0.95.0 it says
47
47
  * out loud when two projects are bound to one repo, and on a terminal it is a
48
48
  * menu (↑/↓, enter) whose verbs disconnect THIS box from a project or forget
49
- * a credential here — `--remove <id>` / `--forget <id>` for a script.
49
+ * a credential here — `--remove <id>` / `--forget <id>` for a script. A
50
+ * DIRECTORY SERVES ONE PROJECT: login refuses to bind a second project to a
51
+ * repo, and a store that already holds two for one repo refuses to start
52
+ * until one is deleted in the app or disconnected here.
50
53
  *
51
54
  * Env:
52
55
  * FLOWVIANT_MACHINE_TOKEN the machine credential (or use `flowviant login`);
@@ -89,7 +92,8 @@ if (process.argv[2] === 'login') {
89
92
  // A login the person CANCELLED at the second-project question saved nothing,
90
93
  // so there is no credential for the child to serve — starting it would end
91
94
  // in "no credential found" over a choice they just made on purpose.
92
- if (noStart || !login?.saved) process.exit(0);
95
+ if (!login?.saved) process.exit(1); // refused: the directory already serves another project
96
+ if (noStart) process.exit(0);
93
97
  // Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
94
98
  // the credential at IMPORT time — which was before the login we just did — so
95
99
  // this process still has an empty FLEET_TOKEN and would exit with "no
@@ -460,7 +464,7 @@ const externalToken =
460
464
  * existed, so this process cannot serve; the child can. */
461
465
  async function reexecAfterLogin() {
462
466
  const login = await runLogin({ thenStart: false });
463
- if (!login?.saved) process.exit(0); // cancelled at the second-project question — nothing to serve
467
+ if (!login?.saved) process.exit(1); // refused: the directory already serves another project
464
468
  const { spawn } = await import('node:child_process');
465
469
  const child = spawn(process.execPath, [process.argv[1]], { stdio: 'inherit', env: process.env });
466
470
  process.exit(await new Promise((resolve) => child.on('exit', (code) => resolve(code ?? 0))));
@@ -470,7 +474,9 @@ async function reexecAfterLogin() {
470
474
  // the connected date to the COLLIDING rows only — production held two projects
471
475
  // both called "BRIF AI", both bound to the same checkout, so this picker
472
476
  // offered two identical lines and the only way to answer was to guess. See
473
- // credentials.mjs for why the suffix is not on every row.
477
+ // credentials.mjs for why the suffix is not on every row. (That exact case is
478
+ // a refusal now rather than a picker — a directory serves one project — but
479
+ // two same-named projects can still meet in the no-match picker.)
474
480
  function listLines(entries, { projectRowLabel }) {
475
481
  return entries
476
482
  .map(
@@ -486,6 +492,20 @@ if (!FLEET_TOKEN) {
486
492
  console.error(`error: ${CREDENTIAL.error}. \`flowviant projects\` lists what is stored.`);
487
493
  process.exit(1);
488
494
  }
495
+ // A DIRECTORY SERVES ONE PROJECT (2026-09-23, the owner, verbatim: "a
496
+ // directory cannot have more than one project on flowviant. if one already
497
+ // exists, it would warn the user and ask them to delete the project on
498
+ // flowviant first. because 2 projects shouldnt be able to edit a directory
499
+ // at the same time."). This used to be a PICKER — two projects bound to one
500
+ // checkout, choose which this daemon serves — which is a control offering to
501
+ // do the forbidden thing politely. It is a refusal now, on a TTY and
502
+ // headless alike, naming both projects and the remedy. `credentialRefusal`
503
+ // is the same sentence login prints when it declines to bind a second one.
504
+ if (CREDENTIAL.reason === 'multiple-bound') {
505
+ const { directoryTakenRefusal } = await import('./lib/credentials.mjs');
506
+ console.error(directoryTakenRefusal(CREDENTIAL.choices, CREDENTIAL.repoRoot));
507
+ process.exit(1);
508
+ }
489
509
  if (CREDENTIAL.choices?.length && interactive) {
490
510
  const creds = await import('./lib/credentials.mjs');
491
511
  const { originSlug } = await import('./lib/git.mjs');
@@ -494,9 +514,7 @@ if (!FLEET_TOKEN) {
494
514
  console.log(
495
515
  CREDENTIAL.reason === 'outside-repo'
496
516
  ? 'flowviant is not inside a git repo, and more than one project is connected here.'
497
- : CREDENTIAL.reason === 'multiple-bound'
498
- ? `More than one connected project names this repo (${repoRoot}) — pick which one this daemon serves:`
499
- : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
517
+ : `This repo (${repoRoot}) is not connected to any project yet. Connected on this machine:`
500
518
  );
501
519
 
502
520
  const loginLabel = `connect ${repoRoot ? 'this repo' : 'a repo'} to a different project (flowviant login)`;
@@ -505,16 +523,13 @@ if (!FLEET_TOKEN) {
505
523
  // this only decides which row the cursor starts on, using the repo's folder
506
524
  // name and its github repo-name against the stored project names. A unique
507
525
  // match becomes ONE keypress; a wrong guess costs nothing, because the human
508
- // still confirms. `multiple-bound` gets no hint — every choice already names
509
- // this repo, so nothing distinguishes them.
526
+ // still confirms. (Two projects bound to THIS repo never reach here: that
527
+ // is a refusal above, since 2026-09-23.)
510
528
  const slug = repoRoot ? originSlug(repoRoot) : null;
511
- const likely =
512
- CREDENTIAL.reason === 'multiple-bound'
513
- ? -1
514
- : creds.likelyChoiceIndex(choices, {
515
- repoBasename: repoRoot ? basename(repoRoot) : null,
516
- repoSlugName: slug ? slug.split('/')[1] : null,
517
- });
529
+ const likely = creds.likelyChoiceIndex(choices, {
530
+ repoBasename: repoRoot ? basename(repoRoot) : null,
531
+ repoSlugName: slug ? slug.split('/')[1] : null,
532
+ });
518
533
 
519
534
  // Bounded like the confirm below, and for the same reason — but silence
520
535
  // means something DIFFERENT here and the difference is load-bearing. There
@@ -37,13 +37,27 @@
37
37
  * this is somebody's disk and one place checking is one deploy from zero) are
38
38
  * reported BY NAME and never read. Over `ARTIFACT_MAX_BYTES` likewise.
39
39
  *
40
- * ── TEXT IS SCRUBBED ──
40
+ * ── TEXT IS SCRUBBED; A BINARY CARRYING A SECRET IS WITHHELD ──
41
41
  *
42
42
  * html, md, svg, json, csv and txt pass through `envScrub` before upload — the
43
43
  * turn trace's own scrub, for the trace's own reason: a page the agent wrote
44
44
  * can quote a value out of the checkout's `.env`, and an artifact is served to
45
- * a browser. Binary images are not scrubbed (there is no text to match), and
46
- * the SERVER computes the sha256 of what it stored; the one sent here is for
45
+ * a browser.
46
+ *
47
+ * The binary types CAN carry text, and this header used to say they could
48
+ * not. A PNG has tEXt chunks, a PDF has streams that are often stored
49
+ * uncompressed, a docx/pptx/xlsx is a zip whose entries may be STORED rather
50
+ * than deflated — and a turn that can write a file can write a secret into
51
+ * any of them. What they cannot be is REWRITTEN: `envScrub` swapping bytes
52
+ * inside one corrupts it, and a corrupted binary served as the thing the agent
53
+ * made is worse than none. So each binary is checked against the same scrub
54
+ * list as exact byte substrings (`secretIn`, env.mjs) before it is uploaded,
55
+ * and a hit SKIPS the file with a warn line naming it and the variable — never
56
+ * a redacted copy, never a by-name row the page would draw as a file it merely
57
+ * could not show. STATED: a value inside a deflated stream is not a substring
58
+ * of the file, so this catches what is stored plainly and nothing more.
59
+ *
60
+ * The SERVER computes the sha256 of what it stored; the one sent here is for
47
61
  * the log.
48
62
  *
49
63
  * ── DELIVERY ──
@@ -91,6 +105,15 @@ export const ARTIFACT_TYPES = {
91
105
  json: { mime: 'application/json', text: true },
92
106
  csv: { mime: 'text/csv', text: true },
93
107
  txt: { mime: 'text/plain', text: true },
108
+ // DOCUMENTS (0.97.0): a Word file, a deck, a sheet or a PDF — what the
109
+ // machine's docx/pptx/xlsx/pdf skills write. Binary, so never REWRITTEN (a
110
+ // scrub inside a zip or a PDF corrupts it) but checked for a known secret's
111
+ // bytes and withheld on a hit — see the header. Under the same cap. The app
112
+ // offers the first three as a download and frames the PDF.
113
+ docx: { mime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', text: false },
114
+ pptx: { mime: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: false },
115
+ xlsx: { mime: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: false },
116
+ pdf: { mime: 'application/pdf', text: false },
94
117
  };
95
118
 
96
119
  export function artifactTypeFor(name) {
@@ -182,7 +205,7 @@ function readNoFollow(path, limit) {
182
205
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
183
206
  * disk the agent may have changed since).
184
207
  */
185
- export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s) {
208
+ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub = (s) => s, secretIn = () => null) {
186
209
  const type = artifactTypeFor(entry.name);
187
210
  const fields = {
188
211
  ...(sessionId ? { sessionId } : { agentId }),
@@ -199,7 +222,11 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
199
222
  return null; // vanished, or a symlink swapped in: nothing to say
200
223
  }
201
224
  if (!bytes) return { fields: { ...fields, tooLarge: '1' }, bytes: null };
202
- if (type.text) {
225
+ if (!type.text) {
226
+ // A binary is never rewritten; one carrying a known secret is not sent.
227
+ const secret = secretIn(bytes);
228
+ if (secret) return { withheld: secret, fields };
229
+ } else {
203
230
  bytes = Buffer.from(String(scrub(bytes.toString('utf8'))), 'utf8');
204
231
  // A redaction marker can be longer than the value it replaced.
205
232
  if (bytes.byteLength > ARTIFACT_MAX_BYTES) {
@@ -222,7 +249,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
222
249
  * finished turn and never awaited by the turn (a slow uplink must not hold the
223
250
  * next turn behind a readout); `retryPending` rides the settle retry beat.
224
251
  */
225
- export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetchImpl, log = () => {} }) {
252
+ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, secretIn, fetchImpl, log = () => {} }) {
226
253
  const url = String(fleetUrl).replace(/\/agents\/?$/, '/artifact');
227
254
  const doFetch = fetchImpl ?? ((...a) => fetch(...a));
228
255
  const pending = new Map(); // `${owner}:${name}` -> { body, tries }
@@ -284,8 +311,12 @@ export function createArtifactReporter({ fleetUrl, token, userAgent, scrub, fetc
284
311
  const changed = changedArtifacts(before, scanArtifacts(placeDir));
285
312
  let n = 0;
286
313
  for (const entry of changed) {
287
- const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub);
314
+ const body = buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
288
315
  if (!body) continue;
316
+ if (body.withheld) {
317
+ log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
318
+ continue;
319
+ }
289
320
  const key = `${sessionId ?? agentId}:${entry.name}`;
290
321
  pending.delete(key); // this copy supersedes a held older one
291
322
  await send(key, body, 0);
@@ -12,6 +12,7 @@
12
12
  */
13
13
 
14
14
  import { spawn } from 'node:child_process';
15
+ import { fileURLToPath } from 'node:url';
15
16
  import { SAFE } from './config.mjs';
16
17
  import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
17
18
 
@@ -20,6 +21,46 @@ import { runtimeById, humanizeClaudeTool, THINK_MARKER } from './runtimes.mjs';
20
21
  // the strings live.
21
22
  export * from './prompts.mjs';
22
23
 
24
+ /**
25
+ * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
26
+ * hooks/readGuard.mjs for what it refuses and the probe that proved it fires.
27
+ *
28
+ * A `Bash(git log:*)` allow is a PREFIX: it names the program and says nothing
29
+ * about the arguments, and `git log --output=<path>` writes any file while
30
+ * `-c core.fsmonitor=…` / `--ext-diff` run a program. A reviewer landed
31
+ * `git log -1 --format='tformat:x' --output=pwned.txt` under RESEARCH_PERM with
32
+ * no denial, and it re-measured the same way here. So every list below that
33
+ * promises "reads, and nothing else" carries a `PreToolUse` hook through
34
+ * `--settings`, measured on Claude Code 2.1.281: it fires under
35
+ * `--allowedTools`, exit 2 blocks the call, and its sentence reaches the model.
36
+ *
37
+ * NEVER ON THE BUILD POSTURE: `--dangerously-skip-permissions` is the
38
+ * operator's own choice for a turn that is meant to write, and a guard there
39
+ * would be refusing the job. Nor on PLAN_MODE_PERM, which is the CLI's own
40
+ * posture on the owner's own tab, not a list this file promises anything about.
41
+ *
42
+ * The hook runs as `<this node> <readGuard.mjs>` — the daemon's own
43
+ * `process.execPath`, never a `node` looked up on a PATH the operator may not
44
+ * have, because a hook that fails to START is a non-blocking error and the
45
+ * command runs anyway.
46
+ */
47
+ export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
48
+ const hookQuote = (s) =>
49
+ process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
50
+ export const READ_GUARD_SETTINGS = JSON.stringify({
51
+ hooks: {
52
+ PreToolUse: [
53
+ {
54
+ matcher: 'Bash',
55
+ hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
56
+ },
57
+ ],
58
+ },
59
+ });
60
+ /** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
61
+ * `--settings` pair must stand before it or be swallowed as a tool name. */
62
+ const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
63
+
23
64
  /**
24
65
  * PLAN — read the repo, write the plan, never the code.
25
66
  *
@@ -37,6 +78,7 @@ export * from './prompts.mjs';
37
78
  * touching.
38
79
  */
39
80
  const PLAN_PERM = [
81
+ ...READ_GUARD,
40
82
  '--allowedTools',
41
83
  'mcp__flowviant',
42
84
  'Read',
@@ -81,6 +123,7 @@ const PERM = SAFE
81
123
  // after every wiki turn, and the vault has its own git history.
82
124
  // (Write/Edit can't be path-scoped here; the worktree reset is the backstop.)
83
125
  const WIKI_PERM = [
126
+ ...READ_GUARD,
84
127
  '--allowedTools',
85
128
  'Read',
86
129
  'Grep',
@@ -112,6 +155,7 @@ const WIKI_PERM = [
112
155
  // "do not change anything" is only an instruction, and instructions are exactly
113
156
  // what an injected question competes with.
114
157
  const CONSULT_PERM = [
158
+ ...READ_GUARD,
115
159
  '--allowedTools',
116
160
  'Read',
117
161
  'Grep',
@@ -126,6 +170,152 @@ const CONSULT_PERM = [
126
170
  'Bash(git rev-parse:*)',
127
171
  ];
128
172
 
173
+ /**
174
+ * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
175
+ * WRITE, and only under `.flowviant/artifacts/`.
176
+ *
177
+ * The card's words are steered by anyone who can file a card, so "change
178
+ * nothing" as prose is only an instruction, and the permission list is the
179
+ * enforcement. What each carries is the ONE write its contract needs:
180
+ *
181
+ * · `Edit(.flowviant/artifacts/**)` — PROBED on Claude Code 2.1.281 before
182
+ * relying on it (2026-09-23): with `--allowedTools 'Read'
183
+ * 'Edit(.flowviant/artifacts/**)'`, a `-p` turn told to write
184
+ * `.flowviant/artifacts/a.html` AND `b.txt` at the root wrote the first and
185
+ * was DENIED the second. The Edit rule governs every file-writing tool,
186
+ * Write included. Spelled `Write(.flowviant/artifacts/**)` — or `./`- or
187
+ * `//abs`-anchored — the same probe was denied BOTH files, so that spelling
188
+ * is not a scoped write at all, it is no write. Relative to the turn's cwd,
189
+ * which is the agent's own worktree, which is where the artifact scan looks.
190
+ * `**` AND NOT `*`, measured the same day: `Edit(.flowviant/artifacts/*)`
191
+ * let a Write to `.flowviant/artifacts/sub/b.html` through exactly as `**`
192
+ * did, so the single-level spelling is not narrower on this CLI and would
193
+ * only read as if it were. The scan keeps top-level files, so the
194
+ * contracts say "no subfolders" — the prompt is the only thing that can.
195
+ *
196
+ * THEY ARE NOT THE SAME READER, and the difference is the network:
197
+ *
198
+ * · DESIGN draws THIS product, so it reads the repo broadly — Read, Grep,
199
+ * Glob and `ls` — and has no web at all (a mockup that went looking on the
200
+ * web would be a mockup of somebody else's product). No git readers and no
201
+ * cat/head/wc: Read covers every one of them, and each git reader was an
202
+ * `--output` away from a write (see READ_GUARD, which it still carries for
203
+ * the `ls` it keeps).
204
+ * · RESEARCH has the web — WebSearch and WebFetch — and a turn that can both
205
+ * read a secret and fetch a URL can send one. So its reading is FENCED to
206
+ * what the card is about: `Read(./**)` (the worktree) plus the project's
207
+ * knowledge library by its absolute path, `Glob(./**)` for names, NO Grep
208
+ * (an unscoped search of the home directory prints matching lines), NO
209
+ * Bash at all, and `.env*` DENIED by name. PROBED on 2.1.281, the whole
210
+ * list with `--add-dir <knowledge>`: a file in the cwd read; `/etc/hostname`,
211
+ * a sibling directory's file and `~/.flowviant/credentials.json` were each
212
+ * refused ("Claude requested permissions to read from …, but you haven't
213
+ * granted it yet"); `.env` was refused ("File is in a directory that is
214
+ * denied by your permission settings"); the knowledge file read. Two
215
+ * controls: WITHOUT the `--disallowedTools` pair, `Read(./**)` read `.env`
216
+ * — the deny is load-bearing; and a bare `Glob` listed `~/.flowviant`
217
+ * while `Glob(./**)` refused it and still listed the cwd and the added
218
+ * directory. The knowledge `Read(//…)` rule is belt: `--add-dir` alone
219
+ * admitted the file, and the rule keeps the read allowed should the
220
+ * adapter ever stop passing that flag.
221
+ * AND `Bash` IS DENIED BY NAME, because leaving it off the allow list is
222
+ * not "no Bash": measured the same day, with Bash absent from
223
+ * `--allowedTools` the CLI still ran `git log -1 --format=%s` and `ls` on
224
+ * its own read-only classifier (it did refuse `git log … --output=…`,
225
+ * "This command requires approval" — the reviewer's write needed the
226
+ * explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
227
+ * `--disallowedTools` the tool was not there at all.
228
+ *
229
+ * NO MCP, and no shell that could commit: the agent turn has no MCP anyway
230
+ * (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
231
+ * commit the mockup it was told to keep out of git.
232
+ *
233
+ * STATED, not closed: research may still read any file in the worktree and
234
+ * name it in a WebFetch URL — the repository is what a research card is about,
235
+ * and a turn that could not read it could not answer. Design may read any
236
+ * file the Read tool reaches and put it in an artifact the server stores;
237
+ * text artifacts are scrubbed of the machine's known secret values on the way
238
+ * out (artifacts.mjs), and binary ones are withheld when they carry one.
239
+ * Design still runs a plain `git log` on the CLI's own read-only classifier
240
+ * though no git reader is allowed (measured) — the guard is what refuses its
241
+ * `--output`. And the Agent tool is offered with no allow rule on this CLI:
242
+ * a research turn with Bash denied launched a subagent to run one, and the
243
+ * subagent had no Bash either (measured) — the rules are inherited. Whether
244
+ * the guard's hook sees a subagent's own calls was not measured here.
245
+ */
246
+ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
247
+ /**
248
+ * PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
249
+ * own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
250
+ * decides and answers with a plan, and refuses each write itself.
251
+ *
252
+ * PROBED on Claude Code 2.1.281 before relying on it (2026-09-23), in a scratch
253
+ * git repo, `-p … --output-format stream-json --verbose`:
254
+ *
255
+ * · ALONE, asked to "add a subtract(a, b) function to app.js": the init event
256
+ * reads `permissionMode: "plan"`, the turn Read the file, ran a read-only
257
+ * `find`, wrote its plan to `~/.claude/plans/<slug>.md` (the CLI's own plan
258
+ * file, outside the repo), tried `ExitPlanMode` and was told "Error: No such
259
+ * tool available: ExitPlanMode. ExitPlanMode is disabled for this session",
260
+ * and ended with the plan as its result text. app.js was untouched.
261
+ * · BESIDE `--dangerously-skip-permissions`, same ask: the init event reads
262
+ * `permissionMode: "bypassPermissions"` — no error, no warning — and the
263
+ * edit LANDED. The bypass silently wins. So this list is a REPLACEMENT for
264
+ * `perm`, never an addition, and a plan turn can never carry both.
265
+ * · WITH `--mcp-config` and `--allowedTools mcp__flowviant`: the server
266
+ * connected and its tools were listed, but a call was refused — "Cannot
267
+ * call mcp__flowviant__log_work while in plan mode." — and without the
268
+ * `--allowedTools` entry, "Claude requested permissions to use
269
+ * mcp__flowviant__log_work, but you haven't granted it yet." Plan mode
270
+ * admitted the call ONLY when the server annotated the tool
271
+ * `readOnlyHint: true`, and none of the session tools (stream_session_turn,
272
+ * log_work, file_card …) are read-only or annotated so. So a plan turn runs
273
+ * PLAIN — no MCP, the plain tab's contract — rather than mounting a control
274
+ * plane whose every call the CLI refuses. See work.mjs.
275
+ */
276
+ export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
277
+ export const DESIGN_PERM = [
278
+ ...READ_GUARD,
279
+ '--allowedTools',
280
+ 'Read',
281
+ 'Grep',
282
+ 'Glob',
283
+ 'Bash(ls:*)',
284
+ ARTIFACT_WRITE,
285
+ ];
286
+
287
+ /** A path a permission rule can name: absolute POSIX, and free of the glob and
288
+ * rule-syntax characters that would change what the rule means. Anything else
289
+ * gets no rule — `--add-dir` still admits the directory (measured). */
290
+ const ruleSafeAbsolute = (p) =>
291
+ typeof p === 'string' && p.startsWith('/') && !/[*?[\]{}()\\\n]/.test(p) ? p.replace(/\/+$/, '') : null;
292
+
293
+ /**
294
+ * RESEARCH, built at spawn — the knowledge library is the one directory
295
+ * outside the worktree it may read, and only the spawn knows where that is.
296
+ * `//` is the CLI's own spelling for an absolute path in a rule.
297
+ */
298
+ export function researchPerm(knowledgeDir) {
299
+ const kd = ruleSafeAbsolute(knowledgeDir);
300
+ return [
301
+ ...READ_GUARD,
302
+ '--allowedTools',
303
+ 'Read(./**)',
304
+ ...(kd ? [`Read(/${kd}/**)`] : []),
305
+ 'Glob(./**)',
306
+ ...(kd ? [`Glob(/${kd}/**)`] : []),
307
+ 'WebSearch',
308
+ 'WebFetch',
309
+ ARTIFACT_WRITE,
310
+ '--disallowedTools',
311
+ 'Read(./.env*)',
312
+ 'Read(./**/.env*)',
313
+ 'Bash',
314
+ ];
315
+ }
316
+ /** The list with no library — what a project that keeps none runs under. */
317
+ export const RESEARCH_PERM = researchPerm(null);
318
+
129
319
  export const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
130
320
 
131
321
  // Sentinels must appear on their OWN line (the prompts require it). Substring
@@ -300,10 +490,17 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
300
490
  // ADOPTABLE — so any headless turn we run for our own purposes would leave
301
491
  // a phantom untitled session in the `+` menu. The caller that needs to
302
492
  // clean up after itself cannot do so without this id.
303
- if (Array.isArray(ev.skills) || typeof ev.session_id === 'string') {
493
+ //
494
+ // `mcpServers` (0.97.0) is the same kind of fact from the same event: the
495
+ // MCP servers and claude.ai connectors the CLI mounted, each with its own
496
+ // status (`needs-auth` above all — a sign-in that has to happen at this
497
+ // box). Relayed raw; `recordMcpServers` (runtimes.mjs) normalises it and
498
+ // drops this daemon's own `flowviant` server.
499
+ if (Array.isArray(ev.skills) || typeof ev.session_id === 'string' || Array.isArray(ev.mcp_servers)) {
304
500
  onInit?.({
305
501
  skills: Array.isArray(ev.skills) ? ev.skills.map(String) : undefined,
306
502
  sessionId: typeof ev.session_id === 'string' ? ev.session_id : undefined,
503
+ mcpServers: Array.isArray(ev.mcp_servers) ? ev.mcp_servers : undefined,
307
504
  });
308
505
  }
309
506
  } else if (ev.type === 'result') {
@@ -351,9 +548,32 @@ export function handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, app
351
548
  // returned string for sentinel detection, and each activity is handed to
352
549
  // `onActivity` so the caller can forward progress. Build-agent turns leave it
353
550
  // off and keep the raw text passthrough + line sentinels.
354
- export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
551
+ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEnv, runtime = 'claude', label, onSpawn, streamJson, answerFromResult, onActivity, onToolEvent, onInit, onUsage, onThreadId, wikiPerm, readOnly, planPerm, planMode, posture, vaultDir, knowledgeDir, resultSchemaArgs, model, effort, adoptResumeId, resumeThreadId, resumeConversationId }) {
355
552
  return new Promise((resolve) => {
356
553
  const rt = runtimeById(runtime);
554
+ // PLAN MODE IS CLAUDE'S (0.97.0). The other adapters build their argv
555
+ // from `profile` and never read `perm`, so a codex or agy turn handed
556
+ // `planMode` would run a BUILD turn wearing the word "plan". The caller
557
+ // refuses first, in words; this is the belt — fail the turn, never
558
+ // substitute.
559
+ if (planMode && rt.id !== 'claude') {
560
+ console.error(`\nerror: plan mode runs on Claude Code only — not '${rt.label}'`);
561
+ resolve('');
562
+ return;
563
+ }
564
+ // …AND SO ARE THE TWO NON-CODE POSTURES, for the same reason and in the
565
+ // same shape. The agent lane asks `canRun` first and settles `nothing` in
566
+ // words; this is the layer no caller can skip. A codex or agy adapter
567
+ // handed `posture: 'design'` never reads `perm` — it builds from `profile`
568
+ // and runs whatever that falls back to, which for codex is its build
569
+ // branch, `--sandbox danger-full-access`: a research card's words with the
570
+ // whole machine to act on. Asked against the runtime's own declared profiles,
571
+ // so the day an adapter learns to express one this opens by itself.
572
+ if ((posture === 'design' || posture === 'research') && !(rt.profiles ?? []).includes(posture)) {
573
+ console.error(`\nerror: a ${posture} card runs on Claude Code only — not '${rt.label}'`);
574
+ resolve('');
575
+ return;
576
+ }
357
577
  if (!rt.args) {
358
578
  // Reached only if a brief names a runtime this daemon declares but cannot
359
579
  // drive. Fail as a turn with no sentinel — the loop already treats that as
@@ -385,7 +605,15 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
385
605
  // of the two and a planning turn that fell through to 'consult' would lose
386
606
  // the control plane it exists to use — it would read the repo, decide what
387
607
  // the slices are, and have no way to write any of them down.
388
- const profile = planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
608
+ // A DESIGN OR RESEARCH CARD'S POSTURE (0.97.0) is asked before all of
609
+ // them, by NAME, because it is the narrowest promise here: write only the
610
+ // artifacts directory. It is set only by the agent lane, from the card's
611
+ // kind; every other caller passes nothing and gets exactly the branch it
612
+ // got before this existed.
613
+ const profile =
614
+ posture === 'design' || posture === 'research'
615
+ ? posture
616
+ : planPerm ? 'plan' : readOnly ? 'consult' : wikiPerm ? 'wiki' : 'build';
389
617
  const args = rt.args({
390
618
  prompt,
391
619
  system,
@@ -418,7 +646,16 @@ export function runTurn({ prompt, resume, system, cwd, mcpConfig, mcpArgs, mcpEn
418
646
  // prompt as a trailing positional, so a flag after it is in the wrong
419
647
  // place.
420
648
  resultSchemaArgs,
421
- perm: planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
649
+ // PLAN MODE REPLACES THE POSTURE, never joins it: beside
650
+ // `--dangerously-skip-permissions` the bypass wins silently (measured —
651
+ // see PLAN_MODE_PERM).
652
+ perm: planMode
653
+ ? PLAN_MODE_PERM
654
+ : profile === 'design'
655
+ ? DESIGN_PERM
656
+ : profile === 'research'
657
+ ? researchPerm(knowledgeDir)
658
+ : planPerm ? PLAN_PERM : readOnly ? CONSULT_PERM : wikiPerm ? WIKI_PERM : PERM,
422
659
  // Handed to the adapter rather than appended here, because WHERE these go
423
660
  // is a property of the CLI: Codex reads its prompt as a trailing
424
661
  // positional, so a flag after it is a flag in the wrong place.
@@ -227,6 +227,42 @@ export function boundElsewhere(entries, repoRoot, projectId) {
227
227
  return entries.filter((e) => e.projectId !== projectId && samePath(e.repoRoot, repoRoot));
228
228
  }
229
229
 
230
+ /**
231
+ * THE REFUSAL, in one place, because two commands print it (2026-09-23).
232
+ *
233
+ * The owner, verbatim: "a directory cannot have more than one project on
234
+ * flowviant. if one already exists, it would warn the user and ask them to
235
+ * delete the project on flowviant first. because 2 projects shouldnt be able
236
+ * to edit a directory at the same time." So `flowviant login` refuses to bind
237
+ * a second project to a repo, and a start in a repo the store already holds
238
+ * two projects for refuses to serve either — the picker that used to stand
239
+ * there was a control offering to do the forbidden thing politely.
240
+ *
241
+ * It names every project on the directory, the rule, and the remedy in the
242
+ * owner's own order: delete the project in Flowviant first. The disconnect
243
+ * this box can do on its own (`flowviant machines`) is named second, for the
244
+ * case where the project must survive somewhere else — it satisfies the same
245
+ * rule, because a project this box no longer serves cannot edit the directory
246
+ * from here.
247
+ */
248
+ export function directoryTakenRefusal(bound, repoRoot, { incoming } = {}) {
249
+ // Every project in the sentence is labelled against the WHOLE set, the
250
+ // incoming one included: a refusal reading "already connected to BRIF AI,
251
+ // so BRIF AI was not connected" is the puzzle it exists to end.
252
+ const all = [...bound, ...(incoming ? [incoming] : [])];
253
+ const names = bound.map((e) => projectRowLabel(e, all));
254
+ const list = names.length > 1 ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}` : names[0];
255
+ const head = incoming
256
+ ? `${repoRoot} is already connected to ${list}, so ${projectRowLabel(incoming, all)} was not connected here.`
257
+ : `${repoRoot} is connected to ${list}.`;
258
+ return (
259
+ `${head}\n` +
260
+ 'A directory serves one project — two projects must not edit it at the same time.\n' +
261
+ 'Delete the project you do not mean in Flowviant first (project settings → General → Delete project),\n' +
262
+ 'or disconnect this box from it with `flowviant machines`, then run this again.'
263
+ );
264
+ }
265
+
230
266
  /** Collapse a name or slug for loose comparison: "My Project", "my-project"
231
267
  * and "myproject" all become "myproject". */
232
268
  function normalizeName(s) {
@@ -387,6 +423,9 @@ export function matchStoredProject(ref) {
387
423
  * this returns. Shapes:
388
424
  * { entry, source: 'project-flag' | 'repo' | 'only', needsConfirm? }
389
425
  * { choices, reason: 'no-match' | 'multiple-bound' | 'outside-repo', repoRoot }
426
+ * (`multiple-bound` is a REFUSAL in cli.mjs since 2026-09-23 — a directory
427
+ * serves one project — and never a picker; the shape is kept because it
428
+ * names the projects the refusal has to print)
390
429
  * { none: true }
391
430
  * { error }
392
431
  *
package/bin/lib/env.mjs CHANGED
@@ -725,3 +725,20 @@ export function scrub(text) {
725
725
  for (const v of values) out = out.split(v.value).join(`[REDACTED:${v.name}]`);
726
726
  return out;
727
727
  }
728
+
729
+ /**
730
+ * The NAME of the first known secret whose value appears in `bytes` as an
731
+ * exact byte substring, or null. For the payloads `scrub` cannot touch: a
732
+ * binary artifact (a PNG's text chunk, a PDF's uncompressed stream, a zip's
733
+ * stored entry) is not text to rewrite — replacing bytes inside one corrupts
734
+ * it — so the caller WITHHOLDS a hit rather than redacting it. The same list,
735
+ * already filtered by `worthRedacting`, so the two can never disagree about
736
+ * what counts as a secret. It sees only what is stored plainly: a value inside
737
+ * a deflated stream is not a substring of the file, and that is stated where
738
+ * it is used (artifacts.mjs).
739
+ */
740
+ export function secretIn(bytes) {
741
+ if (!Buffer.isBuffer(bytes) || !bytes.length || !values.length) return null;
742
+ for (const v of values) if (bytes.includes(v.value, 0, 'utf8')) return v.name;
743
+ return null;
744
+ }