flowviant 0.124.0 → 0.124.1

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 (59) hide show
  1. package/README.md +6 -0
  2. package/bin/cli.mjs +7 -0
  3. package/bin/lib/agentInternalWait.mjs +2 -1
  4. package/bin/lib/agentPlan.mjs +8 -0
  5. package/bin/lib/agentRepositories.mjs +48 -0
  6. package/bin/lib/checkoutIdentity.mjs +25 -0
  7. package/bin/lib/claudeInput.mjs +32 -0
  8. package/bin/lib/claudeSessions.mjs +20 -0
  9. package/bin/lib/credentials.mjs +52 -17
  10. package/bin/lib/daemonLocks.mjs +21 -3
  11. package/bin/lib/fleet.mjs +19 -6
  12. package/bin/lib/fleetRoster.mjs +1 -89
  13. package/bin/lib/fleetStartup.mjs +11 -14
  14. package/bin/lib/holder.mjs +4 -37
  15. package/bin/lib/instance.mjs +20 -7
  16. package/bin/lib/landed.mjs +2 -2
  17. package/bin/lib/login.mjs +39 -4
  18. package/bin/lib/placeLock.mjs +9 -7
  19. package/bin/lib/prWorkflow.mjs +15 -0
  20. package/bin/lib/projectLanded.mjs +16 -0
  21. package/bin/lib/projectRepositories.mjs +60 -0
  22. package/bin/lib/projectTools.mjs +2 -1
  23. package/bin/lib/projectWorktreeHome.mjs +25 -0
  24. package/bin/lib/prompts.mjs +6 -1
  25. package/bin/lib/repositoryDelivery.mjs +64 -0
  26. package/bin/lib/repositoryMerge.mjs +116 -0
  27. package/bin/lib/repositoryPlaces.mjs +179 -0
  28. package/bin/lib/repositorySetup.mjs +58 -0
  29. package/bin/lib/repositoryTool.mjs +71 -0
  30. package/bin/lib/runTurn.mjs +23 -2
  31. package/bin/lib/runtimeAntigravity.mjs +2 -1
  32. package/bin/lib/runtimeClaude.mjs +5 -2
  33. package/bin/lib/runtimeCodex.mjs +2 -1
  34. package/bin/lib/shipMerge.mjs +5 -2
  35. package/bin/lib/startProject.mjs +4 -4
  36. package/bin/lib/trace.mjs +8 -3
  37. package/bin/lib/turnDeclaration.mjs +88 -0
  38. package/bin/lib/turnLock.mjs +48 -8
  39. package/bin/lib/work.mjs +22 -24
  40. package/bin/lib/workAgentCheck.mjs +6 -5
  41. package/bin/lib/workAgentMerges.mjs +19 -2
  42. package/bin/lib/workAgentPrecheck.mjs +5 -5
  43. package/bin/lib/workAgentPublish.mjs +18 -5
  44. package/bin/lib/workAgentRecovery.mjs +85 -0
  45. package/bin/lib/workAgentReview.mjs +5 -3
  46. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  47. package/bin/lib/workAgentTurnExecution.mjs +76 -9
  48. package/bin/lib/workAgentTurnOutcome.mjs +9 -3
  49. package/bin/lib/workAgentTurns.mjs +33 -9
  50. package/bin/lib/workDelivery.mjs +21 -0
  51. package/bin/lib/workDiffs.mjs +18 -11
  52. package/bin/lib/workLiveMessages.mjs +129 -0
  53. package/bin/lib/workPlaceReports.mjs +5 -0
  54. package/bin/lib/workPlaces.mjs +6 -1
  55. package/bin/lib/workRetire.mjs +5 -0
  56. package/bin/lib/workSessionTurns.mjs +72 -40
  57. package/bin/lib/workShip.mjs +17 -16
  58. package/package.json +1 -1
  59. package/npm-shrinkwrap.json +0 -69
package/bin/lib/trace.mjs CHANGED
@@ -60,6 +60,7 @@
60
60
  */
61
61
 
62
62
  import { THINK_MARKER } from './runtimeEvents.mjs';
63
+ import { withoutTurnDeclaration } from './turnDeclaration.mjs';
63
64
 
64
65
  /** One batch every two seconds — the discipline the tab's narrator keeps, for
65
66
  * the same reason: a turn emits hundreds of entries and nobody is reading them
@@ -207,7 +208,8 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
207
208
  * did when a thought was 300 characters.
208
209
  */
