flowviant 0.117.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 (103) hide show
  1. package/README.md +41 -0
  2. package/bin/cli.mjs +44 -0
  3. package/bin/lib/agentAsk.mjs +123 -0
  4. package/bin/lib/agentBrief.mjs +36 -0
  5. package/bin/lib/agentFootprint.mjs +76 -0
  6. package/bin/lib/agentInternalWait.mjs +4 -3
  7. package/bin/lib/agentPlan.mjs +86 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentRepositories.mjs +48 -0
  10. package/bin/lib/agentTaskKinds.mjs +1 -1
  11. package/bin/lib/artifactContracts.mjs +91 -20
  12. package/bin/lib/artifactIteration.mjs +182 -0
  13. package/bin/lib/artifactRecovery.mjs +36 -0
  14. package/bin/lib/artifactReporter.mjs +183 -19
  15. package/bin/lib/artifacts.mjs +6 -3
  16. package/bin/lib/checkoutIdentity.mjs +25 -0
  17. package/bin/lib/claudeAuth.mjs +15 -5
  18. package/bin/lib/claudeInput.mjs +32 -0
  19. package/bin/lib/claudePosture.mjs +110 -14
  20. package/bin/lib/claudeSessions.mjs +20 -0
  21. package/bin/lib/claudeStream.mjs +20 -2
  22. package/bin/lib/codexProjectRead.mjs +63 -0
  23. package/bin/lib/codexResearch.mjs +16 -3
  24. package/bin/lib/commandSpecs.mjs +6 -0
  25. package/bin/lib/credentials.mjs +52 -17
  26. package/bin/lib/daemonLocks.mjs +26 -6
  27. package/bin/lib/desktopContract.mjs +3 -0
  28. package/bin/lib/discoveryContract.mjs +19 -0
  29. package/bin/lib/fleet.mjs +65 -10
  30. package/bin/lib/fleetReports.mjs +44 -3
  31. package/bin/lib/fleetRoster.mjs +1 -89
  32. package/bin/lib/fleetStartup.mjs +11 -14
  33. package/bin/lib/holder.mjs +4 -37
  34. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  35. package/bin/lib/hooks/readGuard.mjs +13 -4
  36. package/bin/lib/imageArtifact.mjs +74 -0
  37. package/bin/lib/instance.mjs +20 -7
  38. package/bin/lib/landed.mjs +2 -2
  39. package/bin/lib/login.mjs +39 -4
  40. package/bin/lib/maintenance.mjs +47 -0
  41. package/bin/lib/maintenanceInventory.mjs +39 -0
  42. package/bin/lib/mcpInstall.mjs +163 -0
  43. package/bin/lib/mcpInventory.mjs +119 -0
  44. package/bin/lib/placeLock.mjs +9 -7
  45. package/bin/lib/planDependencies.mjs +70 -0
  46. package/bin/lib/postureTools.mjs +85 -0
  47. package/bin/lib/prWorkflow.mjs +15 -0
  48. package/bin/lib/projectLanded.mjs +16 -0
  49. package/bin/lib/projectRepositories.mjs +60 -0
  50. package/bin/lib/projectToolRuntimes.mjs +227 -3
  51. package/bin/lib/projectTools.mjs +63 -5
  52. package/bin/lib/projectWorktreeHome.mjs +25 -0
  53. package/bin/lib/prompts.mjs +170 -42
  54. package/bin/lib/repositoryBaseline.mjs +68 -0
  55. package/bin/lib/repositoryDelivery.mjs +64 -0
  56. package/bin/lib/repositoryMerge.mjs +116 -0
  57. package/bin/lib/repositoryPlaces.mjs +179 -0
  58. package/bin/lib/repositorySetup.mjs +58 -0
  59. package/bin/lib/repositoryTool.mjs +71 -0
  60. package/bin/lib/runTurn.mjs +58 -18
  61. package/bin/lib/runtimeAntigravity.mjs +2 -1
  62. package/bin/lib/runtimeCapabilities.mjs +77 -3
  63. package/bin/lib/runtimeClaude.mjs +31 -10
  64. package/bin/lib/runtimeCodex.mjs +28 -59
  65. package/bin/lib/runtimeDetection.mjs +6 -0
  66. package/bin/lib/runtimeEvents.mjs +12 -1
  67. package/bin/lib/runtimeOfferings.mjs +75 -3
  68. package/bin/lib/shipMerge.mjs +5 -2
  69. package/bin/lib/startProject.mjs +4 -4
  70. package/bin/lib/trace.mjs +8 -3
  71. package/bin/lib/turnDeclaration.mjs +88 -0
  72. package/bin/lib/turnDiagnostics.mjs +93 -0
  73. package/bin/lib/turnLock.mjs +48 -8
  74. package/bin/lib/turnProfile.mjs +15 -3
  75. package/bin/lib/work.mjs +48 -29
  76. package/bin/lib/workAgentBrief.mjs +92 -0
  77. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  78. package/bin/lib/workAgentCheck.mjs +6 -5
  79. package/bin/lib/workAgentMerges.mjs +19 -2
  80. package/bin/lib/workAgentPlans.mjs +21 -70
  81. package/bin/lib/workAgentPrecheck.mjs +10 -5
  82. package/bin/lib/workAgentPublish.mjs +18 -5
  83. package/bin/lib/workAgentRecovery.mjs +85 -0
  84. package/bin/lib/workAgentReview.mjs +5 -3
  85. package/bin/lib/workAgentTurnBegun.mjs +6 -4
  86. package/bin/lib/workAgentTurnExecution.mjs +167 -29
  87. package/bin/lib/workAgentTurnOutcome.mjs +20 -5
  88. package/bin/lib/workAgentTurns.mjs +92 -12
  89. package/bin/lib/workArtifactReconcile.mjs +79 -0
  90. package/bin/lib/workCappedTurn.mjs +10 -0
  91. package/bin/lib/workCommandAudit.mjs +8 -1
  92. package/bin/lib/workDelivery.mjs +21 -0
  93. package/bin/lib/workDiffs.mjs +18 -11
  94. package/bin/lib/workLiveMessages.mjs +129 -0
  95. package/bin/lib/workPlaceReports.mjs +5 -0
  96. package/bin/lib/workPlaces.mjs +24 -3
  97. package/bin/lib/workRetire.mjs +5 -0
  98. package/bin/lib/workSessionTurns.mjs +219 -52
  99. package/bin/lib/workShip.mjs +17 -16
  100. package/bin/lib/workToolLog.mjs +70 -3
  101. package/bin/lib/workTurnShape.mjs +6 -4
  102. package/package.json +7 -3
  103. package/npm-shrinkwrap.json +0 -59
