flowviant 0.117.0 → 0.124.1

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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Network delivery is its own reason to change, apart from what is on the disk
6
6
  * (artifacts.mjs) and how a page is screenshotted (artifactPreview.mjs). The
7
- * reporter imports neither: it is HANDED the three functions it runs —
7
+ * uploader is HANDED the three functions it runs —
8
8
  * `listChanged(placeDir, before)` (what the turn wrote), `buildUpload(entry,
9
9
  * owner, scrub, secretIn)` (one scrubbed body) and `renderPreview(bytes)` (the
10
10
  * screenshot) — so the delivery rules below are tested against a fake server
@@ -12,8 +12,8 @@
12
12
  *
13
13
  * ── DELIVERY ──
14
14
  *
15
- * A 2xx is delivered. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
16
- * other than 408/429) is TREATED AS DELIVERED — an older SERVER 404s the whole
15
+ * A 2xx is stored. A considered 4xx (fleetWire.mjs's `isConsideredRefusal`:
16
+ * other than 408/429) ENDS THE RETRY — an older SERVER 404s the whole
17
17
  * route, and holding bodies it will refuse forever would be the trace relay's
18
18
  * wedge wearing a retry's clothes; a refusal of one file (ended session, wrong
19
19
  * owner) is equally permanent. EXCEPT 401 AND 403, which are a "later"
@@ -28,8 +28,21 @@
28
28
  *
29
29
  * A PREVIEW THAT FAILS NEVER STOPS THE ARTIFACT: an HTML body goes out with
30
30
  * `renderState: 'unavailable'` and no preview, the renderer's explicit answer.
31
+ *
32
+ * FILE-CARD DELIVERY REQUIRES STORAGE (2026-10-14). `requireStored` makes
33
+ * unsuccessful first uploads fail the report, as replacements already did.
34
+ * A held body is still retried, but cannot advance an image/model queue into
35
+ * a delivery with no uploaded files. Code and Terminal artifacts keep their
36
+ * existing best-effort upload policy. Explicit recovery may narrow the scan
37
+ * with `includeNames`, so it never attributes a sibling card's files.
31
38
  */
32
39
 
40
+ import { artifactStoredName } from './artifacts.mjs';
41
+ import { fleetHeaders } from './fleetPost.mjs';
42
+ import {
43
+ iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
44
+ presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
45
+ } from './artifactIteration.mjs';
33
46
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
34
47
 
35
48
  /** A held upload is tried this many times in all, then dropped with a line. */
@@ -39,8 +52,8 @@ const MAX_PENDING = 40;
39
52
 
40
53
  /**
41
54
  * The reporter a work manager holds for its life. `report` is called once per
42
- * finished turn and never awaited by the turn (a slow uplink must not hold the
43
- * next turn behind a readout); `retryPending` rides the settle retry beat.
55
+ * finished turn and awaited before settlement: a failed iteration cleanup
56
+ * must not become an optimistic delivery. `retryPending` rides the settle beat.
44
57
  */
