flowviant 0.125.5 → 0.125.7

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 (101) hide show
  1. package/README.md +4 -5
  2. package/bin/lib/agentInternalWait.mjs +1 -5
  3. package/bin/lib/agentPlan.mjs +13 -163
  4. package/bin/lib/agentRepositories.mjs +3 -1
  5. package/bin/lib/claudeInput.mjs +89 -10
  6. package/bin/lib/claudeMachineMcp.mjs +85 -0
  7. package/bin/lib/claudePersonal.mjs +6 -57
  8. package/bin/lib/claudePosture.mjs +6 -15
  9. package/bin/lib/claudeSettingsArg.mjs +67 -0
  10. package/bin/lib/claudeStream.mjs +30 -1
  11. package/bin/lib/claudeSubagentProgress.mjs +12 -28
  12. package/bin/lib/claudeSubagents.mjs +4 -2
  13. package/bin/lib/codexContract.mjs +52 -0
  14. package/bin/lib/codexFencedMount.mjs +275 -0
  15. package/bin/lib/codexPersonal.mjs +1 -265
  16. package/bin/lib/codexProjectRead.mjs +5 -4
  17. package/bin/lib/codexResearch.mjs +9 -3
  18. package/bin/lib/codexSubagentEvents.mjs +11 -7
  19. package/bin/lib/codexSubagents.mjs +55 -52
  20. package/bin/lib/codexToml.mjs +328 -0
  21. package/bin/lib/config.mjs +22 -1
  22. package/bin/lib/conversationIds.mjs +10 -0
  23. package/bin/lib/fleet.mjs +2 -2
  24. package/bin/lib/git.mjs +32 -0
  25. package/bin/lib/heldTool.mjs +49 -0
  26. package/bin/lib/hooks/mcpAllow.mjs +11 -5
  27. package/bin/lib/hooks/orchestrator.mjs +20 -10
  28. package/bin/lib/intakeDraft.mjs +1 -1
  29. package/bin/lib/jsonlTail.mjs +52 -0
  30. package/bin/lib/mcpInventory.mjs +11 -6
  31. package/bin/lib/mcpReasons.mjs +26 -0
  32. package/bin/lib/mcpReportBounds.mjs +10 -0
  33. package/bin/lib/mcpStdio.mjs +35 -0
  34. package/bin/lib/mergeBatch.mjs +6 -24
  35. package/bin/lib/mergeOrder.mjs +18 -0
  36. package/bin/lib/modelJson.mjs +173 -0
  37. package/bin/lib/nativeAgentTools.mjs +72 -0
  38. package/bin/lib/orchestrator.mjs +221 -133
  39. package/bin/lib/orchestratorClient.mjs +17 -21
  40. package/bin/lib/orchestratorHooks.mjs +21 -4
  41. package/bin/lib/orchestratorNative.mjs +58 -0
  42. package/bin/lib/orchestratorPrompt.mjs +7 -4
  43. package/bin/lib/orchestratorProtocol.mjs +25 -3
  44. package/bin/lib/postureTools.mjs +58 -13
  45. package/bin/lib/prWorkflow.mjs +13 -2
  46. package/bin/lib/processGroup.mjs +41 -0
  47. package/bin/lib/projectRepositories.mjs +2 -2
  48. package/bin/lib/projectToolRuntimes.mjs +92 -245
  49. package/bin/lib/projectTools.mjs +60 -19
  50. package/bin/lib/prompts.mjs +47 -18
  51. package/bin/lib/reportJournal.mjs +61 -0
  52. package/bin/lib/repositoryDelivery.mjs +6 -4
  53. package/bin/lib/repositoryMerge.mjs +8 -12
  54. package/bin/lib/repositoryPlaces.mjs +2 -2
  55. package/bin/lib/repositoryPrompts.mjs +42 -0
  56. package/bin/lib/repositoryTool.mjs +9 -32
  57. package/bin/lib/researchUse.mjs +39 -0
  58. package/bin/lib/resumeLost.mjs +21 -1
  59. package/bin/lib/resumeMarker.mjs +43 -0
  60. package/bin/lib/runTurn.mjs +55 -52
  61. package/bin/lib/runtimeAntigravity.mjs +1 -1
  62. package/bin/lib/runtimeCapabilities.mjs +13 -18
  63. package/bin/lib/runtimeClaude.mjs +68 -24
  64. package/bin/lib/runtimeCodex.mjs +12 -6
  65. package/bin/lib/runtimeEvents.mjs +13 -2
  66. package/bin/lib/runtimeOfferings.mjs +12 -5
  67. package/bin/lib/runtimes.mjs +3 -1
  68. package/bin/lib/startProject.mjs +10 -0
  69. package/bin/lib/subagentBounds.mjs +20 -0
  70. package/bin/lib/trace.mjs +30 -5
  71. package/bin/lib/turnDeclaration.mjs +1 -1
  72. package/bin/lib/turnDiagnostics.mjs +9 -12
  73. package/bin/lib/turnReceipts.mjs +64 -0
  74. package/bin/lib/turnSubagents.mjs +91 -0
  75. package/bin/lib/turnUsage.mjs +9 -6
  76. package/bin/lib/update.mjs +18 -4
  77. package/bin/lib/versionOrder.mjs +11 -1
  78. package/bin/lib/work.mjs +9 -9
  79. package/bin/lib/workAgentMerges.mjs +5 -7
  80. package/bin/lib/workAgentPrecheck.mjs +5 -5
  81. package/bin/lib/workAgentRecovery.mjs +2 -1
  82. package/bin/lib/workAgentResume.mjs +121 -0
  83. package/bin/lib/workAgentTools.mjs +111 -0
  84. package/bin/lib/workAgentTurnExecution.mjs +82 -246
  85. package/bin/lib/workAgentTurnOutcome.mjs +30 -3
  86. package/bin/lib/workAgentTurnReports.mjs +16 -0
  87. package/bin/lib/workAgentTurnShape.mjs +90 -0
  88. package/bin/lib/workAgentTurns.mjs +28 -12
  89. package/bin/lib/workArtifactReconcile.mjs +3 -2
  90. package/bin/lib/workAttachments.mjs +27 -5
  91. package/bin/lib/workMergeBatch.mjs +7 -5
  92. package/bin/lib/workNarration.mjs +32 -0
  93. package/bin/lib/workPlaceReports.mjs +3 -3
  94. package/bin/lib/workPlaces.mjs +14 -0
  95. package/bin/lib/workReportQueue.mjs +18 -1
  96. package/bin/lib/workSessionRuntime.mjs +2 -5
  97. package/bin/lib/workSessionTurns.mjs +68 -80
  98. package/bin/lib/workShip.mjs +8 -18
  99. package/bin/lib/workTurnShape.mjs +6 -4
  100. package/bin/lib/worktreeDiff.mjs +29 -5
  101. package/package.json +1 -1
