flowviant 0.116.0 → 0.117.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 (135) hide show
  1. package/README.md +31 -12
  2. package/bin/cli.mjs +13 -17
  3. package/bin/lib/admission.mjs +7 -14
  4. package/bin/lib/agentInternalWait.mjs +83 -0
  5. package/bin/lib/agentPlan.mjs +56 -24
  6. package/bin/lib/agentPublish.mjs +12 -13
  7. package/bin/lib/agentTaskKinds.mjs +4 -7
  8. package/bin/lib/agySessions.mjs +3 -3
  9. package/bin/lib/artifactPreview.mjs +92 -18
  10. package/bin/lib/artifactReporter.mjs +16 -8
  11. package/bin/lib/artifacts.mjs +64 -60
  12. package/bin/lib/authproxy.mjs +2 -1
  13. package/bin/lib/childEnv.mjs +9 -4
  14. package/bin/lib/claude.mjs +10 -20
  15. package/bin/lib/claudePersonal.mjs +71 -6
  16. package/bin/lib/claudePosture.mjs +119 -42
  17. package/bin/lib/claudeSessions.mjs +7 -7
  18. package/bin/lib/claudeStream.mjs +7 -11
  19. package/bin/lib/cliLimit.mjs +2 -2
  20. package/bin/lib/codexHome.mjs +17 -0
  21. package/bin/lib/codexPersonal.mjs +44 -3
  22. package/bin/lib/codexRelay.mjs +21 -5
  23. package/bin/lib/codexResearch.mjs +180 -0
  24. package/bin/lib/config.mjs +37 -24
  25. package/bin/lib/conversationIds.mjs +51 -0
  26. package/bin/lib/credentials.mjs +2 -7
  27. package/bin/lib/daemonLocks.mjs +88 -0
  28. package/bin/lib/daemonState.mjs +5 -5
  29. package/bin/lib/deploy.mjs +6 -9
  30. package/bin/lib/deployCheckout.mjs +26 -22
  31. package/bin/lib/deployRunner.mjs +54 -18
  32. package/bin/lib/deployWire.mjs +32 -23
  33. package/bin/lib/desktopContract.mjs +3 -5
  34. package/bin/lib/env.mjs +104 -35
  35. package/bin/lib/fleet.mjs +7 -9
  36. package/bin/lib/fleetPost.mjs +172 -25
  37. package/bin/lib/fleetReports.mjs +130 -170
  38. package/bin/lib/fleetStartup.mjs +6 -6
  39. package/bin/lib/fleetWire.mjs +42 -0
  40. package/bin/lib/git.mjs +111 -61
  41. package/bin/lib/gitMergeIdentity.mjs +2 -8
  42. package/bin/lib/grant.mjs +23 -7
  43. package/bin/lib/holder.mjs +108 -81
  44. package/bin/lib/hooks/readGuard.mjs +41 -7
  45. package/bin/lib/install.mjs +80 -33
  46. package/bin/lib/instance.mjs +36 -83
  47. package/bin/lib/intakeDraft.mjs +4 -1
  48. package/bin/lib/knowledgeFileSync.mjs +31 -0
  49. package/bin/lib/knowledgeFiles.mjs +28 -83
  50. package/bin/lib/landed.mjs +22 -29
  51. package/bin/lib/launchCommand.mjs +91 -0
  52. package/bin/lib/listeners.mjs +0 -5
  53. package/bin/lib/localSessions.mjs +52 -39
  54. package/bin/lib/login.mjs +22 -12
  55. package/bin/lib/loginPath.mjs +22 -17
  56. package/bin/lib/machineListing.mjs +6 -3
  57. package/bin/lib/mcp-cli.mjs +9 -10
  58. package/bin/lib/noWindowEnv.mjs +23 -3
  59. package/bin/lib/ownedDirs.mjs +99 -0
  60. package/bin/lib/pinnedCapture.mjs +383 -0
  61. package/bin/lib/prWorkflow.mjs +94 -17
  62. package/bin/lib/preflight.mjs +8 -25
  63. package/bin/lib/preview.mjs +19 -25
  64. package/bin/lib/previewGatePolicy.mjs +34 -9
  65. package/bin/lib/previewRegistry.mjs +4 -11
  66. package/bin/lib/printable.mjs +70 -7
  67. package/bin/lib/procRegistry.mjs +33 -13
  68. package/bin/lib/procScan.mjs +2 -3
  69. package/bin/lib/procStat.mjs +76 -0
  70. package/bin/lib/processGroup.mjs +48 -0
  71. package/bin/lib/processes.mjs +10 -24
  72. package/bin/lib/projectToolRuntimes.mjs +101 -16
  73. package/bin/lib/projectTools.mjs +50 -32
  74. package/bin/lib/prompts.mjs +41 -19
  75. package/bin/lib/repoPath.mjs +19 -0
  76. package/bin/lib/repoState.mjs +17 -19
  77. package/bin/lib/runTurn.mjs +268 -210
  78. package/bin/lib/runtimeAntigravity.mjs +16 -5
  79. package/bin/lib/runtimeCapabilities.mjs +140 -20
  80. package/bin/lib/runtimeClaude.mjs +55 -5
  81. package/bin/lib/runtimeCodex.mjs +245 -205
  82. package/bin/lib/runtimeDetection.mjs +2 -2
  83. package/bin/lib/runtimeEvents.mjs +16 -10
  84. package/bin/lib/runtimeLimits.mjs +5 -6
  85. package/bin/lib/runtimeOfferings.mjs +24 -11
  86. package/bin/lib/runtimes.mjs +10 -0
  87. package/bin/lib/shipSweep.mjs +5 -3
  88. package/bin/lib/shot.mjs +91 -17
  89. package/bin/lib/sleep.mjs +11 -0
  90. package/bin/lib/startProject.mjs +5 -5
  91. package/bin/lib/statePaths.mjs +40 -0
  92. package/bin/lib/tty.mjs +25 -15
  93. package/bin/lib/turnLock.mjs +7 -14
  94. package/bin/lib/turnProfile.mjs +1 -1
  95. package/bin/lib/ui.mjs +0 -3
  96. package/bin/lib/uninstall.mjs +30 -26
  97. package/bin/lib/update.mjs +79 -27
  98. package/bin/lib/uplinkScrub.mjs +296 -31
  99. package/bin/lib/vault.mjs +78 -10
  100. package/bin/lib/vaultDiff.mjs +5 -2
  101. package/bin/lib/views.mjs +37 -12
  102. package/bin/lib/wikiRunner.mjs +40 -61
  103. package/bin/lib/work.mjs +17 -7
  104. package/bin/lib/workAdoptCarry.mjs +2 -4
  105. package/bin/lib/workAgentCheck.mjs +104 -57
  106. package/bin/lib/workAgentMerges.mjs +62 -57
  107. package/bin/lib/workAgentPlans.mjs +71 -139
  108. package/bin/lib/workAgentPrecheck.mjs +71 -138
  109. package/bin/lib/workAgentPublish.mjs +6 -8
  110. package/bin/lib/workAgentTurnBegun.mjs +15 -11
  111. package/bin/lib/workAgentTurnExecution.mjs +46 -53
  112. package/bin/lib/workAgentTurnReports.mjs +9 -37
  113. package/bin/lib/workAgentTurns.mjs +4 -3
  114. package/bin/lib/workAttachments.mjs +66 -11
  115. package/bin/lib/workBrain.mjs +9 -4
  116. package/bin/lib/workCappedTurn.mjs +109 -0
  117. package/bin/lib/workChildren.mjs +5 -5
  118. package/bin/lib/workCommandAudit.mjs +11 -2
  119. package/bin/lib/workDiffs.mjs +52 -6
  120. package/bin/lib/workIntake.mjs +35 -60
  121. package/bin/lib/workPlaceReports.mjs +7 -4
  122. package/bin/lib/workPlaces.mjs +96 -30
  123. package/bin/lib/workPreviews.mjs +47 -112
  124. package/bin/lib/workProcesses.mjs +8 -35
  125. package/bin/lib/workPullRequests.mjs +37 -56
  126. package/bin/lib/workReportQueue.mjs +100 -148
  127. package/bin/lib/workRetire.mjs +2 -1
  128. package/bin/lib/workSessionRuntime.mjs +38 -37
  129. package/bin/lib/workSessionTurns.mjs +111 -189
  130. package/bin/lib/workShip.mjs +43 -23
  131. package/bin/lib/workTurnShape.mjs +163 -0
  132. package/npm-shrinkwrap.json +59 -0
  133. package/package.json +3 -2
  134. package/bin/lib/fleetJobs.mjs +0 -120
  135. 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';