45
58
  export function createArtifactReporter({
46
59
  fleetUrl,
@@ -53,13 +66,68 @@ export function createArtifactReporter({
53
66
  listChanged,
54
67
  buildUpload,
55
68
  renderPreview,
69
+ /** `(placeDir, owner) => path | null` — where a place records what it held
70
+ * (artifactIteration.mjs). Null keeps no record: only a turn's own
71
+ * snapshot then measures a deletion. */
72
+ seenPath = () => null,
73
+ /** `(placeDir) => ms | null` — when that worktree was cut. Null (the
74
+ * checkout, anything unreadable) gives a place without a record no
75
+ * legacy evidence at all. */
76
+ placeBornAt = () => null,
56
77
  }) {
57
78
  const url = fleetEndpoint('artifact', fleetUrl);
58
79
  const doFetch = fetchImpl ?? ((...a) => fetch(...a));
59
80
  const pending = new Map(); // `${owner}:${name}` -> { body, tries }
60
81
 
61
- /** true = settled (delivered or permanently refused); false = hold it. */
82
+ const prepare = async ({ placeDir, sessionId, agentId, before }) => {
83
+ if (!before) return '';
84
+ before.iterationSignature = iterationSignature(placeDir);
85
+ delete before.stored;
86
+ delete before.seen;
87
+ before.inventoryFailed = false;
88
+ const query = new URLSearchParams(sessionId ? { sessionId } : { agentId });
89
+ try {
90
+ const sentAt = Date.now();
91
+ const response = await doFetch(`${fleetEndpoint('artifacts', fleetUrl)}?${query}`, {
92
+ method: 'GET', headers: fleetHeaders({ token, json: false }), signal: AbortSignal.timeout(15_000),
93
+ });
94
+ if (!response.ok) {
95
+ // Older servers have no iteration door. Other failures are unknown,
96
+ // not an empty inventory and not a successful cleanup.
97
+ before.inventoryFailed = response.status !== 404;
98
+ return '';
99
+ }
100
+ const result = await response.json();
101
+ if (!Array.isArray(result.data)) { before.inventoryFailed = true; return ''; }
102
+ before.stored = result.data.filter((a) => a && typeof a.id === 'string' && typeof a.name === 'string');
103
+ const path = seenPath(placeDir, sessionId ?? agentId);
104
+ before.seen = seenArtifacts(readSeenArtifacts(path), before.stored, presentArtifacts(placeDir) ?? new Set(),
105
+ serverBornAt(placeBornAt(placeDir), response.headers?.get?.('date'), sentAt));
106
+ writeSeenArtifacts(path, before.seen);
107
+ return iterationInstructions(before.stored);
108
+ } catch { before.inventoryFailed = true; return ''; }
109
+ };
110
+
111
+ /** A response distinguishes delivery from a permanent refusal and a retry. */
62
112
  const post = async (body) => {
113
+ if (body.removal) {
114
+ try {
115
+ const res = await doFetch(`${url}/remove`, {
116
+ method: 'POST', headers: fleetHeaders({ token }), signal: AbortSignal.timeout(30_000),
117
+ body: JSON.stringify(body.removal),
118
+ });
119
+ const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
120
+ if (res.ok && result?.data?.complete === true) {
121
+ discardLocalArtifact(body.placeDir, body.fields.name, body.local);
122
+ return { settled: true, success: true };
123
+ }
124
+ log(`artifact ${body.fields.name}: ${result?.error ?? `removal failed (HTTP ${res.status})`}`);
125
+ return { settled: res.status !== 401 && res.status !== 403 && isConsideredRefusal(res.status), success: false };
126
+ } catch (error) {
127
+ log(`artifact ${body.fields.name}: removal incomplete — ${error.message}`);
128
+ return { settled: false, success: false };
129
+ }
130
+ }
63
131
  const form = new FormData();
64
132
  for (const [k, v] of Object.entries(body.fields)) form.set(k, v);
65
133
  if (body.bytes) form.set('file', new Blob([body.bytes]), body.fields.name);
@@ -71,13 +139,17 @@ export function createArtifactReporter({
71
139
  signal: AbortSignal.timeout(30_000),
72
140
  body: form,
73
141
  });
74
- if (res.ok) return true;
142
+ if (res.ok) {
143
+ const result = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
144
+ return { settled: true, success: true, id: result?.data?.id };
145
+ }
75
146
  // A credential blip or an edge page is a "later", bounded by MAX_TRIES
76
147
  // (2026-10-10 ruling — see the header).
77
- if (res.status === 401 || res.status === 403) return false;
78
- return isConsideredRefusal(res.status);
148
+ if (res.status === 401 || res.status === 403) return { settled: false, success: false };
149
+ const failure = typeof res.json === 'function' ? await res.json().catch(() => null) : null;
150
+ return { settled: isConsideredRefusal(res.status), success: false, error: typeof failure?.error === 'string' ? failure.error : failure?.error?.message };
79
151
  } catch {
80
- return false;
152
+ return { settled: false, success: false };
81
153
  }
82
154
  };
83
155
 
@@ -110,17 +182,30 @@ export function createArtifactReporter({
110
182
  const send = (key, body, tries, gen) => {
111
183
  const run = (chains.get(key) ?? Promise.resolve()).then(async () => {
112
184
  if (genOf.get(key) !== gen) return; // superseded before it went out
113
- const ok = await post(body);
185
+ const result = await post(body);
114
186
  if (genOf.get(key) !== gen) return; // a newer copy decides for this key
115
- if (ok) pending.delete(key);
187
+ if (result.settled) pending.delete(key);
116
188
  else hold(key, body, tries + 1);
189
+ if (result.success && body.replacements?.length) {
190
+ result.cleanupFailed = [];
191
+ for (const removal of body.replacements) {
192
+ if (!result.id) { result.cleanupFailed.push(removal.fields.name); continue; }
193
+ removal.removal.replacementId = result.id;
194
+ const removalKey = `${removal.removal.sessionId ?? removal.removal.agentId}:${removal.fields.name}`;
195
+ // Do not let an earlier replacement retire a later turn's retained file.
196
+ if (genOf.get(removalKey) !== removal.generation) continue;
197
+ const removed = await send(removalKey, removal, 0, removal.generation);
198
+ if (!removed?.success) result.cleanupFailed.push(removal.fields.name);
199
+ }
200
+ }
201
+ return result;
117
202
  });
118
203
  const tail = run.catch(() => {});
119
204
  chains.set(key, tail);
120
205
  tail.then(() => {
121
206
  if (chains.get(key) !== tail) return;
122
207
  chains.delete(key);
123
- if (!pending.has(key)) genOf.delete(key); // settled: nothing left to order
208
+ if (!pending.has(key) && genOf.get(key) === gen) genOf.delete(key); // settled: nothing left to order
124
209
  });
125
210
  return run;
126
211
  };
@@ -141,15 +226,37 @@ export function createArtifactReporter({
141
226
 
142
227
  /** Upload what this turn changed in `placeDir`. Resolves to the number of
143
228
  * bodies SENT this call (delivered or held), for the tests and the log. */
144
- const report = async ({ placeDir, before, sessionId, agentId, turnId }) => {
229
+ const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
145
230
  if (!placeDir || (!sessionId && !agentId)) return 0;
146
- const changed = listChanged(placeDir, before);
231
+ const changes = iterationChanges(placeDir, before);
232
+ if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
233
+ const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before) : [])]);
234
+ const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
235
+ for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
236
+ if (!stored.has(name)) throw new Error(`No stored artifact named ${name} belongs to this conversation.`);
237
+ }
238
+ // Cancel held uploads immediately, including a copy currently in flight.
239
+ for (const name of removing) {
240
+ const key = `${sessionId ?? agentId}:${name}`;
241
+ pending.delete(key);
242
+ genOf.set(key, ++generation);
243
+ }
244
+ const changed = idle ? [] : listChanged(placeDir, before).filter((e) =>
245
+ !removing.has(artifactStoredName(e.name)) && (!includeNames || includeNames.has(e.name)));
246
+ const delivered = new Map();
247
+ const failures = before?.inventoryFailed
248
+ ? ['Stored artifact inventory could not be measured; cleanup is incomplete. Retry.'] : [];
147
249
  let n = 0;
148
250
  for (const entry of changed) {
149
251
  const body = buildUpload(entry, { sessionId, agentId, turnId }, scrub, secretIn);
150
- if (!body) continue;
252
+ if (!body) {
253
+ if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
254
+ continue;
255
+ }
151
256
  if (body.withheld) {
152
257
  log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
258
+ if (stored.has(body.fields.name)) failures.push(`artifact ${body.fields.name} replacement was withheld; its predecessor was retained`);
259
+ else if (requireStored) failures.push(`artifact ${body.fields.name} was withheld; it was not delivered`);
153
260
  continue;
154
261
  }
155
262
  if (body.bytes && /\.html?$/i.test(entry.name)) {
@@ -157,15 +264,72 @@ export function createArtifactReporter({
157
264
  body.fields.renderState = measured.renderState;
158
265
  if (measured.preview) body.preview = measured.preview;
159
266
  }
160
- const key = `${sessionId ?? agentId}:${entry.name}`;
267
+ body.replacements = Object.entries(changes.replace).filter(([, to]) => to === body.fields.name).map(([name]) => {
268
+ const key = `${sessionId ?? agentId}:${name}`;
269
+ return { fields: { name }, removal: { ...(sessionId ? { sessionId } : { agentId }), id: stored.get(name).id, name },
270
+ placeDir, local: localArtifact(placeDir, name), generation: genOf.get(key) };
271
+ });
272
+ const key = `${sessionId ?? agentId}:${body.fields.name}`;
161
273
  pending.delete(key); // this copy supersedes a held older one
162
274
  const gen = ++generation;
163
275
  genOf.set(key, gen); // …and one still in flight
164
- await send(key, body, 0, gen);
276
+ const result = await send(key, body, 0, gen);
277
+ if (result?.success && result.id) delivered.set(body.fields.name, result.id);
278
+ if (result?.cleanupFailed?.length) failures.push(`artifact cleanup incomplete: ${result.cleanupFailed.join(', ')}`);
279
+ if (!result?.success && (requireStored || stored.has(body.fields.name) || result?.error)) {
280
+ failures.push(result?.error ?? `artifact ${body.fields.name}${stored.has(body.fields.name) ? ' replacement' : ''} was not delivered`);
281
+ }
165
282
  n++;
166
283
  }
284
+ const removed = new Set();
285
+ for (const name of removing) {
286
+ const old = stored.get(name);
287
+ if (!old) continue; // A local file may belong to a sibling Terminal tab.
288
+ const replacementName = changes.replace[name];
289
+ const replacementId = replacementName ? delivered.get(replacementName) : null;
290
+ if (replacementName && !replacementId) {
291
+ failures.push(`replacement ${replacementName} was not delivered; ${name} was retained`);
292
+ continue;
293
+ }
294
+ if (replacementName) continue; // Its upload carries the removal through retries.
295
+ const body = {
296
+ fields: { name },
297
+ removal: { ...(sessionId ? { sessionId } : { agentId }), id: old.id, name,
298
+ ...(replacementId ? { replacementId } : {}) },
299
+ placeDir, local: localArtifact(placeDir, name),
300
+ };
301
+ const key = `${sessionId ?? agentId}:${name}`;
302
+ const gen = ++generation;
303
+ genOf.set(key, gen);
304
+ const result = await send(key, body, 0, gen);
305
+ if (result?.success) removed.add(name);
306
+ else failures.push(`artifact ${name} could not be removed; cleanup is incomplete`);
307
+ }
308
+ if (before?.stored) {
309
+ // What this place holds now joins its record; a failed removal stays
310
+ // in it, so the next measurement retries rather than forgets.
311
+ const present = presentArtifacts(placeDir) ?? new Set();
312
+ const owned = new Set([...before.stored.map((a) => a.name), ...present, ...delivered.keys()]);
313
+ writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId),
314
+ [...(before.seen ?? []), ...present, ...delivered.keys()]
315
+ .filter((name) => owned.has(name) && !removed.has(name)));
316
+ }
317
+ if (failures.length) throw new Error(failures.join('; '));
167
318
  return n;
168
319
  };
169
320
 
170
- return { report, retryPending, pendingCount: () => pending.size };
321
+ /**
322
+ * AN IDLE DELIVERY, RECONCILED WITHOUT A TURN (2026-10-11 follow-up). A
323
+ * discard was relayed only when a turn settled, so an agent waiting in
324
+ * Review kept every stored file an earlier daemon never removed. Nothing
325
+ * is uploaded here; the place's measured discards are retired and a
326
+ * failure throws, exactly as a turn's report would.
327
+ */
328
+ const reconcile = async ({ placeDir, before, sessionId, agentId }) => {
329
+ if (!placeDir || !before || (!sessionId && !agentId)) return 0;
330
+ await prepare({ placeDir, before, sessionId, agentId });
331
+ return report({ placeDir, before, sessionId, agentId, idle: true });
332
+ };
333
+
334
+ return { prepare, report, reconcile, retryPending, pendingCount: () => pending.size };
171
335
  }
