flowviant 0.125.7 → 0.125.12

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 (65) 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 +48 -0
  6. package/bin/lib/agentPlan.mjs +14 -2
  7. package/bin/lib/agentTaskTypes.mjs +11 -7
  8. package/bin/lib/artifactContracts.mjs +8 -0
  9. package/bin/lib/artifactIteration.mjs +18 -0
  10. package/bin/lib/artifactReporter.mjs +16 -3
  11. package/bin/lib/claudePosture.mjs +21 -0
  12. package/bin/lib/claudeStream.mjs +25 -4
  13. package/bin/lib/codexResearch.mjs +35 -5
  14. package/bin/lib/commandSpecs.mjs +8 -5
  15. package/bin/lib/daemonJournal.mjs +102 -0
  16. package/bin/lib/daemonLogging.mjs +22 -18
  17. package/bin/lib/fleet.mjs +16 -6
  18. package/bin/lib/fleetReports.mjs +12 -5
  19. package/bin/lib/hooks/orchestrator.mjs +3 -1
  20. package/bin/lib/logReplay.mjs +133 -0
  21. package/bin/lib/nativeAgentTools.mjs +20 -3
  22. package/bin/lib/observedLines.mjs +63 -0
  23. package/bin/lib/orchestrator.mjs +40 -7
  24. package/bin/lib/orchestratorNative.mjs +133 -3
  25. package/bin/lib/orchestratorPrompt.mjs +51 -4
  26. package/bin/lib/postureTools.mjs +14 -6
  27. package/bin/lib/printable.mjs +1 -1
  28. package/bin/lib/prompts.mjs +122 -7
  29. package/bin/lib/repositoryDelivery.mjs +1 -1
  30. package/bin/lib/repositoryMerge.mjs +1 -1
  31. package/bin/lib/researchHandoff.mjs +144 -0
  32. package/bin/lib/researchHandoffText.mjs +3 -0
  33. package/bin/lib/researchKnowledge.mjs +60 -0
  34. package/bin/lib/researchRestoration.mjs +38 -0
  35. package/bin/lib/resultInputs.mjs +83 -0
  36. package/bin/lib/runTurn.mjs +76 -43
  37. package/bin/lib/runtimeClaude.mjs +9 -4
  38. package/bin/lib/runtimeCodex.mjs +51 -22
  39. package/bin/lib/runtimeEvents.mjs +17 -7
  40. package/bin/lib/testFindings.mjs +60 -0
  41. package/bin/lib/testFindingsContract.mjs +2 -0
  42. package/bin/lib/turnDiagnostics.mjs +2 -1
  43. package/bin/lib/turnOutput.mjs +63 -0
  44. package/bin/lib/turnRecord.mjs +11 -0
  45. package/bin/lib/turnSubagents.mjs +13 -3
  46. package/bin/lib/ui.mjs +2 -2
  47. package/bin/lib/uplinkScrub.mjs +49 -0
  48. package/bin/lib/views.mjs +49 -58
  49. package/bin/lib/wikiRunner.mjs +2 -2
  50. package/bin/lib/work.mjs +2 -2
  51. package/bin/lib/workActivity.mjs +65 -0
  52. package/bin/lib/workAgentBriefRunner.mjs +2 -2
  53. package/bin/lib/workAgentMerges.mjs +57 -52
  54. package/bin/lib/workAgentPlans.mjs +76 -27
  55. package/bin/lib/workAgentPrecheck.mjs +1 -1
  56. package/bin/lib/workAgentTurnExecution.mjs +55 -9
  57. package/bin/lib/workAgentTurnOutcome.mjs +12 -3
  58. package/bin/lib/workAgentTurnReports.mjs +17 -3
  59. package/bin/lib/workAgentTurnShape.mjs +8 -5
  60. package/bin/lib/workAgentTurns.mjs +8 -6
  61. package/bin/lib/workIntake.mjs +8 -2
  62. package/bin/lib/workMergeBatch.mjs +7 -1
  63. package/bin/lib/workPlanRecovery.mjs +50 -0
  64. package/bin/lib/workSessionTurns.mjs +7 -2
  65. package/package.json +1 -1
package/README.md CHANGED
@@ -67,7 +67,7 @@ The binary and global npm installs update when idle. The binary fetches the rele
67
67
  | `flowviant login` | Connect this repo to a project (approve the link it prints) |
68
68
  | `flowviant stop` | Stop every flowviant daemon on this computer |
69
69
  | `flowviant status` | Each project connected here: repo, running/serving, live agents |
70
- | `flowviant logs [-f]` | This repo's daemon log (`-f` follows it) |
70
+ | `flowviant logs [-f] [-n <lines>] [--turn <id>] [--project <name|id>]` | Replay or follow retained local diagnostics; optionally select one turn |
71
71
  | `flowviant open` | Open this project's board in the browser |
72
72
  | `flowviant projects` | Every stored login and the repo it serves |
73
73
  | `flowviant machines` | Every computer serving your projects; a menu on a terminal |