@@ -28,6 +30,78 @@ import { claudeAuthContext } from './claudeAuth.mjs';
28
30
  import { readBaseTools, toolReadout } from './projectTools.mjs';
29
31
  import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
30
32
 
33
+ /**
34
+ * ONE READOUT LANE (audit 2026-10-10, F087/F752). The four readouts below —
35
+ * local sessions, repo state, env and tools — were four hand copies of one
36
+ * state machine over module-level `let`s, and their gate orders and refusal
37
+ * rules had drifted without a word. The machine, once:
38
+ *
39
+ * 1. latched quiet? say nothing (`scanBeforeGate` moves this AFTER the
40
+ * build — the env lane, whose scan feeds the scrubber and must run
41
+ * whether or not anything can be posted);
42
+ * 2. scanned less than `scanMs` ago? say nothing, else stamp the scan;
43
+ * 3. build the payload STRING — a throw says nothing (a readout must never
44
+ * throw into the poll), and null means there is nothing to say;
45
+ * 4. equal to the last payload the server ACCEPTED (and, with `resendMs`,
46
+ * accepted inside that window)? say nothing;
47
+ * 5. POST it with the machine credential;
48
+ * 6. a status `isPermanent` names latches the lane quiet until restart;
49
+ * 7. remember the payload only on a 2xx — anything else forgets it, so the
50
+ * next scan retries rather than dedup-suppressing a report nobody got.
51
+ *
52
+ * Each lane's real differences are PARAMETERS, kept exactly as they were:
53
+ * only local sessions has a resend window; local sessions and repo state
54
+ * latch on a 404 alone, env and tools on `envReportIsPermanent`. Unifying the
55
+ * latch rule (wire.md's one "a permanent 4xx is delivered") stays an open
56
+ * call: a 400 latch would silence repo-state for the life of the process
57
+ * after one bad payload.
58
+ *
59
+ * Returns the lane's verdict — 'quiet' | 'throttled' | 'scan-failed' |
60
+ * 'nothing' | 'deduped' | 'accepted' | 'retry' | 'stopped' — which the
61
+ * daemon ignores (every caller is a bare `void`) and the tests drive.
62
+ */
63
+ function createReportLane({
64
+ endpoint,
65
+ scanMs = 60_000,
66
+ resendMs = null,
67
+ timeoutMs = 15_000,
68
+ isPermanent = (status) => status === 404,
69
+ scanBeforeGate = false,
70
+ }) {
71
+ const url = fleetEndpoint(endpoint, FLEET_URL);
72
+ let unsupported = false; // the server refused for good — quiet until restart
73
+ let scanAt = 0;
74
+ let sent = null; // the last payload the server ACCEPTED, stringified
75
+ let sentAt = 0;
76
+ const report = async (build) => {
77
+ if (!scanBeforeGate && unsupported) return 'quiet';
78
+ if (Date.now() - scanAt < scanMs) return 'throttled';
79
+ scanAt = Date.now();
80
+ let payload;
81
+ try {
82
+ payload = build();
83
+ } catch {
84
+ return 'scan-failed';
85
+ }
86
+ if (scanBeforeGate && unsupported) return 'quiet';
87
+ if (payload == null) return 'nothing';
88
+ if (payload === sent && (resendMs == null || Date.now() - sentAt < resendMs)) return 'deduped';
89
+ const r = await fleetRequest(url, { body: payload, timeoutMs });
90
+ if (isPermanent(r.status)) {
91
+ unsupported = true;
92
+ return 'stopped';
93
+ }
94
+ sent = r.ok ? payload : null;
95
+ sentAt = r.ok ? Date.now() : 0;
96
+ return r.ok ? 'accepted' : 'retry';
97
+ };
98
+ /** "Look again on the next beat" — clears the throttle, nothing else. */
99
+ report.rescan = () => {
100
+ scanAt = 0;
101
+ };
102
+ return report;
103
+ }
104
+
31
105
  /**
32
106
  * Terminal-session presence: tell the server which Claude sessions exist in
33
107
  * this repo (localSessions.mjs reads them off Claude's own on-disk state), so
@@ -40,57 +114,17 @@ import { runnerToolCapabilities } from './projectToolRuntimes.mjs';
40
114
  * process stops asking (a deploy that adds it also restarts nothing on this
41
115
  * machine, so silence-until-restart costs one daemon restart, not a feature).
42
116
  */
