flowviant 0.103.0 → 0.104.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 (140) hide show
  1. package/bin/cli.mjs +87 -474
  2. package/bin/lib/admission.mjs +19 -3
  3. package/bin/lib/agentPlan.mjs +2 -2
  4. package/bin/lib/agentTaskKinds.mjs +93 -0
  5. package/bin/lib/agySessions.mjs +158 -0
  6. package/bin/lib/artifactPolicy.mjs +44 -0
  7. package/bin/lib/artifactPreview.mjs +52 -0
  8. package/bin/lib/artifactReporter.mjs +163 -0
  9. package/bin/lib/artifacts.mjs +47 -227
  10. package/bin/lib/authReject.mjs +16 -4
  11. package/bin/lib/authproxy.mjs +47 -304
  12. package/bin/lib/boxIdentity.mjs +245 -0
  13. package/bin/lib/childEnv.mjs +11 -9
  14. package/bin/lib/claude.mjs +14 -851
  15. package/bin/lib/claudeAuth.mjs +3 -3
  16. package/bin/lib/claudePosture.mjs +367 -0
  17. package/bin/lib/claudeSessions.mjs +411 -0
  18. package/bin/lib/claudeStream.mjs +207 -0
  19. package/bin/lib/cliLimit.mjs +34 -0
  20. package/bin/lib/commandSpecs.mjs +173 -0
  21. package/bin/lib/config.mjs +11 -42
  22. package/bin/lib/credentialDate.mjs +22 -0
  23. package/bin/lib/credentials.mjs +21 -26
  24. package/bin/lib/daemonLogging.mjs +55 -0
  25. package/bin/lib/daemonState.mjs +64 -0
  26. package/bin/lib/deploy.mjs +148 -647
  27. package/bin/lib/deployCheckout.mjs +265 -0
  28. package/bin/lib/deployConfig.mjs +142 -0
  29. package/bin/lib/deployRunner.mjs +311 -0
  30. package/bin/lib/deployWire.mjs +41 -0
  31. package/bin/lib/desktopContract.mjs +56 -105
  32. package/bin/lib/env.mjs +49 -484
  33. package/bin/lib/fleet.mjs +183 -2383
  34. package/bin/lib/fleetJobs.mjs +120 -0
  35. package/bin/lib/fleetPost.mjs +40 -0
  36. package/bin/lib/fleetReports.mjs +392 -0
  37. package/bin/lib/fleetRoster.mjs +470 -0
  38. package/bin/lib/fleetStartup.mjs +196 -0
  39. package/bin/lib/fleetWake.mjs +45 -0
  40. package/bin/lib/fleetWire.mjs +46 -0
  41. package/bin/lib/git.mjs +2 -2
  42. package/bin/lib/gitMergeIdentity.mjs +51 -0
  43. package/bin/lib/grant.mjs +5 -3
  44. package/bin/lib/help.mjs +3 -125
  45. package/bin/lib/holder.mjs +482 -0
  46. package/bin/lib/instance.mjs +124 -46
  47. package/bin/lib/knowledge.mjs +16 -799
  48. package/bin/lib/knowledgeFetch.mjs +38 -0
  49. package/bin/lib/knowledgeFileSync.mjs +112 -0
  50. package/bin/lib/knowledgeFiles.mjs +487 -0
  51. package/bin/lib/knowledgeLibrary.mjs +249 -0
  52. package/bin/lib/landed.mjs +2 -1
  53. package/bin/lib/listeners.mjs +99 -141
  54. package/bin/lib/localSessions.mjs +30 -555
  55. package/bin/lib/login.mjs +3 -2
  56. package/bin/lib/loginPath.mjs +3 -5
  57. package/bin/lib/machineDisconnect.mjs +203 -0
  58. package/bin/lib/machineEnv.mjs +22 -0
  59. package/bin/lib/machineLimits.mjs +61 -0
  60. package/bin/lib/machineListing.mjs +338 -0
  61. package/bin/lib/machines.mjs +78 -455
  62. package/bin/lib/machinesCommand.mjs +214 -0
  63. package/bin/lib/mcp-cli.mjs +2 -1
  64. package/bin/lib/patchRevert.mjs +60 -0
  65. package/bin/lib/prWorkflow.mjs +157 -0
  66. package/bin/lib/preflight.mjs +2 -1
  67. package/bin/lib/pressurePolicy.mjs +104 -0
  68. package/bin/lib/preview.mjs +2 -272
  69. package/bin/lib/previewFramePolicy.mjs +112 -0
  70. package/bin/lib/previewGatePolicy.mjs +236 -0
  71. package/bin/lib/previewRegistry.mjs +235 -0
  72. package/bin/lib/printable.mjs +2 -2
  73. package/bin/lib/procRegistry.mjs +28 -18
  74. package/bin/lib/procScan.mjs +146 -0
  75. package/bin/lib/processes.mjs +29 -37
  76. package/bin/lib/projectToolRuntimes.mjs +138 -0
  77. package/bin/lib/projectTools.mjs +13 -88
  78. package/bin/lib/prompts.mjs +28 -42
  79. package/bin/lib/repoPath.mjs +26 -0
  80. package/bin/lib/resources.mjs +7 -69
  81. package/bin/lib/resumeLost.mjs +107 -0
  82. package/bin/lib/runTurn.mjs +324 -0
  83. package/bin/lib/runtimeAntigravity.mjs +230 -0
  84. package/bin/lib/runtimeCapabilities.mjs +334 -0
  85. package/bin/lib/runtimeClaude.mjs +126 -0
  86. package/bin/lib/runtimeCodex.mjs +217 -0
  87. package/bin/lib/runtimeDetection.mjs +121 -0
  88. package/bin/lib/runtimeEvents.mjs +482 -0
  89. package/bin/lib/runtimeOfferings.mjs +4 -1
  90. package/bin/lib/runtimes.mjs +42 -1402
  91. package/bin/lib/safeFileName.mjs +74 -0
  92. package/bin/lib/standDownExit.mjs +181 -0
  93. package/bin/lib/standDownGate.mjs +48 -0
  94. package/bin/lib/startProject.mjs +279 -0
  95. package/bin/lib/trace.mjs +3 -3
  96. package/bin/lib/turnLock.mjs +68 -0
  97. package/bin/lib/turnProfile.mjs +77 -0
  98. package/bin/lib/uninstall.mjs +55 -45
  99. package/bin/lib/uninstallCopies.mjs +112 -0
  100. package/bin/lib/update.mjs +30 -17
  101. package/bin/lib/uplinkScrub.mjs +265 -0
  102. package/bin/lib/vault.mjs +22 -151
  103. package/bin/lib/vaultDiff.mjs +183 -0
  104. package/bin/lib/versionOrder.mjs +42 -0
  105. package/bin/lib/views.mjs +17 -17
  106. package/bin/lib/wikiRunner.mjs +630 -0
  107. package/bin/lib/work.mjs +188 -3034
  108. package/bin/lib/workAdoptCarry.mjs +94 -0
  109. package/bin/lib/workAgentCheck.mjs +278 -0
  110. package/bin/lib/workAgentMerges.mjs +54 -142
  111. package/bin/lib/workAgentPlans.mjs +14 -12
  112. package/bin/lib/workAgentPrecheck.mjs +393 -0
  113. package/bin/lib/workAgentPublish.mjs +177 -0
  114. package/bin/lib/workAgentReview.mjs +8 -666
  115. package/bin/lib/workAgentTurnBegun.mjs +109 -0
  116. package/bin/lib/workAgentTurnExecution.mjs +604 -0
  117. package/bin/lib/workAgentTurnOutcome.mjs +207 -0
  118. package/bin/lib/workAgentTurnReports.mjs +189 -0
  119. package/bin/lib/workAgentTurns.mjs +91 -898
  120. package/bin/lib/workAttachments.mjs +93 -0
  121. package/bin/lib/workBrain.mjs +53 -0
  122. package/bin/lib/workChildren.mjs +110 -0
  123. package/bin/lib/workCommandAudit.mjs +62 -0
  124. package/bin/lib/workDiffs.mjs +2 -1
  125. package/bin/lib/workNarration.mjs +152 -0
  126. package/bin/lib/workPlaceReports.mjs +426 -0
  127. package/bin/lib/workPlaces.mjs +228 -0
  128. package/bin/lib/workPreviews.mjs +31 -9
  129. package/bin/lib/workProcesses.mjs +4 -5
  130. package/bin/lib/workPullRequests.mjs +52 -126
  131. package/bin/lib/workReportQueue.mjs +254 -0
  132. package/bin/lib/workRetire.mjs +185 -0
  133. package/bin/lib/workSessionGroups.mjs +166 -0
  134. package/bin/lib/workSessionRuntime.mjs +164 -0
  135. package/bin/lib/workSessionTokens.mjs +68 -0
  136. package/bin/lib/workSessionTurns.mjs +892 -0
  137. package/bin/lib/workShip.mjs +9 -29
  138. package/bin/lib/workToolLog.mjs +76 -0
  139. package/package.json +3 -2
  140. package/bin/lib/patch.mjs +0 -363