@@ -76,6 +76,85 @@ The binary and global npm installs update when idle. The binary fetches the rele
76
76
  | `flowviant clean` | Delete kept agent worktrees |
77
77
  | `flowviant uninstall` | Remove every copy (`--purge` for logins too) |
78
78
 
79
+ ## Native activity feed
80
+
81
+ `flowviant` (or `flowviant start`) defaults to an append-only chronological
82
+ feed. Each work record carries its observation time, readable work name and
83
+ stable `@` turn label; observed native children keep their parent turn and a
84
+ child label. Long names and routine labels are shortened to fit ordinary
85
+ terminals; errors and questions keep their actual words and line breaks.
86
+
87
+ Starts, failures, interruptions, plan proposals and validated agent deliveries
88
+ appear immediately. A delivery says **Delivered for review**; only the merge
89
+ lane's measured landing says **Merged on the base branch**. Questions retain
90
+ their text and the existing app destination. Answer them in Flowviant.
91
+ Connection failures and recovery, serving changes and stopping remain visible.
92
+
93
+ Routine narration and tool activity are summarized as **Latest activity**
94
+ every ten seconds while fresh activity arrives, and flushed before immediate
95
+ observations or at close. A quiet interval prints nothing. A latest observation
96
+ is not proof that a command is still running. At most 256 pending turn/child
97
+ labels are held, each limited to 120 characters; reaching that bound flushes
98
+ them in observation order. This is a feed, not a live dashboard; `flowviant
99
+ status` remains the on-demand snapshot.
100
+
101
+ Use `flowviant --detail` or `flowviant start --detail` to print each runtime
102
+ activity as it arrives. This selects the existing detailed activity stream;
103
+ fuller supplied narration and tool data are available through logs below.
104
+ The choice survives inline login, PATH adoption and self-update relaunches.
105
+ It is inherited for that launch, not saved as a project preference. A service
106
+ can set `FLOWVIANT_OUTPUT=detail`; `--detail` also works on `flowviant login`.
107
+ Both presentations capture the same diagnostic detail before rendering.
108
+ Legacy idle snapshots stay in retained diagnostics in compact mode.
109
+ Environment-token launches attach the project journal when the roster first
110
+ identifies the project, before starting its CLI work. If
111
+ retention is unavailable or a write fails, the live record stays detailed.
112
+
113
+ ## Local diagnostic detail
114
+
115
+ `flowviant logs` shows the last 80 retained lines for this repo's project,
116
+ including daemon diagnostics and native CLI detail. Use `-n 300` for more lines,
117
+ `-f` or `--follow` for new lines across rotation and daemon restarts, and
118
+ `--project <name|id>` for another connected project. To isolate concurrent work,
119
+ copy the `@` turn label from the compact feed (or the full `turn` id from detail):
120
+
121
+ ```sh
122
+ flowviant logs --turn @<label> -n 300
123
+ flowviant logs --turn <turn-id> --follow --project <name-or-id>
124
+ ```
125
+
126
+ The filter accepts an exact full turn id or the deterministic twelve-hex `@`
127
+ SHA-256 label of that id. It includes observed native children and excludes
128
+ unattributed daemon diagnostics. With no filter those diagnostics remain
129
+ visible. Replay includes the current log and its one predecessor, oldest first.
130
+ Each project retains **two files of at most 2 MiB each**, under
131
+ `~/.flowviant/daemon-<project-hash>.log` and `.log.1`, with mode `0600`.
132
+ Retention is by bytes, not a guaranteed duration; older detail expires as work
133
+ continues. New log directories use mode `0700`.
134
+
135
+ Native entries retain observed timestamps, readable work names, full work/turn
136
+ ids and available native-child ids, with supplied narration, answers, tool
137
+ calls/results and CLI stderr/errors. This is **local diagnostic detail, not a
138
+ complete vendor transcript**: it contains no unavailable reasoning or raw
139
+ vendor event archive, and only tool information the adapters expose. Each
140
+ encoded native record is capped at **64 KiB**, after redaction; a clipped entry
141
+ carries `[truncated: local detail record exceeded 64 KiB]`. Oversized legacy
142
+ console entries carry `[truncated]`. Tail limits, detected rotation/write gaps,
143
+ unreadable records and missing selected-turn detail are explicit. A missing
144
+ turn may have expired or predate this capture; it does not mean the turn did
145
+ not run.
146
+
147
+ Capture is independent of app trace uploads and foreground presentation. Local
148
+ storage and replay use the daemon's existing learned-secret and credential-shape
149
+ scrubber, with its documented exemptions; supplied per-turn MCP/login values
150
+ are masked too. Known multiline secret fragments and streamed PEM bodies are
151
+ masked before storage. Terminal controls are removed at storage and replay.
152
+ Unknown secrets outside those sources/shapes remain outside that guarantee.
153
+ If storage fails, work continues, the daemon reports that detail could not be
154
+ retained, and a successful later write marks the gap. Foreground presentation
155
+ never changes retained detail; desktop `--json-events` still reserves stdout for
156
+ machine events and writes human output to stderr.
157
+
79
158
  ## Uninstall