43
- const LOCAL_SESSIONS_URL = fleetEndpoint('local-sessions', FLEET_URL);
44
- const LOCAL_SESSIONS_SCAN_MS = 60_000;
45
117
  // The web hides a report older than 10 minutes (presence must not linger as
46
118
  // fact after the machine dies), so an UNCHANGED report is re-sent inside that
47
119
  // window anyway — the re-send is the machine's heartbeat on this fact, and
48
120
  // 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
- }
121
+ const localSessionsLane = createReportLane({ endpoint: 'local-sessions', resendMs: 5 * 60_000 });
122
+ export function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeIds }) {
123
+ // scanLocalSessions orders deterministically, so this string only changes
124
+ // when the facts on disk do — the dedup compares whole payloads.
125
+ return localSessionsLane(() =>
126
+ JSON.stringify({ sessions: scanLocalSessions({ repoRoot, excludeDirs, excludeIds }) })
127
+ );
94
128
  }
95
129
 
96
130
  /**
@@ -108,32 +142,24 @@ export async function maybeReportLocalSessions({ repoRoot, excludeDirs, excludeI
108
142
  * exists — so re-posting an unchanged list would be a write per machine per
109
143
  * five minutes to say nothing at all.
110
144
  */
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 {
145
+ const repoStateLane = createReportLane({ endpoint: 'repo-state' });
146
+ export function maybeReportRepoState({ repoRoot, baseRef }) {
147
+ return repoStateLane(() => {
122
148
  // The PARAM, and nothing else: this function sits at MODULE scope, where
123
149
  // 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.
150
+ // reached for it anyway, the ReferenceError landed in the lane's build
151
+ // catch — written for an unreadable repo, silent by design — and this
152
+ // endpoint was never once posted to. The caller reads the loop's live
153
+ // `baseRef` at call time, so the value here is always current.
128
154
  const state = repoState(repoRoot, baseRef);
129
- if (!state) return; // not readable — say nothing rather than say "none"
155
+ if (!state) return null; // not readable — say nothing rather than say "none"
130
156
  /**
131
157
  * WHICH CREDENTIAL THIS MACHINE'S CLI RESOLVES, on the same beat.
132
158
  *
133
159
  * It rides this report rather than getting an endpoint of its own because
134
160
  * it is the same KIND of fact — something only the machine can see, pushed
135
161
  * because a pull client can never be asked — and it inherits this
136
- * function's three economies for free: scanned once a minute, deduped
162
+ * lane's three economies for free: scanned once a minute, deduped
137
163
  * against the last ACCEPTED payload, and silent forever once an older
138
164
  * server 404s.
139
165
  *
@@ -149,32 +175,8 @@ export async function maybeReportRepoState({ repoRoot, baseRef }) {
149
175
  * report, and an older SERVER strips the unknown key in its zod parse and
150
176
  * stores the rest exactly as before.
151
177
  */
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
- }
178
+ return JSON.stringify({ ...state, auth: claudeAuthContext() });
179
+ });
178
180
  }
