flowviant 0.125.7 → 0.125.9

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 (55) hide show
  1. package/README.md +80 -1
  2. package/bin/cli.mjs +7 -1
  3. package/bin/lib/activityFeed.mjs +69 -0
  4. package/bin/lib/agentMergeReports.mjs +46 -0
  5. package/bin/lib/agentMergeResolution.mjs +44 -0
  6. package/bin/lib/agentPlan.mjs +6 -2
  7. package/bin/lib/claudePosture.mjs +21 -0
  8. package/bin/lib/claudeStream.mjs +25 -4
  9. package/bin/lib/codexResearch.mjs +33 -5
  10. package/bin/lib/commandSpecs.mjs +8 -5
  11. package/bin/lib/daemonJournal.mjs +102 -0
  12. package/bin/lib/daemonLogging.mjs +22 -18
  13. package/bin/lib/fleet.mjs +16 -6
  14. package/bin/lib/fleetReports.mjs +12 -5
  15. package/bin/lib/hooks/orchestrator.mjs +3 -1
  16. package/bin/lib/logReplay.mjs +133 -0
  17. package/bin/lib/nativeAgentTools.mjs +20 -3
  18. package/bin/lib/observedLines.mjs +63 -0
  19. package/bin/lib/orchestrator.mjs +40 -7
  20. package/bin/lib/orchestratorNative.mjs +133 -3
  21. package/bin/lib/orchestratorPrompt.mjs +50 -4
  22. package/bin/lib/postureTools.mjs +14 -6
  23. package/bin/lib/printable.mjs +1 -1
  24. package/bin/lib/repositoryMerge.mjs +1 -1
  25. package/bin/lib/researchHandoff.mjs +144 -0
  26. package/bin/lib/researchHandoffText.mjs +3 -0
  27. package/bin/lib/researchKnowledge.mjs +60 -0
  28. package/bin/lib/researchRestoration.mjs +38 -0
  29. package/bin/lib/runTurn.mjs +76 -43
  30. package/bin/lib/runtimeClaude.mjs +9 -4
  31. package/bin/lib/runtimeCodex.mjs +51 -22
  32. package/bin/lib/runtimeEvents.mjs +17 -7
  33. package/bin/lib/turnDiagnostics.mjs +2 -1
  34. package/bin/lib/turnOutput.mjs +63 -0
  35. package/bin/lib/turnRecord.mjs +11 -0
  36. package/bin/lib/turnSubagents.mjs +13 -3
  37. package/bin/lib/ui.mjs +2 -2
  38. package/bin/lib/uplinkScrub.mjs +49 -0
  39. package/bin/lib/views.mjs +49 -58
  40. package/bin/lib/wikiRunner.mjs +2 -2
  41. package/bin/lib/work.mjs +2 -2
  42. package/bin/lib/workActivity.mjs +65 -0
  43. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  44. package/bin/lib/workAgentMerges.mjs +57 -52
  45. package/bin/lib/workAgentPlans.mjs +76 -27
  46. package/bin/lib/workAgentPrecheck.mjs +1 -1
  47. package/bin/lib/workAgentTurnExecution.mjs +42 -8
  48. package/bin/lib/workAgentTurnReports.mjs +17 -3
  49. package/bin/lib/workAgentTurnShape.mjs +6 -4
  50. package/bin/lib/workAgentTurns.mjs +8 -6
  51. package/bin/lib/workIntake.mjs +8 -2
  52. package/bin/lib/workMergeBatch.mjs +7 -1
  53. package/bin/lib/workPlanRecovery.mjs +50 -0
  54. package/bin/lib/workSessionTurns.mjs +5 -2
  55. package/package.json +1 -1