@@ -243,8 +243,8 @@ export function snapshotArtifacts(placeDir) {
243
243
  }
244
244
 
245
245
  /** What the turn wrote: every entry whose (name, size, mtime) is not what the
246
- * snapshot held. A deleted file is not reported — the server's copy is
247
- * scrollback and goes with the session or the agent. */
246
+ * snapshot held. Deletions and replacement relationships are reported by
247
+ * artifactIteration.mjs separately from upload bodies. */
248
248
  export function changedArtifacts(before, entries) {
249
249
  return entries.filter((e) => before?.get(e.name) !== sigOf(e));
250
250
  }
@@ -293,6 +293,9 @@ function safeArtifactName(raw) {
293
293
  return clean ? cutKeepingExtension(clean) : 'artifact';
294
294
  }
295
295
 
296
+ /** The stored name is also the key for iteration and queued upload cancellation. */
297
+ export const artifactStoredName = (name) => name.split('/').map(safeArtifactName).join('/');
298
+
296
299
  /**
297
300
  * ONE UPLOAD BODY, as data — fields plus optional bytes — so a held body is
298
301
  * re-sent exactly as it was first built (scrubbed once, never re-read from a
@@ -303,7 +306,7 @@ export function buildArtifactUpload(entry, { sessionId, agentId, turnId }, scrub
303
306
  const fields = {
304
307
  ...(sessionId ? { sessionId } : { agentId }),
305
308
  ...(turnId ? { turnId } : {}),
306
- name: entry.name.split('/').map(safeArtifactName).join('/'),
309
+ name: artifactStoredName(entry.name),
307
310
  bytes: String(entry.size),
308
311
  };
309
312
  if (!type) return { fields, bytes: null }; // reported by name only
@@ -0,0 +1,25 @@
1
+ import { basename } from 'node:path';
2
+ import { pathToFileURL } from 'node:url';
3
+ import { gitOrNull } from './git.mjs';
4
+
5
+ /** Login registers addresses without sending a credential in an HTTPS URL.
6
+ * A checkout with no origin has its local path as its repository address. */
7
+ export function checkoutIdentity(repoRoot) {
8
+ let remote = gitOrNull(['remote', 'get-url', 'origin'], repoRoot) ?? pathToFileURL(repoRoot).href;
9
+ if (remote.includes('://')) {
10
+ try {
11
+ const url = new URL(remote);
12
+ url.password = '';
13
+ if (url.protocol !== 'ssh:') url.username = '';
14
+ url.search = '';
15
+ url.hash = '';
16
+ remote = url.hostname.toLowerCase() === 'github.com'
17
+ ? url.pathname.replace(/^\//, '').replace(/\.git\/?$/, '')
18
+ : url.toString();
19
+ } catch { remote = repoRoot; }
20
+ }
21
+ const ssh = remote.match(/^(?:git@)?github\.com:([^/]+\/[^/]+?)(?:\.git)?$/i);
22
+ if (ssh) remote = ssh[1];
23
+ const name = basename(repoRoot).toLowerCase().replace(/[^a-z0-9_-]/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 40) || 'app';
24
+ return { name, remote };
25
+ }
@@ -26,8 +26,9 @@
26
26
  * daemon started, so a variable exported weeks ago is still in there long
27
27
  * after it left your shell. A stale one fails in a way a fresh `claude`
28
28
  * never reproduces.
29
- * · THE HOME. The CLI reads its store from $HOME. A daemon under systemd, a
30
- * different user, or a container has a different one than the shell you
29
+ * · THE STORE. The CLI uses $CLAUDE_CONFIG_DIR, else $HOME/.claude. A daemon
30
+ * under systemd, a different user, or a container has a different one than
31
+ * the shell you
31
32
  * tested in, so "I am logged in" was measured on the wrong file.
32
33
  *
33
34
  * ── WHAT THIS IS NOT ──
@@ -75,11 +76,19 @@ export const AUTH_ENV_VARS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAU
75
76
  /** Claude Code's OAuth store, relative to the HOME the CLI will be handed. */
76
77
  const CRED_REL = ['.claude', '.credentials.json'];
77
78
 
79
+ /** The same location Claude's personal settings reader uses. */
80
+ export function claudeCredentialPath(env = process.env, home = null) {
81
+ const root = home ?? env.HOME ?? env.USERPROFILE ?? null;
82
+ return env.CLAUDE_CONFIG_DIR
83
+ ? join(env.CLAUDE_CONFIG_DIR, '.credentials.json')
84
+ : root ? join(root, ...CRED_REL) : null;
85
+ }
86
+
78
87
 
79
88
  /**
80
89
  * What the CLI on this machine will resolve, as far as it can be seen without
81
90
  * spending anything: no process spawned, no request made, no quota touched.
82
- * Just this process's own environment and one file's metadata.
91
+ * Just this process's own environment and the CLI-selected file's metadata.
83
92
  *
84
93
  * `env` and `home` are parameters rather than reads of `process` so the tests
85
94
  * can drive every branch — and so the caller passes the SAME environment the
@@ -98,9 +107,10 @@ export function claudeAuthContext({ env = process.env, home = null } = {}) {
98
107
  let refreshExpiresAt = null;
99
108
  let accessExpiresAt = null;
100
109
  let subscriptionType = null;
101
- if (HOME) {
110
+ const credentialPath = claudeCredentialPath(env, HOME);
111
+ if (credentialPath) {
102
112
  try {
103
- const raw = readFileSync(join(HOME, ...CRED_REL), 'utf8');
113
+ const raw = readFileSync(credentialPath, 'utf8');
104
114
  const oauth = JSON.parse(raw)?.claudeAiOauth;
105
115
  if (oauth && typeof oauth === 'object') {
106
116
  hasFile = true;
@@ -0,0 +1,32 @@
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 = () => {} } = {}) {
5
+ const sent = new Set();
6
+ const pending = new Set();
7
+ let accepting = true;
8
+ let ended = false;
9
+ let resultSeen = false;
10
+ child.stdin.on('error', () => { accepting = false; }); // EPIPE leaves the message queued
11
+ const send = (id, prompt) => {
12
+ if (!accepting || ended || sent.has(id) || child.stdin.destroyed || child.stdin.writableEnded) return false;
13
+ const message = { type: 'user', uuid: id, session_id: '', message: { role: 'user', content: prompt }, parent_tool_use_id: null };
14
+ sent.add(id);
15
+ pending.add(id);
16
+ try { child.stdin.write(JSON.stringify(message) + '\n'); return true; }
17
+ catch { pending.delete(id); accepting = false; return false; }
18
+ };
19
+ return {
20
+ send,
21
+ event(event) {
22
+ if (event.type === 'user' && event.isReplay === true && pending.delete(event.uuid)) onTaken(event.uuid);
23
+ if (event.type === 'result') {
24
+ resultSeen = true;
25
+ accepting = false;
26
+ if ((pending.size === 0 || event.is_error === true) && !ended) { ended = true; child.stdin.end(); }
27
+ }
28
+ },
29
+ close() { accepting = false; ended = true; },
30
+ get resultSeen() { return resultSeen; },
31
+ };
32
+ }
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { fileURLToPath } from 'node:url';
14
14
  import { SAFE } from './config.mjs';
15
+ import { runningCompiledBinary } from './launchCommand.mjs';
15
16
 
16
17
  /**
17
18
  * THE READ GUARD, ON EVERY CURATED POSTURE (2026-09-23) — see
@@ -35,20 +36,40 @@ import { SAFE } from './config.mjs';
35
36
  * `process.execPath`, never a `node` looked up on a PATH the operator may not
36
37
  * have, because a hook that fails to START is a non-blocking error and the
37
38
  * command runs anyway.
39
+ *
40
+ * …AND AS `<flowviant> __hook read-guard` UNDER THE COMPILED BINARY
41
+ * (2026-10-11). There `process.execPath` IS flowviant and the script path is
42
+ * Bun's virtual `/$bunfs/…`, so the old command was `flowviant
43
+ * '/$bunfs/root/hooks/readGuard.mjs'`: the CLI answered "is not a command"
44
+ * with exit 1 (measured on the installed 0.116.0), a non-blocking error, and
45
+ * every guarded command ran — the curl install had no read guard. The CLI now
46
+ * runs the hook itself (bin/cli.mjs, `__hook`), before anything else loads.
38
47
  */
39
48
  export const READ_GUARD_PATH = fileURLToPath(new URL('./hooks/readGuard.mjs', import.meta.url));
49
+ export const MCP_ALLOW_PATH = fileURLToPath(new URL('./hooks/mcpAllow.mjs', import.meta.url));
40
50
  const hookQuote = (s) =>
41
51
  process.platform === 'win32' ? `"${s}"` : `'${String(s).replace(/'/g, `'\\''`)}'`;
52
+ /** The command a hook runs as: this node and the script, or this binary and
53
+ * its `__hook` word (`compiled` is runningCompiledBinary's answer; a test
54
+ * passes it). */
55
+ export function hookCommand(name, scriptPath, { compiled = runningCompiledBinary() } = {}) {
56
+ return compiled ? `${hookQuote(process.execPath)} __hook ${name}` : `${hookQuote(process.execPath)} ${hookQuote(scriptPath)}`;
57
+ }
42
58
  export const READ_GUARD_SETTINGS = JSON.stringify({
43
59
  hooks: {
44
60
  PreToolUse: [
45
61
  {
46
62
  matcher: 'Bash',
47
- hooks: [{ type: 'command', command: `${hookQuote(process.execPath)} ${hookQuote(READ_GUARD_PATH)}` }],
63
+ hooks: [{ type: 'command', command: hookCommand('read-guard', READ_GUARD_PATH) }],
48
64
  },
49
65
  ],
50
66
  },
51
67
  });
68
+ /** The MCP allow hook's entry (hooks/mcpAllow.mjs), matched on MCP tools only. */
69
+ export const MCP_ALLOW_HOOK = Object.freeze({
70
+ matcher: 'mcp__.*',
71
+ hooks: [{ type: 'command', command: hookCommand('mcp-allow', MCP_ALLOW_PATH) }],
72
+ });
52
73
  /** Spread FIRST into a curated list: `--allowedTools` is variadic, so the
53
74
  * `--settings` pair must stand before it or be swallowed as a tool name. */
54
75
  const READ_GUARD = ['--settings', READ_GUARD_SETTINGS];
@@ -278,7 +299,9 @@ export function consultPermFor(knowledgeDir) {
278
299
 
279
300
  /**
280
301
  * THE TWO NON-CODE POSTURES (0.97.0) — a design card and a research card may
281
- * WRITE, and only under `.flowviant/artifacts/`.
302
+ * HISTORICAL ARTIFACT PROBES before 2026-10-12. Current artifact lists below
303
+ * add guarded readers, web and scratch; arbitrary programs still cannot
304
+ * bypass the write fence. WRITE was only under `.flowviant/artifacts/`.
282
305
  *
283
306
  * The card's words are steered by anyone who can file a card, so "change
284
307
  * nothing" as prose is only an instruction, and the permission list is the
@@ -337,11 +360,26 @@ export function consultPermFor(knowledgeDir) {
337
360
  * explicit `Bash(git log:*)` prefix the old list carried). With `Bash` in
338
361
  * `--disallowedTools` the tool was not there at all.
339
362
  *
340
- * NO MCP, and no shell that could commit: the agent turn has no MCP anyway
341
- * (see SYSTEM_AGENT's header), and a posture that could run `git commit` could
342
- * commit the mockup it was told to keep out of git.
363
+ * THE PROJECT'S MCP SERVERS (2026-10-10, the owner): each list allows
364
+ * `mcp__<server>` for every server the agent lane mounted from the base
365
+ * branch's reviewed `.mcp.json` (projectTools.mjs) — the set a code card gets.
366
+ * Under `--allowedTools` an MCP tool with no rule is refused ("Claude requested
367
+ * permissions to use mcp__…, but you haven't granted it yet", measured on the
368
+ * plan posture), so the rule IS the mount. AND THE MACHINE'S OWN (2026-10-11):
369
+ * an agent turn mounts this machine's user/local servers and claude.ai
370
+ * connectors too, whose names only the CLI knows, so the lane adds the MCP
371
+ * allow hook (`withMachineMcp`, below). No shell that could commit: a posture
372
+ * that could run `git commit` could commit the mockup it was told to keep out
373
+ * of git.
343
374
  *
344
- * STATED, not closed: research may still read any file in the worktree and
375
+ * STATED, not closed: an MCP server is a process of its own, outside every
376
+ * list here — it reads, writes and reaches the network as the person who runs
377
+ * it, and a turn steered by a card's words can hand it anything the turn read,
378
+ * or ask it to act: the machine's claude.ai connectors are its owner's
379
+ * accounts. On research that sits beside the web. The base branch's
380
+ * `.mcp.json` is the repository's reviewed choice, and the machine's servers
381
+ * are its owner's (docs/decisions.md, 2026-10-10 and 2026-10-11).
382
+ * Research may still read any file in the worktree and
345
383
  * name it in a WebFetch URL — the repository is what a research card is about,
346
384
  * and a turn that could not read it could not answer. Design may read any
347
385
  * file in its worktree and put it in an artifact the server stores; text
@@ -356,6 +394,12 @@ export function consultPermFor(knowledgeDir) {
356
394
  * the guard's hook sees a subagent's own calls was not measured here.
357
395
  */
358
396
  const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
397
+ /** `mcp__<server>` for each mounted project server — a name the base snapshot
398
+ * already admitted (projectTools.mjs `nameOk`), checked again here because a
399
+ * rule is a pattern: anything but a plain server name gets no rule, and
400
+ * Flowviant's own control plane is never a project's server. */
401
+ const projectMcpRules = (names = []) =>
402
+ names.filter((n) => typeof n === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(n) && n !== 'flowviant').map((n) => `mcp__${n}`);
359
403
  /**
360
404
  * PLAN MODE (0.97.0) — a Workbench tab's `planMode` switch, and Claude Code's
361
405
  * own `--permission-mode plan` INSTEAD OF every list above: the CLI reads,
@@ -386,17 +430,22 @@ const ARTIFACT_WRITE = 'Edit(.flowviant/artifacts/**)';
386
430
  * plane whose every call the CLI refuses. See workTurnShape.mjs.
387
431
  */
388
432
  export const PLAN_MODE_PERM = ['--permission-mode', 'plan'];
389
- export function designPermFor(knowledgeDir) {
433
+ export function designPermFor(knowledgeDir, projectMcp = []) {
390
434
  return [
391
435
  ...READ_GUARD,
392
436
  '--allowedTools',
393
437
  ...fencedReads(knowledgeDir),
394
- 'Bash(ls:*)',
438
+ ...CONSULT_BASH,
439
+ 'WebSearch',
440
+ 'WebFetch',
395
441
  ARTIFACT_WRITE,
442
+ 'Edit(.flowviant/tmp/**)',
443
+ ...projectMcpRules(projectMcp),
396
444
  // A mockup is uploaded and runs scripts in a frame that may navigate
397
445
  // itself, so the checkout's own secrets are denied by name too — the same
398
446
  // pair research carries, measured load-bearing there.
399
447
  '--disallowedTools',
448
+ 'Agent',
400
449
  'Read(./.env*)',
401
450
  'Read(./**/.env*)',
402
451
  ];
@@ -408,18 +457,21 @@ export const DESIGN_PERM = designPermFor(null);
408
457
  * outside the worktree it may read, and only the spawn knows where that is.
409
458
  * `//` is the CLI's own spelling for an absolute path in a rule.
410
459
  */
411
- export function researchPerm(knowledgeDir) {
460
+ export function researchPerm(knowledgeDir, projectMcp = []) {
412
461
  return [
413
462
  ...READ_GUARD,
414
463
  '--allowedTools',
415
464
  ...fencedReads(knowledgeDir),
465
+ ...CONSULT_BASH,
416
466
  'WebSearch',
417
467
  'WebFetch',
418
468
  ARTIFACT_WRITE,
469
+ 'Edit(.flowviant/tmp/**)',
470
+ ...projectMcpRules(projectMcp),
419
471
  '--disallowedTools',
472
+ 'Agent',
420
473
  'Read(./.env*)',
421
474
  'Read(./**/.env*)',
422
- 'Bash',
423
475
  ];
424
476
  }
425
477
  /** The list with no library — what a project that keeps none runs under. */
@@ -429,6 +481,8 @@ export const RESEARCH_PERM = researchPerm(null);
429
481
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
430
482
  * entry per name in turnProfile.mjs (a test holds the two tables equal). The
431
483
  * builders that take the knowledge directory get it; the fixed lists ignore it.
484
+ * `projectMcp` is the names of the project servers the lane mounted; only the
485
+ * design and research lists take them (`projectMcp` in turnProfile.mjs).
432
486
  */
433
487
  const CLAUDE_PERM = {
434
488
  build: () => PERM,
@@ -436,8 +490,8 @@ const CLAUDE_PERM = {
436
490
  consult: (kd) => consultPermFor(kd),
437
491
  plan: (kd) => planPermFor(kd),
438
492
  'plan-mode': () => PLAN_MODE_PERM,
439
- design: (kd) => designPermFor(kd),
440
- research: (kd) => researchPerm(kd),
493
+ design: (kd, _vault, mcp) => designPermFor(kd, mcp),
494
+ research: (kd, _vault, mcp) => researchPerm(kd, mcp),
441
495
  // THE IMAGE POSTURE IS CODEX'S (0.114.0) and Claude does not declare it
442
496
  // (runtimeClaude.mjs `profiles`), so turnProfile.mjs refuses a Claude turn
443
497
  // under it before argv is built. The entry exists because `runTurn` looks
@@ -447,8 +501,50 @@ const CLAUDE_PERM = {
447
501
  image: (kd) => consultPermFor(kd),
448
502
  };
449
503
  export const CLAUDE_PERM_PROFILES = Object.freeze(Object.keys(CLAUDE_PERM));
450
- export function claudePermFor(profileName, knowledgeDir, vaultDir) {
504
+ export function claudePermFor(profileName, knowledgeDir, vaultDir, projectMcp = []) {
451
505
  const build = Object.hasOwn(CLAUDE_PERM, profileName) ? CLAUDE_PERM[profileName] : null;
452
506
  if (!build) throw new Error(`no Claude permission list for profile '${profileName}'`);
453
- return build(knowledgeDir, vaultDir);
507
+ return build(knowledgeDir, vaultDir, projectMcp);
508
+ }
509
+
510
+ /**
511
+ * THE MACHINE'S MCP SERVERS ON AN AGENT TURN (2026-10-11, the owner: "the
512
+ * server that is running flowviant's claude HAS these MCPs yet the agents are
513
+ * not using them"). A code, write-up, mockup, 3D-model or deck turn mounts what
514
+ * this machine's Claude Code has — its user and local servers and the account's
515
+ * claude.ai connectors — beside the base branch's `.mcp.json` copy. So the turn
516
+ * drops `--strict-mcp-config` (runtimeClaude.mjs), and its one `--settings`
517
+ * carries two things:
518
+ *
519
+ * · `disabledMcpjsonServers`, every server named in a `.mcp.json` from the
520
+ * worktree up to `/`. Without strict, Claude Code reads `.mcp.json` in the
521
+ * cwd AND EVERY PARENT (measured, 2.1.290: a parent directory's server
522
+ * loaded) wherever a settings scope approves it, so the agent's own edit of
523
+ * its worktree's file would mount before review. A flag
524
+ * `enableAllProjectMcpServers: false` did NOT override a lower scope's
525
+ * `true`; the disabled list did, by name, and left the base copy loaded
526
+ * through `--mcp-config` (a `dynamic` server) untouched — measured with both
527
+ * a lower `enableAllProjectMcpServers` and a lower `enabledMcpjsonServers`.
528
+ * · the MCP allow hook (`MCP_ALLOW_HOOK`), because a fenced list refuses an
529
+ * MCP tool it does not name and a connector's name is the account's. The
530
+ * build's bypass needs no hook; a SAFE build's curated list does, and the
531
+ * hook is harmless under the bypass, so every mount carries it.
532
+ *
533
+ * `perm` is the posture's list; a list with no `--settings` (the build) gets
534
+ * one before its lists (`--allowedTools` is variadic). The fenced turn's
535
+ * personal keys are folded in after (claudePersonal.mjs); the posture's keys
536
+ * win there, and none of the person's kept keys is one of these.
537
+ *
538
+ * STATED, not closed: plugins stay the person's own on the build and out of
539
+ * every fenced turn (`--setting-sources ''`), so a plugin's MCP servers reach
540
+ * code cards only — a plugin also brings hooks, which would be a second author
541
+ * of a fenced posture.
542
+ */
543
+ 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)];
454
550
  }