package/README.md CHANGED
@@ -3,8 +3,8 @@
3
3
  Run your own coding CLIs as build agents for [Flowviant](https://flowviant.com) — [Claude Code](https://claude.com/claude-code), Codex or Antigravity, on your own credentials. This daemon holds your sessions, keeps a worktree per tab, and ships branches on your word. The daemon does not send your Claude, Codex or GitHub logins to Flowviant; it shells out to CLIs you signed in yourself.
4
4
 
5
5
  ```sh
6
- curl -fsSL https://api.flowviant.com/install.sh | sh
7
- flowviant # approve the code in Flowviant → connected
6
+ curl -fsSL https://api.flowviant.com/install.sh | sh && ~/.flowviant/bin/flowviant
7
+ # approve the code in Flowviant → connected; new terminals just run `flowviant`
8
8
  ```
9
9
 
10
10
  Login keeps going straight into the daemon — there is no second command to run.
@@ -31,11 +31,10 @@ On the machine that runs the daemon:
31
31
  Download the standalone binary, then start it in your checkout:
32
32
 
33
33
  ```sh
34
- curl -fsSL https://api.flowviant.com/install.sh | sh
35
- flowviant
34
+ curl -fsSL https://api.flowviant.com/install.sh | sh && ~/.flowviant/bin/flowviant
36
35
  ```
37
36
 
38
- It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
37
+ The full path is for this first run only: the installer adds its folder to your shell's startup file, which new terminals read. It shows a short code. Open your project's **Workbench** in Flowviant and enter the code where it offers to connect a machine. The credential is stored at `~/.flowviant/credentials.json`, and from then on `flowviant` just runs. The installer prints a PATH hint if needed; `FLOWVIANT_INSTALL_DIR` changes its destination. It verifies SHA-256 and never uses sudo.
39
38
 
40
39
  Run `flowviant login` from another checkout and choose the same project in the
41
40
  browser to add its repository to that board. Project settings → General lists
@@ -25,11 +25,7 @@ export async function runAgentWithInternalWaits(run, options) {
25
25
  let cliFailed = false;
26
26
  let lastAnswer = null;
27
27
  let currentUsage = null;
28
- const combine = () => {
29
- if (!currentUsage) return total;
30
- return Object.fromEntries(['input', 'output', 'cacheCreate', 'cacheRead'].map((k) =>
31
- [k, (total?.[k] ?? 0) + (currentUsage[k] ?? 0)]));
32
- };
28
+ const combine = () => currentUsage ? addUsage(total, currentUsage) : total;
33
29
  const out = await run({
34
30
  ...options,
35
31
  ...(attempt ? {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * READING A MODEL'S ANSWER, in the agent lane's shapes: a planner's
3
- * PROPOSAL, an agent's own TURN RESULT, the AI pre-review's TRIAGE, and an
4
- * incoming ticket's one-card DRAFT (2026-09-28).
3
+ * PROPOSAL, an agent's own TURN RESULT and the AI pre-review's TRIAGE. The
4
+ * objects themselves are found by modelJson.mjs (2026-10-18).
5
5
  *
6
6
  * The scratch agent behind a Deploy press is asked for one JSON object and
7
7
  * nothing else. This is what turns its final message into a proposal, and it is
@@ -32,6 +32,8 @@ import { scrub as envScrub, scrubCut } from './uplinkScrub.mjs';
32
32
  import { groupDependencies } from './planDependencies.mjs';
33
33
  import { AGENT_TASK_KINDS } from './agentTaskKinds.mjs';
34
34
  import { blockedAnswer } from './agentAsk.mjs';
35
+ import { isResearchUse } from './researchUse.mjs';
36
+ import { candidateObjects } from './modelJson.mjs';
35
37
 
36
38
  /** The biggest a single proposal may be. Bounds on a machine, not a policy:
37
39
  * the server caps these again at its own boundary. */
@@ -40,164 +42,6 @@ const MAX_TASKS_PER_AGENT = 60;
40
42
  const MAX_NAME = 80;
41
43
  const MAX_NOTE = 1000;
42
44
 
43
- /**
44
- * Find the objects.
45
- *
46
- * A fenced block first, because that is what was asked for. Otherwise every `{`
47
- * in the text is tried as a start, and its BALANCED end is found by counting
48
- * braces while skipping string literals — each caller then takes the first
49
- * candidate whose SHAPE is the one it asked for.
50
- *
51
- * The obvious cheap version — first `{` to last `}` — is wrong in a way a test
52
- * caught: a planner that writes "I looked at {the auth module} first" before its
53
- * JSON produces a span starting at the wrong brace, and the whole plan is lost
54
- * to a sentence. Scanning candidates costs nothing at this size and cannot be
55
- * defeated by prose.
56
- *
57
- * ONE SCANNER FOR EVERY READER (2026-09-16). It was written
58
- * twice — once here and once inline in `parseTurnResult` — and a third copy was
59
- * about to be written for the precheck. A brace scanner that skips string
60
- * literals is exactly the kind of thing where two copies quietly stop agreeing
61
- * about escapes and nobody notices, because the disagreement only shows up on a
62
- * card title with a quote in it.
63
- */
64
- /**
65
- * BOUNDED, BECAUSE THE TEXT IS A MODEL'S (2026-09-24, the audit).
66
- *
67
- * The scan used to walk from EVERY `{` to the end of the text, and parse every
68
- * balanced span it found — quadratic on both counts. A final message of eighty
69
- * thousand `{` (a quoted fixture, brace padding, a turn steered into writing
70
- * one) took nine seconds of synchronous CPU, and while it ran nothing else on
71
- * the machine moved: no poll, no lease renewal, no other lane. Three bounds,
72
- * none of which a real answer comes near:
73
- *
74
- * · top-level objects are scanned whole, including long un-fenced replies;
75
- * `fencedBody` and the brace scan are linear, with no prose-length slice;
76
- * · each `{`'s closing brace is found ONCE (see `closers`), so the scan is a
77
- * single right-to-left pass instead of one walk per brace;
78
- * · only spans that can open an object (`{` then a key or `}`) are handed to
79
- * JSON.parse. Every TOP-LEVEL span is parsed — top-level spans are
80
- * disjoint, so together they are at most the scanned window — and the
81
- * NESTED ones (a span inside an earlier candidate) share a budget of
82
- * `MAX_NESTED_PARSES` spans and `MAX_NESTED_CHARS` characters, so a run of
83
- * nested braces that parse is not re-parsed thousands of times.
84
- *
85
- * THE BUDGET NEVER REACHES A TOP-LEVEL SPAN, and the review found why it must
86
- * not: the first cut counted every span from the left against one budget, so
87
- * an answer that followed 250 JSON lines of a quoted fixture (or one large
88
- * nested document) was never parsed at all — the turn read as `nothing` and a
89
- * delivered agent went to Stuck. The contract puts the answer LAST and at the
90
- * top level, which is the one span a budget must never cost.
91
- */
92
- const MAX_NESTED_PARSES = 200;
93
- const MAX_NESTED_CHARS = 4 * 1024 * 1024;
94
-
95
- export function candidateObjects(text) {
96
- const fenced = fencedBody(text);
97
- const raw = text;
98
- const bodies = [];
99
- if (fenced !== null) bodies.push(fenced);
100
- const close = closers(raw);
101
- let topEnd = -1;
102
- let nestedParses = 0;
103
- let nestedChars = 0;
104
- for (let i = 0; i < raw.length; i++) {
105
- if (raw[i] !== '{') continue;
106
- const end = close.get(i);
107
- if (!(end > i) || !/^\{\s*["}]/.test(raw.slice(i, i + 64))) continue;
108
- if (i > topEnd) {
109
- topEnd = end;
110
- } else {
111
- const len = end - i + 1;
112
- if (nestedParses >= MAX_NESTED_PARSES || nestedChars + len > MAX_NESTED_CHARS) continue;
113
- nestedParses++;
114
- nestedChars += len;
115
- }
116
- bodies.push(raw.slice(i, end + 1));
117
- }
118
- const out = [];
119
- for (const body of bodies) {
120
- if (!body.trim()) continue;
121
- try {
122
- const v = JSON.parse(body);
123
- if (v && typeof v === 'object') out.push(v);
124
- } catch {
125
- /* the next candidate may be the object */
126
- }
127
- }
128
- return out;
129
- }
130
-
131
- /**
132
- * THE FIRST FENCED BLOCK'S BODY, or null — what
133
- * `text.match(/```(?:json)?\s*([\s\S]*?)```/)[1]` answered, found in ONE
134
- * forward pass (2026-10-10, the audit's F079).
135
- *
136
- * That regex ran over the WHOLE text and backtracks quadratically on an opening fence
137
- * followed by a long whitespace run with no closing fence: the greedy `\s*` and
138
- * the lazy body re-scan the run from every split. Measured: 80 KB took 0.6 s,
139
- * 320 KB nine seconds, of synchronous CPU in which no poll, lease or other
140
- * lane moved — and a turn's stdout is model-written and uncapped. Same
141
- * answer, by `indexOf`: the first ``` anywhere, an optional `json`, the
142
- * whitespace after it skipped, and the body runs to the next ```. If that
143
- * opening has no closer, no later opening can have one either, so there is no
144
- * fence. The search stays over the whole text.
145
- */
146
- const FENCE_SPACE = /\s*/y;
147
- function fencedBody(text) {
148
- const open = text.indexOf('```');
149
- if (open < 0) return null;
150
- let at = open + 3;
151
- if (text.startsWith('json', at)) at += 4;
152
- FENCE_SPACE.lastIndex = at;
153
- FENCE_SPACE.exec(text);
154
- at = FENCE_SPACE.lastIndex;
155
- const close = text.indexOf('```', at);
156
- return close < 0 ? null : text.slice(at, close);
157
- }
158
-
159
- /**
160
- * For every `{`, the index of the `}` that closes it when read as the START of
161
- * an object (outside any string), or -1 — the answer `balanced` gives, for all
162
- * of them in one pass.
163
- *
164
- * Right to left, so every `{` AFTER the one being closed already has its
165
- * answer. A walk that meets such a `{` outside a string jumps straight to that
166
- * brace's closer — the string state from there on is the same for both walks,
167
- * so the nested object ends where it ends — and if that brace never closes,
168
- * neither does this one. What is left for each walk is its own top level.
169
- */
170
- // The conversation-prose filter uses the same brace/string boundaries.
171
- export { closers as jsonObjectClosers };
172
- function closers(s) {
173
- const close = new Map();
174
- for (let i = s.lastIndexOf('{'); i >= 0; i = i > 0 ? s.lastIndexOf('{', i - 1) : -1) {
175
- let inStr = false;
176
- let esc = false;
177
- let end = -1;
178
- for (let k = i + 1; k < s.length; k++) {
179
- const ch = s[k];
180
- if (inStr) {
181
- if (esc) esc = false;
182
- else if (ch === '\\') esc = true;
183
- else if (ch === '"') inStr = false;
184
- continue;
185
- }
186
- if (ch === '"') inStr = true;
187
- else if (ch === '{') {
188
- const inner = close.get(k);
189
- if (inner === undefined || inner < 0) break;
190
- k = inner;
191
- } else if (ch === '}') {
192
- end = k;
193
- break;
194
- }
195
- }
196
- close.set(i, end);
197
- }
198
- return close;
199
- }
200
-
201
45
  function extract(raw) {
202
46
  return candidateObjects(raw).find((v) => Array.isArray(v.agents)) ?? null;
203
47
  }
@@ -411,7 +255,7 @@ export function parseTurnResult(text, scrub = envScrub) {
411
255
  if (v.status === 'delivered') {
412
256
  return {
413
257
  outcome: 'delivered',
414
- ...(['tasks', 'context', 'document'].includes(v.researchUse) ? { researchUse: v.researchUse } : {}),
258
+ ...(isResearchUse(v.researchUse) ? { researchUse: v.researchUse } : {}),
415
259
  ...(Array.isArray(v.mergeOrder) ? { mergeOrder: v.mergeOrder.filter((id) => typeof id === 'string').slice(0, 64) } : {}),
416
260
  ...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
417
261
  ...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
@@ -447,6 +291,12 @@ export const MAX_PRECHECK_OVERALL = 1200;
447
291
  export const MAX_PRECHECK_CRITERIA = 12;
448
292
  export const MAX_PRECHECK_CRITERION = 200;
449
293
  export const MAX_PRECHECK_LINE = 160;
294
+ /** The verdict words the server's boundary admits (shared `agents.schema.ts`
295
+ * PRECHECK_CARD_VERDICTS / PRECHECK_CRITERION_VERDICTS, 2026-10-18), held
296
+ * equal by the release gate (rule 31): a word kept here and refused there
297
+ * is a reading the panel never draws. */
298
+ export const PRECHECK_CARD_VERDICTS = ['ok', 'concerns'];
299
+ export const PRECHECK_CRITERION_VERDICTS = ['met', 'concern', 'unclear'];
450
300
 
451
301
  /**
452
302
  * ONE CARD'S CRITERIA, as the reader judged them (2026-10-10).
@@ -464,7 +314,7 @@ function precheckCriteria(raw, scrub) {
464
314
  if (out.length >= MAX_PRECHECK_CRITERIA) break;
465
315
  if (!k || typeof k !== 'object') continue;
466
316
  if (typeof k.criterion !== 'string' || !k.criterion.trim()) continue;
467
- if (k.verdict !== 'met' && k.verdict !== 'concern' && k.verdict !== 'unclear') continue;
317
+ if (!PRECHECK_CRITERION_VERDICTS.includes(k.verdict)) continue;
468
318
  if (k.line != null && typeof k.line !== 'string') continue;
469
319
  const criterion = scrubCut(k.criterion.trim(), MAX_PRECHECK_CRITERION, scrub);
470
320
  const line = typeof k.line === 'string' ? scrubCut(k.line.trim(), MAX_PRECHECK_LINE, scrub) : '';
@@ -528,7 +378,7 @@ export function parsePrecheck(text, scrub = envScrub) {
528
378
  if (!c || typeof c !== 'object') continue;
529
379
  const taskId = typeof c.taskId === 'string' ? c.taskId.trim().slice(0, 64) : '';
530
380
  if (!taskId || seen.has(taskId)) continue;
531
- if (c.verdict !== 'ok' && c.verdict !== 'concerns') continue;
381
+ if (!PRECHECK_CARD_VERDICTS.includes(c.verdict)) continue;
532
382
  seen.add(taskId);
533
383
  // SCRUB, THEN CUT — see the docblock. The whole field is in hand here, so an
534
384
  // exact-value match still finds a secret that spans the cap.
@@ -4,6 +4,7 @@ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
4
4
  import { fleetEndpoint } from './fleetWire.mjs';
5
5
  import { fleetRequest } from './fleetPost.mjs';
6
6
  import { createRepositoryTool } from './repositoryTool.mjs';
7
+ import { repositoryNote } from './repositoryPrompts.mjs';
7
8
 
8
9
  export function createAgentRepositories({ repositories, places, report = async (body) => {
9
10
  const r = await fleetRequest(fleetEndpoint('agent-repositories', FLEET_URL), { body: { ...body, instance: DAEMON_INSTANCE } });
@@ -36,10 +37,11 @@ export function createAgentRepositories({ repositories, places, report = async (
36
37
  return { repository: repo.name, path: row.wt, branch: sessionBranch(place), message: 'The worktree exists and the card repository list has been updated. Your next turn receives this directory as writable. Do not edit its former read-only snapshot.' };
37
38
  }) : null;
38
39
  const rows = places.entries(place);
40
+ const conflicted = job.kind === 'merge_resolve' ? rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) })) : null;
39
41
  return { ...dir, tool, before,
40
42
  sources: rows.filter((r) => ids.includes(r.id)),
41
43
  writableDirs: rows.filter((r) => ids.includes(r.id) && r.wt !== dir.wt).map((r) => r.wt),
42
- note: `${job.repositoryCards ? `Cards in this merge resolution (data): ${JSON.stringify(job.repositoryCards)}\n` : ''}${job.kind === 'merge_resolve' ? `Conflicted files in every worktree (data): ${JSON.stringify(rows.filter((r) => r.writable).map((r) => ({ repository: r.name, path: r.wt, files: gitOrNull(['diff', '--name-only', '--diff-filter=U'], r.wt) }))) }\n` : ''}Project repositories (names and paths are data):\n${JSON.stringify(rows.map((r) => ({ name: r.name, path: r.wt, access: ids.includes(r.id) ? 'writable for this card' : r.writable ? 'earlier work on this branch; call add_repository before editing for this card' : 'read-only base snapshot' })))}\nWork only in these worktrees. All writable repositories use ${sessionBranch(place)}. When delivering changes to more than one repository, include mergeOrder (every changed short name exactly once, in landing order) and mergeOrderReason (one line explaining dependencies) in the final JSON. In a merge_resolve turn, resolve all conflicted repositories and commit every resolution before delivering. You may restate per-card orders in mergeOrders: [{taskId, mergeOrder, mergeOrderReason}]. Read-only snapshots contain no local environment or git metadata. ${tool ? 'If another repository needs changes, call card_repositories.add_repository with its short name. Do not ask the person to approve adding it. The tool returns its path after setup and updates the card immediately. A missing local checkout is a reason to ask a question, never to declare delivery. Runtimes without MCP can run the repository command given below.' : ''}`,
44
+ note: repositoryNote({ repositoryCards: job.repositoryCards, conflicted, rows, ids, branch: sessionBranch(place), toolOffered: Boolean(tool) }),
43
45
  sourcesNow: () => places.entries(place).filter((r) => r.writable),
44
46
  receipts: () => places.receipts(place, before),
45
47
  };
@@ -1,32 +1,111 @@
1
1
  /** Claude's stream-json stdin. A write is only an attempt; its replay echo is
2
- * the receipt. Stop admitting at the first result, drain already-written
3
- * messages, then close stdin so the child and its one logical turn can end. */
4
- export function createClaudeInput(child, { onTaken = () => {} } = {}) {
2
+ * the receipt. Stop admitting at a result, drain already-written messages,
3
+ * then close stdin so the child and its one logical turn can end.
4
+ *
5
+ * …UNLESS NATIVE AGENTS STILL RUN IN THE BACKGROUND (2026-10-17). Claude Code
6
+ * 2.1.302 lists them on the parent stream (`background_tasks_changed`), emits
7
+ * a parent result while they run, and with stdin open takes a later message
8
+ * as a new parent turn (measured): live notes, child questions and slot wakes
9
+ * still have a reader. Input closes at a result that finds no agent listed and
10
+ * nothing written pending — never at the emptied list itself: the agents'
11
+ * notification turn follows it and may start the next wave, and a slot wake
12
+ * for that wave arrives just then (both measured). Only agents hold it: a
13
+ * background shell (a dev server) may never end, and the CLI's own wind-down
14
+ * waits on those. A CLI that never lists tasks closes at its first result. */
15
+ /**
16
+ * THE CLI'S OWN CONTEXT MEASURE (2026-10-19). `get_context_usage` with
17
+ * `detail: 'summary'` answers from the last response's usage and local
18
+ * estimates — no model call and no token-count request (measured offline on
19
+ * 2.1.303). It names the window, the tokens in it and the native auto-compact
20
+ * threshold, so `compactAt` is the CLI's, never a guess; an auto-compact that
21
+ * is off has none (null). A CLI that answers an error, or never answers,
22
+ * reports nothing: unknown draws nothing (ContextReadout.tsx).
23
+ */
24
+ export function contextFromUsage(r) {
25
+ const used = r?.totalTokens;
26
+ const window = r?.maxTokens;
27
+ if (!Number.isSafeInteger(used) || used < 0 || !Number.isSafeInteger(window) || window <= 0) return null;
28
+ const threshold = r.isAutoCompactEnabled === true && Number.isFinite(r.autoCompactThreshold) && r.autoCompactThreshold > 0
29
+ ? Math.min(1, r.autoCompactThreshold / window)
30
+ : null;
31
+ return { usedTokens: used, windowTokens: window, compactAt: threshold, compacting: false };
32
+ }
33
+ const CONTEXT_EVERY_MS = 3_000;
34
+
35
+ export function createClaudeInput(child, { onTaken = () => {}, onContext = null } = {}) {
5
36
  const sent = new Set();
6
37
  const pending = new Set();
7
- let accepting = true;
38
+ let draining = false; // a result found no live agent: admit nothing, close once pending is read
39
+ let broken = false;
8
40
  let ended = false;
9
41
  let resultSeen = false;
10
- child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
42
+ let agents = 0; // live background agents, as the parent stream last listed them
43
+ child.stdin.on('error', () => { broken = true; }); // EPIPE leaves the message queued
44
+ const end = () => { if (!ended) { ended = true; child.stdin.end(); } };
45
+ // The context readout's asks: one in flight, at most one per window, and
46
+ // one more owed when something moved while it was out — at once when what
47
+ // moved was a compaction ('now'), else on the window ('later').
48
+ let ctxSeq = 0;
49
+ let ctxAsked = null;
50
+ let ctxAt = 0;
51
+ let ctxOwed = null;
52
+ let compacting = false;
53
+ let lastContext = null;
54
+ const sayContext = (ctx) => {
55
+ lastContext = ctx;
56
+ try { onContext(ctx); } catch { /* a readout never fails a turn */ }
57
+ };
58
+ const askContext = (now = false) => {
59
+ if (!onContext || ended || broken || child.stdin.destroyed || child.stdin.writableEnded) return;
60
+ if (ctxAsked || (!now && Date.now() - ctxAt < CONTEXT_EVERY_MS)) {
61
+ if (ctxOwed !== 'now') ctxOwed = now ? 'now' : 'later';
62
+ return;
63
+ }
64
+ ctxAsked = `fv-context-${++ctxSeq}`;
65
+ ctxAt = Date.now();
66
+ ctxOwed = null;
67
+ const request = { type: 'control_request', request_id: ctxAsked, request: { subtype: 'get_context_usage', detail: 'summary' } };
68
+ try { child.stdin.write(JSON.stringify(request) + '\n'); } catch { ctxAsked = null; }
69
+ };
11
70
  const send = (id, prompt) => {
12
- if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
71
+ if (draining || broken || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
13
72
  const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
14
73
  sent.add(id);
15
74
  pending.add(id);
16
75
  try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
17
- catch { pending.delete(id); accepting = false; return false; }
76
+ catch { pending.delete(id); broken = true; return false; }
18
77
  };
19
78
  return {
20
79
  send,
21
80
  event(event) {
22
81
  if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
82
+ if (event.type === 'control_response' && ctxAsked && event.response?.request_id === ctxAsked) {
83
+ ctxAsked = null;
84
+ const ctx = event.response.subtype === 'success' ? contextFromUsage(event.response.response) : null;
85
+ if (ctx) sayContext({ ...ctx, compacting });
86
+ if (ctxOwed) askContext(ctxOwed === 'now');
87
+ }
88
+ if (event.type === 'system' && event.subtype === 'init') askContext(true);
89
+ if (event.type === 'assistant') askContext();
90
+ if (event.type === 'system' && event.subtype === 'status') {
91
+ const now = event.status === 'compacting';
92
+ if (now && !compacting && lastContext) sayContext({ ...lastContext, compacting: true });
93
+ if (!now && compacting) askContext(true);
94
+ compacting = now;
95
+ }
96
+ if (event.type === 'system' && event.subtype === 'compact_boundary') { compacting = false; askContext(true); }
97
+ if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
98
+ agents = event.tasks.filter(task => task?.task_type === 'local_agent').length;
99
+ if (agents > 0) draining = false; // a drained message's turn started agents
100
+ }
23
101
  if (event.type === 'result') {
24
102
  resultSeen = true;
25
- accepting = false;
26
- if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
103
+ if (event.is_error === true) end();
104
+ else if (agents > 0) draining = false;
105
+ else { draining = true; if (pending.size === 0) end(); }
27
106
  }
28
107
  },
29
- close() { accepting = false; ended = true; },
108
+ close() { ended = true; },
30
109
  get resultSeen() { return resultSeen; },
31
110
  };
32
111
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * THE MACHINE'S OWN MCP SERVERS THAT A CARD TURN'S SETTINGS SOURCES NEVER
3
+ * REACH (2026-10-17). The 2026-10-11 ruling mounts "the user and local
4
+ * servers" on every card turn, and neither arrived where the person keeps
5
+ * them. MEASURED on Claude Code 2.1.302, a scratch CLAUDE_CONFIG_DIR whose
6
+ * `.claude.json` held one user and one local server:
7
+ * · `--setting-sources ''` (every fenced posture: write-up, mockup, 3D model,
8
+ * deck) loaded NEITHER — a research card on the owner's box saw the
9
+ * claude.ai connectors and no Roblox_Studio, a user-scope server;
10
+ * · `--setting-sources user` (a code card) loaded the user server only;
11
+ * · a local server (`claude mcp add`'s default scope) is keyed on the
12
+ * CANONICAL git root — the main checkout, which the CLI also resolves a
13
+ * linked worktree to (re-measured 2026-10-17: a worktree cwd with no
14
+ * `--setting-sources` loaded it). What drops it on a card is the `local`
15
+ * source the card's list leaves out, not the worktree's path.
16
+ * So the lane names what is missing and the adapter hands it over through a
17
+ * private `--mcp-config` file (runtimeClaude.mjs): `user` when the turn's
18
+ * sources drop the user scope, and the checkout's local servers always. A
19
+ * local name shadows a user one, the CLI's own precedence; an `--mcp-config`
20
+ * server beat a user server of the same name (measured), so a code card keeps
21
+ * that order too.
22
+ *
23
+ * ITS OWN MODULE (2026-10-18, the SOLID pass). It sat in claudePersonal.mjs,
24
+ * whose ruling is the person's settings allowlist and private settings file
25
+ * (2026-09-29, 2026-10-10), and was the only reason that module imported git;
26
+ * the agent lane imported claudePersonal.mjs for this function alone. Where
27
+ * Claude Code keeps user and local servers in `.claude.json` is a different
28
+ * fact, which moves on its own: a change to how the CLI keys them, or a new
29
+ * scope, opens this file and no other (claudeMachineMcp.test.mjs pins the
30
+ * file name to here).
31
+ */
32
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
33
+ import { homedir } from 'node:os';
34
+ import { basename, dirname, join, resolve } from 'node:path';
35
+ import { gitOrNull } from './git.mjs';
36
+
37
+ /** Far past any real CLI config — `.claude.json` also holds every project's
38
+ * history; a bigger one is not read. */
39
+ const CLI_CONFIG_MAX_BYTES = 32 * 1024 * 1024;
40
+ const isRecord = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
41
+
42
+ /**
43
+ * THREE STATES (2026-10-17): `{}` when the file is absent (measured: the
44
+ * machine has none), `null` when it exists but could not be read, is too big
45
+ * or is not an object (unknown), so a caller never says a server is mounted
46
+ * that was never read.
47
+ */
48
+ export function machineMcpServers({ user = false, checkout = null, env = process.env } = {}) {
49
+ const path = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, '.claude.json') : join(env.HOME || homedir(), '.claude.json');
50
+ let raw;
51
+ try {
52
+ if (statSync(path).size > CLI_CONFIG_MAX_BYTES) return null;
53
+ raw = JSON.parse(readFileSync(path, 'utf8'));
54
+ } catch (error) {
55
+ return error?.code === 'ENOENT' ? {} : null;
56
+ }
57
+ if (!isRecord(raw)) return null;
58
+ const out = {};
59
+ const take = (servers) => {
60
+ if (!isRecord(servers)) return;
61
+ for (const [name, config] of Object.entries(servers)) if (name !== '__proto__' && isRecord(config)) out[name] = config;
62
+ };
63
+ if (user) take(raw.mcpServers);
64
+ if (typeof checkout === 'string' && checkout && isRecord(raw.projects)) {
65
+ const keys = [resolve(checkout)];
66
+ try { keys.push(realpathSync(checkout)); } catch { /* the resolved path alone */ }
67
+ const root = canonicalGitRoot(checkout);
68
+ if (root) keys.unshift(root);
69
+ const key = keys.find((k) => Object.hasOwn(raw.projects, k));
70
+ if (key) take(raw.projects[key]?.mcpServers);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /** The main checkout a directory's repository resolves to — the key the CLI
76
+ * files a local server under when the connected folder is itself a linked
77
+ * worktree. Null when git cannot say. */
78
+ function canonicalGitRoot(dir) {
79
+ const common = gitOrNull(['rev-parse', '--path-format=absolute', '--git-common-dir'], dir);
80
+ try {
81
+ return common && basename(common) === '.git' ? realpathSync(dirname(common)) : null;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
@@ -46,9 +46,10 @@
46
46
  * swept on the next write.
47
47
  */
48
48
  import { randomBytes } from 'node:crypto';
49
- import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
49
+ import { chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
50
50
  import { homedir } from 'node:os';
51
- import { basename, dirname, join, resolve } from 'node:path';
51
+ import { basename, dirname, join } from 'node:path';
52
+ import { replaceSettings } from './claudeSettingsArg.mjs';
52
53
  import { MACHINE_CREDENTIAL_ENV } from './machineEnv.mjs';
53
54
  import { WINDOW_ENV_REMOVED, withoutWindows } from './noWindowEnv.mjs';
54
55
 
@@ -141,56 +142,6 @@ export function personalClaudeSettings(env = process.env) {
141
142
  return out;
142
143
  }
143
144
 
144
- /** Far past any real CLI config — `.claude.json` also holds every project's
145
- * history; a bigger one is not read. */
146
- const CLI_CONFIG_MAX_BYTES = 32 * 1024 * 1024;
147
- const isRecord = (v) => v != null && typeof v === 'object' && !Array.isArray(v);
148
-
149
- /**
150
- * THE MACHINE'S OWN MCP SERVERS THAT A CARD TURN'S SETTINGS SOURCES NEVER
151
- * REACH (2026-10-17). The 2026-10-11 ruling mounts "the user and local
152
- * servers" on every card turn, and neither arrived where the person keeps
153
- * them. MEASURED on Claude Code 2.1.302, a scratch CLAUDE_CONFIG_DIR whose
154
- * `.claude.json` held one user and one local server:
155
- * · `--setting-sources ''` (every fenced posture: write-up, mockup, 3D model,
156
- * deck) loaded NEITHER — a research card on the owner's box saw the
157
- * claude.ai connectors and no Roblox_Studio, a user-scope server;
158
- * · `--setting-sources user` (a code card) loaded the user server only;
159
- * · a local server is keyed on the git root it was added from (`claude mcp
160
- * add`'s default scope), which is the person's checkout — never the
161
- * agent's worktree, so no card turn could see one even with every source.
162
- * So the lane names what is missing and the adapter hands it over through a
163
- * private `--mcp-config` file (runtimeClaude.mjs): `user` when the turn's
164
- * sources drop the user scope, and the checkout's local servers always. A
165
- * local name shadows a user one, the CLI's own precedence; an `--mcp-config`
166
- * server beat a user server of the same name (measured), so a code card keeps
167
- * that order too. `{}` when the file is absent, unreadable or not an object.
168
- */
169
- export function machineMcpServers({ user = false, checkout = null, env = process.env } = {}) {
170
- const path = env.CLAUDE_CONFIG_DIR ? join(env.CLAUDE_CONFIG_DIR, '.claude.json') : join(env.HOME || homedir(), '.claude.json');
171
- let raw;
172
- try {
173
- if (statSync(path).size > CLI_CONFIG_MAX_BYTES) return {};
174
- raw = JSON.parse(readFileSync(path, 'utf8'));
175
- } catch {
176
- return {};
177
- }
178
- if (!isRecord(raw)) return {};
179
- const out = {};
180
- const take = (servers) => {
181
- if (!isRecord(servers)) return;
182
- for (const [name, config] of Object.entries(servers)) if (name !== '__proto__' && isRecord(config)) out[name] = config;
183
- };
184
- if (user) take(raw.mcpServers);
185
- if (typeof checkout === 'string' && checkout && isRecord(raw.projects)) {
186
- const keys = [resolve(checkout)];
187
- try { keys.push(realpathSync(checkout)); } catch { /* the resolved path alone */ }
188
- const key = keys.find((k) => Object.hasOwn(raw.projects, k));
189
- if (key) take(raw.projects[key]?.mcpServers);
190
- }
191
- return out;
192
- }
193
-
194
145
  /** Where a fenced turn's settings file lives — the daemon's own state
195
146
  * directory, private to this user. */
196
147
  export const turnSettingsDir = () => join(homedir(), '.flowviant', 'run');
@@ -233,7 +184,8 @@ export function writeTurnSettings(settings, { dir = turnSettingsDir() } = {}) {
233
184
  }
234
185
 
235
186
  /** Remove the settings file a turn's argv names, when it is one of ours — and
236
- * the machine-MCP file (`machineMcpServers`), written the same way. */
187
+ * the machine-MCP file (claudeMachineMcp.mjs `machineMcpServers`), written
188
+ * the same way. */
237
189
  export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
238
190
  if (!Array.isArray(args)) return;
239
191
  args.forEach((a, i) => {
@@ -254,8 +206,5 @@ export function releaseTurnSettings(args, { dir = turnSettingsDir() } = {}) {
254
206
  export function withPersonalSettings(perm, kept, { write = writeTurnSettings } = {}) {
255
207
  const personal = kept && typeof kept === 'object' && !Array.isArray(kept) ? kept : {};
256
208
  if (!Object.keys(personal).length) return [...perm];
257
- const at = perm.indexOf('--settings');
258
- if (at < 0) return ['--settings', write(personal), ...perm];
259
- const posture = JSON.parse(perm[at + 1]);
260
- return [...perm.slice(0, at), '--settings', write({ ...personal, ...posture }), ...perm.slice(at + 2)];
209
+ return replaceSettings(perm, (posture) => write({ ...personal, ...posture }));
261
210
  }
@@ -13,6 +13,7 @@
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { SAFE } from './config.mjs';
15
15
  import { runningCompiledBinary } from './launchCommand.mjs';
16
+ import { withSettings } from './claudeSettingsArg.mjs';
16
17
 
17
18
  /**
18
19
  * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
@@ -479,8 +480,10 @@ export const RESEARCH_PERM = researchPerm(null);
479
480
 
480
481
  /**
481
482
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
482
- * entry per name in turnProfile.mjs (a test holds the two tables equal). The
483
- * builders that take the knowledge directory get it; the fixed lists ignore it.
483
+ * entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
484
+ * `profiles`; a test holds the two equal). runTurn asks for it through the
485
+ * row (`CLAUDE_RUNTIME.perm`), on Claude alone. The builders that take the
486
+ * knowledge directory get it; the fixed lists ignore it.
484
487
  * `projectMcp` is the names of the project servers the lane mounted; only the
485
488
  * design and research lists take them (`projectMcp` in turnProfile.mjs).
486
489
  */
@@ -492,13 +495,6 @@ const CLAUDE_PERM = {
492
495
  'plan-mode': () => PLAN_MODE_PERM,
493
496
  design: (kd, _vault, mcp) => designPermFor(kd, mcp),
494
497
  research: (kd, _vault, mcp) => researchPerm(kd, mcp),
495
- // THE IMAGE POSTURE IS CODEX'S (0.114.0) and Claude does not declare it
496
- // (runtimeClaude.mjs `profiles`), so turnProfile.mjs refuses a Claude turn
497
- // under it before argv is built. The entry exists because `runTurn` looks
498
- // Claude's list up BY NAME for every runtime (the adapter that is not Claude
499
- // ignores it), and a missing name throws. Were it ever reached on Claude it
500
- // is the consult list: read-only, no write at all — failing closed.
501
- image: (kd) => consultPermFor(kd),
502
498
  };
503
499
  export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
504
500
  export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp = []) {
@@ -541,10 +537,5 @@ export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp =
541
537
  * of a fenced posture.
542
538
  */
543
539
  export function withMachineMcp(perm, disabledProjectMcp) {
544
- const extra = { disabledMcpjsonServers: [...disabledProjectMcp] };
545
- const at = perm.indexOf('--settings');
546
- if (at < 0) return ['--settings', JSON.stringify({ ...extra, hooks: { PreToolUse: [MCP_ALLOW_HOOK] } }), ...perm];
547
- const posture = JSON.parse(perm[at + 1]);
548
- const hooks = { ...(posture.hooks ?? {}), PreToolUse: [...(posture.hooks?.PreToolUse ?? []), MCP_ALLOW_HOOK] };
549
- return [...perm.slice(0, at), '--settings', JSON.stringify({ ...posture, ...extra, hooks }), ...perm.slice(at + 2)];
540
+ return withSettings(perm, { disabledMcpjsonServers: [...disabledProjectMcp], hooks: { PreToolUse: [MCP_ALLOW_HOOK] } });
550
541
  }