@@ -0,0 +1,63 @@
1
+ /** The human output contract for one CLI invocation. Each sink write is a
2
+ * complete record; no turn leaves a prefix or an unfinished line in the
3
+ * shared stream. `work` is local attribution, never a new machine event.
4
+ * Callers supply { kind, name, id, turnId }; the writer also receives the
5
+ * complete sanitized record as its third argument for subsequent local sinks.
6
+ * Its fourth argument is retention: true / failed false / unconfigured null.
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { basename } from 'node:path';
10
+ import { format } from 'node:util';
11
+ import { printableLine, printableText } from './printable.mjs';
12
+ import { formatTurnRecord } from './turnRecord.mjs';
13
+ import { retainTurnRecord } from './daemonLogging.mjs';
14
+ import { createLocalDetailScrubber } from './uplinkScrub.mjs';
15
+ import { closeActivityTurn, presentTurnRecord } from './activityFeed.mjs';
16
+
17
+ const nameOf = (value) => printableLine(printableText(value), 120);
18
+ const idOf = (value) => typeof value === 'string' && value ? printableLine(printableText(value), Infinity) : null;
19
+
20
+ export function createTurnOutput({ work = {}, label, cwd, runtime = 'claude', mcpEnv }, {
21
+ now = Date.now,
22
+ write = (text, source) => (source === 'error' ? process.stderr : process.stdout).write(text),
23
+ } = {}) {
24
+ // A lane without a server turn (wiki/pre-review/brief) gets a local
25
+ // invocation id. It is never presented as the CLI's native session id.
26
+ const redact = createLocalDetailScrubber(mcpEnv);
27
+ const retain = (text, meta = {}) => retainTurnRecord({ ...identity, ...meta, kind: redact.text(work.kind || (label ? identity.kind : runtime)), name: redact.text(work.name || identity.name),
28
+ text: printableText(meta.source === 'stderr' ? redact.stderr(text) : redact.text(text)) });
29
+ const runtimeName = nameOf(runtime) || 'CLI';
30
+ const turnId = idOf(work.turnId) || randomUUID();
31
+ const identity = Object.freeze({
32
+ kind: nameOf(work.kind) || nameOf(printableText(label)) || runtimeName,
33
+ name: nameOf(work.name) || nameOf(cwd ? basename(cwd) : '') || runtimeName,
34
+ workId: idOf(work.id) || turnId,
35
+ turnId,
36
+ });
37
+ const emit = (raw, { observedAt = now(), agentId, source = 'activity', full, kind: activityKind } = {}) => {
38
+ const text = printableText(String(raw));
39
+ if (!text) return;
40
+ const child = idOf(agentId);
41
+ const record = Object.freeze({ ...identity, observedAt, source, text, ...(activityKind ? { activityKind } : {}), ...(child ? { agentId: child } : {}) });
42
+ const retained = retain(typeof full === 'string' ? full : String(raw), { observedAt, source, ...(child ? { agentId: child } : {}) });
43
+ presentTurnRecord(record, formatTurnRecord(record, { label }), retained, write);
44
+ };
45
+ const tool = (name, input, meta = {}, observedAt = now()) => {
46
+ try {
47
+ const heading = meta.result ? `tool result ${meta.id || ''}${meta.isError ? ' (failed)' : ''}${meta.exitCode !== undefined ? ` exit=${meta.exitCode}` : ''}` : `tool call ${name || 'tool'}${meta.id ? ` id=${meta.id}` : ''}`;
48
+ // Scrub strings before JSON escaping can hide multiline secret values.
49
+ const value = meta.result ? meta.detail ?? meta.output ?? '' : input;
50
+ const data = typeof value === 'string' ? value : JSON.stringify(value, (_key, field) => typeof field === 'string' ? redact.text(field) : field, 2);
51
+ if (meta.result && (meta.isError === true || typeof meta.exitCode === 'number' && meta.exitCode !== 0))
52
+ emit(data || heading, { observedAt, agentId: meta.agentId, source: 'error' });
53
+ return retain(heading + '\n' + data, { observedAt, source: meta.result ? 'tool_result' : 'tool_call', ...(meta.agentId ? { agentId: idOf(meta.agentId) } : {}) });
54
+ } catch { return retain('[tool detail unavailable: could not serialize the supplied data]', { observedAt, source: 'tool_call' }); }
55
+ };
56
+ return {
57
+ identity, emit, tool,
58
+ event: (text, event = 'lifecycle') => emit(text, { source: event }),
59
+ close: () => closeActivityTurn(turnId),
60
+ detail: (text, meta = {}) => retain(text, { observedAt: now(), source: 'activity', ...meta }),
61
+ error: (error) => emit(format(error), { source: 'error' }),
62
+ };
63
+ }
@@ -0,0 +1,11 @@
1
+ /** One complete attributable human record. Shared by live output and replay. */
2
+ import { c, stamp } from './ui.mjs';
3
+ import { printableText } from './printable.mjs';
4
+
5
+ export function formatTurnRecord(record, { label, color = true } = {}) {
6
+ const { observedAt, kind, name, workId, turnId, agentId, text } = record;
7
+ const lane = `[${kind}]`;
8
+ const prefix = `${color ? stamp(observedAt) : printableText(stamp(observedAt))} ${label || (color ? c.cyan(lane) : lane)} ${JSON.stringify(name)} work=${JSON.stringify(workId)} turn=${JSON.stringify(turnId)}${agentId ? ` child=${JSON.stringify(agentId)}` : ''} `;
9
+ const body = text.endsWith('\n') ? text.slice(0, -1) : text;
10
+ return body.split('\n').map(line => prefix + line).join('\n') + '\n';
11
+ }
@@ -23,6 +23,7 @@ import { handleStreamLine } from './claudeStream.mjs';
23
23
  import { createClaudeSubagents } from './claudeSubagents.mjs';
24
24
  import { createClaudeSubagentProgress } from './claudeSubagentProgress.mjs';
25
25
  import { createCodexSubagentReader } from './codexSubagents.mjs';
26
+ import { humanizeClaudeTool } from './runtimeEvents.mjs';
26
27
  import { SUBAGENT_CAP } from './subagentBounds.mjs';
27
28
 
28
29
  /**
@@ -30,7 +31,7 @@ import { SUBAGENT_CAP } from './subagentBounds.mjs';
30
31
  * sinks, which a Claude child's transcript rows feed as its stream does.
31
32
  */
