flowviant 0.117.0 → 0.124.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 (64) hide show
  1. package/README.md +35 -0
  2. package/bin/cli.mjs +37 -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 +2 -2
  7. package/bin/lib/agentPlan.mjs +78 -7
  8. package/bin/lib/agentPrerequisiteContract.mjs +43 -0
  9. package/bin/lib/agentTaskKinds.mjs +1 -1
  10. package/bin/lib/artifactContracts.mjs +91 -20
  11. package/bin/lib/artifactIteration.mjs +182 -0
  12. package/bin/lib/artifactRecovery.mjs +36 -0
  13. package/bin/lib/artifactReporter.mjs +183 -19
  14. package/bin/lib/artifacts.mjs +6 -3
  15. package/bin/lib/claudeAuth.mjs +15 -5
  16. package/bin/lib/claudePosture.mjs +110 -14
  17. package/bin/lib/claudeStream.mjs +20 -2
  18. package/bin/lib/codexProjectRead.mjs +63 -0
  19. package/bin/lib/codexResearch.mjs +16 -3
  20. package/bin/lib/commandSpecs.mjs +6 -0
  21. package/bin/lib/daemonLocks.mjs +5 -3
  22. package/bin/lib/desktopContract.mjs +3 -0
  23. package/bin/lib/discoveryContract.mjs +19 -0
  24. package/bin/lib/fleet.mjs +46 -4
  25. package/bin/lib/fleetReports.mjs +44 -3
  26. package/bin/lib/hooks/mcpAllow.mjs +60 -0
  27. package/bin/lib/hooks/readGuard.mjs +13 -4
  28. package/bin/lib/imageArtifact.mjs +74 -0
  29. package/bin/lib/maintenance.mjs +47 -0
  30. package/bin/lib/maintenanceInventory.mjs +39 -0
  31. package/bin/lib/mcpInstall.mjs +163 -0
  32. package/bin/lib/mcpInventory.mjs +119 -0
  33. package/bin/lib/planDependencies.mjs +70 -0
  34. package/bin/lib/postureTools.mjs +85 -0
  35. package/bin/lib/projectToolRuntimes.mjs +227 -3
  36. package/bin/lib/projectTools.mjs +61 -4
  37. package/bin/lib/prompts.mjs +164 -41
  38. package/bin/lib/repositoryBaseline.mjs +68 -0
  39. package/bin/lib/runTurn.mjs +35 -16
  40. package/bin/lib/runtimeCapabilities.mjs +77 -3
  41. package/bin/lib/runtimeClaude.mjs +27 -9
  42. package/bin/lib/runtimeCodex.mjs +27 -59
  43. package/bin/lib/runtimeDetection.mjs +6 -0
  44. package/bin/lib/runtimeEvents.mjs +12 -1
  45. package/bin/lib/runtimeOfferings.mjs +75 -3
  46. package/bin/lib/turnDiagnostics.mjs +93 -0
  47. package/bin/lib/turnProfile.mjs +15 -3
  48. package/bin/lib/work.mjs +28 -7
  49. package/bin/lib/workAgentBrief.mjs +92 -0
  50. package/bin/lib/workAgentBriefRunner.mjs +59 -0
  51. package/bin/lib/workAgentPlans.mjs +21 -70
  52. package/bin/lib/workAgentPrecheck.mjs +5 -0
  53. package/bin/lib/workAgentTurnExecution.mjs +93 -22
  54. package/bin/lib/workAgentTurnOutcome.mjs +11 -2
  55. package/bin/lib/workAgentTurns.mjs +60 -4
  56. package/bin/lib/workArtifactReconcile.mjs +79 -0
  57. package/bin/lib/workCappedTurn.mjs +10 -0
  58. package/bin/lib/workCommandAudit.mjs +8 -1
  59. package/bin/lib/workPlaces.mjs +18 -2
  60. package/bin/lib/workSessionTurns.mjs +148 -13
  61. package/bin/lib/workToolLog.mjs +70 -3
  62. package/bin/lib/workTurnShape.mjs +6 -4
  63. package/npm-shrinkwrap.json +12 -2
  64. package/package.json +7 -3
@@ -26,6 +26,14 @@
26
26
  * Claude Code's and, since 0.115.0, Codex's; image (0.114.0)
27
27
  * is Codex's.
28
28
  *