179
181
 
180
182
  /**
@@ -247,16 +249,24 @@ export async function maybeReportRepoState({ repoRoot, baseRef }) {
247
249
  * Pure and exported so the decision can be proved without a credential, a
248
250
  * server or a poll — the same shape `shouldStop` and `createHolderWatch` keep
249
251
  * in holder.mjs.
252
+ *
253
+ * DELIBERATELY NARROWER than fleetWire.mjs's `isConsideredRefusal` (every 4xx
254
+ * but 408/429), which the settle lanes read: a settle that treats a 401 as
255
+ * decided loses one body, a readout that latches on it goes dark for the life
256
+ * of the process. Two vocabularies, each named, never folded together.
250
257
  */
251
258
  export function envReportIsPermanent(status) {
252
259
  return status === 400 || status === 404 || status === 422;
253
260
  }
254
261
 
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
262
+ // Scans BEFORE the latch: the scrubber has no dedup and must reflect the
263
+ // newest read every time, so a server that cannot take the report never costs
264
+ // this box its redaction (see the docblock above `envReportIsPermanent`).
265
+ const envLane = createReportLane({
266
+ endpoint: 'env-report',
267
+ isPermanent: envReportIsPermanent,
268
+ scanBeforeGate: true,
269
+ });
260
270
  /**
261
271
  * Returns a one-word VERDICT — 'throttled' | 'scan-failed' | 'quiet' |
262
272
  * 'deduped' | 'accepted' | 'retry' | 'stopped'. The daemon ignores it (the
@@ -265,83 +275,33 @@ let envReportSent = null; // the last report the server ACCEPTED, stringified
265
275
  * readout whose only proof is that its source LOOKS right is the inert-pin
266
276
  * class this repo has caught five times.
267
277
  */
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 {
278
+ export function maybeReportEnv(repoRoot) {
279
+ return envLane(() => {
273
280
  // The SCAN happens even when the POST cannot — see the docblock: the
274
281
  // 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,
282
+ const report = scanEnvForScrub(repoRoot);
283
+ return JSON.stringify({
284
+ pubkey: myPubB64(),
285
+ files: report.files,
286
+ filesTotal: report.filesTotal,
287
+ varsTotal: report.varsTotal,
297
288
  });
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
- }
289
+ });
315
290
  }