80
159
 
81
160
  `flowviant uninstall` stops local daemons and removes installed copies. It keeps logins and local state in `~/.flowviant`, so reinstalling resumes them. Use `flowviant uninstall --purge` to disconnect this box from stored projects and delete that local data too. Add `--yes` to skip confirmation.
package/bin/cli.mjs CHANGED
@@ -115,6 +115,11 @@ if (process.argv[2] === '__hook') {
115
115
  // is that the login ended; wait for stdout to drain before exiting.
116
116
  const flushStdout = () => new Promise((resolve) => process.stdout.write('', resolve));
117
117
 
118
+ // Preserve an explicit foreground choice across login, PATH and update
119
+ // relaunches, including paths that build fresh argv. No setting is saved.
120
+ const { outputMode, configureActivityOutput } = await import('./lib/activityFeed.mjs');
121
+ process.env.FLOWVIANT_OUTPUT = outputMode(process.argv.slice(2), process.env);
122
+
118
123
  // `flowviant start` is the spelled-out form of a bare `flowviant`; everything
119
124
  // below reads the start path from argv, so the word is simply dropped.
120
125
  if (process.argv[2] === 'start' && !process.argv.slice(3).some((arg) => arg === '--help' || arg === '-h')) process.argv.splice(2, 1);
@@ -229,7 +234,7 @@ async function statusCommand() {
229
234
  async function logsCommand() {
230
235
  const { runLogs } = await import('./lib/views.mjs');
231
236
  await runLogs(process.argv);
232
- process.exit(0);
237
+ process.exit(process.exitCode || 0);
233
238
  }
234
239
 
235
240
  // `flowviant open` — this repo's board, in the browser.
@@ -484,4 +489,5 @@ const { installDaemonLogging } = await import('./lib/daemonLogging.mjs');
484
489
  const { daemonLogPath } = await import('./lib/daemonState.mjs');
485
490
  const logProjectId = CREDENTIAL?.entry?.projectId;
486
491
  installDaemonLogging(logProjectId ? daemonLogPath(logProjectId) : null, { jsonEvents: process.argv.includes('--json-events') });
492
+ configureActivityOutput(process.env.FLOWVIANT_OUTPUT);
487
493
  await runFleetDaemon({ afterLock });
@@ -0,0 +1,69 @@
1
+ /** Local foreground presentation. Retention happens before this sink. Unknown
2
+ * or failed retention always falls back to the complete detailed live record.
3
+ * A timer consumes fresh observations; it never infers liveness from silence.
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { c, stamp } from './ui.mjs';
7
+ import { printableLine, printableText } from './printable.mjs';
8
+
9
+ export const turnToken = id => '@' + createHash('sha256').update(id).digest('hex').slice(0, 12);
10
+ export const matchesTurn = (id, selection) => selection === id || selection === turnToken(id);
11
+ export const outputMode = (argv, env) => argv.includes('--detail') || env.FLOWVIANT_OUTPUT === 'detail' ? 'detail' : 'compact';
12
+ const routine = new Set(['think', 'say', 'bash', 'glob', 'list', 'plan', 'read', 'search', 'tool', 'write']);
13
+
14
+ export function formatActivityRecord(record) {
15
+ const name = printableLine(printableText(record.name), 24);
16
+ const child = record.agentId ? ` child=${turnToken(record.agentId)}` : '';
17
+ const prefix = `${stamp(record.observedAt)} ${c.cyan(`[${printableLine(record.kind, 16)} ${name} ${turnToken(record.turnId)}${child}]`)} `;
18
+ const body = printableText(record.text).replace(/\n$/, '');
19
+ return body.split('\n').map(line => prefix + line).join('\n') + '\n';
20
+ }
21
+
22
+ export function createActivityFeed({ intervalMs = 10_000, maxPending = 256,
23
+ schedule = setTimeout, cancel = clearTimeout } = {}) {
24
+ const pending = new Map();
25
+ let timer = null;
26
+ const flush = () => {
27
+ if (timer !== null) { cancel(timer); timer = null; }
28
+ const rows = [...pending.values()];
29
+ pending.clear();
30
+ for (const { record, write } of rows) {
31
+ // Only the label is coalesced. Full text was already retained, including
32
+ // tools/results. Observation time belongs to the latest supplied event.
33
+ const latest = { ...record, text: `Latest activity: ${printableLine(record.text, 120)}` };
34
+ write(formatActivityRecord(latest), latest.source, latest, true);
35
+ }
36
+ };
37
+ return {
38
+ present(record, detail, retained, write) {
39
+ if (retained === true && record.source === 'activity' && routine.has(record.activityKind)) {
40
+ const key = JSON.stringify([record.turnId, record.agentId]);
41
+ pending.delete(key); // Map order is the order of the latest observations.
42
+ pending.set(key, { record: { ...record, text: printableLine(record.text, 120) }, write });
43
+ if (pending.size >= maxPending) flush();
44
+ else if (timer === null) { timer = schedule(flush, intervalMs); timer.unref?.(); }
45
+ return;
46
+ }
47
+ // A question/error/outcome is immediate, after older pending activity.
48
+ flush();
49
+ write(retained === true ? formatActivityRecord(record) : detail, record.source, record, retained);
50
+ },
51
+ flush,
52
+ close(turnId) {
53
+ if ([...pending.values()].some(row => row.record.turnId === turnId)) flush();
54
+ },
55
+ };
56
+ }
57
+
58
+ let feed = null;
59
+ export const compactOutput = () => feed !== null;
60
+ export function configureActivityOutput(mode) {
61
+ feed?.flush();
62
+ feed = mode === 'compact' ? createActivityFeed() : null;
63
+ }
64
+ export const flushActivityOutput = () => feed?.flush();
65
+ export const closeActivityTurn = turnId => feed?.close(turnId);
66
+ export function presentTurnRecord(record, detail, retained, write) {
67
+ if (feed) feed.present(record, detail, retained, write);
68
+ else write(detail, record.source, record, retained);
69
+ }
@@ -0,0 +1,46 @@
1
+ /** Persist an outcome before sending it. Uncertain acknowledgement retries the
2
+ * receipt, never the Git operation; the original lease instance survives restart. */
3
+ import { join } from 'node:path';
4
+ import { createReportJournal } from './reportJournal.mjs';
5
+ import { fleetRequest } from './fleetPost.mjs';
6
+ import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
7
+ import { FLEET_URL, DAEMON_INSTANCE } from './config.mjs';
8
+
9
+ export function createMergeReports({ baseDir, delivered = async () => {} }) {
10
+ const journal = createReportJournal(join(baseDir, 'agent-merge-reports'));
11
+ const pending = new Map(journal.load().work.filter(([id, held]) =>
12
+ held?.body?.agentId === id && typeof held.body.ok === 'boolean' && typeof held.body.instance === 'string'));
13
+ const sending = new Set();
14
+ const retryAt = new Map();
15
+ const save = () => journal.save(pending, new Map());
16
+ const send = async (id) => {
17
+ if (sending.has(id) || (retryAt.get(id) ?? 0) > Date.now()) return false;
18
+ const held = pending.get(id);
19
+ if (!held) return true;
20
+ sending.add(id);
21
+ try {
22
+ const r = await fleetRequest(fleetEndpoint('agent-merge-done', FLEET_URL), { body: held.body });
23
+ // A parsed receipt includes already-settled/lost-holder results. Auth,
24
+ // HTML, malformed JSON and deploy skew retain the exact body.
25
+ if (r.ok && r.json?.success === true && typeof r.json?.data?.settled === 'boolean') {
26
+ pending.delete(id);
27
+ retryAt.delete(id);
28
+ save();
29
+ await delivered(held, r.json.data.settled);
30
+ return true;
31
+ }
32
+ retryAt.set(id, Date.now() + (r.retryAfterMs ?? (isConsideredRefusal(r.status) ? 60_000 : 1000)));
33
+ return false;
34
+ } finally { sending.delete(id); }
35
+ };
36
+ return {
37
+ pending,
38
+ async report(body, meta = {}) {
39
+ const id = String(body.agentId);
40
+ pending.set(id, { body: { ...body, instance: DAEMON_INSTANCE }, ...meta });
41
+ save();
42
+ return send(id);
43
+ },
44
+ async flush() { for (const id of [...pending.keys()]) await send(id); },
45
+ };
46
+ }
@@ -0,0 +1,48 @@
1
+ /** Evidence belongs to the failed attempt, so an answer after committing can
2
+ * verify the same repair without inventing another commit. */
3
+ import { readFileSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { git, isAncestor } from './git.mjs';
6
+
7
+ const marker = (wt) => resolve(wt, git(['rev-parse', '--git-path', 'flowviant-merge-resolution'], wt));
8
+ const sha = (value) => typeof value === 'string' && /^[a-f0-9]{40,64}$/.test(value);
9
+
10
+ export function gitReportedConflict(error, wt) {
11
+ if (/^CONFLICT \(/m.test(`${error?.stdout ?? ''}\n${error?.stderr ?? ''}`)) return true;
12
+ try {
13
+ return Boolean(git(['diff', '--name-only', '--diff-filter=U'], wt) || git(['rev-parse', '--verify', 'MERGE_HEAD'], wt));
14
+ } catch { return false; }
15
+ }
16
+
17
+ export function clearMergeFailure(wt) {
18
+ try { rmSync(marker(wt), { force: true }); } catch { /* no marker */ }
19
+ }
20
+
21
+ export function recordMergeFailure(wt, base, conflict) {
22
+ try {
23
+ writeFileSync(marker(wt), JSON.stringify({ before: git(['rev-parse', 'HEAD'], wt), base: git(['rev-parse', base], wt), conflict }));
24
+ } catch { clearMergeFailure(wt); }
25
+ }
26
+
27
+ export function measuredMergeResolution(wt, before, base) {
28
+ let start;
29
+ try { start = JSON.parse(readFileSync(marker(wt), 'utf8')); }
30
+ catch (error) {
31
+ if (error?.code !== 'ENOENT') return false;
32
+ start = { before, base: (() => { try { return git(['rev-parse', base], wt); } catch { return null; } })(), conflict: true };
33
+ }
34
+ if (!sha(start?.before) || !sha(start?.base) || typeof start.conflict !== 'boolean') return false;
35
+ try {
36
+ const tip = git(['rev-parse', 'HEAD'], wt);
37
+ if (git(['status', '--porcelain', '--untracked-files=no'], wt)) return false;
38
+ if (gitReportedConflict(null, wt)) return false;
39
+ git(['merge-base', '--is-ancestor', start.before, tip], wt);
40
+ git(['cat-file', '-e', `${start.base}^{commit}`], wt);
41
+ // Base may have advanced since landing; ancestry still observes that work
42
+ // on base without inventing another commit or trusting an absent marker.
43
+ if (isAncestor(tip, base, wt)) return true;
44
+ if (start.conflict && tip === start.before && tip !== start.base) return false;
45
+ git(['merge-base', '--is-ancestor', start.base, tip], wt);
46
+ return true;
47
+ } catch { return false; }
48
+ }
@@ -1,3 +1,4 @@
1
+ import { parseTestFindings } from './testFindings.mjs';
1
2
  /**
2
3
  * READING A MODEL'S ANSWER, in the agent lane's shapes: a planner's
3
4
  * PROPOSAL, an agent's own TURN RESULT and the AI pre-review's TRIAGE. The
@@ -48,8 +49,12 @@ function extract(raw) {
48
49
 
49
50
  export function parseProposal(text, scrub = envScrub) {
50
51
  const raw = String(text ?? '');
51
- const parsed = extract(raw);
52
- if (!parsed) return null;
52
+ const found = extract(raw);
53
+ if (!found) return null;
54
+ // A model says "none" with null as often as with []: both are an absent key,
55
+ // never a malformed list that throws the whole plan away.
56
+ const parsed = { ...found };
57
+ for (const key of ['dependencies', 'collisions', 'evidence', 'repos']) if (parsed[key] === null) delete parsed[key];
53
58
 
54
59
  const agents = [];
55
60
  for (const [i, g] of parsed.agents.slice(0, MAX_AGENTS).entries()) {
@@ -260,6 +265,13 @@ export function parseTurnResult(text, scrub = envScrub) {
260
265
  ...(typeof v.mergeOrderReason === 'string' ? { mergeOrderReason: clean(v.mergeOrderReason, 1000) } : {}),
261
266
  ...(Array.isArray(v.mergeOrders) ? { mergeOrders: v.mergeOrders.slice(0, 60) } : {}),
262
267
  answer: typeof v.summary === 'string' ? scrub(v.summary) : '',
268
+ ...(typeof v.verification === 'string' && v.verification.trim()
269
+ ? { verification: scrub(v.verification.trim()) } : {}),
270
+ ...(typeof v.handover === 'string' && v.handover.trim()
271
+ ? { handover: scrub(v.handover.trim()) } : {}),
272
+ ...(typeof v.automation === 'string' && v.automation.trim()
273
+ ? { automation: scrub(v.automation.trim()) } : {}),
274
+ ...(parseTestFindings(v.testFindings, scrub) ? { testFindings: parseTestFindings(v.testFindings, scrub) } : {}),
263
275
  ...progressOf(v, scrub),
264
276
  raised: Array.isArray(v.raised)
265
277
  ? v.raised
@@ -41,31 +41,34 @@ export const AGENT_TASK_TYPES = Object.freeze({
41
41
  label: 'Bug fix',
42
42
  kind: 'code',
43
43
  contract:
44
- 'reproduce it first, with a failing test where the repository has tests, then make that test pass.',
44
+ 'reproduce it first, with a failing test where the repository has tests, then make that test pass; ' +
45
+ 'retain reproduction and before/after evidence in verification, including unverified limits.',
45
46
  }),
46
47
  tests: Object.freeze({
47
48
  label: 'Tests',
48
49
  kind: 'code',
49
50
  contract:
50
51
  'add tests for code that already exists and change no production code; if a test exposes a bug, ' +
51
- 'say so in your summary instead of fixing it.',
52
+ 'report reproducible testFindings for human selection instead of fixing it.',
52
53
  }),
53
54
  refactor: Object.freeze({
54
55
  label: 'Refactor',
55
56
  kind: 'code',
56
- contract: 'behaviour must not change; if an existing test would have to change, stop and ask.',
57
+ contract: 'behaviour must not change; if an existing test would have to change, stop and ask; ' +
58
+ 'retain behaviour invariants and evidence in verification, including unrun checks.',
57
59
  }),
58
60
  infra: Object.freeze({
59
61
  label: 'Infra & deploy',
60
62
  kind: 'code',
61
- contract: 'change the configuration in the repository; never run a deploy or touch a live environment.',
63
+ contract: 'change the configuration in the repository; never run a deploy or touch a live environment; ' +
64
+ 'include rollout, rollback, required inputs and verification in handover for a human to execute.',
62
65
  }),
63
66
  automation: Object.freeze({
64
67
  label: 'Automation',
65
68
  kind: 'code',
66
69
  contract:
67
- "add the scheduled job to the repository (a CI schedule, or a script and its schedule) so it runs " +
68
- "where the repository's CI runs; do not run it.",
70
+ 'add the job and its schedule to the repository; retain schedule/timezone, trigger, runner, ' +
71
+ 'side effects, retries and notifications in automation; do not run it.',
69
72
  }),
70
73
  review: Object.freeze({
71
74
  label: 'Code review',
@@ -82,7 +85,8 @@ export const AGENT_TASK_TYPES = Object.freeze({
82
85
  prototype: Object.freeze({
83
86
  label: 'Prototype',
84
87
  kind: 'design',
85
- contract: 'make every control in the flow work, with realistic sample data, in the one page.',
88
+ contract: 'make every control in the flow work, with realistic sample data, in the one page; ' +
89
+ 'identify sample data, simulated actions and mocked integrations; never perform real external side effects.',
86
90
  }),
87
91
  slides: Object.freeze({
88
92
  label: 'Slides',
@@ -328,6 +328,14 @@ WHAT TO DO:
328
328
  no notes, no scripts, no discarded variants. Keep each file under
329
329
  ${IMAGE_CAP_WORDS}; if one is larger, save it as WebP or at a smaller size.
330
330
 
331
+ For a selected-image revision, read the exact retained input and use the
332
+ native image EDIT capability with those bytes/reference. Preserve the requested
333
+ size, crop/transparency and style, and explicit replacement/retention choices.
334
+ Keep retained alternatives under their existing names; a new output alone
335
+ discards nothing. Never regenerate a similar picture, switch to a newer file,
336
+ or stage another output kind. If native editing is unavailable or fails,
337
+ relay its actual limitation/error and stop for a decision.
338
+
331
339
  ${NO_WINDOW} Look at a picture through your own tools,
332
340
  never an image viewer.
333
341
 
@@ -180,3 +180,21 @@ export function iterationInstructions(stored) {
180
180
  `Write a fresh declaration containing only this turn's changes. Keep the declaration out of git. ` +
181
181
  `Do not claim remote deletion succeeded yourself: the daemon reports any cleanup failure.`;
182
182
  }
183
+
184
+ /** Retention is bound to the measured stored version, never just its name.
185
+ * Every unselected file is protected; only explicit selected-version discards
186
+ * may retire. An older selection cannot replace a newer same-name image. */
187
+ export function imageRevisionProtection(stored, policy) {
188
+ if (!policy) return new Set();
189
+ if (!Array.isArray(stored)) throw new Error('The image revision inventory is unavailable; retained alternatives were not changed.');
190
+ const allowed = [...(policy.replaceSource ? [policy.source] : []), ...policy.discard];
191
+ return new Set(stored.filter(row => !allowed.some(p => p.id === row.id && p.sha256 === row.sha256)).map(row => row.name));
192
+ }
193
+ export function imageRevisionInstructions(policy, inputs) {
194
+ if (!policy) return '';
195
+ const file = inputs?.find(f => (f.artifactId === policy.source.id || f.library?.id === policy.source.id) && f.sha256 === policy.source.sha256);
196
+ if (!file) throw new Error('The selected image revision has no exact retained input.');
197
+ return `SELECTED IMAGE EDIT INPUT: .flowviant/inputs/${file.contextId}/${file.name} (SHA-256 ${file.sha256}). Read this exact file before editing with your native image edit tool. Never choose a newest image or generate a stand-in. If this CLI/tool cannot edit these bytes, stop and relay the actual error for a decision. Size, crop, transparency, style and retention choices are in the retained context. ` +
198
+ (policy.replaceSource ? 'The person chose replacement of this selected version only; declare its old-to-new relationship after writing the replacement. ' : 'Keep the original image and use a distinct new output filename. ') +
199
+ `Only these explicit alternative discards are authorized: ${JSON.stringify(policy.discard)}. Every other stored file is retained. Do not delete or overwrite retained alternatives. A new image alone discards nothing.`;
200
+ }
@@ -40,7 +40,7 @@
40
40
  import { artifactStoredName } from './artifacts.mjs';
41
41
  import { fleetHeaders } from './fleetPost.mjs';
42
42
  import {
43
- iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
43
+ imageRevisionProtection, iterationSignature, iterationChanges, deletedArtifacts, localArtifact, discardLocalArtifact, iterationInstructions,
44
44
  presentArtifacts, readSeenArtifacts, writeSeenArtifacts, seenArtifacts, serverBornAt,
45
45
  } from './artifactIteration.mjs';
46
46
  import { fleetEndpoint, isConsideredRefusal } from './fleetWire.mjs';
@@ -229,8 +229,17 @@ export function createArtifactReporter({
229
229
  const report = async ({ placeDir, before, sessionId, agentId, turnId, idle = false, requireStored = false, includeNames = null }) => {
230
230
  if (!placeDir || (!sessionId && !agentId)) return 0;
231
231
  const changes = iterationChanges(placeDir, before);
232
+ const protectedNames = imageRevisionProtection(before?.stored, before?.imageRevision);
233
+ // Forget ownership of absent retained copies, so a later idle sweep cannot
234
+ // reinterpret this revision's protected absence as an implicit discard.
235
+ if (before?.imageRevision) {
236
+ const present = presentArtifacts(placeDir) ?? new Set();
237
+ writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId), [...(before.seen ?? [])].filter(name => !protectedNames.has(name) || present.has(name)));
238
+ before.seen = new Set([...(before.seen ?? [])].filter(name => !protectedNames.has(name) || present.has(name)));
239
+ for (const name of [...changes.remove, ...Object.keys(changes.replace)]) if (protectedNames.has(name)) throw new Error(`Image revision retained ${name}; its discard or replacement was not authorized.`);
240
+ }
232
241
  if (!before?.stored && (changes.remove.length || Object.keys(changes.replace).length)) throw new Error('Stored artifact inventory could not be measured; removal was not attempted.');
233
- const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before) : [])]);
242
+ const removing = new Set([...changes.remove, ...Object.keys(changes.replace), ...(before?.stored ? deletedArtifacts(placeDir, before).filter(name => !protectedNames.has(name)) : [])]);
234
243
  const stored = new Map((before?.stored ?? []).map((a) => [a.name, a]));
235
244
  for (const name of [...changes.remove, ...Object.keys(changes.replace)]) {
236
245
  if (!stored.has(name)) throw new Error(`No stored artifact named ${name} belongs to this conversation.`);
@@ -253,6 +262,10 @@ export function createArtifactReporter({
253
262
  if (requireStored) failures.push(`artifact ${entry.name} could not be read; it was not delivered`);
254
263
  continue;
255
264
  }
265
+ if (protectedNames.has(body.fields.name)) {
266
+ if (body.fields.sha256 !== stored.get(body.fields.name)?.sha256) failures.push(`Image revision retained ${body.fields.name}; use a different output filename.`);
267
+ continue;
268
+ }
256
269
  if (body.withheld) {
257
270
  log(`artifact ${entry.name}: not uploaded — it contains the value of ${body.withheld} from this machine's environment`);
258
271
  if (stored.has(body.fields.name)) failures.push(`artifact ${body.fields.name} replacement was withheld; its predecessor was retained`);
@@ -312,7 +325,7 @@ export function createArtifactReporter({
312
325
  const owned = new Set([...before.stored.map((a) => a.name), ...present, ...delivered.keys()]);
313
326
  writeSeenArtifacts(seenPath(placeDir, sessionId ?? agentId),
314
327
  [...(before.seen ?? []), ...present, ...delivered.keys()]
315
- .filter((name) => owned.has(name) && !removed.has(name)));
328
+ .filter((name) => owned.has(name) && !removed.has(name) && (!protectedNames.has(name) || present.has(name))));
316
329
  }
317
330
  if (failures.length) throw new Error(failures.join('; '));
318
331
  return n;
@@ -478,6 +478,27 @@ export function researchPerm(knowledgeDir, projectMcp = []) {
478
478
  /** The list with no library — what a project that keeps none runs under. */
479
479
  export const RESEARCH_PERM = researchPerm(null);
480
480
 
481
+ /**
482
+ * AN ORCHESTRATED RESEARCH CARD'S LIST (2026-10-19): the `Agent` its fence
483
+ * denies is allowed instead, so the gate's children can start. MEASURED on
484
+ * Claude Code 2.1.303 (offline): a research child inherits the parent's rules
485
+ * — a Write to `src/` and a `touch` were refused, `/etc/hostname` and `.env`
486
+ * unreadable, its PreToolUse hooks carried `agent_id` — but its Write under
487
+ * `.flowviant/artifacts/` landed, so the gate refuses a child's file writes
488
+ * (orchestrator.mjs, nativeAgentTools.mjs `write`). Every other entry stays.
489
+ */
490
+ export function withNativeChildren(perm) {
491
+ const out = [];
492
+ let denying = false;
493
+ for (const arg of perm) {
494
+ if (arg.startsWith('--')) denying = arg === '--disallowedTools';
495
+ if (denying && arg === 'Agent') continue;
496
+ out.push(arg);
497
+ if (arg === '--allowedTools') out.push('Agent');
498
+ }
499
+ return out;
500
+ }
501
+
481
502
  /**
482
503
  * CLAUDE'S PERMISSION LIST FOR A TURN PROFILE, by the profile's name — one
483
504
  * entry per profile Claude declares, plus plan-mode (runtimeClaude.mjs
@@ -100,7 +100,7 @@ export function taskPlanChanged(tasks, result) {
100
100
  }
101
101
 
102
102
  export function handleStreamLine(line, options) {
103
- const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks } = options;
103
+ const { cwd, emit, onActivity, onToolEvent, appendText, answerFromResult, onInit, onUsage, onRateLimit, onModel, subagents, tasks, onDetail } = options;
104
104
  let ev;
105
105
  try {
106
106
  ev = JSON.parse(line);
@@ -118,9 +118,11 @@ export function handleStreamLine(line, options) {
118
118
  // A sidechain with no identity must never be attributed to the parent.
119
119
  if (ev.isSidechain && !agentId) return;
120
120
  subagents?.observe(ev);
121
+ if (ev.type === 'result' && !ev.is_error && typeof ev.result === 'string')
122
+ onDetail?.(ev.result, { source: 'answer', ...(agentId ? { agentId } : {}) });
121
123
  const push = (a) => {
122
124
  if (!a || !a.label) return;
123
- emit(a.label + '\n');
125
+ emit(a.kind === 'error' ? a.full ?? a.label : a.label + '\n', { ...a, ...(agentId ? { agentId } : {}) });
124
126
  onActivity?.({ ...a, ...(agentId ? { agentId } : {}) });
125
127
  };
126
128
  if (ev.type === 'stream_event') {
@@ -198,6 +200,7 @@ export function handleStreamLine(line, options) {
198
200
  id: b.tool_use_id, result: true, isError: b.is_error === true,
199
201
  ...(agentId ? { agentId } : {}),
200
202
  output: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.slice(0, 8).filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
203
+ detail: typeof b.content === 'string' ? b.content : Array.isArray(b.content) ? b.content.filter(x => x?.type === 'text' && typeof x.text === 'string').map(x => x.text).join('\n') : '',
201
204
  exitCode: ev.tool_use_result?.exit_code ?? ev.tool_use_result?.exitCode,
202
205
  });
203
206
  }
@@ -237,6 +240,13 @@ export function handleStreamLine(line, options) {
237
240
  tools: Array.isArray(ev.tools) ? ev.tools : undefined,
238
241
  });
239
242
  }
243
+ } else if (ev.type === 'result' && agentId && ev.is_error) {
244
+ // A child's failure belongs in its record, never in the parent's answer
245
+ // or token count. The stream's observed spawn-call id remains its owner.
246
+ const listed = Array.isArray(ev.errors) ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : '';
247
+ const msg = listed || ev.result || ev.error?.message || ev.error || ev.subtype;
248
+ const full = typeof msg === 'string' ? msg : JSON.stringify(msg);
249
+ if (full) push({ kind: 'error', label: oneLine(full), full });
240
250
  } else if (ev.type === 'result' && !agentId) {
241
251
  /**
242
252
  * WHAT THE TURN SPENT, BEFORE ANYTHING ELSE IN THIS BRANCH.
@@ -251,7 +261,10 @@ export function handleStreamLine(line, options) {
251
261
  const usage = usageFromResult(ev);
252
262
  if (usage) onUsage?.(usage);
253
263
  // The final assistant text (carries WIKI_DONE / REGROUND_DONE).
254
- if (typeof ev.result === 'string') appendText(ev.result + '\n');
264
+ if (typeof ev.result === 'string') {
265
+ appendText(ev.result + '\n');
266
+ if (ev.is_error) push({ kind: 'error', label: oneLine(ev.result), full: ev.result });
267
+ }
255
268
  else if (ev.is_error || ev.subtype) {
256
269
  // A result that carries no text is a FAILED turn (a limit, a refused
257
270
  // permission, an aborted run). Under `answerFromResult` this is the only
@@ -268,7 +281,15 @@ export function handleStreamLine(line, options) {
268
281
  ? ev.errors.filter((e) => typeof e === 'string' && e.trim()).join('; ')
269
282
  : '';
270
283
  const msg = listed || ev.error?.message || ev.error || ev.subtype;
271
- appendText(`${typeof msg === 'string' ? msg : JSON.stringify(msg)}\n`);
284
+ const text = typeof msg === 'string' ? msg : JSON.stringify(msg);
285
+ appendText(`${text}\n`);
286
+ if (ev.is_error || listed || ev.error || String(ev.subtype).startsWith('error'))
287
+ push({ kind: 'error', label: oneLine(text), full: listed ? ev.errors.filter(e => typeof e === 'string' && e.trim()).join('\n') : text });
288
+ }
289
+ } else if (ev.type === 'error') {
290
+ const text = ev.error?.message ?? ev.message;
291
+ if (typeof text === 'string' && text) {
292
+ push({ kind: 'error', label: oneLine(text), full: text });
272
293
  }
273
294
  } else if (ev.type === 'rate_limit_event') {
274
295
  /**