@@ -5,26 +5,28 @@ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mj
5
5
  import { git, isSafePathSegment } from './git.mjs';
6
6
  import { parseProposal } from './agentPlan.mjs';
7
7
  import { c, note } from './ui.mjs';
8
- import { runTurn } from './claude.mjs';
8
+ import { runTurn } from './runTurn.mjs';
9
9
  import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
10
- import { scrub as envScrub } from './env.mjs';
11
- import { pickRuntimeFor, removeProbeTranscript, RUNTIMES } from './runtimes.mjs';
10
+ import { scrub as envScrub } from './uplinkScrub.mjs';
11
+ import { RUNTIMES } from './runtimes.mjs';
12
+ import { pickRuntimeFor } from './runtimeDetection.mjs';
13
+ import { removeProbeTranscript } from './runtimeCapabilities.mjs';
14
+ import { fleetEndpoint } from './fleetWire.mjs';
15
+ import { REPO_PLACE } from './workPlaces.mjs';
12
16
 
13
17
  export function createWorkAgentPlans({
14
18
  postBestEffort,
15
19
  baseDir,
16
20
  baseRef,
17
- REPO_PLACE,
18
21
  inPlace,
19
22
  repoRoot,
20
23
  admit,
21
- state,
24
+ placeLocks,
25
+ workChildren,
22
26
  }) {
23
- const AGENT_PLAN_DONE_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-done');
24
- const AGENT_PLAN_ACTIVITY_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-activity');
25
- const AGENT_PLAN_CLAIM_URL = FLEET_URL.replace(/\/agents\/?$/, '/agent-plan-claim');
26
-
27
- const { placeLocks, workChildren } = state;
27
+ const AGENT_PLAN_DONE_URL = fleetEndpoint('agent-plan-done', FLEET_URL);
28
+ const AGENT_PLAN_ACTIVITY_URL = fleetEndpoint('agent-plan-activity', FLEET_URL);
29
+ const AGENT_PLAN_CLAIM_URL = fleetEndpoint('agent-plan-claim', FLEET_URL);
28
30
 
29
31
  // ── AGENT PLAN JOBS: the Deploy press ──────────────────────────────────────
30
32
  //
@@ -33,7 +35,7 @@ export function createWorkAgentPlans({
33
35
  // proposes on the board, and ACCEPTING is what spawns anything. Nothing here
34
36
  // creates a worktree, a branch or a card.
35
37
  //
36
- // READ-ONLY IN THE CHECKOUT. `readOnly: true` selects `consultPermFor` (the
38
+ // READ-ONLY IN THE CHECKOUT. `profile: 'consult'` selects `consultPermFor` (the
37
39
  // fenced Read/Glob rules plus a few path-validated `git`/`ls`/`cat` reads —
38
40
  // no Write, no Edit, no mkdir, no rm) and NO MCP is passed at all, so this
39
41
  // turn has no control plane to reach even if the repository it reads tries
@@ -323,7 +325,7 @@ export function createWorkAgentPlans({
323
325
  system: SYSTEM_PLAN,
324
326
  // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
325
327
  // passed empty, so there is no control plane on this turn at all.
326
- readOnly: true,
328
+ profile: 'consult',
327
329
  cwd: repoRoot,
328
330
  runtime: rt,
329
331
  streamJson: true,
@@ -0,0 +1,393 @@
1
+ /**
2
+ * THE AI PRE-REVIEW — a fresh, read-only reader of the agent's branch, run on
3
+ * the review-entry beat after the check, whose reading is posted as a label.
4
+ *
5
+ * Split out of workAgentReview.mjs (SOLID F039): this half changes for
6
+ * MODEL-REPORTING reasons (which CLI reads, what it is shown, how its answer
7
+ * is parsed, scrubbed and re-sent), the project check for SECURITY ones. The
8
+ * review entry beat sequences the two and owns neither.
9
+ */
10
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
11
+ import { git } from './git.mjs';
12
+ import { parsePrecheck } from './agentPlan.mjs';
13
+ import { readStash } from './agentCards.mjs';
14
+ import { c, note } from './ui.mjs';
15
+ import { runTurn } from './runTurn.mjs';
16
+ import { SYSTEM_PRECHECK, AGENT_PRECHECK_KICKOFF } from './prompts.mjs';
17
+ import { scrub as envScrub } from './uplinkScrub.mjs';
18
+ import { pickRuntimeFor } from './runtimeDetection.mjs';
19
+ import { removeProbeTranscript } from './runtimeCapabilities.mjs';
20
+ import { taskIdsFromMessage } from './worktreeDiff.mjs';
21
+ import { limitLine } from './cliLimit.mjs';
22
+ import { fleetEndpoint } from './fleetWire.mjs';
23
+
24
+ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workChildren }) {
25
+ const AGENT_PRECHECK_URL = fleetEndpoint('agent-precheck', FLEET_URL);
26
+
27
+ // ── THE AI PRE-REVIEW ──────────────────────────────────────────────────────
28
+ //
29
+ // A FRESH Claude reads the branch before the human does. The owner asked for
30
+ // it in these words: "before having the user manually check, can we have the
31
+ // daemon … spawn an agent to review the work so basically we get an ai to look
32
+ // at the review before a human looks at it for a double check."
33
+ //
34
+ // IT IS NOT THE AGENT CHECKING ITSELF. `runTurn` is called with no `resume`,
35
+ // so there is no conversation to inherit: an agent that spent four turns
36
+ // arguing itself into a design defends that design, and asked whether its work
37
+ // meets the card it answers from the very context that produced the work. The
38
+ // reviewer stands IN the agent's worktree because it needs the code and the
39
+ // diff, under the `consult` profile (`consultPermFor` — the fenced Read/Glob rules plus
40
+ // a few path-validated git reads) with NO MCP passed at all, so there is no
41
+ // control plane on this turn even if the repository it reads tries to steer
42
+ // it.
43
+ //
44
+ // IT LABELS AND NEVER BLOCKS — the check's own law (workAgentCheck.mjs). Approve,
45
+ // the per-card verdicts and the ship quiz do not know this exists. Every exit
46
+ // below POSTS NOTHING, and the server renders an absent precheck as nothing:
47
+ // a failed, timed-out, skipped or unparseable read leaves the human's review
48
+ // exactly as it was before this feature existed. Ignorance never withholds.
49
+ //
50
+ // NO VERSION FLOOR. This is a daemon→server report on a NEW endpoint, so an
51
+ // older daemon simply never posts, and an older SERVER 404s — which `postPre`
52
+ // treats as delivered-and-done for the agent-trace reason stated there.
53
+ //
54
+ // IT RIDES THE BEAT THE CHECK ALREADY OWNS (`runReviewEntry`), AFTER it: the
55
+ // check is a local command and this is a model call, so the cheap answer lands
56
+ // on the row first and a wedged reviewer cannot delay it.
57
+ /**
58
+ * FIVE MINUTES, and `runTurn` has no timer of its own.
59
+ *
60
+ * Half the planner's cap, because this turn is strictly smaller — it reads one
61
+ * branch's diff and answers, where a planner reads a repository to decide
62
+ * whether a batch of work collides. And it is HELD INSIDE THE PLACE WRITER
63
+ * LOCK by the beat it rides, so every minute here is a minute the agent's next
64
+ * turn (or its merge) is waiting: a generous cap on a label would be spending
65
+ * the work's time on a note about the work.
66
+ */
67
+ const PRECHECK_TIMEOUT_MS = 5 * 60_000;
68
+ /** Commit subjects handed to the reviewer. A bound on the prompt, not on the
69
+ * branch — the reviewer reads the diff itself, and the log is context. */
70
+ const PRECHECK_LOG_LINES = 80;
71
+
72
+ /**
73
+ * ONE PRE-REVIEW, POSTED.
74
+ *
75
+ * Resolves TRUE for a permanent refusal as well as a success, and that is
76
+ * deliberate — the `postAgentTrace` rule, for the same reason: a server with
77
+ * no such route 404s this body and will 404 every retry of it, so re-sending
78
+ * would be a wedge wearing a retry's clothes. A NETWORK error resolves false
79
+ * and is retried ONCE, because unlike a trace batch this body cost a whole
80
+ * model call and losing it to a blip means the operator paid for a label
81
+ * nobody ever sees.
82
+ *
83
+ * THE RETRY IS NOT A GUARANTEE THAT THE FIRST ATTEMPT FAILED, and since the
84
+ * body started carrying `usage` (0.90.0) that matters. The `catch` below
85
+ * takes the 30-second `AbortSignal` with everything else, so a server that
86
+ * was merely SLOW — and committed — receives this identical body twice. The
87
+ * store was always idempotent; the token counts riding it were not, and a
88
+ * timed-out-but-committed post added them twice. The SERVER closes that: its
89
+ * write refuses a body whose text already sits on the row, so a re-send
90
+ * stores the same reading and charges nothing for it. Nothing here needs to
91
+ * change, and nothing here may start assuming a thrown fetch means the
92
+ * server never saw this.
93
+ */
94
+ const postPre = async (body) => {
95
+ try {
96
+ const res = await fetch(AGENT_PRECHECK_URL, {
97
+ method: 'POST',
98
+ headers: {
99
+ Authorization: `Bearer ${FLEET_TOKEN}`,
100
+ 'User-Agent': USER_AGENT,
101
+ 'Content-Type': 'application/json',
102
+ },
103
+ signal: AbortSignal.timeout(30_000),
104
+ body: JSON.stringify(body),
105
+ });
106
+ return (
107
+ res.ok ||
108
+ (res.status >= 400 && res.status < 500 && res.status !== 408 && res.status !== 429)
109
+ );
110
+ } catch {
111
+ return false; // a blip — worth one more attempt at a model call's answer
112
+ }
113
+ };
114
+
115
+ /**
116
+ * THE BRANCH'S OWN COMMITS, subject + trailers, and the card ids they name.
117
+ *
118
+ * MEASURED, never asserted — the same reason `commitsBetween` exists. The
119
+ * trailer ids are what let the prompt say "N earlier cards' specs are not on
120
+ * this box" honestly: the difference between the cards this branch claims and
121
+ * the specs this disk holds is a fact, and a box that adopted the agent
122
+ * mid-run is exactly the case where it is non-zero.
123
+ *
124
+ * IT CANNOT THROW. `baseRef()` is free text an owner typed and may name no
125
+ * ref at all; an unreadable range costs the reviewer its context, never the
126
+ * review-entry beat it is standing in.
127
+ */
128
+ const branchLog = (wt) => {
129
+ let out;
130
+ try {
131
+ out = git(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
132
+ } catch {
133
+ return { text: '', taskIds: [] };
134
+ }
135
+ if (typeof out !== 'string') return { text: '', taskIds: [] };
136
+ // LINEAR, never backtracking (audit 2026-09-24) — this was
137
+ // `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
138
+ // quadratic against a long trailing run of whitespace on one line
139
+ // (measured ~1s at 40KB), and a branch's own commit log is exactly the
140
+ // kind of text an agent's commits can grow past that. `taskIdsFromMessage`
141
+ // is the same line-split, length-capped reader every other trailer scan
142
+ // in this codebase uses.
143
+ const taskIds = taskIdsFromMessage(out);
144
+ const text = out
145
+ .split('\n')
146
+ .filter((l) => l.trim())
147
+ .slice(0, PRECHECK_LOG_LINES)
148
+ .join('\n');
149
+ return { text, taskIds: [...taskIds] };
150
+ };
151
+
152
+ const runPrecheck = async (agentId, wt, agentName) => {
153
+ /**
154
+ * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
155
+ * planner uses, and for the same reason: this prompt was written against
156
+ * Claude, and a machine with no read-only-capable runtime simply does not
157
+ * produce a precheck. Nothing is posted and nothing is said on the row.
158
+ */
159
+ const rt = pickRuntimeFor('consult');
160
+ if (!rt) return;
161
+ /**
162
+ * THE PRESSURE GUARD, at the spawn, exactly as every other unattended lane
163
+ * asks it. `churn` and never `interactive`: nobody is watching this, and a
164
+ * label is the first thing that should not be started on a box that is
165
+ * struggling. Deferring here does NOT queue anything — there is no job and
166
+ * no re-offer — so a precheck skipped under pressure is simply a precheck
167
+ * that did not happen, which is what absence already means.
168
+ */
169
+ const hold = admit('churn');
170
+ if (hold) {
171
+ note(`${c.cyan('pre-review')} ${c.dim(`— skipped: ${hold.reason}`)}`);
172
+ return;
173
+ }
174
+ const releaseSlot = admit.reserve();
175
+
176
+ // THE HEAD THE READING BELONGS TO, taken BEFORE the turn — the
177
+ // `checkFingerprint` shape, so a commit landing after this voids it rather
178
+ // than letting an old reading label a branch it never saw. Optional: an
179
+ // unreadable head costs the staleness comparison, not the precheck.
180
+ let headSha = null;
181
+ try {
182
+ headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || null;
183
+ } catch {
184
+ headSha = null;
185
+ }
186
+
187
+ const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
188
+ const log = branchLog(wt);
189
+ /** Cards the BRANCH names that this box has no spec for. Measured, not
190
+ * guessed — see agentCards.mjs on why a stash is per-box. */
191
+ const held = new Set(stash.map((s) => s.taskId));
192
+ const missingSpecs = log.taskIds.filter((id) => !held.has(id)).length;
193
+
194
+ let out = '';
195
+ let child = null;
196
+ let timer = null;
197
+ /** The cap fired: the CLI was still running when this machine stopped it. */
198
+ let wedged = false;
199
+ /**
200
+ * WHAT THE READING SPENT — the agent-turn lane's own relay, on the one
201
+ * other lane that charges an AGENT (2026-09-19).
202
+ *
203
+ * It rides the agent and never a card link: a pre-review belongs to no card
204
+ * (the same reason `workChildren` gets a null task id below), so the four
205
+ * counters it adds to are the container's alone. That is also why an
206
+ * agent's links can never sum to its total, and the schema says so.
207
+ *
208
+ * A WEDGED OR UNPARSEABLE READING POSTS NOTHING AT ALL, so it charges
209
+ * nothing either — which under-reports a turn that really did spend. That
210
+ * is the honest direction: there is no row to put it on, and inventing a
211
+ * settle to carry a number would be a post whose only content is a bill.
212
+ */
213
+ let usage = null;
214
+ /**
215
+ * THE CONVERSATION THIS READING SPEAKS UNDER — held for exactly one reason:
216
+ * to DELETE the transcript it leaves behind (review, 2026-09-17).
217
+ *
218
+ * This is the first thing in the daemon to run a second `claude -p` inside
219
+ * an AGENT's worktree, and the agent's own resume is `--continue`, which is
220
+ * CWD-KEYED — the invariant `runAgentTurn` states in words ("ONE AGENT IS
221
+ * ONE DIRECTORY, so the CLI's own cwd-keyed resume is exactly right here").
222
+ * Leaving this turn's `~/.claude/projects/<munged-cwd>/<id>.jsonl` in place
223
+ * makes the read-only stranger the newest conversation in that directory,
224
+ * so the agent's NEXT turn — a send-back's re-queued card, a merge-resolve,
225
+ * a human's typed answer — resumes "you are a SECOND reviewer… YOU ARE
226
+ * READ-ONLY" instead of its own four-turn context, under build permissions.
227
+ * That is the 0.69.0 Workbench cross-resume and codex's `resume --last`,
228
+ * arriving a third time by a third route.
229
+ *
230
+ * `removeProbeTranscript` is exported for precisely this and already serves
231
+ * the skills probe and the dev-command resolver.
232
+ */
233
+ let preSession = null;
234
+ try {
235
+ /**
236
+ * THE CAP RESOLVES THE WAIT ITSELF rather than waiting for `close` after
237
+ * the kill — the shape the project check and the planner both keep. A
238
+ * SIGKILLed process whose stdio a grandchild still holds can be slow to
239
+ * emit `close`, or never emit it, and this promise is inside the place's
240
+ * writer lock.
241
+ */
242
+ let stopWaiting = () => {};
243
+ const capped = new Promise((r) => {
244
+ stopWaiting = r;
245
+ });
246
+ const turn = runTurn({
247
+ prompt: AGENT_PRECHECK_KICKOFF({
248
+ agentName,
249
+ cards: stash.map((s) => s.prompt).join('\n---\n'),
250
+ missingSpecs,
251
+ commits: log.text,
252
+ diffCommand: `git diff ${baseRef()}...HEAD`,
253
+ }),
254
+ system: SYSTEM_PRECHECK,
255
+ // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
256
+ // passed empty, so there is no control plane on this turn at all.
257
+ profile: 'consult',
258
+ cwd: wt,
259
+ runtime: rt,
260
+ // NO `resume`, AND THAT IS THE FEATURE. A resumed turn would be the
261
+ // agent grading its own homework out of its own context; this is a
262
+ // stranger reading a diff.
263
+ streamJson: true,
264
+ answerFromResult: true,
265
+ label: c.cyan('[pre-review]'),
266
+ // The id the CLI reports at `system.init` — harvested off the stream
267
+ // this turn already parses, no probe and no extra spawn. Held only so
268
+ // the `finally` below can delete this turn's transcript; see
269
+ // `preSession`.
270
+ onInit: (i) => {
271
+ if (typeof i.sessionId === 'string' && i.sessionId.trim())
272
+ preSession = i.sessionId.trim();
273
+ },
274
+ // The CLI's own count for this reading. Set, never accumulated: one
275
+ // turn is one `result` event, and the adding-up is the server's.
276
+ // Tagged with the CLI, so a Claude pre-review of a Codex agent is
277
+ // charged as Claude's and not folded into the agent's own figure.
278
+ onUsage: (u) => {
279
+ usage = { ...u, runtime: rt };
280
+ },
281
+ onSpawn: (ch) => {
282
+ child = ch;
283
+ // No task id: a pre-review belongs to no card, and the machine
284
+ // snapshot's per-task rows must not invent one. It still COUNTS
285
+ // against the machine's ceiling — see liveTurnCount.
286
+ workChildren.set(ch, null);
287
+ releaseSlot();
288
+ // ARMED AT THE SPAWN, not at entry: time spent getting here is not a
289
+ // wedged CLI. The CHILD and never its group — a read-only turn starts
290
+ // no server, so there is nothing behind it worth signalling and
291
+ // everything to lose by signalling somebody else's.
292
+ timer = setTimeout(() => {
293
+ wedged = true;
294
+ try {
295
+ ch.kill('SIGKILL');
296
+ } catch {
297
+ /* already gone */
298
+ }
299
+ stopWaiting('');
300
+ }, PRECHECK_TIMEOUT_MS);
301
+ timer.unref?.();
302
+ },
303
+ });
304
+ out = await Promise.race([turn, capped]);
305
+ } catch {
306
+ return; // a label may never fail the beat it rides
307
+ } finally {
308
+ if (timer) clearTimeout(timer);
309
+ if (child) workChildren.delete(child);
310
+ releaseSlot();
311
+ /**
312
+ * DELETE THIS READING'S TRANSCRIPT, on EVERY exit — settled, wedged and
313
+ * killed, or thrown — because every one of them leaves the file behind
314
+ * and the agent's `--continue` reads the newest one in the directory.
315
+ *
316
+ * AFTER the kill and ON A DELAY, the skills probe's own shape: the
317
+ * transcript is the CHILD's file, so removing it while the child is still
318
+ * dying races a recreate. Unref'd — it must not hold the process open.
319
+ */
320
+ if (preSession) setTimeout(() => removeProbeTranscript(wt, preSession), 750).unref?.();
321
+ }
322
+
323
+ // A WEDGED READING POSTS NOTHING. There is no row to settle and nobody
324
+ // waiting on an answer, so the honest record is that no pre-review exists —
325
+ // the same silence a machine that never ran one leaves.
326
+ if (wedged) {
327
+ note(`${c.cyan('pre-review')} ${c.dim('— ran past five minutes and was stopped')}`);
328
+ return;
329
+ }
330
+
331
+ /**
332
+ * SCRUBBED ON THE WAY OUT, every string — AND SCRUBBED BEFORE IT IS CUT,
333
+ * which is the order that matters and the reason `envScrub` rides INTO the
334
+ * parser rather than being applied to what comes back out.
335
+ *
336
+ * This turn read the repository with `cat` and `git show` in a worktree
337
+ * holding the project's materialized dev secrets, and its answer is about to
338
+ * be stored and rendered to every member of the project. `scrub` replaces
339
+ * EXACT full values, so a note capped first and scrubbed second hands the
340
+ * scrub a credential already cut in half: it matches nothing and the
341
+ * surviving prefix ships. The check's output lane learned exactly that the
342
+ * expensive way, and this lane relearned it in review (2026-09-17) — see
343
+ * `parsePrecheck`, which now caps only what it has already redacted.
344
+ */
345
+ const result = parsePrecheck(out, envScrub);
346
+
347
+ /**
348
+ * A QUOTA LIMIT SKIPS THE PRE-REVIEW AND PARKS NOTHING — AND A LIMIT IS
349
+ * ONLY A LIMIT WHEN THE READING PRODUCED NOTHING.
350
+ *
351
+ * `limitLine` is a literal phrase match over the CLI's whole output, and
352
+ * under `answerFromResult` that output IS the reviewer's answer — so a
353
+ * pre-review OF rate-limiting code, or any triage that quotes the phrase,
354
+ * read as a quota failure and threw a perfectly good reading away. The
355
+ * agent-turn lane fixed this exact false positive once (`const limit = res
356
+ * ? null : limitLine(out)`); gating on "nothing parsed" is what makes the
357
+ * match mean what it says.
358
+ *
359
+ * And when it IS a limit, nothing parks. `postAgentParked` stops EVERY
360
+ * agent on the project, because the CLI login is shared — the right answer
361
+ * when the thing that hit the limit was somebody's actual work, the wrong
362
+ * one here: parking a whole fleet because a LABEL could not be written
363
+ * would let an optional readout take the product's primary lane down. The
364
+ * branch is still reviewable; it just has no note on it.
365
+ */
366
+ if (!result) {
367
+ if (limitLine(out)) {
368
+ note(`${c.cyan('pre-review')} ${c.dim('— skipped: the CLI reported a limit')}`);
369
+ }
370
+ // UNPARSEABLE POSTS NOTHING. A half-read triage is a plausible-looking
371
+ // paragraph nobody wrote, rendered on the surface where somebody decides
372
+ // whether a branch reaches main — see parsePrecheck.
373
+ return;
374
+ }
375
+
376
+ const body = {
377
+ agentId,
378
+ ...(headSha ? { headSha } : {}),
379
+ ...(usage ? { usage } : {}),
380
+ cards: result.cards.map((cd) => ({
381
+ taskId: cd.taskId,
382
+ verdict: cd.verdict,
383
+ ...(cd.note ? { note: cd.note } : {}),
384
+ })),
385
+ ...(result.overall ? { overall: result.overall } : {}),
386
+ };
387
+ // ONE RETRY, and only for a network error — see `postPre`. A permanent
388
+ // refusal is an older server, and asking it again changes nothing.
389
+ if (!(await postPre(body))) await postPre(body);
390
+ };
391
+
392
+ return { runPrecheck };
393
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * PUBLISHING AN AGENT'S BRANCH — the push to the name the server gave it, the
3
+ * fetch that brings a published branch back to a box that lost it, and the
4
+ * two process-local records (what this machine pushed; what it has seen at
5
+ * the remote ref) that the push's lease and the worktree report read.
6
+ *
7
+ * Split out of work.mjs (2026-09-26, SOLID F037). The argv shapes are
8
+ * agentPublish.mjs's (pure, tested there); this module owns the STATE and the
9
+ * network calls. The agent lane pushes after a settle and fetches at the
10
+ * begun-guard, the sweep keeps a published branch current, the merge lane
11
+ * retires the ref — all through the maps and two functions returned here.
12
+ */
13
+ import { git } from './git.mjs';
14
+ import { publishPushArgs, publishFetchArgs, publishErrorText } from './agentPublish.mjs';
15
+ import { scrub as envScrub } from './uplinkScrub.mjs';
16
+
17
+ export function createWorkAgentPublish({ repoRoot, gitNet }) {
18
+ // ── PUBLISHING AN AGENT'S BRANCH ───────────────────────────────────────────
19
+ //
20
+ // The server names the target (`agentTurnJobs[].publishTo`) and this machine
21
+ // pushes to it. Two laws hold the whole lane up:
22
+ //
23
+ // A PUSH NEVER BLOCKS OR FAILS A TURN. It is tail work after the settle, it
24
+ // is try/catch'd like every sweep, and a failure is REPORTED in git's own
25
+ // words rather than thrown. An agent whose remote push fails has still done
26
+ // its work, still committed it, and still settled.
27
+ //
28
+ // THE SERVER LEARNS OF A PUSH ONLY BY BEING TOLD. Nothing here composes a
29
+ // name: an absent `publishTo` is a project that has publishing off, or a
30
+ // server older than this daemon, and in both the honest behaviour is to push
31
+ // nothing and report nothing. Absence keeps one meaning.
32
+ /**
33
+ * WHAT THIS MACHINE LAST DID WITH EACH AGENT'S BRANCH, by place —
34
+ * `{ ref, sha }` for a push that landed, `{ ref, error, at, tried }` for one
35
+ * that did not. Mutually exclusive by construction, which is what makes the
36
+ * report's two keys mutually exclusive without a second rule.
37
+ *
38
+ * Process-local on purpose: it is a record of what THIS daemon pushed, so a
39
+ * restart re-pushes once and re-reports — a push of the same sha to the same
40
+ * ref is a no-op at the remote, and re-learning beats trusting a file about
41
+ * something a rebase can invalidate.
42
+ */
43
+ const agentPublished = new Map();
44
+ /**
45
+ * WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
46
+ * the ONLY input to the push's lease.
47
+ *
48
+ * It is deliberately NOT `agentPublished`: that map is the REPORT record (what
49
+ * this machine pushed, and what the server may be told), while this one is an
50
+ * observation of the REMOTE's own position, which a box also gets by FETCHING
51
+ * a ref it never pushed. A box that fetch-continues an agent has seen the ref
52
+ * and must be able to lease against it; a box that has seen nothing pushes
53
+ * with no force flag at all.
54
+ *
55
+ * Process-local for the same reason the record beside it is: a restart has
56
+ * observed nothing, and an unforced push is the honest thing to do about that
57
+ * — it lands, or it refuses and says so.
58
+ */
59
+ const agentRemoteAt = new Map();
60
+ /** A failed push retries on the next sweep, but not FOREVER at sweep cadence:
61
+ * a remote that refuses (no credentials on this box, a protected prefix)
62
+ * would otherwise cost a blocking network call per agent per minute for the
63
+ * life of the daemon. A moved branch always retries immediately — the
64
+ * throttle is on repeating the SAME attempt, never on new work. */
65
+ const PUBLISH_RETRY_MS = 5 * 60_000;
66
+ /** The tip of an agent's local branch, or null when this box does not hold it
67
+ * — which is a perfectly ordinary state (the begun-guard's whole subject) and
68
+ * means there is nothing to publish, never that a push failed. */
69
+ const agentBranchSha = (place) => {
70
+ try {
71
+ return (
72
+ git(['rev-parse', '--verify', '--quiet', `refs/heads/session/${place}`], repoRoot) || null
73
+ );
74
+ } catch {
75
+ return null;
76
+ }
77
+ };
78
+ /**
79
+ * PUSH ONE AGENT'S BRANCH TO THE NAME THE SERVER GAVE IT.
80
+ *
81
+ * Returns TRUE when the recorded state CHANGED, because the caller's next act
82
+ * is a worktree report and a report that says what the last one said is a
83
+ * write per minute restating a fact. The rule this serves is the one every
84
+ * settle keeps: an action that changes what the machine would measure must
85
+ * cause a new measurement — and only then.
86
+ *
87
+ * Never throws.
88
+ */
89
+ const publishAgentBranch = async (place, target) => {
90
+ const sha = agentBranchSha(place);
91
+ if (!sha) return false;
92
+ const prev = agentPublished.get(place);
93
+ if (prev?.ref === target && prev.sha === sha) return false; // already there
94
+ if (
95
+ prev?.ref === target &&
96
+ prev.error &&
97
+ prev.tried === sha &&
98
+ Date.now() - (prev.at ?? 0) < PUBLISH_RETRY_MS
99
+ )
100
+ return false; // the same attempt failed moments ago
101
+ // THE LEASE IS THIS PROCESS'S OWN LAST SIGHTING of that ref, and nothing
102
+ // else — never git's remote-tracking ref, which this daemon's own sweep
103
+ // fetch refreshes (the argument is in `publishPushArgs`). An expectation
104
+ // recorded against a DIFFERENT ref is no expectation for this one.
105
+ const seen = agentRemoteAt.get(place);
106
+ // A ref that is not the server's shape never reaches argv. Silent, because
107
+ // a refusal here is a feature not happening on this turn, not a failure of
108
+ // it — and a `publishError` about a value we declined to use would be this
109
+ // machine reporting on a push it never attempted.
110
+ const args = publishPushArgs(place, target, seen?.ref === target ? seen.sha : null);
111
+ if (!args) return false;
112
+ try {
113
+ gitNet(args, 60_000);
114
+ agentPublished.set(place, { ref: target, sha });
115
+ agentRemoteAt.set(place, { ref: target, sha });
116
+ return true;
117
+ } catch (e) {
118
+ const error = publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''));
119
+ agentPublished.set(place, { ref: target, error, at: Date.now(), tried: sha });
120
+ // A repeat of a failure already reported is not news; the server's stored
121
+ // sentence is already this one.
122
+ return !(prev?.error === error && prev.ref === target);
123
+ }
124
+ };
125
+ /**
126
+ * BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
127
+ * promise, and the only thing that lets an agent's work outlive its box.
128
+ *
129
+ * Reached from ONE place: the begun-guard's refusal arm, where this machine
130
+ * has just MEASURED that it holds neither the agent's worktree nor its branch.
131
+ * The server only sends `publishedRef` when it heard about a real push, so
132
+ * this is not a guess at a remote branch — it is a fetch of one a machine
133
+ * reported writing.
134
+ *
135
+ * ── WHAT IT RECOVERS, AND WHAT IT CANNOT ──
136
+ *
137
+ * COMMITS COME BACK. The CONVERSATION DOES NOT: the CLI's held context lives
138
+ * in the box that ran it and nothing here transports it. The turn kickoff
139
+ * re-prompts from the card, which is the honest continuation — an agent that
140
+ * picks up its own commits and re-reads its own card, never one that remembers
141
+ * the argument. Nothing this returns may be phrased as if it did.
142
+ *
143
+ * THREE ANSWERS, because two would lie. `null` is "there was nothing to try"
144
+ * (no ref, or one whose shape this machine will not put in argv), and it must
145
+ * leave the existing refusal EXACTLY as it was — a sentence about a fetch
146
+ * nobody attempted is worse than the plain refusal. `{ ok: false, why }` is a
147
+ * measured failure, relayed in git's own words. `{ ok: true }` is only ever
148
+ * returned after re-reading the ref: a fetch that exits 0 having created
149
+ * nothing would otherwise walk straight into `placeWtFor` cutting a fresh
150
+ * branch off base — the context-free redo the guard above exists to prevent,
151
+ * wearing this feature's name.
152
+ */
153
+ const fetchPublishedBranch = (place, ref) => {
154
+ const args = publishFetchArgs(ref, place);
155
+ if (!args) return null;
156
+ try {
157
+ gitNet(args, 120_000);
158
+ } catch (e) {
159
+ return {
160
+ ok: false,
161
+ why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
162
+ };
163
+ }
164
+ const sha = agentBranchSha(place);
165
+ if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
166
+ // WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
167
+ // observation of the remote's own position, and for a box that continues an
168
+ // agent it never started it is the ONLY one it will ever have — without it
169
+ // this box's first push would carry no lease and would have to go unforced.
170
+ // It is not a `publishAgentBranch` record: this machine pushed nothing, so
171
+ // the server is told nothing.
172
+ agentRemoteAt.set(place, { ref, sha });
173
+ return { ok: true };
174
+ };
175
+
176
+ return { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch };
177
+ }