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
@@ -6,18 +6,20 @@
6
6
  *
7
7
  * Split out of fleet.mjs (SOLID audit 2026-09-26, F038). Each lane is its own
8
8
  * throttle, its own dedup against the last ACCEPTED payload, and its own
9
- * "the server refused for good — quiet until restart" flag; all of that is
10
- * module state, and none of it is the reconcile loop's business. The loop
11
- * fires each one and forgets it (`void`), which is the one contract they all
12
- * keep: a readout must never throw into, or delay, the poll.
9
+ * "the server refused for good — quiet until restart" flag; since 2026-10-10
10
+ * (F087) that state lives in one `createReportLane` closure per lane rather
11
+ * than four hand-kept sets of module `let`s, and none of it is the reconcile
12
+ * loop's business. The loop fires each one and forgets it (`void`), which is
13
+ * the one contract they all keep: a readout must never throw into, or delay,
14
+ * the poll.
13
15
  *
14
16
  * Every lane is a DAEMON→SERVER report, so none needs a floor: an older server
15
17
  * 404s once and the lane goes quiet; an older daemon simply never posts.
16
18
  */
17
19
 
18
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
20
+ import { FLEET_URL } from './config.mjs';
19
21
  import { fleetEndpoint } from './fleetWire.mjs';
20
- import { postToFleet } from './fleetPost.mjs';
22
+ import { fleetRequest, postToFleet } from './fleetPost.mjs';
21
23
  import { myPubB64 } from './boxIdentity.mjs';
22
24
  import { scanEnvForScrub } from './env.mjs';
23
25
  import { machineSnapshot } from './resources.mjs';
@@ -25,8 +27,83 @@ import { detectRuntimes } from './runtimeDetection.mjs';
25
27
  import { scanLocalSessions } from './localSessions.mjs';
26
28
  import { repoState } from './repoState.mjs';
27
29
  import { claudeAuthContext } from './claudeAuth.mjs';
28
- import { readBaseTools, toolReadout } from './projectTools.mjs';
30
+ import { prepareAgentTools, readBaseTools, toolReadout } from './projectTools.mjs';
29
31
  import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
32
+ import { buildMcpInventory } from './mcpInventory.mjs';
33
+ import { knownClaudeMcpInventory, probeMcpNow } from './runtimeCapabilities.mjs';
34
+ import { codexMcpServers } from './runtimeOfferings.mjs';
35
+
36
+ /**
37
+ * ONE READOUT LANE (audit 2026-10-10, F087/F752). The four readouts below —
38
+ * local sessions, repo state, env and tools — were four hand copies of one
39
+ * state machine over module-level `let`s, and their gate orders and refusal
40
+ * rules had drifted without a word. The machine, once:
41
+ *
42
+ * 1. latched quiet? say nothing (`scanBeforeGate` moves this AFTER the
43
+ * build — the env lane, whose scan feeds the scrubber and must run
44
+ * whether or not anything can be posted);
45
+ * 2. scanned less than `scanMs` ago? say nothing, else stamp the scan;
46
+ * 3. build the payload STRING — a throw says nothing (a readout must never
47
+ * throw into the poll), and null means there is nothing to say;
48
+ * 4. equal to the last payload the server ACCEPTED (and, with `resendMs`,
49
+ * accepted inside that window)? say nothing;
50
+ * 5. POST it with the machine credential;
51
+ * 6. a status `isPermanent` names latches the lane quiet until restart;
52
+ * 7. remember the payload only on a 2xx — anything else forgets it, so the
53
+ * next scan retries rather than dedup-suppressing a report nobody got.
54
+ *
55
+ * Each lane's real differences are PARAMETERS, kept exactly as they were:
56
+ * only local sessions has a resend window; local sessions and repo state
57
+ * latch on a 404 alone, env and tools on `envReportIsPermanent`. Unifying the
58
+ * latch rule (wire.md's one "a permanent 4xx is delivered") stays an open
59
+ * call: a 400 latch would silence repo-state for the life of the process
60
+ * after one bad payload.
61
+ *
62
+ * Returns the lane's verdict — 'quiet' | 'throttled' | 'scan-failed' |
63
+ * 'nothing' | 'deduped' | 'accepted' | 'retry' | 'stopped' — which the
64
+ * daemon ignores (every caller is a bare `void`) and the tests drive.
65
+ */
66
+ function createReportLane({
67
+ endpoint,
68
+ scanMs = 60_000,
69
+ resendMs = null,
70
+ timeoutMs = 15_000,
71
+ isPermanent = (status) => status === 404,
72
+ scanBeforeGate = false,
73
+ }) {
74
+ const url = fleetEndpoint(endpoint, FLEET_URL);
75
+ let unsupported = false; // the server refused for good — quiet until restart
76
+ let scanAt = 0;
77
+ let sent = null; // the last payload the server ACCEPTED, stringified
78
+ let sentAt = 0;
79
+ const report = async (build) => {
80
+ if (!scanBeforeGate && unsupported) return 'quiet';
81
+ if (Date.now() - scanAt < scanMs) return 'throttled';
82
+ scanAt = Date.now();
83
+ let payload;
84
+ try {
85
+ payload = build();
86
+ } catch {
87
+ return 'scan-failed';
88
+ }
89
+ if (scanBeforeGate && unsupported) return 'quiet';
90
+ if (payload == null) return 'nothing';
91
+ if (payload === sent && (resendMs == null || Date.now() - sentAt < resendMs)) return 'deduped';
92
+ const r = await fleetRequest(url, { body: payload, timeoutMs });
93
+ if (isPermanent(r.status)) {
94
+ unsupported = true;
95
+ return 'stopped';
96
+ }
97
+ sent = r.ok ? payload : null;
98
+ sentAt = r.ok ? Date.now() : 0;
99
+ return r.ok ? 'accepted' : 'retry';
100
+ };
101
+ /** "Look again on the next beat" — clears the throttle, nothing else. */
102
+ report.rescan = () => {
103
+ scanAt = 0;
104
+ };
105
+ return report;
106
+ }
30
107
 