316
291
 
317
- const TOOLS_REPORT_URL = fleetEndpoint('tools-report', FLEET_URL);
318
- let toolsReportSent = null;
319
- let toolsReportUnsupported = false;
320
- let toolsReportAt = 0;
292
+ // Latches on the env lane's short list for the same reason: a 401 or a 429 is
293
+ // a "later", and a lane that dies invisibly must not die for one.
294
+ const toolsLane = createReportLane({ endpoint: 'tools-report', isPermanent: envReportIsPermanent });
321
295
  /** 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 {
296
+ export function maybeReportTools(repoRoot, baseRef) {
297
+ return toolsLane(() => {
298
+ const pubkey = myPubB64();
299
+ if (!pubkey) return null;
330
300
  const snapshot = readBaseTools(repoRoot, baseRef);
331
301
  const installed = detectRuntimes().filter((r) => r.installed);
332
302
  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;
336
- 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; }
303
+ return JSON.stringify({ pubkey, tools: toolReadout(snapshot, runner) });
304
+ });
345
305
  }
346
306
 
347
307
  /**
@@ -350,7 +310,7 @@ export async function maybeReportTools(repoRoot, baseRef) {
350
310
  * it (through fleet.mjs's `onRepoChanged`) after a ship, a retirement or a cut.
351
311
  */
352
312
  export function repoStateChanged() {
353
- repoStateScanAt = 0;
313
+ repoStateLane.rescan();
354
314
  }
355
315
 
356
316
  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
+ }