flowviant 0.115.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 +99 -145
  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
@@ -8,16 +8,17 @@
8
8
  * the process-group kill), the pre-review for MODEL-REPORTING ones. The review
9
9
  * entry beat sequences the two and owns neither.
10
10
  */
11
- import { readFileSync, lstatSync, rmSync, readdirSync } from 'node:fs';
12
- import { spawn } from 'node:child_process';
13
- import { join } from 'node:path';
11
+ import { readFileSync, realpathSync, rmSync } from 'node:fs';
12
+ import { spawn as spawnProcess } from 'node:child_process';
13
+ import { dirname, join } from 'node:path';
14
14
  import { FLEET_URL } from './config.mjs';
15
- import { git } from './git.mjs';
15
+ import { gitOrNull } from './git.mjs';
16
16
  import { warn } from './ui.mjs';
17
- import { ARTIFACT_DIR, artifactTypeFor } from './artifacts.mjs';
17
+ import { ARTIFACT_DIR, artifactTypeFor, walkArtifactDir } from './artifacts.mjs';
18
+ import { flowviantParentState, realDirState } from './ownedDirs.mjs';
19
+ import { killGroup } from './processGroup.mjs';
18
20
  import { scrub as envScrub } from './uplinkScrub.mjs';
19
- import { withoutMachineCredentials } from './machineEnv.mjs';
20
- import { agentBrowserHome, openBrowserHome, withoutWindows } from './noWindowEnv.mjs';
21
+ import { agentBrowserHome, openBrowserHome, unattendedEnv } from './noWindowEnv.mjs';
21
22
  import { fleetEndpoint } from './fleetWire.mjs';
22
23
  import { workLanesStopped } from './standDownGate.mjs';
23
24
 
@@ -35,54 +36,75 @@ import { workLanesStopped } from './standDownGate.mjs';
35
36
  * before any person looked — and the directory is git-excluded, so the file
36
37
  * never appears in the diff a reviewer reads.
37
38
  *