31
108
  /**
32
109
  * Terminal-session presence: tell the server which Claude sessions exist in
@@ -40,57 +117,17 @@ import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
40
117
  * process stops asking (a deploy that adds it also restarts nothing on this
41
118
  * machine, so silence-until-restart costs one daemon restart, not a feature).
42
119
  */
43
- const LOCAL_SESSIONS_URL = fleetEndpoint('local-sessions', FLEET_URL);
44
- const LOCAL_SESSIONS_SCAN_MS = 60_000;
45
120
  // The web hides a report older than 10 minutes (presence must not linger as
46
121
  // fact after the machine dies), so an UNCHANGED report is re-sent inside that
47
122
  // window anyway — the re-send is the machine's heartbeat on this fact, and
48
123
  // suppressing it entirely would blank the strip while everything still holds.
49
- const LOCAL_SESSIONS_RESEND_MS = 5 * 60_000;
50
- let localSessionsUnsupported = false; // the server 404'd — quiet until restart
51
- let localSessionsScanAt = 0;
52
- let localSessionsSent = null; // last payload the server ACCEPTED, stringified
53
- let localSessionsSentAt = 0;
54
- export async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
55
- if (localSessionsUnsupported) return;
56
- if (Date.now() - localSessionsScanAt < LOCAL_SESSIONS_SCAN_MS) return;
57
- localSessionsScanAt = Date.now();
58
- let payload;
59
- try {
60
- // scanLocalSessions orders deterministically, so this string only changes
61
- // when the facts on disk do — the dedup below compares whole payloads.
62
- payload = JSON.stringify({
63
- sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }),
64
- });
65
- } catch {
66
- return; // presence must never throw into the poll loop
67
- }
68
- if (payload === localSessionsSent && Date.now() - localSessionsSentAt < LOCAL_SESSIONS_RESEND_MS)
69
- return;
70
- try {
71
- const res = await fetch(LOCAL_SESSIONS_URL, {
72
- method: 'POST',
73
- headers: {
74
- Authorization: `Bearer ${FLEET_TOKEN}`,
75
- 'User-Agent': USER_AGENT,
76
- 'Content-Type': 'application/json',
77
- },
78
- signal: AbortSignal.timeout(15_000),
79
- body: payload,
80
- });
81
- if (res.status === 404) {
82
- localSessionsUnsupported = true; // older server — it REPLACED nothing here
83
- return;
84
- }
85
- // Only an accepted report counts as sent; anything else forgets the
86
- // last-sent payload so the next pass retries instead of dedup-suppressing
87
- // a report the server never received.
88
- localSessionsSent = res.ok ? payload : null;
89
- localSessionsSentAt = res.ok ? Date.now() : 0;
90
- } catch {
91
- localSessionsSent = null;
92
- localSessionsSentAt = 0;
93
- }
124
+ const localSessionsLane = createReportLane({ endpoint: 'local-sessions', resendMs: 5 * 60_000 });
125
+ export function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
126
+ // scanLocalSessions orders deterministically, so this string only changes
127
+ // when the facts on disk do — the dedup compares whole payloads.
128
+ return localSessionsLane(() =>
129
+ JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }) })
130
+ );
94
131
  }
