flowviant 0.116.0 → 0.124.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/README.md +66 -12
  2. package/bin/cli.mjs +50 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentAsk.mjs +123 -0
  5. package/bin/lib/agentBrief.mjs +36 -0
  6. package/bin/lib/agentFootprint.mjs +76 -0
  7. package/bin/lib/agentInternalWait.mjs +83 -0
  8. package/bin/lib/agentPlan.mjs +134 -31
  9. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  10. package/bin/lib/agentPublish.mjs +12 -13
  11. package/bin/lib/agentTaskKinds.mjs +5 -8
  12. package/bin/lib/agySessions.mjs +3 -3
  13. package/bin/lib/artifactContracts.mjs +91 -20
  14. package/bin/lib/artifactIteration.mjs +182 -0
  15. package/bin/lib/artifactPreview.mjs +92 -18
  16. package/bin/lib/artifactRecovery.mjs +36 -0
  17. package/bin/lib/artifactReporter.mjs +194 -22
  18. package/bin/lib/artifacts.mjs +70 -63
  19. package/bin/lib/authproxy.mjs +2 -1
  20. package/bin/lib/childEnv.mjs +9 -4
  21. package/bin/lib/claude.mjs +10 -20
  22. package/bin/lib/claudeAuth.mjs +15 -5
  23. package/bin/lib/claudePersonal.mjs +71 -6
  24. package/bin/lib/claudePosture.mjs +227 -54
  25. package/bin/lib/claudeSessions.mjs +7 -7
  26. package/bin/lib/claudeStream.mjs +27 -13
  27. package/bin/lib/cliLimit.mjs +2 -2
  28. package/bin/lib/codexHome.mjs +17 -0
  29. package/bin/lib/codexPersonal.mjs +44 -3
  30. package/bin/lib/codexProjectRead.mjs +63 -0
  31. package/bin/lib/codexRelay.mjs +21 -5
  32. package/bin/lib/codexResearch.mjs +193 -0
  33. package/bin/lib/commandSpecs.mjs +6 -0
  34. package/bin/lib/config.mjs +37 -24
  35. package/bin/lib/conversationIds.mjs +51 -0
  36. package/bin/lib/credentials.mjs +2 -7
  37. package/bin/lib/daemonLocks.mjs +90 -0
  38. package/bin/lib/daemonState.mjs +5 -5
  39. package/bin/lib/deploy.mjs +6 -9
  40. package/bin/lib/deployCheckout.mjs +26 -22
  41. package/bin/lib/deployRunner.mjs +54 -18
  42. package/bin/lib/deployWire.mjs +32 -23
  43. package/bin/lib/desktopContract.mjs +6 -5
  44. package/bin/lib/discoveryContract.mjs +19 -0
  45. package/bin/lib/env.mjs +104 -35
  46. package/bin/lib/fleet.mjs +53 -13
  47. package/bin/lib/fleetPost.mjs +172 -25
  48. package/bin/lib/fleetReports.mjs +172 -171
  49. package/bin/lib/fleetStartup.mjs +6 -6
  50. package/bin/lib/fleetWire.mjs +42 -0
  51. package/bin/lib/git.mjs +111 -61
  52. package/bin/lib/gitMergeIdentity.mjs +2 -8
  53. package/bin/lib/grant.mjs +23 -7
  54. package/bin/lib/holder.mjs +108 -81
  55. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  56. package/bin/lib/hooks/readGuard.mjs +54 -11
  57. package/bin/lib/imageArtifact.mjs +74 -0
  58. package/bin/lib/install.mjs +80 -33
  59. package/bin/lib/instance.mjs +36 -83
  60. package/bin/lib/intakeDraft.mjs +4 -1
  61. package/bin/lib/knowledgeFileSync.mjs +31 -0
  62. package/bin/lib/knowledgeFiles.mjs +28 -83
  63. package/bin/lib/landed.mjs +22 -29
  64. package/bin/lib/launchCommand.mjs +91 -0
  65. package/bin/lib/listeners.mjs +0 -5
  66. package/bin/lib/localSessions.mjs +52 -39
  67. package/bin/lib/login.mjs +22 -12
  68. package/bin/lib/loginPath.mjs +22 -17
  69. package/bin/lib/machineListing.mjs +6 -3
  70. package/bin/lib/maintenance.mjs +47 -0
  71. package/bin/lib/maintenanceInventory.mjs +39 -0
  72. package/bin/lib/mcp-cli.mjs +9 -10
  73. package/bin/lib/mcpInstall.mjs +163 -0
  74. package/bin/lib/mcpInventory.mjs +119 -0
  75. package/bin/lib/noWindowEnv.mjs +23 -3
  76. package/bin/lib/ownedDirs.mjs +99 -0
  77. package/bin/lib/pinnedCapture.mjs +383 -0
  78. package/bin/lib/planDependencies.mjs +70 -0
  79. package/bin/lib/postureTools.mjs +85 -0
  80. package/bin/lib/prWorkflow.mjs +94 -17
  81. package/bin/lib/preflight.mjs +8 -25
  82. package/bin/lib/preview.mjs +19 -25
  83. package/bin/lib/previewGatePolicy.mjs +34 -9
  84. package/bin/lib/previewRegistry.mjs +4 -11
  85. package/bin/lib/printable.mjs +70 -7
  86. package/bin/lib/procRegistry.mjs +33 -13
  87. package/bin/lib/procScan.mjs +2 -3
  88. package/bin/lib/procStat.mjs +76 -0
  89. package/bin/lib/processGroup.mjs +48 -0
  90. package/bin/lib/processes.mjs +10 -24
  91. package/bin/lib/projectToolRuntimes.mjs +328 -19
  92. package/bin/lib/projectTools.mjs +109 -34
  93. package/bin/lib/prompts.mjs +205 -60
  94. package/bin/lib/repoPath.mjs +19 -0
  95. package/bin/lib/repoState.mjs +17 -19
  96. package/bin/lib/repositoryBaseline.mjs +68 -0
  97. package/bin/lib/runTurn.mjs +286 -209
  98. package/bin/lib/runtimeAntigravity.mjs +16 -5
  99. package/bin/lib/runtimeCapabilities.mjs +215 -21
  100. package/bin/lib/runtimeClaude.mjs +78 -10
  101. package/bin/lib/runtimeCodex.mjs +217 -209
  102. package/bin/lib/runtimeDetection.mjs +8 -2
  103. package/bin/lib/runtimeEvents.mjs +28 -11
  104. package/bin/lib/runtimeLimits.mjs +5 -6
  105. package/bin/lib/runtimeOfferings.mjs +99 -14
  106. package/bin/lib/runtimes.mjs +10 -0
  107. package/bin/lib/shipSweep.mjs +5 -3
  108. package/bin/lib/shot.mjs +91 -17
  109. package/bin/lib/sleep.mjs +11 -0
  110. package/bin/lib/startProject.mjs +5 -5
  111. package/bin/lib/statePaths.mjs +40 -0
  112. package/bin/lib/tty.mjs +25 -15
  113. package/bin/lib/turnDiagnostics.mjs +93 -0
  114. package/bin/lib/turnLock.mjs +7 -14
  115. package/bin/lib/turnProfile.mjs +16 -4
  116. package/bin/lib/ui.mjs +0 -3
  117. package/bin/lib/uninstall.mjs +30 -26
  118. package/bin/lib/update.mjs +79 -27
  119. package/bin/lib/uplinkScrub.mjs +296 -31
  120. package/bin/lib/vault.mjs +78 -10
  121. package/bin/lib/vaultDiff.mjs +5 -2
  122. package/bin/lib/views.mjs +37 -12
  123. package/bin/lib/wikiRunner.mjs +40 -61
  124. package/bin/lib/work.mjs +44 -13
  125. package/bin/lib/workAdoptCarry.mjs +2 -4
  126. package/bin/lib/workAgentBrief.mjs +92 -0
  127. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  128. package/bin/lib/workAgentCheck.mjs +104 -57
  129. package/bin/lib/workAgentMerges.mjs +62 -57
  130. package/bin/lib/workAgentPlans.mjs +82 -199
  131. package/bin/lib/workAgentPrecheck.mjs +76 -138
  132. package/bin/lib/workAgentPublish.mjs +6 -8
  133. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  134. package/bin/lib/workAgentTurnExecution.mjs +131 -67
  135. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  136. package/bin/lib/workAgentTurnReports.mjs +9 -37
  137. package/bin/lib/workAgentTurns.mjs +64 -7
  138. package/bin/lib/workArtifactReconcile.mjs +79 -0
  139. package/bin/lib/workAttachments.mjs +66 -11
  140. package/bin/lib/workBrain.mjs +9 -4
  141. package/bin/lib/workCappedTurn.mjs +119 -0
  142. package/bin/lib/workChildren.mjs +5 -5
  143. package/bin/lib/workCommandAudit.mjs +18 -2
  144. package/bin/lib/workDiffs.mjs +52 -6
  145. package/bin/lib/workIntake.mjs +35 -60
  146. package/bin/lib/workPlaceReports.mjs +7 -4
  147. package/bin/lib/workPlaces.mjs +113 -31
  148. package/bin/lib/workPreviews.mjs +47 -112
  149. package/bin/lib/workProcesses.mjs +8 -35
  150. package/bin/lib/workPullRequests.mjs +37 -56
  151. package/bin/lib/workReportQueue.mjs +100 -148
  152. package/bin/lib/workRetire.mjs +2 -1
  153. package/bin/lib/workSessionRuntime.mjs +38 -37
  154. package/bin/lib/workSessionTurns.mjs +256 -199
  155. package/bin/lib/workShip.mjs +43 -23
  156. package/bin/lib/workToolLog.mjs +70 -3
  157. package/bin/lib/workTurnShape.mjs +165 -0
  158. package/npm-shrinkwrap.json +69 -0
  159. package/package.json +9 -4
  160. package/bin/lib/fleetJobs.mjs +0 -120
  161. package/bin/lib/patchRevert.mjs +0 -60