38
- * What an artifact IS is already a closed list (`ARTIFACT_TYPES`), and the
39
- * scan only ever shows TOP-LEVEL regular files: anything else there is
40
- * nothing the product will show, so removing it before the check costs
41
- * nothing a person could see. Symlinks and subdirectories go whole; a
42
- * `.flowviant` that is not a real directory is left alone (that is the
43
- * repository's own content, not something a turn wrote). Returns the names
44
- * removed, for the log line.
39
+ * What an artifact IS is already a closed list (`ARTIFACT_TYPES`, which holds
40
+ * no script type), and WHERE one may stand is the scan's own walk
41
+ * (`walkArtifactDir`, artifacts.mjs — one walk since 2026-10-10, the audit's
42
+ * F067). This header used to say the scan showed only TOP-LEVEL files, and
43
+ * the fence removed every subdirectory whole: a 3D-model page
44
+ * (`<name>/index.html`) or a bundle's sidecars went the moment the queue
45
+ * emptied, and a send-back turn that did not rewrite the page settled
46
+ * "nothing". So real directories within the scan's depth stay and are walked;
47
+ * at ANY depth a dot-name, a symlink, anything that is not a regular file, a
48
+ * file off the allowlist, and a directory deeper than the scan looks is
49
+ * removed — nothing the product will show, so removing it costs nothing a
50
+ * person could see. A `.flowviant` that is not a real directory is left alone
51
+ * (that is the repository's own content, not something a turn wrote). Returns
52
+ * the relative paths removed, for the log line.
45
53
  */
46
54
  export function clearNonArtifacts(wt) {
47
55
  const removed = [];
48
- try {
49
- const parent = lstatSync(join(wt, '.flowviant'));
50
- if (parent.isSymbolicLink() || !parent.isDirectory()) return removed;
51
- const dir = join(wt, ARTIFACT_DIR);
52
- const st = lstatSync(dir);
53
- if (st.isSymbolicLink() || !st.isDirectory()) {
56
+ if (flowviantParentState(wt) !== 'dir') return removed;
57
+ const dir = join(wt, ARTIFACT_DIR);
58
+ const state = realDirState(dir);
59
+ if (state === 'none') return removed; // the ordinary case
60
+ if (state === 'other') {
61
+ // A link or a file AT the directory's name: the link goes, never its target.
62
+ try {
54
63
  rmSync(dir, { force: true, recursive: false });
55
64
  removed.push(ARTIFACT_DIR);
56
- return removed;
57
- }
58
- for (const name of readdirSync(dir)) {
59
- const p = join(dir, name);
60
- let e;
61
- try {
62
- e = lstatSync(p);
63
- } catch {
64
- continue; // vanished between the list and the stat
65
- }
66
- const keep = e.isFile() && !name.startsWith('.') && artifactTypeFor(name) !== null;
67
- if (keep) continue;
68
- try {
69
- rmSync(p, { recursive: true, force: true });
70
- removed.push(name);
71
- } catch {
72
- /* best-effort — reported by omission */
73
- }
65
+ } catch {
66
+ /* best-effort — reported by omission */
74
67
  }
68
+ return removed;
69
+ }
70
+ // A REMOVAL STAYS INSIDE THE DIRECTORY. The walk descends only what `lstat`
71
+ // called a real directory, but this one deletes, so each entry's parent is
72
+ // resolved again right before it goes: a folder swapped for a link after
73
+ // its look is never a way to remove what the link points at.
74
+ let root;
75
+ try {
76
+ root = realpathSync(dir);
75
77
  } catch {
76
- /* no artifacts directory — the ordinary case */
78
+ return removed;
77
79
  }
80
+ const inside = (name, path) => {
81
+ try {
82
+ return realpathSync(dirname(path)) === join(root, dirname(name));
83
+ } catch {
84
+ return false;
85
+ }
86
+ };
87
+ walkArtifactDir(wt, ({ name, path, kind }) => {
88
+ if (kind === 'dir' || (kind === 'file' && artifactTypeFor(name) !== null)) return;
89
+ if (!inside(name, path)) return;
90
+ try {
91
+ // `rm -r` on a link removes the link; on a real directory, its contents
92
+ // (links inside it unlinked, never followed).
93
+ rmSync(path, { recursive: true, force: true });
94
+ removed.push(name);
95
+ } catch {
96
+ /* best-effort — reported by omission */
97
+ }
98
+ });
78
99
  return removed;
79
100
  }
80
101
 
81
102
  /**
82
103
  * THE ENVIRONMENT THE PROJECT'S CHECK RUNS UNDER: the daemon's own, minus the
83
104
  * machine credential — the same rule `cliEnv` (runTurn.mjs) applies to a turn,
84
- * imported from its one home (machineEnv.mjs) rather than from the CLI
85
- * module, so the check never depends on the CLI module's spawn helpers. Nothing a check runs needs the credential the daemon
105
+ * imported from its one home (`unattendedEnv`, noWindowEnv.mjs) rather than
106
+ * from the CLI module, so the check never depends on the CLI module's spawn
107
+ * helpers. Nothing a check runs needs the credential the daemon
86
108
  * authenticates with; everything else is what the agent's own turn saw when it
87
109
  * ran the same tests.
88
110
  */
@@ -90,10 +112,22 @@ export function checkEnv(env = process.env, browserHome = null) {
90
112
  // …and, like that turn since 2026-09-29, no way to a window: a test run
91
113
  // nobody is watching that opens a headed browser or an HTML report opens it
92
114
  // on nobody's behalf (noWindowEnv.mjs). The agent's own browser profile.
93
- return withoutWindows(withoutMachineCredentials(env), browserHome);
115
+ return unattendedEnv(env, { browserHome });
94
116
  }
95
117
 
96
- export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, groupKillChildren, sessionMetaPath }) {
118
+ /**
119
+ * `spawn` is node's unless a test hands one in: the release below is about a
120
+ * child whose 'close' never comes, which a real process only shows with a
121
+ * survivor outside its group holding the pipe.
122
+ */
123
+ export function createWorkAgentCheck({
124
+ repoRoot,
125
+ postBestEffort,
126
+ workChildren,
127
+ groupKillChildren,
128
+ sessionMetaPath,
129
+ spawn = spawnProcess,
130
+ }) {
97
131
  const AGENT_CHECK_DONE_URL = fleetEndpoint('agent-check-done', FLEET_URL);
98
132
 
99
133
  // ── THE PROJECT'S OWN CHECK, and the MERGE ─────────────────────────────────
@@ -136,7 +170,12 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
136
170
 
137
171
  const runCheck = async (agentId, wt) => {
138
172
  const cmd = readCheckCommand();
139
- const headSha = (git(['rev-parse', 'HEAD'], wt) || '').trim() || undefined;
173
+ // OPTIONAL, like the pre-review's twin read: an unreadable head (a
174
+ // worktree retired mid-beat, an unborn branch) costs the answer its
175
+ // headSha, never the answer. This read was the one copy left unguarded,
176
+ // and its throw kept the row on its previous, older check label (audit
177
+ // 2026-10-10, F144).
178
+ const headSha = gitOrNull(['rev-parse', 'HEAD'], wt) ?? undefined;
140
179
  if (!cmd) {
141
180
  await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
142
181
  return;
@@ -161,6 +200,21 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
161
200
  // After the teardown no check starts; this promise stays pending.
162
201
  if (workLanesStopped()) return;
163
202
  let child;
203
+ /**
204
+ * ONE RELEASE, from every way the check ends (2026-10-10, the audit's
205
+ * F152). Only 'close' and 'error' used to untrack the child, and
206
+ * nothing ever pruned `groupKillChildren` — so a timed-out check whose
207
+ * pipe a survivor outside its group still held (a watcher, a dev server
208
+ * it daemonised) never closed, and stayed a live turn for good: the
209
+ * machine's admission count inflated and `workBusy` held off every
210
+ * self-update until a restart, while the set kept one dead child per
211
+ * run. Idempotent; the timer, 'error' and 'close' each call it.
212
+ */
213
+ const release = () => {
214
+ if (!child) return;
215
+ workChildren.delete(child);
216
+ groupKillChildren.delete(child);
217
+ };
164
218
  try {
165
219
  // DETACHED, so the child's pid is its PROCESS GROUP. A check is almost
166
220
  // always a shell that spawns the real runner, and signalling the shell
@@ -242,17 +296,10 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
242
296
  child.stdout?.on('data', keep);
243
297
  child.stderr?.on('data', keep);
244
298
  const timer = setTimeout(() => {
245
- try {
246
- // The GROUP, not the child: killing the shell leaves whatever it
247
- // started running, which is the thing actually taking ten minutes.
248
- process.kill(-child.pid, 'SIGKILL');
249
- } catch {
250
- try {
251
- child.kill('SIGKILL');
252
- } catch {
253
- /* already gone */
254
- }
255
- }
299
+ // The GROUP, not the child: killing the shell leaves whatever it
300
+ // started running, which is the thing actually taking ten minutes.
301
+ killGroup(child, 'SIGKILL');
302
+ release();
256
303
  text += '\n[flowviant] the check ran past ten minutes and was stopped';
257
304
  // RESOLVE HERE TOO. Waiting for 'close' after a kill is the shape that
258
305
  // hangs: if the group is already gone the event never arrives, and this
@@ -261,13 +308,13 @@ export function createWorkAgentCheck({ repoRoot, postBestEffort, workChildren, g
261
308
  }, CHECK_TIMEOUT_MS);
262
309
  child.on('error', (e) => {
263
310
  clearTimeout(timer);
264
- workChildren.delete(child);
311
+ release();
265
312
  text += String(e?.message || e);
266
313
  finish('failed');
267
314
  });
268
315
  child.on('close', (code) => {
269
316
  clearTimeout(timer);
270
- workChildren.delete(child);
317
+ release();
271
318
  finish(code === 0 ? 'passed' : 'failed');
272
319
  });
273
320
  });
@@ -1,16 +1,33 @@
1
1
  /** Agent merge claims, pull-request approval, and branch cleanup. */
2
- import { ensureOpenPr, ghFirstLine, ghReady, mergeAndVerifyTip, PR_URL_RE } from './prWorkflow.mjs';
2
+ import { ensureOpenPr, ghReady, mergeAndVerifyTip, PR_URL_RE, relayedFailure } from './prWorkflow.mjs';
3
3
  import { existsSync } from 'node:fs';
4
4
  import { execFileSync } from 'node:child_process';
5
5
  import { join } from 'node:path';
6
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT, DAEMON_INSTANCE } from './config.mjs';
7
- import { git, gitNet as gitNetIn, baseBranchName, isSafePathSegment } from './git.mjs';
6
+ import { FLEET_URL } from './config.mjs';
7
+ import { git, gitOrNull, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch, stripUrlUserinfo } from './git.mjs';
8
8
  import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
9
9
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
10
10
  import { gitMergeIn } from './gitMergeIdentity.mjs';
11
11
  import { warn } from './ui.mjs';
12
12
  import { scrub as envScrub } from './uplinkScrub.mjs';
13
13
  import { fleetEndpoint } from './fleetWire.mjs';
14
+ import { claimFleetLease, settleLease } from './fleetPost.mjs';
15
+ import { worktreePath } from './workPlaces.mjs';
16
+
17
+ /** Git puts merge conflicts on stdout, while refusals usually use stderr.
18
+ * Keep both before the command wrapper, scrub before bounding, and put Git's
19
+ * reason first so the server's shorter mergeError cap keeps the useful part.
20
+ * A genuine conflict stays in the worktree for the merge_resolve turn.
21
+ * Userinfo is stripped too (2026-10-10, F153): the merge's own push to
22
+ * `origin` can print the remote URL, token and all, into a team-visible
23
+ * mergeError. */
24
+ export function agentMergeFailureDetail(error) {
25
+ const output = [error?.stdout, error?.stderr]
26
+ .map((value) => String(value ?? '').trim())
27
+ .filter(Boolean)
28
+ .join('\n');
29
+ return envScrub(stripUrlUserinfo(output || String(error?.message || error))).slice(0, 2000);
30
+ }
14
31
 
15
32
  export function createWorkAgentMerges({
16
33
  repoRoot,
@@ -39,42 +56,12 @@ export function createWorkAgentMerges({
39
56
  // landed observer's own fetch sees it, and the cards close there.
40
57
  const agentMerges = new Set(); // agent ids in flight on this tick
41
58
 
42
- const claimAgentMerge = async (agentId) => {
43
- try {
44
- const res = await fetch(AGENT_MERGE_CLAIM_URL, {
45
- method: 'POST',
46
- headers: {
47
- Authorization: `Bearer ${FLEET_TOKEN}`,
48
- 'User-Agent': USER_AGENT,
49
- 'Content-Type': 'application/json',
50
- },
51
- signal: AbortSignal.timeout(15_000),
52
- body: JSON.stringify({ agentId, instance: DAEMON_INSTANCE }),
53
- });
54
- const j = await res.json().catch(() => null);
55
- return j?.data?.claimed === true;
56
- } catch {
57
- return false; // the peer may hold it; doing nothing is the safe answer
58
- }
59
- };
59
+ // The peer may hold it; doing nothing is the safe answer.
60
+ const claimAgentMerge = (agentId) => claimFleetLease(AGENT_MERGE_CLAIM_URL, { agentId });
60
61
 
61
- const postAgentMerge = async (body) => {
62
- try {
63
- await fetch(AGENT_MERGE_DONE_URL, {
64
- method: 'POST',
65
- headers: {
66
- Authorization: `Bearer ${FLEET_TOKEN}`,
67
- 'User-Agent': USER_AGENT,
68
- 'Content-Type': 'application/json',
69
- },
70
- signal: AbortSignal.timeout(30_000),
71
- body: JSON.stringify({ ...body, instance: DAEMON_INSTANCE }),
72
- });
73
- } catch {
74
- /* the lease lapses and the job is re-offered — a merge is idempotent
75
- against an already-merged branch, which mergeOutward detects */
76
- }
77
- };
62
+ // On a failure the lease lapses and the job is re-offered — a merge is
63
+ // idempotent against an already-merged branch, which mergeOutward detects.
64
+ const postAgentMerge = (body) => settleLease(AGENT_MERGE_DONE_URL, body, { timeoutMs: 30_000 });
78
65
 
79
66
  // A merge COMMIT needs an identity the machine may not have; the probe and
80
67
  // the daemon's fallback live in gitMergeIdentity.mjs.
@@ -132,7 +119,7 @@ export function createWorkAgentMerges({
132
119
  // does not know exists.
133
120
  try {
134
121
  await inPlace(place, true, async () => {
135
- const wt = join(baseDir, 'sessions', place);
122
+ const wt = worktreePath(baseDir, place);
136
123
  if (!existsSync(wt)) {
137
124
  await report({ agentId, ok: false, detail: 'the worktree is gone' });
138
125
  return;
@@ -154,7 +141,7 @@ export function createWorkAgentMerges({
154
141
  await report({
155
142
  agentId,
156
143
  ok: false,
157
- detail: envScrub(String(e?.message || e)).slice(0, 2000),
144
+ detail: agentMergeFailureDetail(e),
158
145
  });
159
146
  return;
160
147
  }
@@ -168,21 +155,19 @@ export function createWorkAgentMerges({
168
155
  // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
169
156
  // a detached HEAD — so the guard below was unreachable and the throw
170
157
  // escaped the claimed merge, leaving it unsettled until its lease lapsed.
171
- let branch = '';
172
- try {
173
- branch = (git(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) || '').trim();
174
- } catch {
175
- branch = '';
176
- }
158
+ // `gitOrNull` reads that as no branch.
159
+ const branch = gitOrNull(['symbolic-ref', '--quiet', '--short', 'HEAD'], wt) ?? '';
177
160
  if (!branch) {
178
161
  // A detached HEAD names no branch, so there is nothing to merge and
179
162
  // nothing to record. An ambiguity in git, not a rule of ours.
180
163
  await report({ agentId, ok: false, detail: 'this worktree is on a detached HEAD' });
181
164
  return;
182
165
  }
183
- const tip = (git(['rev-parse', 'HEAD'], wt) || '').trim();
184
- const countOut = git(['rev-list', '--count', `${baseRef()}..HEAD`], wt);
185
- const count = Number((countOut || '0').trim()) || 0;
166
+ const tip = gitOrNull(['rev-parse', 'HEAD'], wt) ?? '';
167
+ // The count stays a THROWING read, inside the merge's own catch: a
168
+ // range git cannot answer must never read as 0, which is "already on
169
+ // base" and reports success.
170
+ const count = Number(git(['rev-list', '--count', `${baseRef()}..HEAD`], wt)) || 0;
186
171
  if (count === 0) {
187
172
  // Already on base — an idempotent re-offer, or an agent that changed
188
173
  // nothing. Reported as a SUCCESS: the branch's work is on base, which
@@ -249,7 +234,7 @@ export function createWorkAgentMerges({
249
234
  * work on that ref that nobody approved; the plain push of the checked
250
235
  * out branch is the honest fallback.
251
236
  */
252
- const ownBranch = branch === `session/${place}`;
237
+ const ownBranch = branch === sessionBranch(place);
253
238
  const head =
254
239
  ownBranch && isPublishRef(job.publishedRef) ? job.publishedRef : branch;
255
240
  try {
@@ -268,14 +253,15 @@ export function createWorkAgentMerges({
268
253
  agentRemoteAt.set(place, { ref: head, sha: tip });
269
254
  }
270
255
  } catch (e) {
271
- // SCRUBBED. Every other failure on this path relays `gh`'s own words,
272
- // but a push writes the REMOTE URL to stderr and a remote can carry a
273
- // token in its userinfo — so this one line is the only place on the
274
- // agent merge path that can leak a credential into a stored,
275
- // team-visible `mergeError`.
256
+ // SCRUBBED AND USERINFO-STRIPPED (`relayedFailure`). A push writes
257
+ // the REMOTE URL to stderr and a remote can carry a token in its
258
+ // userinfo, and this line becomes a stored, team-visible
259
+ // `mergeError`. The env scrub alone left `//user:token@` standing
260
+ // (audit 2026-10-10, F153); gh's own refusals below take the same
261
+ // relay inside prWorkflow.mjs.
276
262
  // A refused push is the remote or its login (no `origin`, a
277
263
  // credential, a protected ref) — the person's to fix, not the branch.
278
- await report({ agentId, ok: false, fix: 'person', detail: envScrub(ghFirstLine(e)) });
264
+ await report({ agentId, ok: false, fix: 'person', detail: relayedFailure(e) });
279
265
  return;
280
266
  }
281
267
  const pr = ensureOpenPr(head, prBase, { cwd: repoRoot });
@@ -309,6 +295,19 @@ export function createWorkAgentMerges({
309
295
  });
310
296
  return;
311
297
  }
298
+ // GitHub lists no open pull request FROM this head in this
299
+ // repository, so nothing was merged — never one picked by a selector
300
+ // that merely looks like the branch (prWorkflow.mjs).
301
+ if (merged.noPr) {
302
+ await report({
303
+ agentId,
304
+ ok: false,
305
+ detail:
306
+ `there is no open pull request from ${head} in this repository to merge` +
307
+ (linked ? ` — the one opened is at ${prUrl}` : ' — approve again to open one'),
308
+ });
309
+ return;
310
+ }
312
311
  if (merged.notOnBase) {
313
312
  // gh said yes and base does not have the tip: a merge queue may
314
313
  // still be running it, or an older PR merged. The server closes
@@ -354,7 +353,7 @@ export function createWorkAgentMerges({
354
353
  // resolve turn on the server. A conflict says nothing, and the
355
354
  // agent resolves it in its worktree as before.
356
355
  ...(e?.fix === 'person' ? { fix: 'person' } : {}),
357
- detail: envScrub(String(e?.message || e)).slice(0, 2000),
356
+ detail: agentMergeFailureDetail(e),
358
357
  });
359
358
  return;
360
359
  }
@@ -362,6 +361,12 @@ export function createWorkAgentMerges({
362
361
  onRepoChanged();
363
362
  landed.observe();
364
363
  });
364
+ } catch (e) {
365
+ // Unexpected Git failures must retain their diagnostics too. The belt
366
+ // below remains for paths which exit without an error or a report.
367
+ if (!reported) {
368
+ await report({ agentId, ok: false, detail: agentMergeFailureDetail(e) });
369
+ }
365
370
  } finally {
366
371
  if (!reported) {
367
372
  // Belt over braces. A merge this daemon claimed and cannot account for