@@ -0,0 +1,92 @@
1
+ /** A readout alongside ONE running CLI, never another worker on its branch.
2
+ * No events since the last snapshot means no model call. A stopped child,
3
+ * pause, park or lost holder prevents a call or a late result from publishing. */
4
+ import { AGENT_BRIEF_SYSTEM, parseAgentBrief, briefModelFor } from './agentBrief.mjs';
5
+
6
+ export const AGENT_BRIEF_INTERVAL_MS = 3 * 60_000;
7
+ export const AGENT_BRIEF_FIRST_MS = 20_000;
8
+ export const AGENT_BRIEF_INPUT_MAX = 24_000;
9
+ export function makeAgentBrief({ runtime, model, context, summarize, post, scrub = (s) => s, models = () => [], now = Date.now,
10
+ setTimer = setTimeout, clearTimer = clearTimeout }) {
11
+ let running = false;
12
+ let stopped = false;
13
+ let timer = null;
14
+ let sequence = 0;
15
+ let consumed = 0;
16
+ let entries = [];
17
+ let flight = null;
18
+ let controller = null;
19
+ let held = null;
20
+ const append = (kind, data) => {
21
+ if (stopped || data == null) return;
22
+ // Scrub before trimming so a cut never turns a secret into an unrecognized prefix.
23
+ let text;
24
+ try {
25
+ text = (typeof data === 'string' ? scrub(data) : JSON.stringify(data, (_key, value) => {
26
+ // Redact decoded strings BEFORE JSON escapes quotes and newlines;
27
+ // otherwise a multiline secret no longer matches its learned value.
28
+ if (typeof value === 'string') return scrub(value);
29
+ if (value && typeof value === 'object' && !Array.isArray(value))
30
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [scrub(key), item]));
31
+ return value;
32
+ })).slice(0, 4_000);
33
+ } catch { return; } // an optional readout must never fail the working CLI
34
+ if (!text.trim()) return;
35
+ entries.push({ seq: ++sequence, kind, text });
36
+ while (entries.length > 100 || entries.reduce((sum, e) => sum + e.text.length, 0) > AGENT_BRIEF_INPUT_MAX) entries.shift();
37
+ };
38
+ const schedule = (ms = AGENT_BRIEF_INTERVAL_MS) => {
39
+ if (timer || stopped || !running) return;
40
+ timer = setTimer(() => { timer = null; void tick(); }, ms);
41
+ timer?.unref?.();
42
+ };
43
+ const refresh = async () => {
44
+ if (!running || stopped || (!held && sequence === consumed)) return;
45
+ // Retry a paid-for reading without paying for another. The API rechecks
46
+ // holder and turn in the write, and duplicate/older snapshots cannot win.
47
+ if (held) { if (await post(held)) held = null; return; }
48
+ const brain = briefModelFor(runtime, typeof model === 'function' ? model() : model, models());
49
+ if (!brain) return;
50
+ controller = new AbortController();
51
+ const state = await context(controller.signal);
52
+ if (state?.unsupported) { stopped = true; if (timer) clearTimer(timer); timer = null; return; }
53
+ if (stopped || !running || !state?.active || controller.signal.aborted) return;
54
+ // Card changes and daemon restarts do not reset the agent's three-minute
55
+ // cadence. Both dates came from the server, not this machine's clock.
56
+ if (state.brief && Date.parse(state.sourceAt) - Date.parse(state.brief.updatedAt) < AGENT_BRIEF_INTERVAL_MS) return;
57
+ const through = sequence;
58
+ const events = entries.filter((e) => e.seq > consumed);
59
+ if (!events.length) return;
60
+ const prompt = JSON.stringify({ previous: state.brief ?? null, taskId: state.taskId ?? null,
61
+ omittedEvents: Math.max(0, events[0].seq - consumed - 1), events: events.map(({ kind, text }) => ({ kind, text })) });
62
+ const out = await summarize({ runtime, ...brain, system: AGENT_BRIEF_SYSTEM, prompt, signal: controller.signal });
63
+ if (stopped || !running || controller.signal.aborted) return;
64
+ const content = parseAgentBrief(typeof out === 'string' ? out : out?.out, scrub);
65
+ if (!content) return;
66
+ consumed = through;
67
+ entries = entries.filter((e) => e.seq > through);
68
+ held = { ...content, sourceAt: state.sourceAt ?? new Date(now()).toISOString(), ...(out?.usage ? { usage: out.usage } : {}) };
69
+ if (await post(held)) held = null;
70
+ };
71
+ const tick = () => {
72
+ if (flight) return flight;
73
+ flight = refresh().catch(() => {}).finally(() => { flight = null; controller = null; schedule(); });
74
+ return flight;
75
+ };
76
+ return {
77
+ append, tick,
78
+ running(active) {
79
+ running = active;
80
+ if (active) schedule(AGENT_BRIEF_FIRST_MS);
81
+ else { if (timer) clearTimer(timer); timer = null; controller?.abort(); }
82
+ },
83
+ async stop() {
84
+ stopped = true;
85
+ running = false;
86
+ if (timer) clearTimer(timer);
87
+ timer = null;
88
+ controller?.abort();
89
+ await flight;
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,59 @@
1
+ /** A separate, short-lived CLI conversation using the same vendor's login.
2
+ * It stands in an empty scratch directory, never in the working agent's
3
+ * cwd-keyed resume. No project tools, no MCP, no repository instructions. */
4
+ import { mkdtempSync, rmSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { runTurn } from './runTurn.mjs';
8
+ import { runCappedTurn } from './workCappedTurn.mjs';
9
+ import { makeAgentBrief } from './workAgentBrief.mjs';
10
+ import { knownCodexModels } from './runtimeOfferings.mjs';
11
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
12
+ import { fleetRequest } from './fleetPost.mjs';
13
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
14
+ import { scrub } from './uplinkScrub.mjs';
15
+
16
+ export function createAgentBriefRunner({ admit, workChildren, run = runTurn, request = fleetRequest }) {
17
+ return ({ agentId, turnId, runtime, model }) => {
18
+ const identity = { agentId, turnId, instance: DAEMON_INSTANCE };
19
+ return makeAgentBrief({ runtime, model, scrub, models: knownCodexModels,
20
+ context: async (signal) => {
21
+ // Node >=20, including versions before AbortSignal.any was added.
22
+ const abort = new AbortController();
23
+ const timeout = AbortSignal.timeout(15_000);
24
+ const cancel = () => abort.abort();
25
+ signal.addEventListener('abort', cancel, { once: true });
26
+ timeout.addEventListener('abort', cancel, { once: true });
27
+ if (signal.aborted) cancel();
28
+ try {
29
+ const r = await request(fleetEndpoint('agent-brief-context', FLEET_URL), { body: identity, signal: abort.signal });
30
+ return r.ok ? r.json?.data : isConsideredRefusal(r.status) ? { unsupported: true } : null;
31
+ } finally {
32
+ signal.removeEventListener('abort', cancel);
33
+ timeout.removeEventListener('abort', cancel);
34
+ }
35
+ },
36
+ post: async (content) => {
37
+ const r = await request(fleetEndpoint('agent-brief', FLEET_URL), { body: { ...identity, ...content }, timeoutMs: 15_000 });
38
+ return r.ok || isConsideredRefusal(r.status);
39
+ },
40
+ summarize: async ({ signal, ...turnArgs }) => {
41
+ if (signal.aborted || admit('churn')) return '';
42
+ const releaseSlot = admit.reserve();
43
+ let cwd = null;
44
+ let usage = null;
45
+ try {
46
+ cwd = mkdtempSync(join(tmpdir(), 'flowviant-brief-'));
47
+ const result = await runCappedTurn({ runTurn: run, workChildren, releaseSlot, signal,
48
+ capMs: 60_000, transcriptCwd: cwd,
49
+ turnArgs: { ...turnArgs, cwd, profile: 'consult', answerFromResult: true, label: '[brief]',
50
+ onUsage: (u) => { usage = { ...u, runtime: turnArgs.runtime }; } } });
51
+ return result.wedged ? '' : { out: result.out, usage };
52
+ } finally {
53
+ releaseSlot();
54
+ if (cwd) rmSync(cwd, { recursive: true, force: true });
55
+ }
56
+ },
57
+ });
58
+ };
59
+ }
@@ -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' });
@@ -1,11 +1,14 @@
1
1
  /** Agent plan claims, read-only planning turns, and proposal reports. */
2
- import { existsSync } from 'node:fs';
2
+ import { discoveryGuide } from './discoveryContract.mjs';
3
+ import { compareRepositoryBaselines, measureRepositoryBaseline, repositoryBaselineOf } from './repositoryBaseline.mjs';
4
+ import { knowledgeDirFor } from './knowledgeFiles.mjs';
5
+ import { createToolLog } from './workToolLog.mjs';
3
6
  import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
4
- import { gitOrNull, isSafePathSegment } from './git.mjs';
7
+ import { measureAgentFootprint, liveAgentContext } from './agentFootprint.mjs';
5
8
  import { parseProposal } from './agentPlan.mjs';
6
9
  import { c, note } from './ui.mjs';
7
10
  import { runTurn as defaultRunTurn } from './runTurn.mjs';
8
- import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF } from './prompts.mjs';
11
+ import { SYSTEM_PLAN, AGENT_PLAN_KICKOFF, withProjectContext, REPOSITORY_BASELINE_PARAGRAPH } from './prompts.mjs';
9
12
  import { scrub as envScrub } from './uplinkScrub.mjs';
10
13
  import { canRun, runtimeById, runtimeLabel } from './runtimes.mjs';
11
14
  import { detectRuntimes as defaultDetectRuntimes, pickRuntimeFor as defaultPickRuntimeFor } from './runtimeDetection.mjs';
@@ -13,7 +16,7 @@ import { brainFor } from './workBrain.mjs';
13
16
  import { runCappedTurn } from './workCappedTurn.mjs';
14
17
  import { fleetEndpoint } from './fleetWire.mjs';
15
18
  import { claimFleetLease, fleetHeaders } from './fleetPost.mjs';
16
- import { REPO_PLACE, worktreePath } from './workPlaces.mjs';
19
+ import { REPO_PLACE } from './workPlaces.mjs';
17
20
  import { addUsage } from './turnUsage.mjs';
18
21
 
19
22
  export function createWorkAgentPlans({
@@ -133,56 +136,6 @@ export function createWorkAgentPlans({
133
136
  // The peer may hold it; doing nothing is the safe answer.
134
137
  const claimAgentPlan = (id) => claimFleetLease(AGENT_PLAN_CLAIM_URL, { id });
135
138
 
136
- /**
137
- * What a live agent has already TOUCHED, measured here rather than sent.
138
- *
139
- * The server could have shipped a copy of this, and deliberately does not:
140
- * these are directories on THIS machine, and a server-side copy would be a
141
- * second, staler source of truth for a fact the daemon is standing on top of.
142
- *
143
- * Uncommitted work AND commits against base, because a planner asking "will
144
- * these collide" cares about both — a file this agent has already rewritten
145
- * and merged into its own branch collides exactly as hard as one it is
146
- * editing now.
147
- */
148
- const agentChangedFiles = (placeId) => {
149
- if (!placeId || !isSafePathSegment(placeId)) return [];
150
- const wt = worktreePath(baseDir, placeId);
151
- if (!existsSync(wt)) return [];
152
- const out = new Set();
153
- for (const args of [
154
- ['diff', '--name-only', 'HEAD'],
155
- ['diff', '--name-only', `${baseRef()}...HEAD`],
156
- ['ls-files', '--others', '--exclude-standard'],
157
- ]) {
158
- /**
159
- * `git()` THROWS; it does not return a non-string.
160
- *
161
- * The guard below was `if (typeof r !== 'string') continue`, which is a
162
- * value `execFileSync` can never produce — so every one of these calls
163
- * was effectively unguarded. And this runs over the LIVE AGENTS a plan
164
- * job carries, which include agents still in `planning`: they have no
165
- * worktree yet, so `git diff` in a directory that does not exist exits
166
- * non-zero and throws straight out of `runAgentPlan` — after the press
167
- * has been claimed and before the try/finally below. The press then sat
168
- * `planning` until its expiry, having spent nothing and explained
169
- * nothing, on what would be an ordinary second Deploy.
170
- *
171
- * An agent whose files cannot be read contributes none — `gitOrNull`
172
- * (git.mjs) reads a failure as null. That is the honest answer and it
173
- * is what the planner should be told.
174
- */
175
- const r = gitOrNull(args, wt);
176
- if (r == null) continue;
177
- for (const line of r.split('\n')) {
178
- const f = line.trim();
179
- if (f) out.add(f);
180
- if (out.size >= 60) return [...out];
181
- }
182
- }
183
- return [...out];
184
- };
185
-
186
139
  /** See `runAgentTurn` — the admission reservation, released the moment the
187
140
  * planner's CLI exists. */
188
141
  const runAgentPlan = async (job, releaseSlot = () => {}) => {
@@ -268,20 +221,6 @@ export function createWorkAgentPlans({
268
221
  lastPlanBeat = Date.now();
269
222
  void postAgentPlanActivity(id, line);
270
223
  };
271
- const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map((a) => ({
272
- id: String(a?.id ?? ''),
273
- name: String(a?.name ?? ''),
274
- status: String(a?.status ?? ''),
275
- // Belt: `agentChangedFiles` is defensive internally, but it resolves a
276
- // place first and this whole block sits outside the try below.
277
- changedFiles: (() => {
278
- try {
279
- return agentChangedFiles(a?.placeId);
280
- } catch {
281
- return [];
282
- }
283
- })(),
284
- }));
285
224
 
286
225
  let out = '';
287
226
  /** The cap fired: the CLI was still running when this machine stopped it. */
@@ -294,7 +233,8 @@ export function createWorkAgentPlans({
294
233
  * a report the retry posts again adds nothing. Null until the CLI reports.
295
234
  */
296
235
  let usage = null;
297
- const spent = () => (usage ? { usage } : {});
236
+ const discovery = createToolLog(repoRoot);
237
+ const spent = () => ({ ...(usage ? { usage } : {}), tools: discovery.toolLog });
298
238
  // Said BEFORE the lock is asked for, because a reader only waits when a
299
239
  // writer holds the checkout or is queued ahead of it — which is exactly
300
240
  // this condition, read one line before we join the queue.
@@ -315,6 +255,12 @@ export function createWorkAgentPlans({
315
255
  * fork a read-only planner into a build tab, and being what their own
316
256
  * `claude --continue` resumed.
317
257
  */
258
+ const liveAgents = (Array.isArray(job.liveAgents) ? job.liveAgents : []).map(a =>
259
+ liveAgentContext(a, measureAgentFootprint({ placeId: a?.placeId, baseDir, baseRef })));
260
+ const knowledgeDir = knowledgeDirFor(repoRoot);
261
+ discovery.toolLog.baseline = measureRepositoryBaseline({ cwd: repoRoot, baseRef, knowledgeDir });
262
+ discovery.toolLog.captureBaseline = repositoryBaselineOf(job.captureBaseline);
263
+ discovery.toolLog.reconciliation = compareRepositoryBaselines(discovery.toolLog.baseline, discovery.toolLog.captureBaseline);
318
264
  ({ out, wedged } = await runCappedTurn({
319
265
  runTurn,
320
266
  capMs: PLAN_TURN_TIMEOUT_MS,
@@ -322,12 +268,14 @@ export function createWorkAgentPlans({
322
268
  releaseSlot,
323
269
  transcriptCwd: repoRoot,
324
270
  turnArgs: {
271
+ diagnosticPlanId: id,
325
272
  prompt: AGENT_PLAN_KICKOFF({
326
273
  tasks,
327
274
  liveAgents,
328
275
  agentCap: Number.isInteger(job.agentCap) && job.agentCap > 0 ? job.agentCap : 3,
329
276
  }),
330
- system: SYSTEM_PLAN,
277
+ system: withProjectContext(SYSTEM_PLAN, { knowledgeDir }) + '\n\n' + discoveryGuide(rt, 'planning') + '\n\n' + REPOSITORY_BASELINE_PARAGRAPH(discovery.toolLog.baseline, discovery.toolLog.captureBaseline, discovery.toolLog.reconciliation),
278
+ knowledgeDir,
331
279
  // READ-ONLY, and no MCP: `mcpArgs` is omitted entirely rather than
332
280
  // passed empty, so there is no control plane on this turn at all.
333
281
  profile: 'consult',
@@ -342,6 +290,7 @@ export function createWorkAgentPlans({
342
290
  onActivity: (a) => {
343
291
  if (a?.label) narrate(String(a.label));
344
292
  },
293
+ onToolEvent: discovery.pushToolEvent,
345
294
  // Tagged with the CLI that counted it; one turn, so the sum is the
346
295
  // one report (turnUsage.mjs keeps it null-safe).
347
296
  onUsage: (u) => {
@@ -354,11 +303,13 @@ export function createWorkAgentPlans({
354
303
  }));
355
304
  });
356
305
  } catch (e) {
306
+ discovery.toolLog.state = 'partial';
357
307
  await postAgentPlan({ id, error: envScrub(String(e?.message || e)).slice(0, 500), ...spent() });
358
308
  return;
359
309
  }
360
310
 
361
311
  if (wedged) {
312
+ discovery.toolLog.state = 'partial';
362
313
  // NEVER LEAVE A CLAIMED PRESS UNREPORTED — the belt the merge lane wears
363
314
  // beneath this one. The machine's own words, because the machine is the
364
315
  // only side that knows: the server would have expired this press in
@@ -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
@@ -314,10 +314,15 @@ export function createWorkAgentPrecheck({ baseRef, admit, sessionMetaPath, workC
314
314
  agentId,
315
315
  ...(headSha ? { headSha } : {}),
316
316
  ...(usage ? { usage } : {}),
317
+ // Named fields, never a spread of what the parser returned: a field it
318
+ // keeps must be passed on here as well. `criteria` rides beside the verdict
319
+ // and note (2026-10-10), and an older server strips it and draws the one
320
+ // row it always drew.
317
321
  cards: result.cards.map((cd) => ({
318
322
  taskId: cd.taskId,
319
323
  verdict: cd.verdict,
320
324
  ...(cd.note ? { note: cd.note } : {}),
325
+ ...(cd.criteria ? { criteria: cd.criteria } : {}),
321
326
  })),
322
327
  ...(result.overall ? { overall: result.overall } : {}),
323
328
  };
@@ -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
+ }
@@ -27,17 +27,19 @@ export function createWorkAgentReview({ runCheck, runPrecheck }) {
27
27
  * INSIDE the place's writer lock on a path whose callers settle real work —
28
28
  * `runAgentMerge` in particular reports a claimed merge after this returns.
29
29
  */
30
- const runReviewEntry = async (agentId, wt, agentName) => {
30
+ const runReviewEntry = async (agentId, wt, agentName, repository) => {
31
+ let result = { status: 'unknown' };
31
32
  try {
32
- await runCheck(agentId, wt);
33
+ result = await runCheck(agentId, wt, repository) ?? result;
33
34
  } catch {
34
35
  /* the row keeps its previous check answer, which is null the first time */
35
36
  }
36
37
  try {
37
- await runPrecheck(agentId, wt, agentName);
38
+ await runPrecheck(agentId, wt, agentName, repository);
38
39
  } catch {
39
40
  /* no pre-review is posted, and absence renders nothing */
40
41
  }
42
+ return result;
41
43
  };
42
44
 
43
45
  return { runReviewEntry };
@@ -40,11 +40,11 @@ import { worktreePath } from './workPlaces.mjs';
40
40
  * cleaned up on THIS box is same-box recovery of real committed work and
41
41
  * behaves exactly as it did before this guard existed.
42
42
  *
43
- * The remedy is the stop path and nothing else. "Reconnect the other
44
- * machine" is not reachable from here once holdership has moved, and a
45
- * remedy somebody cannot carry out is worse than none.
43
+ * This remains the same-box and older-server refusal. The recovery coordinator
44
+ * now inspects foreign work before this ordinary turn path: after a person's
45
+ * machine swap it can replace missing work with an explicit start from briefs.
46
46
  */
47
- export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch }) {
47
+ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublishedBranch, onAvailable = () => {} }) {
48
48
  // The caller validated `place` (workAgentTurns); an unsafe one would give a
49
49
  // null directory AND a null ref below, and the null ref stands the guard
50
50
  // down — the same fail-open direction as an unreadable repo.
@@ -105,9 +105,11 @@ export function begunTurnRefusal(job, place, { baseDir, repoRoot, fetchPublished
105
105
  (fetched ? ` Its published branch could not be fetched (${fetched.why}).` : '')
106
106
  );
107
107
  }
108
+ onAvailable();
108
109
  // The branch is here and measured. `placeWtFor`'s attach fallback
109
110
  // opens a worktree on it below — the same path a directory somebody
110
111
  // cleaned up already takes, on commits this box now genuinely holds.
111
112
  }
113
+ if (hasBranch || (wtDir && existsSync(wtDir))) onAvailable();
112
114
  return null;
113
115
  }