32
33
  export function createTurnSubagents({ runtime, cwd, since, env, owned, onSubagent, onSpawnResult, resumeThreadId,
33
- sinks: { emit, onActivity, onToolEvent, appendText } }) {
34
+ sinks: { emit, onActivity, onToolEvent, appendText, onDetail } }) {
34
35
  // An orchestrator bounds its own records and settles them at close
35
36
  // (orchestrator.mjs). Counted here by stream id, refused retries used up
36
37
  // the 32 and later children's updates were dropped; the exit sweep
@@ -48,11 +49,20 @@ export function createTurnSubagents({ runtime, cwd, since, env, owned, onSubagen
48
49
  const claudeProgress = runtime === 'claude' && onSubagent ? createClaudeSubagentProgress({
49
50
  cwd, since, env,
50
51
  onLine: line => handleStreamLine(line, { cwd, emit, onActivity, onToolEvent, appendText,
51
- answerFromResult: true, subagents }),
52
+ answerFromResult: true, subagents, onDetail }),
52
53
  }) : null;
53
54
  const codexAgents = runtime === 'codex' && onSubagent ? createCodexSubagentReader({
54
55
  since, env,
55
- onSubagent: reportSubagent, onActivity, onToolEvent,
56
+ onSubagent: reportSubagent,
57
+ onActivity: (activity) => {
58
+ emit(activity.kind === 'error' ? activity.full ?? activity.label : activity.label + '\n', activity);
59
+ onActivity?.(activity);
60
+ },
61
+ onToolEvent: (name, input, meta) => {
62
+ const activity = humanizeClaudeTool(name, input, cwd);
63
+ if (activity && !meta?.result) emit(activity.label + '\n', { ...activity, ...meta });
64
+ onToolEvent?.(name, input, meta);
65
+ },
56
66
  onSpawnResult,
57
67
  }) : null;
58
68
  const resumed = runtime === 'codex' && typeof resumeThreadId === 'string' ? resumeThreadId : null;
package/bin/lib/ui.mjs CHANGED
@@ -9,8 +9,8 @@ export const c = {
9
9
  blue: wrap(34), magenta: wrap(35), cyan: wrap(36), gray: wrap(90),
10
10
  };
11
11
 
12
- const stamp = () => {
13
- const d = new Date();
12
+ export const stamp = (observedAt = Date.now()) => {
13
+ const d = new Date(observedAt);
14
14
  const p = (n) => String(n).padStart(2, '0');
15
15
  return c.gray(`${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`);
16
16
  };
@@ -528,3 +528,52 @@ export function secretIn(bytes) {
528
528
  }
529
529
  return null;
530
530
  }
531
+
532
+ /** Local diagnostic retention has the same learned values and token shapes.
533
+ * A supplied per-turn MCP/login credential is known directly, even before an
534
+ * env rescan. stderr arrives as complete physical lines, so keep PEM masking
535
+ * state and mask the lines of learned multiline secrets too. This does not
536
+ * change uplink scrubbing or the CLI's foreground output.
537
+ */
538
+ export function createLocalDetailScrubber(extraEnv = {}) {
539
+ const extras = Object.entries(extraEnv).filter(([name, value]) =>
540
+ /(?:TOKEN|SECRET|PASSWORD|PASSWD|API_KEY|AUTH|CAPABILITY)(?:_|$)/i.test(name) && typeof value === 'string' && value);
541
+ const text = raw => {
542
+ let out = scrub(String(raw ?? ''));
543
+ const known = [...values.map(v => [v.name, v.value]), ...extras].sort((a, b) => b[1].length - a[1].length);
544
+ for (const [name, value] of known) {
545
+ const pieces = value.includes('\n') ? [value, ...value.split(/\r?\n/).filter(Boolean)] : [value];
546
+ for (const piece of pieces) out = out.split(piece).join(`[REDACTED:${name}]`);
547
+ }
548
+ return out;
549
+ };
550
+ let privateKey = false;
551
+ return {
552
+ text,
553
+ stderr(raw) {
554
+ const value = String(raw), trailing = value.endsWith('\n');
555
+ const lines = value.split('\n');
556
+ if (trailing) lines.pop();
557
+ return lines.map(line => {
558
+ PEM_BEGIN_RE.lastIndex = 0;
559
+ const begin = PEM_BEGIN_RE.exec(line);
560
+ PEM_END_RE.lastIndex = begin ? begin.index + begin[0].length : 0;
561
+ const end = PEM_END_RE.exec(line);
562
+ if (privateKey || begin) {
563
+ const prefix = privateKey ? '' : line.slice(0, begin.index);
564
+ privateKey = !end;
565
+ return text(prefix + '[REDACTED:PRIVATE_KEY]' + (end ? line.slice(end.index + end[0].length) : ''));
566
+ }
567
+ return text(line);
568
+ }).join('\n') + (trailing ? '\n' : '');
569
+ },
570
+ };
571
+ }
572
+
573
+ /** Durable handoff fields share the portable server filter (parity checked). */
574
+ export function filterResearchText(text) {
575
+ return text
576
+ .replace(/(\bBearer\s+)[A-Za-z0-9._~+\/-]+=*/gi, '$1[REDACTED:credential]')
577
+ .replace(/(["']?(?:[A-Za-z0-9_]*(?:api[_-]?key|token|secret|password|passwd|credential)|authorization)["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;\n}]+)/gi, '$1[REDACTED:credential]')
578
+ .replace(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gi, '$1[REDACTED:credential]@');
579
+ }
package/bin/lib/views.mjs CHANGED
@@ -8,8 +8,7 @@
8
8
  */
9
9
 
10
10
  import { spawn } from 'node:child_process';
11
- import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
12
- import { StringDecoder } from 'node:string_decoder';
11
+ import { statSync } from 'node:fs';
13
12
  import { APP_URL, FLEET_URL, USER_AGENT, VERSION } from './config.mjs';