@@ -22,9 +22,12 @@ import {
22
22
  symlinkSync,
23
23
  unlinkSync,
24
24
  } from 'node:fs';
25
- import { homedir } from 'node:os';
26
- import { basename, isAbsolute, join, relative, sep } from 'node:path';
25
+ import { isAbsolute, join, relative, sep } from 'node:path';
27
26
  import { git, gitNetAsync } from './git.mjs';
27
+ import { worktreeHomeDir } from './statePaths.mjs';
28
+ // WHICH FILES ARE ENV FILES has one home, beside the report's own narrower
29
+ // rule; that block says why the two differ (2026-10-10, the audit's F085).
30
+ import { isCheckoutEnvFile } from './env.mjs';
28
31
 
29
32
  /**
30
33
  * A DEPLOY BUILDS THE BASE COMMIT, NEVER THE OPERATOR'S WORKING TREE
@@ -60,7 +63,6 @@ import { git, gitNetAsync } from './git.mjs';
60
63
  * a workspace package's own CLI still runs from the checkout. Submodules are
61
64
  * not initialised. The code and the commands no longer come from the checkout.
62
65
  */
63
- const ENV_FILE_RE = /^(?:\.env(?:\..+)?|\.dev\.vars)$/;
64
66
  const lexists = (p) => {
65
67
  try {
66
68
  lstatSync(p);
@@ -71,11 +73,15 @@ const lexists = (p) => {
71
73
  };
72
74
 
73
75
  /** Bring the checkout's untracked environment into a deploy worktree. Returns
74
- * every path it created, so the cleanup can unlink them BEFORE the worktree
75
- * is removed (a recursive delete must never be handed a symlink into the
76
- * operator's own node_modules). */
76
+ * `{ made, envFiles }`: `made` is every path it created, so the cleanup can
77
+ * unlink them BEFORE the worktree is removed (a recursive delete must never
78
+ * be handed a symlink into the operator's own node_modules); `envFiles` is
79
+ * every env file it COPIED (2026-10-10, the audit's F085), so the deploy lane
80
+ * can teach the scrubber the values its own build is about to be able to
81
+ * print — nested files the root-only env scan never reads. */
77
82
  export function linkCheckoutEnvironment(repoRoot, dir) {
78
83
  const made = [];
84
+ const envFiles = [];
79
85
  let realRoot = repoRoot;
80
86
  try {
81
87
  realRoot = realpathSync(repoRoot);
@@ -149,11 +155,12 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
149
155
  if (e.isDirectory() && !lexists(dst)) linkNodeModules(src, dst, 0);
150
156
  continue;
151
157
  }
152
- if (e.isFile() && ENV_FILE_RE.test(e.name)) {
158
+ if (e.isFile() && isCheckoutEnvFile(e.name)) {
153
159
  if (!lexists(dst)) {
154
160
  try {
155
161
  copyFileSync(src, dst);
156
162
  made.push(dst);
163
+ envFiles.push(dst);
157
164
  } catch {
158
165
  /* unreadable — the build says so if it needed it */
159
166
  }
@@ -168,15 +175,18 @@ export function linkCheckoutEnvironment(repoRoot, dir) {
168
175
  }
169
176
  };
170
177
  walk('', 0);
171
- return made;
178
+ return { made, envFiles };
172
179
  }
173
180
 
174
181
  /**
175
- * Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup }`, or
182
+ * Cut the deploy worktree at the base tip. Returns `{ dir, sha, cleanup,
183
+ * envFiles }` (`envFiles`: the env files copied in — see
184
+ * `linkCheckoutEnvironment`), or
176
185
  * throws with a sentence when base does not resolve. `worktreeDir` defaults to
177
- * the daemon's own worktree home for this repo (fleetStartup.mjs keys it the same
178
- * way); the job id is hashed into the path, never joined raw — it is a
179
- * server-named string.
186
+ * the daemon's own worktree home for this repo — statePaths.mjs's one
187
+ * derivation, the same one fleetStartup.worktreeHome makes and reaps — and the
188
+ * deploy lane relies on that default rather than passing it; the job id is
189
+ * hashed into the path, never joined raw — it is a server-named string.
180
190
  */
181
191
  export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir }) {
182
192
  if (!baseRef) throw new Error('no base branch resolved, so there is nothing to deploy from');
@@ -195,14 +205,7 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
195
205
  } catch {
196
206
  throw new Error(`the base branch ${baseRef} does not resolve on this machine`);
197
207
  }
198
- const home =
199
- worktreeDir ??
200
- join(
201
- homedir(),
202
- '.flowviant',
203
- 'worktrees',
204
- `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`
205
- );
208
+ const home = worktreeDir ?? worktreeHomeDir(repoRoot);
206
209
  const dir = join(home, 'deploy', createHash('sha256').update(String(jobId)).digest('hex').slice(0, 16));
207
210
  mkdirSync(join(home, 'deploy'), { recursive: true });
208
211
  const drop = (made = [], target = dir) => {
@@ -256,10 +259,11 @@ export async function openDeployCheckout({ repoRoot, baseRef, jobId, worktreeDir
256
259
  }
257
260
  git(['worktree', 'add', '--detach', dir, sha], repoRoot);
258
261
  let made = [];
262
+ let envFiles = [];
259
263
  try {
260
- made = linkCheckoutEnvironment(repoRoot, dir);
264
+ ({ made, envFiles } = linkCheckoutEnvironment(repoRoot, dir));
261
265
  } catch {
262
266
  /* the build says so if it needed any of it */
263
267
  }
264
- return { dir, sha, cleanup: () => drop(made) };
268
+ return { dir, sha, envFiles, cleanup: () => drop(made) };
265
269
  }
@@ -61,19 +61,35 @@
61
61
  * files, and this box's shell is not one). Deriving the redaction list from the
62
62
  * admission list is what stops the two drifting the next time a name is added.
63
63
  *
64
- * The honest residue, stated: a credential that is neither in `DEPLOY_KEEP` nor
65
- * in a checkout `.env*` — one a `build` script fetches for itself, say — is not
64
+ * AND THE NESTED ENV FILES THIS DAEMON COPIES IN ITSELF (2026-10-10, the
65
+ * audit's F085). The root scan reads only the checkout root's `.env*`
66
+ * (env.mjs states that limit), while `openDeployCheckout` copies every
67
+ * `.env*` and `.dev.vars` from tracked directories three levels deep into the
68
+ * worktree the build runs in — `apps/api/.dev.vars` among them — so a build
69
+ * that printed its config relayed values the scrubber had never been told.
70
+ * The checkout now reports which files it copied, and `runDeploy` teaches the
71
+ * scrubber their values for that job's life only (`learnExtraSecrets`), then
72
+ * forgets them with the worktree. The root-only report scan is unchanged.
73
+ * Well-known vendor token SHAPES are redacted too (uplinkScrub.mjs).
74
+ *
75
+ * The honest residue, stated: a credential that is in none of those — one a
76
+ * `build` script fetches for itself, say, in a shape nobody listed — is not
66
77
  * redacted, because this daemon has never seen it.
67
78
  */
68
79
 
69
80
  import { spawn } from 'node:child_process';
70
- import { scrub } from './uplinkScrub.mjs';
81
+ import { forgetExtraSecrets, learnExtraSecrets, scrubTail } from './uplinkScrub.mjs';
71
82
  import { childEnv } from './childEnv.mjs';
72
83
  import { readDeployConfig } from './deployConfig.mjs';
73
84
  import { openDeployCheckout } from './deployCheckout.mjs';
85
+ import { readEnvFileValues } from './env.mjs';
86
+ import { killGroup } from './processGroup.mjs';
74
87
 
75
88
  /** How long one deploy command may run before it is stopped. */
76
89
  const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
90
+ /** How much of a command's output is kept — a memory bound; only the tail is
91
+ * ever read (`tailLines`, the version id). */
92
+ const DEPLOY_OUTPUT_KEEP = 512 * 1024;
77
93
 
78
94
  /** Run a shell command ASYNC (never blocks the daemon's event loop — the
79
95
  * reconcile poll + the deploy heartbeat must keep firing during a long
@@ -88,7 +104,8 @@ const DEPLOY_COMMAND_TIMEOUT_MS = 30 * 60_000;
88
104
  * `deploysInFlight` holding off every self-update). Measured: `sleep 30 &
89
105
  * wait` held the job past its timeout. So the command runs as its own process
90
106
  * group (the shape `gitNetAsync` in git.mjs and the project check in
91
- * workAgentCheck.mjs already use), the timer kills the group, and the timer
107
+ * workAgentCheck.mjs already use), the timer kills the group (`killGroup`,
108
+ * processGroup.mjs — one home since 2026-10-10), and the timer
92
109
  * itself resolves rather than waiting on a pipe a survivor could still hold.
93
110
  * The output gains one line saying so, which reaches the server in the
94
111
  * report's `message` — a relayed fact, not a new wire field.
@@ -131,27 +148,40 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
131
148
  clearTimeout(timer);
132
149
  resolve(v);
133
150
  };
151
+ /**
152
+ * SCRUBBED BEFORE IT IS CUT (2026-10-10, the audit's F143) — workAgentCheck's
153
+ * order. This used to keep the last 512 KB per chunk and scrub only at
154
+ * settle, so a secret straddling the window's edge was cut first and its
155
+ * surviving half matched nothing. Now the window is cut only from text
156
+ * that has been scrubbed whole (`scrubTail`); a secret still arriving at
157
+ * the END of the buffer is not cut, and the next scrub sees it whole.
158
+ *
159
+ * Amortised rather than per chunk, unlike the check's 4000-character tail:
160
+ * re-scrubbing a 512 KB buffer on every small write of a chatty build is
161
+ * quadratic, so the buffer may grow to twice the window before it is
162
+ * scrubbed and cut. The settle-time scrub stays as the second pass over
163
+ * what ships.
164
+ *
165
+ * A DECODER PER STREAM: `setEncoding` hands whole characters, where
166
+ * `d.toString()` split a multi-byte character across two chunks into
167
+ * replacement characters — and a secret holding one could then never
168
+ * match.
169
+ */
134
170
  const cap = (d) => {
135
- buf += d.toString();
136
- if (buf.length > 512 * 1024) buf = buf.slice(-512 * 1024); // bound memory
171
+ buf += d;
172
+ if (buf.length > 2 * DEPLOY_OUTPUT_KEEP) buf = scrubTail(buf, DEPLOY_OUTPUT_KEEP);
137
173
  };
174
+ child.stdout.setEncoding('utf8');
175
+ child.stderr.setEncoding('utf8');
138
176
  child.stdout.on('data', cap);
139
177
  child.stderr.on('data', cap);
140
178
  const timer = setTimeout(() => {
141
- try {
142
- process.kill(-child.pid, 'SIGKILL'); // the whole group
143
- } catch {
144
- try {
145
- child.kill('SIGKILL');
146
- } catch {
147
- /* already gone */
148
- }
149
- }
179
+ killGroup(child, 'SIGKILL'); // the whole group
150
180
  const limit = timeoutMs >= 60_000 ? `${Math.round(timeoutMs / 60_000)} minutes` : `${timeoutMs}ms`;
151
181
  settle({
152
182
  ok: false,
153
183
  code: -1,
154
- out: scrub(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`),
184
+ out: scrubTail(`${buf}\n[flowviant] the command ran past ${limit} and was stopped`, DEPLOY_OUTPUT_KEEP),
155
185
  });
156
186
  }, timeoutMs);
157
187
  // A broken pipe (a child that exits before its stdin is closed) surfaces as
@@ -166,10 +196,10 @@ export function run(command, { cwd, env, timeoutMs = DEPLOY_COMMAND_TIMEOUT_MS }
166
196
  child.stdin.on('error', () => {});
167
197
  child.stdin.end();
168
198
  child.on('close', (code) => {
169
- settle({ ok: code === 0, code: code ?? -1, out: scrub(buf) });
199
+ settle({ ok: code === 0, code: code ?? -1, out: scrubTail(buf, DEPLOY_OUTPUT_KEEP) });
170
200
  });
171
201
  child.on('error', (e) => {
172
- settle({ ok: false, code: -1, out: scrub(`${buf}\n${e.message}`) });
202
+ settle({ ok: false, code: -1, out: scrubTail(`${buf}\n${e.message}`, DEPLOY_OUTPUT_KEEP) });
173
203
  });
174
204
  });
175
205
  }
@@ -203,7 +233,12 @@ export async function runDeploy(job, ctx) {
203
233
  } catch (e) {
204
234
  return { ok: false, message: `deploy not run — ${e.message}`, logs: [] };
205
235
  }
236
+ // THE FILES THIS JOB COPIED IN, TAUGHT TO THE SCRUBBER FOR ITS LIFE ONLY
237
+ // (2026-10-10, the audit's F085) — see the header. Keyed per job, so two
238
+ // deploys never forget each other's.
239
+ const secretsKey = `deploy:${job.id}`;
206
240
  try {
241
+ learnExtraSecrets(secretsKey, (checkout.envFiles ?? []).flatMap((f) => readEnvFileValues(f)));
207
242
  const targets = readDeployConfig(ctx.repoRoot, checkout.sha);
208
243
  const target = targets.find((t) => t.id === job.targetId);
209
244
  if (!target) {
@@ -213,6 +248,7 @@ export async function runDeploy(job, ctx) {
213
248
  const short = checkout.sha.slice(0, 12);
214
249
  return { ...outcome, sha: checkout.sha, message: `${outcome.message} (${short})` };
215
250
  } finally {
251
+ forgetExtraSecrets(secretsKey); // first: it cannot throw, and the cleanup can
216
252
  checkout.cleanup();
217
253
  }
218
254
  }
@@ -1,41 +1,50 @@
1
1
  /**
2
- * THE DEPLOY LANE'S ONE POST — `/fleet/deploy-*`, with the machine credential.
2
+ * THE DEPLOY LANE'S WIRE — `/fleet/deploy-*`, with the machine credential.
3
3
  *
4
4
  * Split out of deploy.mjs (SOLID F060) because two halves of the lane speak
5
5
  * it: the config report (deployConfig.mjs) and the job lease (deploy.mjs —
6
6
  * claim, heartbeat, outcome). A leaf, so neither has to import the other to
7
- * reach the wire, and a change to how the lane authenticates or times out is
8
- * one edit.
7
+ * reach the wire.
8
+ *
9
+ * The request itself is fleetPost.mjs's (audit 2026-10-10, F082): this was a
10
+ * byte-identical copy of `postToFleet`'s skeleton that differed only in what it
11
+ * returned. What stays here is the lane's own CONTRACT over that request — a
12
+ * throw on anything but an accepted answer, carrying whether it was the API's
13
+ * own refusal of the credential — and the lane's 30-second ceiling.
9
14
  */
10
15
 
11
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
16
+ import { FLEET_URL } from './config.mjs';
12
17
  import { fleetEndpoint } from './fleetWire.mjs';
13
- import { isCredentialRejection } from './authReject.mjs';
18
+ import { claimFleetLease, fleetRequest } from './fleetPost.mjs';
14
19
 
15
20
  const deployUrl = (tail) => fleetEndpoint(tail, FLEET_URL);
21
+ const DEPLOY_TIMEOUT_MS = 30_000;
16
22
 
23
+ /** POST to `/fleet/<tail>` and return the reply's `data`. A network error or
24
+ * timeout is rethrown as `fetch` threw it; a refusal (a non-2xx, or a 2xx
25
+ * saying `success: false`) throws an Error carrying `status` and
26
+ * `credentialRejected`. */
17
27
  export async function post(tail, body) {
18
- const res = await fetch(deployUrl(tail), {
19
- method: 'POST',
20
- headers: {
21
- Authorization: `Bearer ${FLEET_TOKEN}`,
22
- 'User-Agent': USER_AGENT,
23
- 'Content-Type': 'application/json',
24
- },
25
- signal: AbortSignal.timeout(30_000),
26
- body: JSON.stringify(body),
27
- });
28
- const json = await res.json().catch(() => ({}));
29
- if (!res.ok || json?.success === false) {
28
+ const r = await fleetRequest(deployUrl(tail), { body, timeoutMs: DEPLOY_TIMEOUT_MS });
29
+ if (r.error) throw r.error;
30
+ const json = r.json;
31
+ if (!r.ok || json?.success === false) {
30
32
  // The sentence is unchanged. What rides beside it is whether this was the
31
33
  // API's OWN refusal of the credential (a Disconnect rotates it, even
32
- // mid-deploy) — authReject.mjs's one rule, never the bare status: an edge
33
- // 401/403 (Cloudflare's bot checks, which this client class trips) is a
34
- // blip and is retried like any other failed post.
35
- const e = new Error(`${tail} failed (${res.status}${json?.error ? `: ${json.error}` : ''})`);
36
- e.status = res.status;
37
- e.credentialRejected = isCredentialRejection(res.status, res.headers.get('content-type'), json);
34
+ // mid-deploy) — authReject.mjs's one rule, asked by fleetRequest, never
35
+ // the bare status: an edge 401/403 (Cloudflare's bot checks, which this
36
+ // client class trips) is a blip and is retried like any other failed post.
37
+ const e = new Error(`${tail} failed (${r.status}${json?.error ? `: ${json.error}` : ''})`);
38
+ e.status = r.status;
39
+ e.credentialRejected = r.credentialRejected;
38
40
  throw e;
39
41
  }
40
42
  return json?.data;
41
43
  }
44
+
45
+ /** Claim a deploy job — the shared lease rule (fleetPost.mjs's
46
+ * `claimFleetLease`: this process's `instance` stamped, the winner read from
47
+ * `data.claimed`, any failure "not claimed"), on this lane's ceiling. */
48
+ export function claim(tail, fields) {
49
+ return claimFleetLease(deployUrl(tail), fields, { timeoutMs: DEPLOY_TIMEOUT_MS });
50
+ }
@@ -8,12 +8,11 @@ import { FLEET_URL, MACHINE_HOST, USER_AGENT, VERSION } from './config.mjs';
8
8
  import { listStoredProjects } from './credentials.mjs';
9
9
  import { readStoredPubB64 } from './boxIdentity.mjs';
10
10
  import { daemonRunningFor, drainingFor } from './instance.mjs';
11
- import { runningViaNpx } from './launchCommand.mjs';
11
+ import { installChannel } from './launchCommand.mjs';
12
12
  import { boxesUrlFrom, classifyMachineResponse, fetchBoxesFor } from './machines.mjs';
13
13
  import { daemonLockPid, daemonLogPath, readDaemonState } from './daemonState.mjs';
14
14
  import { detectRuntimes } from './runtimeDetection.mjs';
15
15
  import { readClaudeAuthStatus } from './runtimeAuth.mjs';
16
- import { runningCompiledBinary } from './update.mjs';
17
16
  import { fleetEndpoint } from './fleetWire.mjs';
18
17
 
19
18
  /**
@@ -40,9 +39,8 @@ export function busyOf(liveState, now = Date.now()) {
40
39
  return liveState.busy;
41
40
  }
42
41
 
43
- export function installChannel() {
44
- return runningCompiledBinary() ? 'binary' : runningViaNpx() ? 'npx' : 'npm-g';
45
- }
42
+ // One home for the channel (launchCommand.mjs); re-exported for views.mjs.
43
+ export { installChannel };
46
44
 
47
45
  export function desktopStatus({ entries = listStoredProjects(), runtimes = detectRuntimes(), runningFor = daemonRunningFor, stateFor = readDaemonState, pidFor = daemonLockPid, drainFor = drainingFor, nowMs = Date.now(), authFor = readClaudeAuthStatus, refreshAuth = false } = {}) {
48
46
  const runtimeRows = runtimes.map((rt) => ({
@@ -66,6 +64,9 @@ export function desktopStatus({ entries = listStoredProjects(), runtimes = detec
66
64
  holder: liveState?.holder ?? null,
67
65
  // Measured by the daemon; null = unknown, never idle (see `busyOf`).
68
66
  busy: busyOf(liveState, nowMs),
67
+ maintenance: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenance === 'string' ? liveState.maintenance : null,
68
+ maintenanceEpoch: busyOf(liveState, nowMs) !== null && typeof liveState?.maintenanceEpoch === 'string' ? liveState.maintenanceEpoch : null,
69
+ liveAgents: busyOf(liveState, nowMs) !== null && Number.isInteger(liveState?.liveAgents) && liveState.liveAgents >= 0 ? liveState.liveAgents : null,
69
70
  // What a standing-down daemon is finishing (`web → prod`), from its
70
71
  // lock's draining mark; null when it is not draining or unknown. A
71
72
  // daemon→desktop report field: an older tray ignores it.
@@ -0,0 +1,19 @@
1
+ /** A discovery contract for the person's CLI, never a server-side diagnosis. */
2
+ export const REGRESSION_INVESTIGATION = `REGRESSION INVESTIGATION — for "this existed before and now is gone", restoration requests, and bugs that persist after Done:
3
+ 1. Keep the REQUESTED OUTCOME separate from a proposed cause. The runtime baseline names cwd, HEAD, execution base and dirty state; it does not prove you inspected relevant code. Reconcile differing or dirty baselines before relying on them.
4
+ 2. Before naming a cause, formula or restoration location, trace current rendering and data flow: locate the UI label/symbol, its render conditions and formatting, the calculation, its data provider, missing/loading inputs, and relevant tests. Reading tests is not running them; never claim tests passed merely because you read them. Do not run write-producing tests or builds under this read-only profile.
5
+ 3. Inspect available local Git history with supported plain readers: git log --oneline -- <relative-path>, git log -p --follow -- <relative-path>, git log -S delta -- <relative-path>, git show <commit>:<relative-path>, and relevant diffs. Compare the previous implementation with current code. Missing/shallow history or an unavailable deployed version limits the comparison; say exactly which comparison remains unverified.
6
+ 4. Explain only supported evidence. A surviving hidden display needs its render condition investigated, not an invented replacement formula. A missing input needs its provider/comparison data traced, not a claim that the display was deleted. A removed implementation needs a verified historical file/commit and a compatibility check against current code before proposing restoration.
7
+ 5. A board card marked Done is history, not proof that an implementation shipped or that the reported behavior works in the deployed version. In capture, dedupe with list_cards and list_staged; use find_cards/read_card for held or completed matches. Refine a pending/staged match in place. A recurrence after Done is new work with followUpOf, never proof of its cause. Scratch planning instead compares the supplied cards and live-agent context; it has no staging MCP.
8
+ 6. A failed read requires supported recovery before claiming repository discovery is unavailable. Preserve the actual failure and each attempted alternative; distinguish syntax refusal, permission denial, missing path/history and absent tool. Runtime outcomes are observations; model-authored path/line/commit citations and causal explanations remain separate claims. A request alone proves no successful read.
9
+ 7. In capture, still STAGE FIRST: if discovery remains unresolved, stage an investigation-first fix for the requested outcome, requiring reproduction and cause establishment before implementation. State unverified causes in assumed, explicit discovery limits and failures/attempts in the brief, and do not invent a formula or restoration path. Use optional grounding when the served staging tool advertises it; otherwise put requested outcome, observed code/history, discovery limits and recovery attempts in clearly separate description sections. Stage before asking the one remaining person-only question, and edit_staged after its answer rather than creating a twin. In scratch planning, retain that investigation-first ordering in the proposal; do not turn an unverified explanation into implementation instructions.`;
10
+
11
+ export function discoveryGuide(runtime, lane) {
12
+ const readers = runtime === 'claude'
13
+ ? `Claude: scoped Read and Glob, in-workspace Grep, and approved plain shell readers (cat/head/ls/wc and git log/show/diff/rev-parse). The read guard rejects shell composition, expansions/globs and unsafe paths; quoted pipes and bracket Git paths can still be refused. Quotes/escaping do not bypass the guard. Prefer native Read/Grep/Glob after such a refusal. Replace git log -G 'delta|change' with separate plain git log -S delta and git log -S change calls. For a bracket working-tree path use native Read with the literal path; for history use a supported parent-relative Git path or a scoped commit diff and inspect the relevant entry. Never disable the guard or request unrestricted Bash.`
14
+ : runtime === 'codex'
15
+ ? `Codex: this profile uses a read-only sandbox with shell discovery such as cat, head, rg and plain Git readers; Claude's dedicated Read/Grep/Glob and read guard are not guaranteed here. Use only tools advertised this turn. No native web, subagents, browser/apps or unrestricted shell are granted. Read only the repository and explicitly supplied project knowledge.`
16
+ : `This runtime: use the native file/list/search readers actually advertised this turn. Shell availability is unverified and may be denied; do not claim all reading is blocked after one shell refusal. Keep the read-only profile.`;
17
+ return `DISCOVERY CAPABILITIES (${runtime}, ${lane}):\n${readers}\n` +
18
+ `Recovery order: use a cwd-relative path; use one plain reader rather than a composed command; use native Read/Grep/Glob where supported, or shell cat/head/rg where permitted; use git show <commit>:<relative-path> when a working-tree file is missing and history is available. If a utility is absent, switch to an advertised reader. Do not try to evade a secret/path denial. Re-read the actual result after each attempt. Preserve the first failure and subsequent recovery attempts, including an unresolved result; then state the remaining limit rather than asking the person to disable protections. ${lane === 'capture' ? 'Only the scoped Flowviant staging tools write records; never change repository files.' : 'Scratch planning has no Flowviant MCP; return a proposal only.'}`;
19
+ }
package/bin/lib/env.mjs CHANGED
@@ -92,7 +92,18 @@ import { learnSecrets } from './uplinkScrub.mjs';
92
92
  * monorepo's shared types. The daemon holds its own line for the same reason
93
93
  * every other boundary in this file does — a report that the server will
94
94
  * reject is a report nobody sees, and clamping here is what makes the
95
- * difference visible as a smaller list rather than a 400.
95
+ * difference visible as a smaller list.
96
+ *
97
+ * ── THE RELEASE GATE HOLDS THEM (2026-10-10, audit F093) ──
98
+ *
99
+ * `ENV_REPORT_BOUNDS` and `ENV_NAME_RE` are exported for
100
+ * `scripts/check-app-parity.mjs`, which compares them with the shared
101
+ * `MACHINE_ENV_MAX_FILES` / `_MAX_VARS` / `_MAX_NAME` / `_FP_LENGTH` and
102
+ * `machineEnvVarSchema`'s name pattern. Drift would be silent: POST
103
+ * /fleet/env-report answers a body its schema refuses with 200 `stored:false`
104
+ * (and its reader drops a bad file alone), so this lane would see `ok`, dedup,
105
+ * and the comparison sheet would simply stop hearing from this box. `vars` is
106
+ * a TOTAL on both ends (the server's reader spends one budget across files).
96
107
  *
97
108
  * ── THE REPORT'S CAPS ARE NOT THE SCRUBBER'S, AND THAT ASYMMETRY IS
98
109
  * DELIBERATE (2026-09-21, the review) ──
@@ -112,23 +123,41 @@ import { learnSecrets } from './uplinkScrub.mjs';
112
123
  * actually read, so the totals are honest about a directory somebody has let
113
124
  * grow without this scan walking an unbounded list of files on the poll's beat.
114
125
  */
115
- const MAX_ENV_FILES = 8;
116
- const MAX_ENV_VARS = 200; // TOTAL across every file, not per file — REPORT only
117
- const MAX_ENV_NAME_CHARS = 64;
126
+ export const ENV_REPORT_BOUNDS = Object.freeze({ files: 8, vars: 200, name: 64, fp: 8 });
127
+ const MAX_ENV_FILES = ENV_REPORT_BOUNDS.files;
128
+ const MAX_ENV_VARS = ENV_REPORT_BOUNDS.vars; // TOTAL across every file, not per file — REPORT only
129
+ const MAX_ENV_NAME_CHARS = ENV_REPORT_BOUNDS.name;
118
130
  const MAX_ENV_FILE_BYTES = 256 * 1024;
119
131
  const MAX_SCAN_FILES = 64;
120
132
  const MAX_SCRUB_VALUES = 2000;
121
- const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
133
+ export const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
122
134
 
123
135
  /** `.env`, `.env.local`, `.env.production` … and never a template. A file whose
124
136
  * name ends `.example` / `.sample` / `.template` is somebody's DOCUMENTATION
125
137
  * of the shape, committed on purpose, and its "values" are placeholders — so
126
138
  * it would fingerprint `your-key-here` on every box and report them as
127
139
  * agreeing, which is the exact opposite of what a comparison is for. */
128
- const isEnvFileName = (name) =>
140
+ export const isEnvFileName = (name) =>
129
141
  (name === '.env' || name.startsWith('.env.')) &&
130
142
  !/\.(example|sample|template)$/i.test(name);
131
143
 
144
+ /**
145
+ * THE OTHER RULE FOR "AN ENV FILE", AND WHY THE TWO DIFFER (2026-10-10, the
146
+ * audit's F085; it moved here from deployCheckout.mjs so both live in one
147
+ * place). `isEnvFileName` decides what the COMPARISON REPORT reads at the
148
+ * checkout root: `.env*` minus templates, because a template's placeholders
149
+ * would fingerprint as agreement. This one decides what a DEPLOY COPIES into
150
+ * its throwaway worktree, at every tracked directory three levels deep: every
151
+ * `.env*` and `.dev.vars` a build might load, templates included, because a
152
+ * build that reads one must find it. Copying is about making the build work;
153
+ * reporting is about a screen a person reads. The deploy lane teaches the
154
+ * scrubber the files it actually copied (deployRunner.mjs), so the wider rule
155
+ * never widens what goes unredacted. (vaultArtefacts.mjs keeps a third, wider
156
+ * net on purpose: it looks only for files the deleted vault wrote, which it
157
+ * recognises by their header, never by their name alone.)
158
+ */
159
+ export const isCheckoutEnvFile = (name) => /^(?:\.env(?:\..+)?|\.dev\.vars)$/.test(name);
160
+
132
161
  /**
133
162
  * ONE LINE OF DOTENV. Blank lines and `#` comments are skipped, an optional
134
163
  * `export ` prefix is allowed, the line is split on the FIRST `=` (a value may
@@ -230,6 +259,55 @@ function closingQuoteLine(lines, start, quote) {
230
259
  return lines.length;
231
260
  }
232
261
 
262
+ /**
263
+ * ONE DOTENV FILE'S TEXT → `[{ name, value }]`, the variables in force — pure
264
+ * (2026-10-10, extracted from `scanEnvFiles` so the deploy lane parses the
265
+ * nested files it copies with the SAME rules the report uses).
266
+ *
267
+ * A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
268
+ * loader resolves a repeated key, so the fingerprint reported is the one
269
+ * actually in force. It also makes the totals honest: a file that sets the
270
+ * same key forty times counts once, not forty times.
271
+ */
272
+ export function parseEnvText(text) {
273
+ const byName = new Map();
274
+ const lines = String(text ?? '').split('\n');
275
+ for (let i = 0; i < lines.length; i++) {
276
+ const parsed = parseEnvLine(lines[i]);
277
+ if (!parsed) continue;
278
+ // A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
279
+ // dropped from BOTH halves and the scan steps over its continuation
280
+ // lines, so a PEM body can never be read as variables of its own.
281
+ if (parsed.unterminated) {
282
+ i = closingQuoteLine(lines, i, parsed.unterminated);
283
+ continue;
284
+ }
285
+ // A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
286
+ // is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
287
+ // truncated names as the same row is worse than rendering neither.
288
+ if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
289
+ if (!ENV_NAME_RE.test(parsed.name)) continue;
290
+ if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
291
+ byName.set(parsed.name, parsed.value);
292
+ }
293
+ return [...byName].map(([name, value]) => ({ name, value }));
294
+ }
295
+
296
+ /**
297
+ * The values in ONE env file, for the scrubber — `[]` for anything that is not
298
+ * a regular file under the scan's size cap (the same `lstat` refusal of a
299
+ * symlink, for the same reason; see `scanEnvFiles`). Never throws.
300
+ */
301
+ export function readEnvFileValues(path) {
302
+ try {
303
+ const st = lstatSync(path);
304
+ if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) return [];
305
+ return parseEnvText(readFileSync(path, 'utf8'));
306
+ } catch {
307
+ return [];
308
+ }
309
+ }
310
+
233
311
  /**
234
312
  * A VALUE FINGERPRINT — sha256 over `<projectId>\n<value>`, first 8 hex
235
313
  * characters.
@@ -249,8 +327,22 @@ function closingQuoteLine(lines, start, quote) {
249
327
  * `development`, `production`, `postgres`, `localhost`, `info` — so anybody who
250
328
  * could read a stored report could recover those values with one precomputed
251
329
  * table, and the eight characters that were chosen to be uninformative became
252
- * a lookup key instead. Salting with the project id makes the table worthless
253
- * without knowing which project it is for, and makes it per-project even then.
330
+ * a lookup key instead. Salting with the project id stops ONE table serving
331
+ * every project, and that is all it stops.
332
+ *
333
+ * ── WHAT THE SALT DOES NOT STOP (2026-10-10, audit F094) ──
334
+ *
335
+ * The project id is no secret: every member of the project has it in the URL,
336
+ * and it is stored beside the report. To anyone who knows it, this digest
337
+ * still CONFIRMS A GUESS — hash `postgres`, `changeme` or every six-digit PIN
338
+ * with the id and compare eight hex — so for a weak value it is a per-project
339
+ * dictionary oracle, not "useless for recovering it". The server therefore
340
+ * never serves it back: the app's comparison receives an opaque equality group
341
+ * per (file, name) instead (`envComparisonView` in the app's
342
+ * `machineEnvReports.ts`). Keying this digest with a secret no member holds —
343
+ * an HMAC under the machine credential, with a scheme marker so mixed daemons
344
+ * read as "not comparable" rather than `differs` — is the at-rest half, and it
345
+ * is still an open item, not done here.
254
346
  *
255
347
  * IT COSTS THE FEATURE NOTHING, and that is what makes the trade obvious: the
256
348
  * screen compares the BOXES OF ONE PROJECT. Two projects agreeing on a value is
@@ -263,7 +355,7 @@ function closingQuoteLine(lines, start, quote) {
263
355
  * deliberately SHORT so nobody mistakes it for a commitment to the value.
264
356
  */
265
357
  const fingerprint = (salt, value) =>
266
- createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, 8);
358
+ createHash('sha256').update(`${salt}\n${value}`, 'utf8').digest('hex').slice(0, ENV_REPORT_BOUNDS.fp);
267
359
 
268
360
  /**
269
361
  * READ THE CHECKOUT'S `.env*` FILES AND REPORT WHAT IS IN THEM BY NAME.
@@ -353,36 +445,13 @@ export function scanEnvFiles(repoRoot, salt) {
353
445
  // file and contributes nothing, and neither is a SYMLINK: see above.
354
446
  const st = lstatSync(abs);
355
447
  if (!st.isFile() || st.size > MAX_ENV_FILE_BYTES) continue;
356
- // A MAP, because LAST WRITE WINS inside one file — which is how a dotenv
357
- // loader resolves a repeated key, so the fingerprint reported is the one
358
- // actually in force. It also makes the totals honest: a file that sets
359
- // the same key forty times counts once, not forty times.
360
- const byName = new Map();
361
- const lines = readFileSync(abs, 'utf8').split('\n');
362
- for (let i = 0; i < lines.length; i++) {
363
- const parsed = parseEnvLine(lines[i]);
364
- if (!parsed) continue;
365
- // A VALUE WE CANNOT READ IS REPORTED AS NOTHING. The variable is
366
- // dropped from BOTH halves and the scan steps over its continuation
367
- // lines, so a PEM body can never be read as variables of its own.
368
- if (parsed.unterminated) {
369
- i = closingQuoteLine(lines, i, parsed.unterminated);
370
- continue;
371
- }
372
- // A NAME THAT FAILS EITHER TEST IS DROPPED, NEVER TRUNCATED: a cut name
373
- // is a DIFFERENT VARIABLE, and a comparison screen rendering two boxes'
374
- // truncated names as the same row is worse than rendering neither.
375
- if (parsed.name.length > MAX_ENV_NAME_CHARS) continue;
376
- if (!ENV_NAME_RE.test(parsed.name)) continue;
377
- if (!byName.has(parsed.name) && byName.size >= MAX_SCRUB_VALUES) continue;
378
- byName.set(parsed.name, parsed.value);
379
- }
380
- out.varsTotal += byName.size;
448
+ const parsed = parseEnvText(readFileSync(abs, 'utf8'));
449
+ out.varsTotal += parsed.length;
381
450
  // Decided BEFORE this file spends any budget, so `budget` reaching zero
382
451
  // inside a file still lets that file's own entry carry what it got.
383
452
  const reporting = canReport && budget > 0 && out.files.length < MAX_ENV_FILES;
384
453
  const vars = [];
385
- for (const [n, value] of byName) {
454
+ for (const { name: n, value } of parsed) {
386
455
  // THE SCRUB LIST IS NOT BOUNDED BY THE REPORT'S CAPS — see the bounds
387
456
  // block above. A secret dropped for being the 201st is a secret in the
388
457
  // server's database.