95
132
 
96
133
  /**
@@ -108,32 +145,24 @@ export async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeI
108
145
  * exists — so re-posting an unchanged list would be a write per machine per
109
146
  * five minutes to say nothing at all.
110
147
  */
111
- const REPO_STATE_URL = fleetEndpoint('repo-state', FLEET_URL);
112
- const REPO_STATE_SCAN_MS = 60_000;
113
- let repoStateUnsupported = false; // the server 404'd — quiet until restart
114
- let repoStateScanAt = 0;
115
- let repoStateSent = null; // last payload the server ACCEPTED, stringified
116
- export async function maybeReportRepoState({ repoRoot, baseRef }) {
117
- if (repoStateUnsupported) return;
118
- if (Date.now() - repoStateScanAt < REPO_STATE_SCAN_MS) return;
119
- repoStateScanAt = Date.now();
120
- let payload;
121
- try {
148
+ const repoStateLane = createReportLane({ endpoint: 'repo-state' });
149
+ export function maybeReportRepoState({ repoRoot, baseRef }) {
150
+ return repoStateLane(() => {
122
151
  // The PARAM, and nothing else: this function sits at MODULE scope, where
123
152
  // runFleetDaemon's `getBaseRef` closure does not exist. An earlier version
124
- // reached for it anyway, the ReferenceError landed in the catch below —
125
- // written for an unreadable repo, silent by design — and this endpoint was
126
- // never once posted to. The caller reads the loop's live `baseRef` at call
127
- // time, so the value here is always current.
153
+ // reached for it anyway, the ReferenceError landed in the lane's build
154
+ // catch — written for an unreadable repo, silent by design — and this
155
+ // endpoint was never once posted to. The caller reads the loop's live
156
+ // `baseRef` at call time, so the value here is always current.
128
157
  const state = repoState(repoRoot, baseRef);
129
- if (!state) return; // not readable — say nothing rather than say "none"
158
+ if (!state) return null; // not readable — say nothing rather than say "none"
130
159
  /**
131
160
  * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
132
161
  *
133
162
  * It rides this report rather than getting an endpoint of its own because
134
163
  * it is the same KIND of fact — something only the machine can see, pushed
135
164
  * because a pull client can never be asked — and it inherits this
136
- * function's three economies for free: scanned once a minute, deduped
165
+ * lane's three economies for free: scanned once a minute, deduped
137
166
  * against the last ACCEPTED payload, and silent forever once an older
138
167
  * server 404s.
139
168
  *
@@ -149,32 +178,8 @@ export async function maybeReportRepoState({ repoRoot, baseRef }) {
149
178
  * report, and an older SERVER strips the unknown key in its zod parse and
150
179
  * stores the rest exactly as before.
151
180
  */
152
- payload = JSON.stringify({ ...state, auth: claudeAuthContext() });
153
- } catch {
154
- return; // a readout must never throw into the poll loop
155
- }
156
- if (payload === repoStateSent) return;
157
- try {
158
- const res = await fetch(REPO_STATE_URL, {
159
- method: 'POST',
160
- headers: {
161
- Authorization: `Bearer ${FLEET_TOKEN}`,
162
- 'User-Agent': USER_AGENT,
163
- 'Content-Type': 'application/json',
164
- },
165
- signal: AbortSignal.timeout(15_000),
166
- body: payload,
167
- });
168
- if (res.status === 404) {
169
- repoStateUnsupported = true; // older server — nothing was replaced here
170
- return;
171
- }
172
- // Only an ACCEPTED report counts: anything else forgets it so the next
173
- // pass retries rather than dedup-suppressing a report nobody received.
174
- repoStateSent = res.ok ? payload : null;
175
- } catch {
176
- repoStateSent = null;
177
- }
181
+ return JSON.stringify({ ...state, auth: claudeAuthContext() });
182
+ });
178
183
  }
179
184
 
180
185
  /**
@@ -247,16 +252,24 @@ export async function maybeReportRepoState({ repoRoot, baseRef }) {
247
252
  * Pure and exported so the decision can be proved without a credential, a
248
253
  * server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
249
254
  * in holder.mjs.
255
+ *
256
+ * DELIBERATELY NARROWER than fleetWire.mjs's `isConsideredRefusal` (every 4xx
257
+ * but 408/429), which the settle lanes read: a settle that treats a 401 as
258
+ * decided loses one body, a readout that latches on it goes dark for the life
259
+ * of the process. Two vocabularies, each named, never folded together.
250
260
  */
251
261
  export function envReportIsPermanent(status) {
252
262
  return status === 400 || status === 404 || status === 422;
253
263
  }
254
264
 
255
- const ENV_REPORT_URL = fleetEndpoint('env-report', FLEET_URL);
256
- const ENV_REPORT_SCAN_MS = 60_000;
257
- let envReportUnsupported = false; // the server refused permanently — quiet until restart
258
- let envReportScanAt = 0;
259
- let envReportSent = null; // the last report the server ACCEPTED, stringified
265
+ // Scans BEFORE the latch: the scrubber has no dedup and must reflect the
266
+ // newest read every time, so a server that cannot take the report never costs
267
+ // this box its redaction (see the docblock above `envReportIsPermanent`).
268
+ const envLane = createReportLane({
269
+ endpoint: 'env-report',
270
+ isPermanent: envReportIsPermanent,
271
+ scanBeforeGate: true,
272
+ });
260
273
  /**
261
274
  * Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
262
275
  * 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
@@ -265,83 +278,71 @@ let envReportSent = null; // the last report the server ACCEPTED, stringified
265
278
  * readout whose only proof is that its source LOOKS right is the inert-pin
266
279
  * class this repo has caught five times.
267
280
  */
268
- export async function maybeReportEnv(repoRoot) {
269
- if (Date.now() - envReportScanAt < ENV_REPORT_SCAN_MS) return 'throttled';
270
- envReportScanAt = Date.now();
271
- let report;
272
- try {
281
+ export function maybeReportEnv(repoRoot) {
282
+ return envLane(() => {
273
283
  // The SCAN happens even when the POST cannot — see the docblock: the
274
284
  // scrubber has no dedup and must reflect the newest read every time.
275
- report = scanEnvForScrub(repoRoot);
276
- } catch {
277
- return 'scan-failed'; // a readout must never throw into the poll loop
278
- }
279
- if (envReportUnsupported) return 'quiet';
280
- const payload = JSON.stringify({
281
- pubkey: myPubB64(),
282
- files: report.files,
283
- filesTotal: report.filesTotal,
284
- varsTotal: report.varsTotal,
285
- });
286
- if (payload === envReportSent) return 'deduped';
287
- try {
288
- const res = await fetch(ENV_REPORT_URL, {
289
- method: 'POST',
290
- headers: {
291
- Authorization: `Bearer ${FLEET_TOKEN}`,
292
- 'User-Agent': USER_AGENT,
293
- 'Content-Type': 'application/json',
294
- },
295
- signal: AbortSignal.timeout(15_000),
296
- body: payload,
285
+ const report = scanEnvForScrub(repoRoot);
286
+ return JSON.stringify({
287
+ pubkey: myPubB64(),
288
+ files: report.files,
289
+ filesTotal: report.filesTotal,
290
+ varsTotal: report.varsTotal,
297
291
  });
298
- // Only a refusal that cannot change stops the lane — see
299
- // `envReportIsPermanent`. A 429 or a 401 during a credential blip is a
300
- // "later", and treating it as a "never" killed env reporting for the life
301
- // of the process with nothing anywhere saying so.
302
- if (envReportIsPermanent(res.status)) {
303
- envReportUnsupported = true;
304
- return 'stopped';
305
- }
306
- // Only an ACCEPTED report counts, the rule `maybeReportRepoState` keeps:
307
- // anything else forgets it so the next pass retries rather than
308
- // dedup-suppressing a report nobody received.
309
- envReportSent = res.ok ? payload : null;
310
- return res.ok ? 'accepted' : 'retry';
311
- } catch {
312
- envReportSent = null;
313
- return 'retry';
314
- }
292
+ });
315
293
  }
316
294
 
317
- const TOOLS_REPORT_URL = fleetEndpoint('tools-report', FLEET_URL);
318
- let toolsReportSent = null;
319
- let toolsReportUnsupported = false;
320
- let toolsReportAt = 0;
321
- /** Repo tool readiness is a per-box daemon report. Never include env values. */
322
- export async function maybeReportTools(repoRoot, baseRef) {
323
- if (Date.now() - toolsReportAt < 60_000) return;
324
- toolsReportAt = Date.now();
325
- if (toolsReportUnsupported) return;
326
- const pubkey = myPubB64();
327
- if (!pubkey) return;
328
- let payload;
329
- try {
295
+ // Latches on the env lane's short list for the same reason: a 401 or a 429 is
296
+ // a "later", and a lane that dies invisibly must not die for one.
297
+ const toolsLane = createReportLane({ endpoint: 'tools-report', isPermanent: envReportIsPermanent });
298
+ /**
299
+ * Repo tool readiness is a per-box daemon report. Never include env values.
300
+ *
301
+ * `mcp` (2026-10-13) is this box's MCP inventory — every server each installed
302
+ * CLI has, by name, source and status, with a connected server's tool names
303
+ * (mcpInventory.mjs). It rides here rather than on the poll because tool names
304
+ * do not fit a URL, and this lane already posts only on change. An older
305
+ * server's schema strips the key.
306
+ */
307
+ export function maybeReportTools(repoRoot, baseRef) {
308
+ return toolsLane(() => {
309
+ const pubkey = myPubB64();
310
+ if (!pubkey) return null;
330
311
  const snapshot = readBaseTools(repoRoot, baseRef);
331
312
  const installed = detectRuntimes().filter((r) => r.installed);
332
313
  const runner = installed.find((r) => runnerToolCapabilities(r.id).mcp)?.id ?? installed[0]?.id ?? 'none';
333
- payload = JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
334
- } catch { return; }
335
- if (payload === toolsReportSent) return;
314
+ const ids = installed.map((r) => r.id);
315
+ let codex = null;
316
+ if (ids.includes('codex')) {
317
+ try { codex = codexMcpServers(); } catch { /* a failed local read is unknown */ }
318
+ }
319
+ const mcp = buildMcpInventory({ installed: ids, snapshot, claude: knownClaudeMcpInventory(), codex });
320
+ return JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner), mcp });
321
+ });
322
+ }
323
+
324
+ /**
325
+ * LOOK AGAIN AT THIS BOX'S MCP SERVERS, NOW (2026-10-13) — after a confirmed
326
+ * install (mcpInstall.mjs). Codex's list is re-read, Claude Code is probed
327
+ * with the base branch's servers mounted (`probeMcpNow`), and the tool report
328
+ * — whose "command not on PATH" was measured before the install — is rescanned
329
+ * and posted without waiting for its beat. Every step is best-effort.
330
+ */
331
+ export async function recheckProjectMcp(repoRoot, baseRef) {
336
332
  try {
337
- const res = await fetch(TOOLS_REPORT_URL, {
338
- method: 'POST',
339
- headers: { Authorization: `Bearer ${FLEET_TOKEN}`, 'User-Agent': USER_AGENT, 'Content-Type': 'application/json' },
340
- signal: AbortSignal.timeout(15_000), body: payload,
341
- });
342
- if (envReportIsPermanent(res.status)) toolsReportUnsupported = true;
343
- toolsReportSent = res.ok ? payload : null;
344
- } catch { toolsReportSent = null; }
333
+ if (detectRuntimes().some((r) => r.id === 'codex' && r.installed)) codexMcpServers({ refresh: true });
334
+ } catch { /* unknown stays unknown */ }
335
+ if (detectRuntimes().some((r) => r.id === 'claude' && r.installed)) {
336
+ let tools = null;
337
+ try {
338
+ tools = prepareAgentTools(readBaseTools(repoRoot, baseRef), 'claude');
339
+ await probeMcpNow(repoRoot, tools.mcpPath);
340
+ } catch { /* no base read, or no claude: the next turn teaches it */ } finally {
341
+ tools?.cleanup();
342
+ }
343
+ }
344
+ toolsLane.rescan();
345
+ await maybeReportTools(repoRoot, baseRef);
345
346
  }
346
347
 
347
348
  /**
@@ -350,7 +351,7 @@ export async function maybeReportTools(repoRoot, baseRef) {
350
351
  * it (through fleet.mjs's `onRepoChanged`) after a ship, a retirement or a cut.
351
352
  */
352
353
  export function repoStateChanged() {
353
- repoStateScanAt = 0;
354
+ repoStateLane.rescan();
354
355
  }
355
356
 
356
357
  const MACHINE_URL = fleetEndpoint('machine', FLEET_URL);
@@ -11,14 +11,13 @@
11
11
 
12
12
  import { mkdirSync, readdirSync, statSync } from 'node:fs';
13
13
  import { execFileSync } from 'node:child_process';
14
- import { createHash } from 'node:crypto';
15
- import { homedir } from 'node:os';
16
- import { join, basename } from 'node:path';
14
+ import { join } from 'node:path';
17
15
  import { FLEET_TOKEN } from './config.mjs';
18
16
  import { git } from './git.mjs';
19
17
  import { info, note, warn, fail } from './ui.mjs';
20
18
  import { acquireInstanceLock } from './instance.mjs';
21
19
  import { emitMachineEvent } from './daemonLogging.mjs';
20
+ import { worktreeHomeDir, worktreeHomeKey } from './statePaths.mjs';
22
21
 
23
22
  /** Takes the lock, or says why not and exits 1. Returns only on success. */
24
23
  export function takeInstanceLockOrExit(repoRoot) {
@@ -90,9 +89,10 @@ export function takeInstanceLockOrExit(repoRoot) {
90
89
  */
91
90
  export function worktreeHome(repoRoot) {
92
91
  // Persistent worktree home (0.9.0) — survives daemon restarts AND reboots,
93
- // so Ctrl+C mid-task never loses local work. Keyed per repo path.
94
- const repoKey = `${basename(repoRoot)}-${createHash('sha256').update(repoRoot).digest('hex').slice(0, 8)}`;
95
- const baseDir = join(homedir(), '.flowviant', 'worktrees', repoKey);
92
+ // so Ctrl+C mid-task never loses local work. Keyed per repo path, by the
93
+ // one derivation deploy checkouts and `flowviant clean` share (statePaths.mjs).
94
+ const repoKey = worktreeHomeKey(repoRoot);
95
+ const baseDir = worktreeHomeDir(repoRoot);
96
96
  mkdirSync(baseDir, { recursive: true });
97
97
  return { repoKey, baseDir };
98
98
  }
@@ -44,3 +44,45 @@ export function fleetEndpoint(name, rosterUrl) {
44
44
  }
45
45
  return String(rosterUrl).replace(ROSTER_TAIL, `/${name}`);
46
46
  }
47
+
48
+ /*
49
+ * ── WHAT A STATUS SAYS (audit 2026-10-10, F139/F155) ───────────────────────
50
+ *
51
+ * Whether a failed settle is retried or treated as decided is a correctness
52
+ * rule: wrong one way and a side-effecting turn re-runs (workReportQueue's
53
+ * 'reject' history, workAgentTurnReports' six-hour loop); wrong the other way
54
+ * and a refused body is hammered forever. It was spelled as the literal
55
+ * expression below five times and re-derived twice more as if-chains, so a
56
+ * new "later" status (425, say) was seven edits. These are the vocabulary;
57
+ * each lane still chooses its own POLICY over them (held and backed off,
58
+ * counted as delivered, latched quiet), and `envReportIsPermanent`
59
+ * (fleetReports.mjs) is deliberately NARROWER and stays its own rule.
60
+ *
61
+ * Pure and config-free like `fleetEndpoint`, so a module handed URLs rather
62
+ * than config (the artifact reporter) can read them too.
63
+ */
64
+
65
+ /** "Not now": a timeout or a rate limit wearing a 4xx. Nothing was decided. */
66
+ export function isNotNow(status) {
67
+ return status === 408 || status === 429;
68
+ }
69
+
70
+ /** The server's CONSIDERED answer: a 4xx other than 408/429. Retrying it
71
+ * only spends the same refusal again. A missing status (a network error)
72
+ * is never a refusal. */
73
+ export function isConsideredRefusal(status) {
74
+ return status >= 400 && status < 500 && status !== 408 && status !== 429;
75
+ }
76
+
77
+ /** Retry-After, in ms — seconds or an HTTP-date — capped so one bad or
78
+ * hostile header cannot stall a caller for an hour. Falls back to the
79
+ * caller's own backoff when the header is absent or unparseable. (Moved
80
+ * verbatim from workReportQueue.mjs, 2026-10-10.) */
81
+ export function retryAfterMs(res, fallbackMs) {
82
+ const h = res?.headers?.get?.('retry-after');
83
+ if (h == null) return fallbackMs;
84
+ const secs = Number(h);
85
+ if (Number.isFinite(secs)) return Math.max(0, Math.min(secs * 1000, 60_000));
86
+ const at = Date.parse(h);
87
+ return Number.isFinite(at) ? Math.max(0, Math.min(at - Date.now(), 60_000)) : fallbackMs;
88
+ }