29
+ * `projectMcp` (2026-10-10, the owner): whether an agent's turn under this
30
+ * profile mounts MCP servers — the project's reviewed base `.mcp.json` copy
31
+ * and, since 2026-10-11 ("the server that is running flowviant's claude HAS
32
+ * these MCPs yet the agents are not using them"), this machine's own CLI
33
+ * servers and claude.ai connectors. Every card's posture: the code card's
34
+ * build, and the write-up's, mockup/3D-model/deck's and image's fences. The
35
+ * consult, plan, wiki and plan-mode turns are not a card's and mount none.
36
+ *
29
37
  * Claude's permission list for each name lives beside the lists themselves
30
38
  * (`claudePermFor`, claudePosture.mjs); this module owns the vocabulary and the
31
39
  * refusals, and imports the card-kind table and the runtime registry (whose
@@ -44,10 +52,12 @@ const declaredBy = (name) =>
44
52
  .join(' or ') || 'no CLI this daemon knows';
45
53
  const article = (word) => (/^[aeiou]/i.test(word) ? 'an' : 'a');
46
54
 
47
- const plain = (adapterProfile) => Object.freeze({ adapterProfile, onlyOn: null, strictMcp: false });
55
+ const plain = (adapterProfile) => Object.freeze({ adapterProfile, onlyOn: null, strictMcp: false, projectMcp: false });
56
+ /** The kind postures that mount MCP servers (2026-10-10; image 2026-10-11). */
57
+ const KIND_PROJECT_MCP = new Set(['design', 'research', 'image']);
48
58
 
49
59
  export const TURN_PROFILES = Object.freeze({
50
- build: plain('build'),
60
+ build: Object.freeze({ ...plain('build'), projectMcp: true }),
51
61
  wiki: plain('wiki'),
52
62
  consult: plain('consult'),
53
63
  plan: plain('plan'),
@@ -58,6 +68,7 @@ export const TURN_PROFILES = Object.freeze({
58
68
  // wearing the word "plan".
59
69
  onlyOn: (rt) => (rt.id === 'claude' ? null : `plan mode runs on Claude Code only — not '${rt.label}'`),
60
70
  strictMcp: true,
71
+ projectMcp: false,
61
72
  }),
62
73
  // The kind postures: fenced to "write only the artifacts directory", and a
63
74
  // runtime that does not declare one would run its build branch instead.
@@ -73,6 +84,7 @@ export const TURN_PROFILES = Object.freeze({
73
84
  ? null
74
85
  : `${article(name)} ${name} card runs on ${declaredBy(name)} only — not '${rt.label}'`,
75
86
  strictMcp: false,
87
+ projectMcp: KIND_PROJECT_MCP.has(name),
76
88
  }),
77
89
  ])
78
90
  ),
@@ -83,7 +95,7 @@ export const TURN_PROFILES = Object.freeze({
83
95
  export const RETIRED_POSTURE_KEYS = Object.freeze(['wikiPerm', 'readOnly', 'planPerm', 'planMode', 'posture']);
84
96
 
85
97
  /**
86
- * `{ name, adapterProfile, onlyOn, strictMcp }` for a turn's options, or
98
+ * `{ name, adapterProfile, onlyOn, strictMcp, projectMcp }` for a turn's options, or
87
99
  * `{ error }` in words. Absent `profile` is build.
88
100
  */
89
101
  export function resolveTurnProfile(opts = {}) {
package/bin/lib/work.mjs CHANGED
@@ -33,6 +33,7 @@
33
33
  * workChildren.mjs the live CLI children, teardown, the admission
34
34
  * workAgentPublish.mjs pushing and fetching an agent's branch
35
35
  * workPlaceReports.mjs the worktree readout: settle, burst, sweep
36
+ * workArtifactReconcile.mjs an idle agent's stored delivery against its worktree
36
37
  * workReportQueue.mjs every settle, held and retried until delivered
37
38
  * workSessionTokens.mjs the per-session work credential
38
39
  * workNarration.mjs the tab's live line and the deferral sentence
@@ -54,8 +55,8 @@
54
55
  * (and, split earlier: workDiffs, workPreviews, workPullRequests, workShip,
55
56
  * workAgentPlans, workAgentCheck, workAgentPrecheck, workAgentReview,
56
57
  * workAgentTurns, workAgentMerges, workProcesses; and out of workAgentTurns,
57
- * SOLID F036: workAgentTurnReports, workAgentTurnExecution,
58
- * workAgentTurnBegun, workAgentTurnOutcome)
58
+ * SOLID F036: workAgentTurnReports, workAgentTurnExecution, workAgentTurnBegun,
59
+ * workAgentTurnOutcome; the live brief: workAgentBrief, workAgentBriefRunner)
59
60
  */
60
61
 
61
62
  import { createWorkProcesses } from './workProcesses.mjs';
@@ -92,6 +93,7 @@ import { warn } from './ui.mjs';
92
93
  import { FLOWVIANT_OWN_PATHS } from './knowledgeLibrary.mjs';
93
94
  import { buildArtifactUpload, changedSince, snapshotArtifacts } from './artifacts.mjs';
94
95
  import { createArtifactReporter } from './artifactReporter.mjs';
96
+ import { createWorkArtifactReconcile } from './workArtifactReconcile.mjs';
95
97
  import { renderDesignPreview } from './artifactPreview.mjs';
96
98
  import { scrub as envScrub, secretIn as envSecretIn } from './uplinkScrub.mjs';
97
99
 
@@ -156,6 +158,13 @@ export function createWorkManager({
156
158
  listChanged: changedSince,
157
159
  buildUpload: buildArtifactUpload,
158
160
  renderPreview: renderDesignPreview,
161
+ // What each place held, per owner, beside its worktree's git dir: it dies
162
+ // with the worktree, so a recreated one proves no discard (artifactIteration.mjs).
163
+ // `sessionMetaPath` and `worktreeBornAt` are declared below; both are called only once the manager is built.
164
+ seenPath: (dir, owner) =>
165
+ /^[A-Za-z0-9_-]{1,64}$/.test(String(owner)) ? sessionMetaPath(dir, 'flowviant-artifacts-seen', owner) : null,
166
+ // …and when that worktree was cut, so rows stored before it are never its own.
167
+ placeBornAt: (dir) => worktreeBornAt(dir),
159
168
  });
160
169
  /**
161
170
  * Snapshot a place's artifact directory before a CLI spawns in it — and make
@@ -192,7 +201,7 @@ export function createWorkManager({
192
201
  const gitNet = (args, ms) => gitNetIn(args, repoRoot, ms);
193
202
 
194
203
  // ── THE SESSION'S OWN HALF — built first, because the lanes below are handed it.
195
- const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath } =
204
+ const { placeOf, notePlace, learnPlaces, placeDir, branchAt, placeWtFor, sessionMetaPath, worktreeBornAt } =
196
205
  createWorkPlaces({ repoRoot, baseDir, baseRef, onRepoChanged });
197
206
  const { sessionGroups, noteSessionGroup, pruneSessionGroups, sessionProcesses } =
198
207
  createWorkSessionGroups({ repoRoot });
@@ -200,6 +209,15 @@ export function createWorkManager({
200
209
  createWorkChildren({ extraLiveTurns });
201
210
  const { agentPublished, agentRemoteAt, publishAgentBranch, fetchPublishedBranch } =
202
211
  createWorkAgentPublish({ repoRoot, gitNet });
212
+ const { reconcileIdleArtifacts } = createWorkArtifactReconcile({
213
+ artifacts,
214
+ placeLocks,
215
+ inPlace,
216
+ placeDir,
217
+ beforeArtifacts,
218
+ getArtifactsAccepted,
219
+ log: (line) => warn(line),
220
+ });
203
221
  const { reportSessionWorktree, reportPlaceWorktrees, burstListeners, reportWorktrees } =
204
222
  createWorkPlaceReports({
205
223
  repoRoot,
@@ -237,8 +255,9 @@ export function createWorkManager({
237
255
  const { fetchAttachments, fetchAgentFiles } = createWorkAttachments();
238
256
  const { sessionRuntime, agyRegistryLookup } = createWorkSessionRuntime({ sessionMetaPath });
239
257
  const { carryDirtyState } = createAdoptCarry({ sessionMetaPath });
240
- const { processWorkTurns, workAnswering } = createWorkSessionTurns({
258
+ const { processWorkTurns, interruptWorkTurns, workAnswering } = createWorkSessionTurns({
241
259
  repoRoot,
260
+ baseRef,
242
261
  baseDir,
243
262
  getMcpUrl,
244
263
  getArtifactsAccepted,
@@ -349,7 +368,7 @@ export function createWorkManager({
349
368
  });
350
369
  const { runReviewEntry } = createWorkAgentReview({ runCheck, runPrecheck });
351
370
 
352
- const { processAgentTurnJobs, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
371
+ const { processAgentTurnJobs, interruptAgentTurns, settleAgentTurns, agentTurns, agentChildren, agentReported } = createWorkAgentTurns({
353
372
  REJECT_RETRY_MS,
354
373
  baseRef,
355
374
  inPlace,
@@ -441,7 +460,7 @@ export function createWorkManager({
441
460
  return {
442
461
  flushWorkReports,
443
462
  learnPlaces,
444
- processWorkTurns,
463
+ processWorkTurns, interruptWorkTurns,
445
464
  processShipJobs,
446
465
  processDiffJobs,
447
466
  processKillJobs,
@@ -453,8 +472,10 @@ export function createWorkManager({
453
472
  shutdownPreviews,
454
473
  retireWorkSessions,
455
474
  reportWorktrees,
475
+ reconcileIdleArtifacts,
456
476
  shutdownWork,
457
477
  workBusy,
478
+ runningAgentCount: () => agentTurns.size,
458
479
  // The machine's own admission answer, and what it is counting. Handed to
459
480
  // the loop so the lanes it owns — the wiki cartographer (wikiRunner.mjs) — ask the
460
481
  // same question, and so the machine snapshot can charge memory to the work
@@ -464,7 +485,7 @@ export function createWorkManager({
464
485
  liveTurnCount,
465
486
  processAgentPlanJobs,
466
487
  processIntakeJobs,
467
- processAgentTurnJobs,
488
+ processAgentTurnJobs, interruptAgentTurns,
468
489
  // Only the roster's stand-down calls this — a displacement, a removal or a
469
490
  // commanded stop (holder.mjs, `standDownDisplaced`); see its comment.
470
491
  // Exported rather than hooked into `shutdownWork` because the signal
@@ -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
+ }
@@ -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
@@ -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
  };