209
210
  prose(kind, text) {
210
- const t = scrub(String(text ?? ''))
211
+ const readable = kind === 'think' ? String(text ?? '') : withoutTurnDeclaration(text);
212
+ const t = scrub(readable)
211
213
  // One line ending, whatever the CLI printed.
212
214
  .replace(/\r\n?/g, '\n')
213
215
  // Spaces and tabs collapse; `\n` is deliberately excluded from the class.
@@ -216,7 +218,7 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
216
218
  .replace(/\n{3,}/g, '\n\n')
217
219
  .trim()
218
220
  .slice(0, TRACE_PROSE_CAP);
219
- if (!t) return;
221
+ if (!t) return null;
220
222
  const k = kind === 'think' ? 'think' : kind === 'say' ? 'say' : 'note';
221
223
  /**
222
224
  * A RUN OF BARE "thinking…" IS ONE STEP, not forty.
@@ -238,9 +240,12 @@ export function makeTraceRelay({ agentId, turnId, post, scrub = (s) => s, run =
238
240
  * steps are not shown" count stays a count of steps that existed.
239
241
  */
240
242
  if (k === 'think' && t === THINK_MARKER && lastEntry?.k === 'think' && lastEntry.t === t) {
241
- return;
243
+ return readable;
242
244
  }
243
245
  push({ k, t });
246
+ // The activity pulse uses this same filtering; its original compact
247
+ // label stands unless protocol text was removed from the full message.
248
+ return readable;
244
249
  },