14
13
  import { detectRepoRoot, listStoredProjects, projectLabel, resolveStoredCredential } from './credentials.mjs';
15
14
  import { desktopStatusRemote, installChannel } from './desktopContract.mjs';
@@ -17,9 +16,11 @@ import { daemonLogPath } from './daemonState.mjs';
17
16
  import { git as runGit } from './git.mjs';
18
17
  import { daemonRunningFor } from './instance.mjs';
19
18
  import { agoFrom } from './machineListing.mjs';
20
- import { printable, printableText } from './printable.mjs';
19
+ import { printable, printableLine, printableText } from './printable.mjs';
21
20
  import { detectRuntimes } from './runtimeDetection.mjs';
22
21
  import { c } from './ui.mjs';
22
+ import { createLogReader } from './logReplay.mjs';
23
+ import { scrub } from './uplinkScrub.mjs';
23
24
  import { versionBelow } from './versionOrder.mjs';
24
25
 
25
26
  // "Behind" in these views is versionBelow (versionOrder.mjs): an unreadable
@@ -101,67 +102,57 @@ export function resolveProjectForView(argv = process.argv, cwd = process.cwd())
101
102
  };
102
103
  }
103
104
 
104
- /**
105
- * The last `count` lines of a file, read from its end — PRINTABLE (2026-10-10,
106
- * the audit's F133). A daemon before this release logged member-written card
107
- * titles raw, so a replay could carry an OSC 52 into whichever terminal runs
108
- * `flowviant logs`. `printableText` keeps the lines' shape; a log written on a
109
- * TTY loses its colours with everything else.
110
- */
105
+ /** Compatibility readout over the same retained-history reader as logs -f. */
111
106
  export function tailLines(path, count) {
112
- const size = statSync(path).size;
113
- const want = Math.min(size, 256 * 1024);
114
- const buf = Buffer.alloc(want);
115
- const fd = openSync(path, 'r');
116
- try { readSync(fd, buf, 0, want, size - want); } finally { closeSync(fd); }
117
- const lines = printableText(buf.toString('utf8')).split('\n');
118
- if (lines.at(-1) === '') lines.pop();
119
- if (want < size) lines.shift(); // the first line is cut mid-way
120
- return { text: lines.slice(-count).join('\n'), size };
107
+ const reader = createLogReader(path);
108
+ try { return { text: reader.snapshot(count).text, size: statSync(path).size }; }
109
+ finally { reader.close(); }
121
110
  }
122
111
 
123
- /**
124
- * What `logs -f` prints of each new chunk: decoded as a STREAM, because a read
125
- * can end inside a multi-byte character, and scrubbed like the tail. A sequence
126
- * split across two reads loses its ESC in the first and its terminator in the
127
- * second, so what is left of it is plain text.
128
- */
129
- export function createLogFollower() {
130
- const decoder = new StringDecoder('utf8');
131
- return (buf) => printableText(decoder.write(buf));
132
- }
133
-
134
- export async function runLogs(argv = process.argv) {
135
- const which = resolveProjectForView(argv);
136
- if (!which.entry) { console.error(which.message); process.exit(1); }
137
- const path = daemonLogPath(which.entry.projectId);
112
+ export async function runLogs(argv = process.argv, {
113
+ resolveProject = resolveProjectForView, pathFor = daemonLogPath,
114
+ write = text => process.stdout.write(text), say = text => console.error(text),
115
+ signal, intervalMs = 500,
116
+ } = {}) {
117
+ const atTurn = argv.indexOf('--turn');
118
+ const turnId = atTurn >= 0 ? argv[atTurn + 1] : argv.find(a => a.startsWith('--turn='))?.slice(7) ?? null;
119
+ if (turnId !== null && (!turnId || turnId.startsWith('-'))) {
120
+ say('error: --turn needs the exact turn id printed in an activity line');
121
+ process.exitCode = 1; return;
122
+ }
123
+ const which = resolveProject(argv);
124
+ if (!which.entry) { say(which.message); process.exitCode = 1; return; }
125
+ const path = pathFor(which.entry.projectId);
138
126
  const at = argv.indexOf('-n');
139
- const count = at >= 0 && Number(argv[at + 1]) > 0 ? Math.floor(Number(argv[at + 1])) : 80;
127
+ const count = at >= 0 && Number.isFinite(Number(argv[at + 1])) && Number(argv[at + 1]) > 0 ? Math.max(1, Math.floor(Number(argv[at + 1]))) : 80;
140
128
  const follow = argv.includes('-f') || argv.includes('--follow');
141
- if (!existsSync(path)) {
142
- console.log(`No log yet for ${projectLabel(which.entry)}. It starts when the daemon first runs: ${c.cyan('flowviant')} in its repo.`);
129
+ const reader = createLogReader(path, { turnId });
130
+ try {
131
+ const snapshot = reader.snapshot(count);
132
+ if (snapshot.text) write(snapshot.text + '\n');
133
+ if (!follow && snapshot.pending) say('An unfinished trailing log record was not replayed.');
134
+ if (snapshot.omitted) say(`Showing the last ${count} retained lines; earlier retained lines were omitted.`);
135
+ if (turnId !== null) {
136
+ if (!snapshot.matched) say(`No retained detail for turn ${printableLine(scrub(turnId), 200)}. It may have expired or predate local detail recording.${follow ? ' Waiting for new detail.' : ''}`);
137
+ else say('Local detail retains at most two 2 MiB files; older detail may have expired.');
138
+ } else if (!snapshot.found) {
139
+ say(`No log yet for ${projectLabel(which.entry)}. It starts when the daemon first runs: flowviant in its repo.`);
140
+ }
143
141
  if (!follow) return;
144
- } else {
145
- const { text } = tailLines(path, count);
146
- if (text) console.log(text);
147
- }
148
- if (!follow) return;
149
- // Poll rather than fs.watch: the daemon rotates the file (a rename), and a
150
- // watch on the old inode would go quiet exactly then.
151
- let offset = existsSync(path) ? statSync(path).size : 0;
152
- const followed = createLogFollower();
153
- for (;;) {
154
- await new Promise((resolve) => setTimeout(resolve, 500));
155
- if (!existsSync(path)) continue;
156
- const size = statSync(path).size;
157
- if (size < offset) offset = 0; // rotated
158
- if (size === offset) continue;
159
- const buf = Buffer.alloc(size - offset);
160
- const fd = openSync(path, 'r');
161
- try { readSync(fd, buf, 0, buf.length, offset); } finally { closeSync(fd); }
162
- process.stdout.write(followed(buf));
163
- offset = size;
164
- }
142
+ while (!signal?.aborted) {
143
+ await new Promise(resolve => {
144
+ const done = () => { clearTimeout(timer); signal?.removeEventListener('abort', done); resolve(); };
145
+ const timer = setTimeout(done, intervalMs);
146
+ signal?.addEventListener('abort', done, { once: true });
147
+ });
148
+ if (signal?.aborted) break;
149
+ const next = reader.poll();
150
+ if (next.text) write(next.text);
151
+ }
152
+ } catch (error) {
153
+ say(`Could not read the local log (${printableText(String(error?.code || 'read failed'))}).`);
154
+ process.exitCode = 1;
155
+ } finally { reader.close(); }
165
156
  }
166
157
 
167
158
  /** The command that opens a URL in this computer's browser. Inside WSL that is
@@ -442,7 +442,7 @@ export function createWikiRunner({
442
442
  profile: 'wiki',
443
443
  vaultDir,
444
444
  runtime: wikiRt,
445
- label: c.cyan('[wiki]'),
445
+ work: { kind: 'wiki', id: vaultDir, name: 'Wiki sweep' },
446
446
  // No skills or connectors are harvested off a wiki turn: it is
447
447
  // fenced (no settings source, strict MCP), so its init names less
448
448
  // than the machine has (runTurn.mjs, `initHarvestFor`).
@@ -492,7 +492,7 @@ export function createWikiRunner({
492
492
  profile: 'wiki',
493
493
  vaultDir,
494
494
  runtime: wikiRt,
495
- label: c.cyan('[wiki]'),
495
+ work: { kind: 'wiki', id: vaultDir, name: task.title },
496
496
  onActivity,
497
497
  onUsage: onUsage(wikiRt),
498
498
  onSpawn: (ch) => {
package/bin/lib/work.mjs CHANGED
@@ -27,7 +27,6 @@
27
27
  * whole — `workBusy` and the returned surface. A pure rule or constant
28
28
  * (REPO_PLACE, the resume-id guards, brainFor, the turn lock) is IMPORTED by
29
29
  * every lane that reads it, never carried through here.
30
- *
31
30
  * workPlaces.mjs where a session works, its worktree, its markers
32
31
  * workSessionGroups.mjs which process groups each tab started
33
32
  * workChildren.mjs the live CLI children, teardown, the admission
@@ -36,7 +35,7 @@
36
35
  * workArtifactReconcile.mjs an idle agent's stored delivery against its worktree
37
36
  * workReportQueue.mjs every settle, held and retried until delivered
38
37
  * workSessionTokens.mjs the per-session work credential
39
- * workNarration.mjs the tab's live line and the deferral sentence
38
+ * workNarration.mjs the tab's live line; workActivity.mjs — local outcomes and destinations
40
39
  * workAttachments.mjs files the human attached, brought to disk (tabs and agents)
41
40
  * workSessionRuntime.mjs which CLI a tab speaks, which conversation resumes
42
41
  * workAdoptCarry.mjs adoption's dirty carry
@@ -48,6 +47,7 @@
48
47
  * workCommandAudit.mjs one turn's `$ …` audit, batched to the server
49
48
  * workBrain.mjs the model and effort a turn may put on argv (pure)
50
49
  * workIntake.mjs one incoming ticket drafted into a card, read-only (2026-09-28)
50
+ * workPlanRecovery.mjs read-only diagnosis and attempt cancellation
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)
@@ -0,0 +1,65 @@
1
+ /** Local lifecycle presentation. Inputs are the lane's validated settlement,
2
+ * never a phrase guessed from narration or a successful process exit.
3
+ */
4
+ import { APP_URL, PROJECT_ID } from './config.mjs';
5
+ import { createTurnOutput } from './turnOutput.mjs';
6
+
7
+ export const agentWork = job => ({ kind: 'agent', id: job.agentId,
8
+ name: job.task?.title || job.agentName || job.agentId, turnId: job.id });
9
+ export const sessionWork = job => ({ kind: 'tab', id: job.sessionId,
10
+ name: job.sessionName || job.sessionId, turnId: job.id });
11
+ export const planWork = job => ({ kind: 'plan', id: job.id,
12
+ name: (Array.isArray(job.tasks) ? job.tasks : []).map(task => task?.title).filter(Boolean).join(' · ') || 'Planning', turnId: job.id });
13
+
14
+ export function workDestination(work, { projectId = PROJECT_ID, appUrl = APP_URL, taskId } = {}) {
15
+ const safe = id => typeof id === 'string' && id && id !== '.' && id !== '..';
16
+ if (!safe(projectId) || !safe(work.id)) return null;
17
+ const base = `${appUrl}/projects/${encodeURIComponent(projectId)}`;
18
+ if (work.kind === 'agent') return `${base}/v2/agents/${encodeURIComponent(work.id)}${safe(taskId) ? `?${new URLSearchParams({ task: taskId })}` : ''}`;
19
+ if (work.kind === 'tab') return `${base}/v2/terminal?${new URLSearchParams({ tab: work.id })}`;
20
+ return `${base}/board`;
21
+ }
22
+
23
+ export function reportWorkOutcome(work, body, destination = workDestination(work)) {
24
+ const output = createTurnOutput({ work });
25
+ let text;
26
+ if (body.interrupted) text = 'Interrupted';
27
+ else if (body.outcome === 'question') text = `Needs input in Flowviant\n${body.answer || ''}`;
28
+ else if (body.outcome === 'delivered') text = 'Delivered for review';
29
+ else if (body.outcome === 'replied') text = 'Replied in the session';
30
+ else if (body.outcome === 'drafted') text = 'Ticket draft ready for review';
31
+ else if (body.outcome === 'proposed') text = 'Plan ready for review';
32
+ else if (body.outcome === 'merged') text = 'Merged on the base branch';
33
+ else text = `Failed\n${body.answer || 'The turn returned no outcome.'}`;
34
+ if (destination) text += `\n${body.outcome === 'question' ? 'Answer in Flowviant: ' : ''}${destination}`;
35
+ output.event(text, body.outcome === 'question' ? 'question' : 'outcome');
36
+ output.close();
37
+ }
38
+
39
+ /** Recovery settlements describe the retained operation, rather than a new
40
+ * review decision. The caller emits this once, before retrying its receipt. */
41
+ export function reportPlanOutcome(job, body) {
42
+ const work = planWork(job);
43
+ if (body.question) return reportWorkOutcome(work, { outcome: 'question',
44
+ answer: [body.question.question, body.question.context].filter(Boolean).join('\n\n') });
45
+ if (job.recovery?.start && body.proposal) {
46
+ const output = createTurnOutput({ work });
47
+ output.event('Start diagnosis complete; continuing the approved Start', 'outcome');
48
+ output.close();
49
+ return;
50
+ }
51
+ reportWorkOutcome(work, { outcome: body.proposal ? 'proposed' : 'nothing', answer: body.error });
52
+ }
53
+
54
+ export function reportAgentOutcome(job, body) {
55
+ const work = agentWork(job);
56
+ if (job.kind === 'merge_resolve' && body.outcome === 'delivered') {
57
+ const output = createTurnOutput({ work });
58
+ output.event(body.mergeResolved === true
59
+ ? 'Merge repair verified; continuing the approved Merge'
60
+ : 'Merge repair returned; verification is still needed', 'outcome');
61
+ output.close();
62
+ return;
63
+ }
64
+ reportWorkOutcome(work, body, workDestination(work, { taskId: job.task?.id }));
65
+ }
@@ -14,7 +14,7 @@ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
14
14
  import { scrub } from './uplinkScrub.mjs';
15
15
 
16
16
  export function createAgentBriefRunner({ admit, workChildren, run = runTurn, request = fleetRequest }) {
17
- return ({ agentId, turnId, runtime, model }) => {
17
+ return ({ agentId, turnId, workName, runtime, model }) => {
18
18
  const identity = { agentId, turnId, instance: DAEMON_INSTANCE };
19
19
  return makeAgentBrief({ runtime, model, scrub, models: knownCodexModels,
20
20
  context: async (signal) => {
@@ -46,7 +46,7 @@ export function createAgentBriefRunner({ admit, workChildren, run = runTurn, req
46
46
  cwd = mkdtempSync(join(tmpdir(), 'flowviant-brief-'));
47
47
  const result = await runCappedTurn({ runTurn: run, workChildren, releaseSlot, signal,
48
48
  capMs: 60_000, transcriptCwd: cwd,
49
- turnArgs: { ...turnArgs, cwd, profile: 'consult', answerFromResult: true, label: '[brief]',
49
+ turnArgs: { ...turnArgs, cwd, profile: 'consult', answerFromResult: true, work: { kind: 'brief', id: agentId, name: workName || 'Work brief' },
50
50
  onUsage: (u) => { usage = { ...u, runtime: turnArgs.runtime }; } } });
51
51
  return result.wedged ? '' : { out: result.out, usage };
52
52
  } finally {
@@ -1,3 +1,5 @@
1
+ import { reportWorkOutcome } from './workActivity.mjs';
2
+ import { createTurnOutput } from './turnOutput.mjs';
1
3
  import { mergeRepositoryPlace } from './repositoryMerge.mjs';
2
4
  import { fleetRequest } from './fleetPost.mjs';
3
5
  /** Agent merge claims, pull-request approval, and branch cleanup. */
@@ -13,7 +15,9 @@ import { gitMergeIn } from './gitMergeIdentity.mjs';
13
15
  import { warn } from './ui.mjs';
14
16
  import { scrub as envScrub } from './uplinkScrub.mjs';
15
17
  import { fleetEndpoint } from './fleetWire.mjs';
16
- import { claimFleetLease, settleLease } from './fleetPost.mjs';
18
+ import { claimFleetLease } from './fleetPost.mjs';
19
+ import { createMergeReports } from './agentMergeReports.mjs';
20
+ import { gitReportedConflict, recordMergeFailure, clearMergeFailure } from './agentMergeResolution.mjs';
17
21
  import { worktreePath } from './workPlaces.mjs';
18
22
 
19
23
  /** Git puts merge conflicts on stdout, while refusals usually use stderr.
@@ -46,7 +50,7 @@ export function createWorkAgentMerges({
46
50
  agentPublished,
47
51
  }) {
48
52
  const AGENT_MERGE_CLAIM_URL = fleetEndpoint('agent-merge-claim', FLEET_URL);
49
- const AGENT_MERGE_DONE_URL = fleetEndpoint('agent-merge-done', FLEET_URL);
53
+
50
54
 
51
55
  // ── THE MERGE ──────────────────────────────────────────────────────────────
52
56
  //
@@ -63,9 +67,28 @@ export function createWorkAgentMerges({
63
67
  // The peer may hold it; doing nothing is the safe answer.
64
68
  const claimAgentMerge = (agentId) => claimFleetLease(AGENT_MERGE_CLAIM_URL, { agentId });
65
69
 
66
- // On a failure the lease lapses and the job is re-offered — a merge is
67
- // idempotent against an already-merged branch, which mergeOutward detects.
68
- const postAgentMerge = (body) => settleLease(AGENT_MERGE_DONE_URL, body, { timeoutMs: 30_000 });
70
+ const retirements = new Map();
71
+ // Remote cleanup is tail work, after acknowledgement and outside the place
72
+ // lock, for both immediate delivery and journal replay.
73
+ const retireAgentMerge = async (id) => {
74
+ const held = retirements.get(id);
75
+ if (!held) return;
76
+ retirements.delete(id);
77
+ const { body, place, publishedRef } = held;
78
+ if (!isSafePathSegment(place)) return;
79
+ clearMergeFailure(worktreePath(baseDir, place));
80
+ agentPublished.delete(place);
81
+ agentRemoteAt.delete(place);
82
+ const args = publishDeleteArgs(publishedRef);
83
+ if (args) {
84
+ try { gitNet(args, 60_000); }
85
+ catch (e) { warn(`agent ${body.agentId}: the published branch could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`); }
86
+ }
87
+ };
88
+ const reports = createMergeReports({ baseDir, delivered: async (held, settled) => {
89
+ if (held.body.ok && settled) retirements.set(String(held.body.agentId), held);
90
+ } });
91
+ const postAgentMerge = (body, meta) => reports.report(body, meta);
69
92
 
70
93
  // A merge COMMIT needs an identity the machine may not have; the probe and
71
94
  // the daemon's fallback live in gitMergeIdentity.mjs.
@@ -81,6 +104,8 @@ export function createWorkAgentMerges({
81
104
  return;
82
105
  }
83
106
  if (!(await claimAgentMerge(agentId))) return;
107
+ const activity = createTurnOutput({ work: { kind: 'merge', id: agentId, name: job.agentName || agentId } });
108
+ activity.event('Merge started', 'start');
84
109
 
85
110
  /**
86
111
  * NO EXIT PATH MAY LEAVE A CLAIMED MERGE UNREPORTED — the same belt the
@@ -111,11 +136,17 @@ export function createWorkAgentMerges({
111
136
  * landed is the case it exists for. It is recorded in `report` rather than
112
137
  * at the three success sites so a fourth one cannot forget it.
113
138
  */
114
- let landedRef = null;
115
139
  const report = async (body) => {
140
+ const wt = worktreePath(baseDir, place);
141
+ if (!job.repositoryMerge && !body.ok) {
142
+ body.conflict ??= gitReportedConflict(null, wt);
143
+ recordMergeFailure(wt, baseRef(), body.conflict);
144
+ }
145
+ // From here the journal owns retries, including a dropped 200 or a
146
+ // restart. The catch must not overwrite this measured outcome.
116
147
  reported = true;
117
- if (body?.ok === true) landedRef = job.publishedRef ?? null;
118
- await postAgentMerge({ ...body, ...(body.detail ? { detail: relayedText(String(body.detail)).slice(0, 2000) } : {}) });
148
+ reportWorkOutcome({ ...activity.identity, id: activity.identity.workId }, { outcome: body.ok ? 'merged' : 'nothing', answer: body.detail });
149
+ await postAgentMerge({ ...body, ...(body.detail ? { detail: relayedText(String(body.detail)).slice(0, 2000) } : {}) }, { place, publishedRef: job.publishedRef ?? null });
119
150
  };
120
151
 
121
152
  // A WRITER on the place, exactly as a ship is. It folds base in and pushes
@@ -160,6 +191,7 @@ export function createWorkAgentMerges({
160
191
  agentId,
161
192
  ok: false,
162
193
  detail: agentMergeFailureDetail(e),
194
+ conflict: gitReportedConflict(e, wt),
163
195
  });
164
196
  return;
165
197
  }
@@ -168,7 +200,11 @@ export function createWorkAgentMerges({
168
200
  // anything merges: a failed merge sends the agent BACK to review, which
169
201
  // is a review-entry beat like any other, and a stale reading standing
170
202
  // over a rebased branch is exactly what `precheckSha` exists to void.
171
- await runReviewEntry(agentId, wt, job.agentName);
203
+ const check = await runReviewEntry(agentId, wt, job.agentName);
204
+ if (check?.status === 'failed') {
205
+ await report({ agentId, ok: false, detail: check.text || check.output || 'The repository check failed after folding base.' });
206
+ return;
207
+ }
172
208
  }
173
209
  // `git()` THROWS on a non-zero exit, and `symbolic-ref` exits non-zero on
174
210
  // a detached HEAD — so the guard below was unreachable and the throw
@@ -367,11 +403,11 @@ export function createWorkAgentMerges({
367
403
  agentId,
368
404
  ok: false,
369
405
  // WHOSE FIX IT IS (0.108.0), when the merge said: a refusal about
370
- // the person's side (`shipMerge.mjs`'s dirty checkout) queues no
371
- // resolve turn on the server. A conflict says nothing, and the
372
- // agent resolves it in its worktree as before.
406
+ // person's side must become a question during diagnosis; it is
407
+ // never permission to modify that checkout or its credentials.
373
408
  ...(e?.fix === 'person' ? { fix: 'person' } : {}),
374
409
  detail: agentMergeFailureDetail(e),
410
+ conflict: gitReportedConflict(e, wt),
375
411
  });
376
412
  return;
377
413
  }
@@ -388,62 +424,31 @@ export function createWorkAgentMerges({
388
424
  } finally {
389
425
  if (!reported) {
390
426
  // Belt over braces. A merge this daemon claimed and cannot account for
391
- // is a FAILURE, said out loud, so the agent goes back to Review with a
427
+ // is a FAILURE, said out loud, so the agent resumes diagnosis with a
392
428
  // reason instead of waiting out an expiry that blames nobody.
393
429
  await postAgentMerge({
394
430
  agentId,
395
431
  ok: false,
396
432
  detail: 'the merge did not complete — check the daemon log',
397
- }).catch(() => {});
398
- }
399
- /**
400
- * …AND THE RETIREMENT IS TAIL WORK, OUTSIDE THE PLACE LOCK — the same
401
- * placement the turn's publish argues for, for the same reason.
402
- *
403
- * `gitNet` is `execFileSync`: it blocks the whole event loop for up to its
404
- * timeout, and inside `inPlace(place, true, …)` it would hold this
405
- * agent's WRITER lock through a remote's bad day, with the merge already
406
- * settled and nothing left that the delay serves. This block is PAST the
407
- * lock: `inPlace` has returned (or thrown) before a `finally` runs.
408
- *
409
- * IN THE `finally` rather than after it, so a throw between the ok settle
410
- * and the end of the locked block cannot strand the ref. `landedRef` is
411
- * set only by a reported success, so there is nothing here to run on any
412
- * other path — and a remote that refuses the delete only warns: the merge
413
- * is the thing that matters, and a landed branch must not become a failed
414
- * approval.
415
- *
416
- * The record goes with the ref. `agentPublished` is what the SWEEP
417
- * republishes from, so leaving the entry behind would let the next sweep
418
- * push the ref straight back — an orphan no later merge job can ever
419
- * carry, and therefore one nothing can delete.
420
- */
421
- if (landedRef) {
422
- agentPublished.delete(place);
423
- agentRemoteAt.delete(place);
424
- const args = publishDeleteArgs(landedRef);
425
- if (args) {
426
- try {
427
- gitNet(args, 60_000);
428
- } catch (e) {
429
- warn(
430
- `agent ${agentId}: the published branch ${landedRef} could not be deleted — ${publishErrorText(envScrub(e?.stderr?.toString?.() || e?.message || ''))}`
431
- );
432
- }
433
- }
433
+ }, { place, publishedRef: job.publishedRef ?? null }).catch(() => {});
434
434
  }
435
+ await retireAgentMerge(agentId);
435
436
  }
436
437
  };
437
438
 
438
439
  const processAgentMergeJobs = (jobs) => {
440
+ void (async () => {
441
+ await reports.flush();
442
+ for (const id of [...retirements.keys()]) if (!agentMerges.has(id)) await retireAgentMerge(id);
443
+ })().catch(() => {});
439
444
  if (!Array.isArray(jobs) || jobs.length === 0) return;
440
445
  for (const job of jobs.slice(0, 2)) {
441
446
  const id = String(job?.batch?.spec?.id || job?.agentId || '');
442
- if (!id || agentMerges.has(id)) continue;
447
+ if (!id || agentMerges.has(id) || reports.pending.has(id)) continue;
443
448
  agentMerges.add(id);
444
449
  void (job.batch ? runBatch(job) : runAgentMerge(job)).finally(() => agentMerges.delete(id));
445
450
  }
446
451
  };
447
452
 
448
- return { processAgentMergeJobs, agentMerges };
453
+ return { processAgentMergeJobs, agentMerges, pendingMergeReports: reports.pending };
449
454
  }