245
250
  /** One structured tool event, exactly as `toolEventOf` built it. A tool this
246
251
  * builder does not know returns null there and nothing is pushed here — a
@@ -0,0 +1,88 @@
1
+ /** A final outcome is protocol, not conversation prose. Filter before trace
2
+ * normalization/caps; the unmodified answer still goes to parseTurnResult.
3
+ * Only standalone objects and JSON/unlabelled fences count. Code examples
4
+ * inside prose or another language's fence stay as the CLI wrote them. */
5
+ import { jsonObjectClosers } from './agentPlan.mjs';
6
+
7
+ const statuses = new Set(['delivered', 'blocked', 'prerequisite', 'nothing']);
8
+ function declaration(body) {
9
+ const raw = body.trim();
10
+ if (!raw.startsWith('{')) return false;
11
+ try {
12
+ const value = JSON.parse(raw);
13
+ return value && typeof value === 'object' && !Array.isArray(value)
14
+ && ((Object.hasOwn(value, 'summary') && Object.hasOwn(value, 'progress'))
15
+ || (typeof value.status === 'string'
16
+ && (statuses.has(value.status) || ['summary', 'progress', 'question'].some(key => Object.hasOwn(value, key)))));
17
+ } catch {
18
+ // A truncated/invalid declaration must not fall through as an answer.
19
+ // Require a protocol key at the root's opening and the declared status;
20
+ // arbitrary malformed JSON remains useful diagnostic output.
21
+ return /^\{\s*"(?:status|summary|progress|question)"\s*:/.test(raw)
22
+ && /"status"\s*:\s*"(?:delivered|blocked|prerequisite|nothing)"/.test(raw);
23
+ }
24
+ }
25
+
26
+ /** Objects on their own lines, including a final object after a preamble.
27
+ * Skip a whole outer span before looking again; nested examples are data. */
28
+ function bareDeclarations(raw) {
29
+ const closes = jsonObjectClosers(raw);
30
+ let kept = '', from = 0, removed = false;
31
+ for (let i = 0; i < raw.length; i++) {
32
+ if (raw[i] !== '{') continue;
33
+ const end = closes.get(i) ?? -1;
34
+ const lineStart = raw.lastIndexOf('\n', i - 1) + 1;
35
+ const nextLine = end < 0 ? -1 : raw.indexOf('\n', end + 1);
36
+ const alone = !raw.slice(lineStart, i).trim()
37
+ && (end < 0 || !raw.slice(end + 1, nextLine < 0 ? raw.length : nextLine).trim());
38
+ const finish = end < 0 ? raw.length : end + 1;
39
+ if (alone && declaration(raw.slice(i, finish))) {
40
+ kept += raw.slice(from, i); from = finish; removed = true;
41
+ }
42
+ if (end >= 0) i = end;
43
+ else {
44
+ // An unclosed outer object owns the remaining candidate. For a stray
45
+ // brace in prose, skip its line instead of rescanning a brace wall.
46
+ const newline = raw.indexOf('\n', i);
47
+ if (alone || newline < 0) break;
48
+ i = newline;
49
+ }
50
+ }
51
+ return removed ? kept + raw.slice(from) : null;
52
+ }
53
+
54
+ export function withoutTurnDeclaration(text) {
55
+ const raw = String(text ?? '');
56
+ const kept = [];
57
+ let block = [], fence = null, json = false, removed = false;
58
+ let prose = [];
59
+ const flushProse = () => {
60
+ const plain = bareDeclarations(prose.join('\n'));
61
+ if (plain !== null) { removed = true; kept.push(plain); }
62
+ else kept.push(...prose);
63
+ prose = [];
64
+ };
65
+ // Consume every fence, including other languages, in one forward pass.
66
+ // A nested JSON example is part of its outer code block, never a result.
67
+ for (const line of raw.split('\n')) {
68
+ if (fence) {
69
+ if (line.trim() === fence) {
70
+ if (json && declaration(block.slice(1).join('\n'))) removed = true;
71
+ else kept.push(...block, line);
72
+ block = []; fence = null;
73
+ } else block.push(line);
74
+ } else {
75
+ const open = line.trim().match(/^(`{3,}|~{3,})[ \t]*([\w-]*)$/);
76
+ if (open) {
77
+ flushProse();
78
+ fence = open[1]; json = !open[2] || open[2].toLowerCase() === 'json'; block = [line];
79
+ } else prose.push(line);
80
+ }
81
+ }
82
+ flushProse();
83
+ if (fence) {
84
+ if (json && declaration(block.slice(1).join('\n'))) removed = true;
85
+ else kept.push(...block);
86
+ }
87
+ return removed ? kept.join('\n').replace(/\n{3,}/g, '\n\n').trim() : raw;
88
+ }
@@ -1,22 +1,32 @@
1
1
  /**
2
2
  * THE CROSS-PROCESS TURN LOCK — the one reading of "is a CLI of ours already
3
- * working in this directory".
3
+ * working in this tab's conversation" (and, for ship, "anywhere in this
4
+ * directory").
4
5
  *
5
6
  * Split out of work.mjs (2026-09-26, SOLID F037). It is read by the session
6
7
  * lane before a spawn and by the ship executor before a merge, and it is pure
7
8
  * over the disk and the kernel: no manager state, so it needs no factory and
8
9
  * is tested directly (turnLock.test.mjs). The lock FILE's path is the
9
- * caller's (`sessionMetaPath(wt, 'flowviant-turn.lock')`); what a pid in it
10
- * means is this module's.
10
+ * caller's (`sessionMetaPath(wt, 'flowviant-turn.lock', sessionId)`); what a
11
+ * pid in it means is this module's.
12
+ *
13
+ * PER TAB, NOT PER DIRECTORY (2026-10-14). It was keyed by the directory alone,
14
+ * on the assumption that a place holds one session. Every tab a person opens
15
+ * shares the checkout, so the one file serialized all of their tabs: a
16
+ * fourteen-minute turn in one tab held a message in two others for eight
17
+ * minutes each. The lock guards a CONVERSATION (two CLIs resuming one held
18
+ * context), so it is scoped by the tab; the unscoped file is what a daemon
19
+ * from before this change wrote, and is still honored while its CLI lives.
11
20
  */
12
- import { existsSync, readFileSync, rmSync } from 'node:fs';
21
+ import { existsSync, readFileSync, readdirSync, rmSync } from 'node:fs';
22
+ import { basename, dirname, join } from 'node:path';
13
23
  import { aliveAndOurs, processStartTime } from './procRegistry.mjs';
14
24
 
15
25
  /**
16
- * The spawn lock: the pid of the CLI currently live in this worktree. A
17
- * restarted daemon must not put a second Claude into a directory the orphan
18
- * of its previous life is still editing — two CLIs appending to one held
19
- * conversation is exactly the incoherence workChains prevents in-process,
26
+ * The spawn lock: the pid of the CLI currently live in this tab. A restarted
27
+ * daemon must not resume the conversation its previous life's orphan still
28
+ * holds — two CLIs appending to one held conversation is exactly the
29
+ * incoherence the synchronous start claim prevents in-process,
20
30
  * and the lock extends that guarantee across a restart. A dead pid is a
21
31
  * stale lock (removed here); a live one means "come back next poll".
22
32
  *
@@ -59,3 +69,33 @@ export const turnLockedByLivePid = (lockPath) => {
59
69
  }
60
70
  return false;
61
71
  };
72
+
73
+ /**
74
+ * ANY TAB'S TURN IN THIS DIRECTORY — ship's question, asked of the unscoped
75
+ * lock path (`sessionMetaPath(wt, 'flowviant-turn.lock')`). Ship folds and
76
+ * merges with git in the directory every tab there shares, so a live CLI of
77
+ * ANY tab defers it, not only the shipping tab's own. Reads the unscoped file
78
+ * and every `<name>-<scope>` beside it; nothing else in the git dir. Return
79
+ * the holding tab's id so the waiting log names the holder, not the shipper.
80
+ * A legacy lock has no tab id: null is an honest unknown.
81
+ */
82
+ export const placeTurnLockHolder = (placeLockPath) => {
83
+ if (!placeLockPath) return null;
84
+ const dir = dirname(placeLockPath);
85
+ const name = basename(placeLockPath);
86
+ let entries;
87
+ try {
88
+ entries = readdirSync(dir);
89
+ } catch {
90
+ return null;
91
+ }
92
+ for (const entry of entries) {
93
+ if (entry !== name && !entry.startsWith(`${name}-`)) continue;
94
+ if (turnLockedByLivePid(join(dir, entry))) {
95
+ return { sessionId: entry === name ? null : entry.slice(name.length + 1) };
96
+ }
97
+ }
98
+ return null;
99
+ };
100
+
101
+ export const placeTurnLockedByLivePid = (placeLockPath) => Boolean(placeTurnLockHolder(placeLockPath));
package/bin/lib/work.mjs CHANGED
@@ -51,17 +51,21 @@
51
51
  * workCappedTurn.mjs one capped read-only consult turn: its child, its slot,
52
52
  * its cap and its transcript — the planner, the pre-review
53
53
  * and intake all run one (2026-10-10)
54
- * turnLock.mjs whether a live CLI of ours holds a directory's turn
54
+ * turnLock.mjs tab pid locks; workLiveMessages.mjs — live input; workDelivery.mjs — queue
55
55
  * (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
56
56
  * workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
57
- * workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
57
+ * workAgentTurns, workAgentRecovery, workAgentMerges, workProcesses; and out of workAgentTurns,
58
58
  * SOLID F036: workAgentTurnReports, workAgentTurnExecution, workAgentTurnBegun,
59
- * workAgentTurnOutcome; the live brief: workAgentBrief, workAgentBriefRunner)
59
+ * workAgentTurnOutcome; repositoryPlaces + agentRepositories: multi-repo places; the live brief: workAgentBrief, workAgentBriefRunner)
60
60
  */
61
61
 
62
+ import { trackedRepositorySetup } from './repositorySetup.mjs';
63
+ import { createRepositoryPlaces } from './repositoryPlaces.mjs';
64
+ import { createAgentRepositories } from './agentRepositories.mjs';
62
65
  import { createWorkProcesses } from './workProcesses.mjs';
63
66
  import { createWorkAgentMerges } from './workAgentMerges.mjs';
64
67
  import { createWorkAgentTurns } from './workAgentTurns.mjs';
68
+ import { createAgentRecovery } from './workAgentRecovery.mjs';
65
69
  import { createWorkAgentCheck } from './workAgentCheck.mjs';
66
70
  import { createWorkAgentPrecheck } from './workAgentPrecheck.mjs';
67
71
  import { createWorkAgentReview } from './workAgentReview.mjs';
@@ -87,7 +91,7 @@ import { createWorkSessionTurns } from './workSessionTurns.mjs';
87
91
  import { createWorkRetire } from './workRetire.mjs';
88
92
  import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
89
93
  import { gitNet as gitNetIn, excludeInWorktree } from './git.mjs';
90
- import { createLandedObserver } from './landed.mjs';
94
+ import { createProjectLandedObserver } from './projectLanded.mjs';
91
95
  import { createPlaceLock } from './placeLock.mjs';
92
96
  import { warn } from './ui.mjs';
93
97
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
@@ -99,6 +103,7 @@ import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
99
103
 
100
104
  export function createWorkManager({
101
105
  repoRoot,
106
+ repositories,
102
107
  baseDir,
103
108
  getBaseRef,
104
109
  getMcpUrl,
@@ -135,10 +140,12 @@ export function createWorkManager({
135
140
  * `origin/<branch you happened to be on>` for the life of the daemon.
136
141
  */
137
142
  const baseRef = () => getBaseRef();
143
+ const repositoryPlaces = repositories && createRepositoryPlaces({ repoRoot, baseDir, repositories, changed: onRepoChanged, setup: (...args) => trackedRepositorySetup(workChildren, groupKillChildren)(...args) });
144
+ const agentRepositories = repositories && createAgentRepositories({ repositories, places: repositoryPlaces });
138
145
  // What arrived on base, whichever road it took — observed after every beat
139
146
  // that can move origin/<base>: the sweep's fetch, a ship's push, a PR merge
140
147
  // this daemon performed. See landed.mjs for the seeding and delivery rules.
141
- const landed = createLandedObserver({ repoRoot, baseRef });
148
+ const landed = createProjectLandedObserver({ repoRoot, baseRef, repositories });
142
149
  /**
143
150
  * THE ARTIFACT RELAY (2026-09-22, 0.94.0) — what a turn wrote under
144
151
  * `.flowviant/artifacts/`, uploaded after it. One reporter for the life of
@@ -200,14 +207,13 @@ export function createWorkManager({
200
207
  */
201
208
  const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
202
209
 
203
- // ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
204
210
  const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
205
211
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
206
212
  const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
207
213
  createWorkSessionGroups({ repoRoot });
208
214
  const { workChildren, groupKillChildren, shutdownWork, liveTurnCount, liveTurns, admit } =
209
215
  createWorkChildren({ extraLiveTurns });
210
- const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
216
+ const { agentPublished, agentRemoteAt, publishAgentBranch, stopAgentPublishing, fetchPublishedBranch, fetchPublishedBranchAsync } =
211
217
  createWorkAgentPublish({ repoRoot, gitNet });
212
218
  const { reconcileIdleArtifacts } = createWorkArtifactReconcile({
213
219
  artifacts,
@@ -220,6 +226,7 @@ export function createWorkManager({
220
226
  });
221
227
  const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
222
228
  createWorkPlaceReports({
229
+ repositoryPlaces,
223
230
  repoRoot,
224
231
  baseRef,
225
232
  placeOf,
@@ -295,15 +302,10 @@ export function createWorkManager({
295
302
  * which lanes share which map (workChildren: plans, review, turns; the
296
303
  * publication maps: turns and merges).
297
304
  */
298
-
299
- const { processDiffJobs } = createWorkDiffs({
300
- repoRoot,
301
- });
302
-
305
+ const { processDiffJobs } = createWorkDiffs({ repoRoot });
303
306
  const { processPreviewJobs, livePreviewIds, retirePreviews, shutdownPreviews } = createWorkPreviews({
304
307
  placeDir,
305
308
  });
306
-
307
309
  const { processPrJobs } = createWorkPullRequests({
308
310
  placeOf,
309
311
  placeDir,
@@ -314,7 +316,6 @@ export function createWorkManager({
314
316
  landed,
315
317
  onRepoChanged,
316
318
  });
317
-
318
319
  const { processShipJobs } = createWorkShipper({
319
320
  inPlace,
320
321
  placeOf,
@@ -330,7 +331,6 @@ export function createWorkManager({
330
331
  shipping,
331
332
  pendingShipReports,
332
333
  });
333
-
334
334
  const { processAgentPlanJobs, planning } = createWorkAgentPlans({
335
335
  postBestEffort,
336
336
  baseDir,
@@ -341,7 +341,6 @@ export function createWorkManager({
341
341
  placeLocks,
342
342
  workChildren,
343
343
  });
344
-
345
344
  const { processIntakeJobs, intake } = createWorkIntake({
346
345
  postBestEffort,
347
346
  inPlace,
@@ -367,9 +366,9 @@ export function createWorkManager({
367
366
  workChildren,
368
367
  });
369
368
  const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
370
-
369
+ const processAgentRecoveryJobs = createAgentRecovery({ baseDir, repoRoot, fetchPublishedBranch: fetchPublishedBranchAsync, inPlace, placeWtFor, reportSessionWorktree });
371
370
  const { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
372
- REJECT_RETRY_MS,
371
+ agentRepositories, REJECT_RETRY_MS,
373
372
  baseRef,
374
373
  inPlace,
375
374
  baseDir,
@@ -383,15 +382,15 @@ export function createWorkManager({
383
382
  reportSessionWorktree,
384
383
  artifacts,
385
384
  runReviewEntry,
386
- publishAgentBranch,
385
+ publishAgentBranch, stopAgentPublishing,
387
386
  admit,
388
387
  workChildren,
389
388
  agentPublished,
390
389
  agentRemoteAt,
391
390
  fetchAgentFiles,
392
391
  });
393
-
394
392
  const { processAgentMergeJobs, agentMerges } = createWorkAgentMerges({
393
+ repositoryPlaces,
395
394
  repoRoot,
396
395
  inPlace,
397
396
  baseDir,
@@ -404,10 +403,9 @@ export function createWorkManager({
404
403
  agentPublished,
405
404
  });
406
405
 
407
- // Built after the agent lane: retirement stops a stopped agent's CLI, and
408
- // only the agent lane knows which child that is.
406
+ // Retirement asks the agent lane which CLI to stop.
409
407
  const { heldSessionIds, retireWorkSessions, freshenManualPlaces } = createWorkRetire({
410
- repoRoot,
408
+ repositoryPlaces, repoRoot,
411
409
  baseDir,
412
410
  baseRef,
413
411
  placeOf,
@@ -485,7 +483,7 @@ export function createWorkManager({
485
483
  liveTurnCount,
486
484
  processAgentPlanJobs,
487
485
  processIntakeJobs,
488
- processAgentTurnJobs, interruptAgentTurns,
486
+ processAgentTurnJobs, processAgentRecoveryJobs, interruptAgentTurns,
489
487
  // Only the roster's stand-down calls this — a displacement, a removal or a
490
488
  // commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
491
489
  // Exported rather than hooked into `shutdownWork` because the signal
@@ -150,9 +150,9 @@ export function createWorkAgentCheck({
150
150
  const CHECK_TIMEOUT_MS = 10 * 60_000;
151
151
  const CHECK_OUTPUT_CAP = 4000;
152
152
 
153
- const readCheckCommand = () => {
153
+ const readCheckCommand = (root = repoRoot) => {
154
154
  try {
155
- const raw = readFileSync(join(repoRoot, '.flowviant', 'check.json'), 'utf8');
155
+ const raw = readFileSync(join(root, '.flowviant', 'check.json'), 'utf8');
156
156
  const cfg = JSON.parse(raw);
157
157
  const cmd = typeof cfg?.command === 'string' ? cfg.command.trim() : '';
158
158
  return cmd ? cmd.slice(0, 500) : null;
@@ -168,8 +168,8 @@ export function createWorkAgentCheck({
168
168
  await postBestEffort(AGENT_CHECK_DONE_URL, body);
169
169
  };
170
170
 
171
- const runCheck = async (agentId, wt) => {
172
- const cmd = readCheckCommand();
171
+ const runCheck = async (agentId, wt, repository) => {
172
+ const cmd = readCheckCommand(repository ? wt : repoRoot);
173
173
  // OPTIONAL, like the pre-review's twin read: an unreadable head (a
174
174
  // worktree retired mid-beat, an unborn branch) costs the answer its
175
175
  // headSha, never the answer. This read was the one copy left unguarded,
@@ -178,7 +178,7 @@ export function createWorkAgentCheck({
178
178
  const headSha = gitOrNull(['rev-parse', 'HEAD'], wt) ?? undefined;
179
179
  if (!cmd) {
180
180
  await postCheck({ agentId, status: 'none', ...(headSha ? { headSha } : {}) });
181
- return;
181
+ return { status: 'none' };
182
182
  }
183
183
  const cleared = clearNonArtifacts(wt);
184
184
  if (cleared.length)
@@ -325,6 +325,7 @@ export function createWorkAgentCheck({
325
325
  output: envScrub(out.text).slice(-CHECK_OUTPUT_CAP),
326
326
  ...(headSha ? { headSha } : {}),
327
327
  });
328
+ return out;
328
329
  };
329
330
 
330
331
  return { runCheck };
@@ -1,9 +1,11 @@
1
+ import { mergeRepositoryPlace } from './repositoryMerge.mjs';
2
+ import { fleetRequest } from './fleetPost.mjs';
1
3
  /** Agent merge claims, pull-request approval, and branch cleanup. */
2
4
  import { ensureOpenPr, ghReady, mergeAndVerifyTip, PR_URL_RE, relayedFailure } from './prWorkflow.mjs';
3
5
  import { existsSync } from 'node:fs';
4
6
  import { execFileSync } from 'node:child_process';
5
7
  import { join } from 'node:path';
6
- import { FLEET_URL } from './config.mjs';
8
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
7
9
  import { git, gitOrNull, gitNet as gitNetIn, baseBranchName, isSafePathSegment, sessionBranch, stripUrlUserinfo } from './git.mjs';
8
10
  import { isPublishRef, publishPushArgs, publishDeleteArgs, publishErrorText } from './agentPublish.mjs';
9
11
  import { mergeOutward as shipMergeOutward } from './shipMerge.mjs';
@@ -30,6 +32,7 @@ export function agentMergeFailureDetail(error) {
30
32
  }
31
33
 
32
34
  export function createWorkAgentMerges({
35
+ repositoryPlaces,
33
36
  repoRoot,
34
37
  inPlace,
35
38
  baseDir,
@@ -111,7 +114,7 @@ export function createWorkAgentMerges({
111
114
  const report = async (body) => {
112
115
  reported = true;
113
116
  if (body?.ok === true) landedRef = job.publishedRef ?? null;
114
- await postAgentMerge(body);
117
+ await postAgentMerge({ ...body, ...(body.detail ? { detail: envScrub(stripUrlUserinfo(String(body.detail))).slice(0, 2000) } : {}) });
115
118
  };
116
119
 
117
120
  // A WRITER on the place, exactly as a ship is. It folds base in and pushes
@@ -119,6 +122,20 @@ export function createWorkAgentMerges({
119
122
  // does not know exists.
120
123
  try {
121
124
  await inPlace(place, true, async () => {
125
+ if (job.repositoryMerge) {
126
+ if (!repositoryPlaces) throw new Error('Project repository worktrees are unavailable.');
127
+ const result = await mergeRepositoryPlace({ job, entries: repositoryPlaces.entries(place), baseDir,
128
+ check: (r) => runReviewEntry(agentId, r.wt, job.agentName, r),
129
+ progress: async (states, retry) => {
130
+ const response = await fleetRequest(fleetEndpoint('agent-merge-progress', FLEET_URL), {
131
+ body: { agentId, instance: DAEMON_INSTANCE, states, retry } });
132
+ if (response.error || response.json?.data?.ok !== true) throw new Error('Repository merge progress was not acknowledged. The next attempt will remeasure base.');
133
+ } });
134
+ if (result.retry) reported = true; // The lease was released; approval remains.
135
+ else await report({ agentId, ...result });
136
+ onRepoChanged();
137
+ return;
138
+ }
122
139
  const wt = worktreePath(baseDir, place);
123
140
  if (!existsSync(wt)) {
124
141
  await report({ agentId, ok: false, detail: 'the worktree is gone' });
@@ -116,8 +116,8 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
116
116
  * ref at all; an unreadable range costs the reviewer its context, never the
117
117
  * review-entry beat it is standing in.
118
118
  */
119
- const branchLog = (wt) => {
120
- const out = gitOrNull(['log', '--format=%s%n%b%n--', `${baseRef()}..HEAD`, '--not', baseRef()], wt);
119
+ const branchLog = (wt, ref = baseRef()) => {
120
+ const out = gitOrNull(['log', '--format=%s%n%b%n--', `${ref}..HEAD`, '--not', ref], wt);
121
121
  if (out == null) return { text: '', taskIds: [] };
122
122
  // LINEAR, never backtracking (audit 2026-09-24) — this was
123
123
  // `out.matchAll(/^\s*Flowviant-Task:\s*(\S+)\s*$/gm)`, whose `\s*$` is
@@ -135,7 +135,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
135
135
  return { text, taskIds: [...taskIds] };
136
136
  };
137
137
 
138
- const runPrecheck = async (agentId, wt, agentName) => {
138
+ const runPrecheck = async (agentId, wt, agentName, repository) => {
139
139
  /**
140
140
  * WHICH CLI READS. `pickRuntimeFor('consult')` — the same picker the scratch
141
141
  * planner uses, and for the same reason: this prompt was written against
@@ -166,7 +166,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
166
166
  const headSha = gitOrNull(['rev-parse', 'HEAD'], wt);
167
167
 
168
168
  const stash = readStash(sessionMetaPath(wt, 'flowviant-agent-cards', agentId));
169
- const log = branchLog(wt);
169
+ const log = branchLog(wt, repository?.baseRef ?? baseRef());
170
170
  /** Cards the BRANCH names that this box has no spec for. Measured, not
171
171
  * guessed — see agentCards.mjs on why a stash is per-box. */
172
172
  const held = new Set(stash.map((s) => s.taskId));
@@ -224,7 +224,7 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
224
224
  cards: stash.map((s) => s.prompt).join('\n---\n'),
225
225
  missingSpecs,
226
226
  commits: log.text,
227
- diffCommand: `git diff ${baseRef()}...HEAD`,
227
+ diffCommand: `git diff ${repository?.baseRef ?? baseRef()}...HEAD`,
228
228
  }),
229
229
  system: SYSTEM_PRECHECK,
230
230
  // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
@@ -8,9 +8,9 @@
8
8
  * agentPublish.mjs's (pure, tested there); this module owns the STATE and the
9
9
  * network calls. The agent lane pushes after a settle and fetches at the
10
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.
11
+ * retires the ref — all through the maps and functions returned here.
12
12
  */
13
- import { gitOrNull, sessionBranchRef } from './git.mjs';
13
+ import { gitOrNull, sessionBranchRef, gitNetAsync } from './git.mjs';
14
14
  import { publishPushArgs, publishFetchArgs, publishErrorText } from './agentPublish.mjs';
15
15
  import { scrub as envScrub } from './uplinkScrub.mjs';
16
16
 
@@ -41,6 +41,8 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
41
41
  * something a rebase can invalidate.
42
42
  */
43
43
  const agentPublished = new Map();
44
+ let publishingStopped = false;
45
+ const stopAgentPublishing = () => { publishingStopped = true; };
44
46
  /**
45
47
  * WHAT THIS PROCESS HAS SEEN AT EACH AGENT'S REMOTE REF — `{ ref, sha }`, and
46
48
  * the ONLY input to the push's lease.
@@ -85,6 +87,7 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
85
87
  * Never throws.
86
88
  */
87
89
  const publishAgentBranch = async (place, target) => {
90
+ if (publishingStopped) return false;
88
91
  const sha = agentBranchSha(place);
89
92
  if (!sha) return false;
90
93
  const prev = agentPublished.get(place);
@@ -124,8 +127,8 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
124
127
  * BRING A PUBLISHED BRANCH BACK DOWN — the other half of the durability
125
128
  * promise, and the only thing that lets an agent's work outlive its box.
126
129
  *
127
- * Reached from ONE place: the begun-guard's refusal arm, where this machine
128
- * has just MEASURED that it holds neither the agent's worktree nor its branch.
130
+ * Reached after the begun guard measures neither a worktree nor a branch,
131
+ * by the ordinary turn path or the holder's recovery inspection.
129
132
  * The server only sends `publishedRef` when it heard about a real push, so
130
133
  * this is not a guess at a remote branch — it is a fetch of one a machine
131
134
  * reported writing.
@@ -159,6 +162,16 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
159
162
  why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')),
160
163
  };
161
164
  }
165
+ return fetchedBranch(place, ref);
166
+ };
167
+ const fetchPublishedBranchAsync = async (place, ref) => {
168
+ const args = publishFetchArgs(ref, place);
169
+ if (!args) return null;
170
+ try { await gitNetAsync(args, repoRoot, 120_000); }
171
+ catch (e) { return { ok: false, why: publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || '')) }; }
172
+ return fetchedBranch(place, ref);
173
+ };
174
+ const fetchedBranch = (place, ref) => {
162
175
  const sha = agentBranchSha(place);
163
176
  if (!sha) return { ok: false, why: 'the fetch reported nothing and left no local branch' };
164
177
  // WHAT THIS PROCESS HAS NOW SEEN AT THAT REMOTE REF. A fetch is an
@@ -171,5 +184,5 @@ export function createWorkAgentPublish({ repoRoot, gitNet }) {
171
184
  return { ok: true };
172
185
  };
173
186
 
174
- return { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch };
187
+ return { agentPublished, agentRemoteAt, publishAgentBranch, stopAgentPublishing, fetchPublishedBranch, fetchPublishedBranchAsync };
175
188
  }
@@ -0,0 +1,85 @@
1
+ import { DAEMON_INSTANCE, FLEET_URL } from './config.mjs';
2
+ import { myPubB64 } from './boxIdentity.mjs';
3
+ import { isSafePathSegment, sessionBranchRef, gitNetAsync } from './git.mjs';
4
+ import { begunTurnRefusal } from './workAgentTurnBegun.mjs';
5
+ import { fleetEndpoint } from './fleetWire.mjs';
6
+ import { fleetRequest } from './fleetPost.mjs';
7
+
8
+ /** Inspect before any cut or CLI. A failed request waits for the next poll. */
9
+ export function createAgentRecovery({
10
+ baseDir, repoRoot, fetchPublishedBranch, inPlace, placeWtFor, reportSessionWorktree,
11
+ boxId = () => myPubB64(), fleetUrl = FLEET_URL,
12
+ request = async (job, id) => {
13
+ const result = await fleetRequest(fleetEndpoint('agent-recover', fleetUrl), { body: {
14
+ agentId: job.agentId, sourceKey: job.sourceKey, sourceBoxId: job.sourceBoxId,
15
+ boxId: id, daemonInstance: DAEMON_INSTANCE,
16
+ } });
17
+ return result.ok && result.json?.data?.recovered === true;
18
+ },
19
+ } = {}) {
20
+ const ready = new Map(); // source snapshot inspected for the next poll
21
+ let checking = null; // one repository fetch at a time; polling never waits
22
+ let lastInspected = null;
23
+ const processAgentRecoveryJobs = (jobs) => {
24
+ const blocked = new Set();
25
+ if (!Array.isArray(jobs)) return blocked;
26
+ const id = boxId();
27
+ if (!id) return blocked;
28
+ const offered = new Set(jobs.map((j) => j?.agentId));
29
+ for (const agent of ready.keys()) if (!offered.has(agent)) ready.delete(agent);
30
+ // A failed inspection must not keep every agent behind it waiting.
31
+ const next = jobs.findIndex((j) => j?.agentId === lastInspected) + 1;
32
+ for (const job of [...jobs.slice(next), ...jobs.slice(0, next)]) {
33
+ if (!job || !isSafePathSegment(job.agentId) || !isSafePathSegment(job.placeId) ||
34
+ sessionBranchRef(job.placeId) === null || typeof job.sourceKey !== 'string' ||
35
+ !job.sourceBoxId || job.sourceBoxId === id || job.placeId !== `a-${job.agentId}`) continue;
36
+ const key = `${job.sourceBoxId}:${job.sourceKey}`;
37
+ if (ready.get(job.agentId) === key) { ready.delete(job.agentId); continue; }
38
+ blocked.add(job.agentId);
39
+ if (checking) continue;
40
+ lastInspected = job.agentId;
41
+ checking = Promise.resolve().then(() => inPlace(job.placeId, true, async () => {
42
+ let available = false;
43
+ const facts = { begunOn: job.begunOn, publishedRef: job.publishedRef };
44
+ // The measurement stays in the begun guard. Only its network fallback
45
+ // is asynchronous here, so a slow origin never stops heartbeats.
46
+ const missing = begunTurnRefusal(facts, job.placeId, {
47
+ baseDir, repoRoot, fetchPublishedBranch: () => null,
48
+ onAvailable: () => { available = true; },
49
+ });
50
+ let refusal = null;
51
+ if (missing) {
52
+ const fetched = await fetchPublishedBranch(job.placeId, job.publishedRef);
53
+ refusal = begunTurnRefusal(facts, job.placeId, {
54
+ baseDir, repoRoot, fetchPublishedBranch: () => fetched,
55
+ onAvailable: () => { available = true; },
56
+ });
57
+ }
58
+ if (!refusal) {
59
+ // Unknown reads start no redo and leave the ordinary begun guard
60
+ // to report its own git failure. Inspect again on a later poll.
61
+ // Review and Stuck have no offered task turn to attach and measure
62
+ // the recovered branch. Report custody here for every available one.
63
+ if (available && placeWtFor?.(job.placeId)) await reportSessionWorktree?.(job.placeId);
64
+ ready.set(job.agentId, key);
65
+ return;
66
+ }
67
+ // Never run or settle the old offer while a restart is being decided.
68
+ await request(job, id);
69
+ })).catch(() => {}).finally(() => { checking = null; });
70
+ }
71
+ return blocked;
72
+ };
73
+ // A test or a graceful owner may wait for the current inspection. The fleet
74
+ // loop deliberately does not: the next poll observes its result.
75
+ processAgentRecoveryJobs.settled = () => checking ?? Promise.resolve();
76
+ return processAgentRecoveryJobs;
77
+ }
78
+
79
+
80
+ /** Refresh before cutting a redo. A failed fetch keeps the prompt pending. */
81
+ export async function refreshRestartBase(job, repoRoot) {
82
+ if (!job.freshAfterSwap) return true;
83
+ try { await gitNetAsync(['fetch', '--prune', 'origin'], repoRoot); return true; }
84
+ catch { return false; }
85
+ }