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.
- package/README.md +80 -1
- package/bin/cli.mjs +7 -1
- package/bin/lib/activityFeed.mjs +69 -0
- package/bin/lib/agentMergeReports.mjs +46 -0
- package/bin/lib/agentMergeResolution.mjs +48 -0
- package/bin/lib/agentPlan.mjs +14 -2
- package/bin/lib/agentTaskTypes.mjs +11 -7
- package/bin/lib/artifactContracts.mjs +8 -0
- package/bin/lib/artifactIteration.mjs +18 -0
- package/bin/lib/artifactReporter.mjs +16 -3
- package/bin/lib/claudePosture.mjs +21 -0
- package/bin/lib/claudeStream.mjs +25 -4
- package/bin/lib/codexResearch.mjs +35 -5
- package/bin/lib/commandSpecs.mjs +8 -5
- package/bin/lib/daemonJournal.mjs +102 -0
- package/bin/lib/daemonLogging.mjs +22 -18
- package/bin/lib/fleet.mjs +16 -6
- package/bin/lib/fleetReports.mjs +12 -5
- package/bin/lib/hooks/orchestrator.mjs +3 -1
- package/bin/lib/logReplay.mjs +133 -0
- package/bin/lib/nativeAgentTools.mjs +20 -3
- package/bin/lib/observedLines.mjs +63 -0
- package/bin/lib/orchestrator.mjs +40 -7
- package/bin/lib/orchestratorNative.mjs +133 -3
- package/bin/lib/orchestratorPrompt.mjs +51 -4
- package/bin/lib/postureTools.mjs +14 -6
- package/bin/lib/printable.mjs +1 -1
- package/bin/lib/prompts.mjs +122 -7
- package/bin/lib/repositoryDelivery.mjs +1 -1
- package/bin/lib/repositoryMerge.mjs +1 -1
- package/bin/lib/researchHandoff.mjs +144 -0
- package/bin/lib/researchHandoffText.mjs +3 -0
- package/bin/lib/researchKnowledge.mjs +60 -0
- package/bin/lib/researchRestoration.mjs +38 -0
- package/bin/lib/resultInputs.mjs +83 -0
- package/bin/lib/runTurn.mjs +76 -43
- package/bin/lib/runtimeClaude.mjs +9 -4
- package/bin/lib/runtimeCodex.mjs +51 -22
- package/bin/lib/runtimeEvents.mjs +17 -7
- package/bin/lib/testFindings.mjs +60 -0
- package/bin/lib/testFindingsContract.mjs +2 -0
- package/bin/lib/turnDiagnostics.mjs +2 -1
- package/bin/lib/turnOutput.mjs +63 -0
- package/bin/lib/turnRecord.mjs +11 -0
- package/bin/lib/turnSubagents.mjs +13 -3
- package/bin/lib/ui.mjs +2 -2
- package/bin/lib/uplinkScrub.mjs +49 -0
- package/bin/lib/views.mjs +49 -58
- package/bin/lib/wikiRunner.mjs +2 -2
- package/bin/lib/work.mjs +2 -2
- package/bin/lib/workActivity.mjs +65 -0
- package/bin/lib/workAgentBriefRunner.mjs +2 -2
- package/bin/lib/workAgentMerges.mjs +57 -52
- package/bin/lib/workAgentPlans.mjs +76 -27
- package/bin/lib/workAgentPrecheck.mjs +1 -1
- package/bin/lib/workAgentTurnExecution.mjs +55 -9
- package/bin/lib/workAgentTurnOutcome.mjs +12 -3
- package/bin/lib/workAgentTurnReports.mjs +17 -3
- package/bin/lib/workAgentTurnShape.mjs +8 -5
- package/bin/lib/workAgentTurns.mjs +8 -6
- package/bin/lib/workIntake.mjs +8 -2
- package/bin/lib/workMergeBatch.mjs +7 -1
- package/bin/lib/workPlanRecovery.mjs +50 -0
- package/bin/lib/workSessionTurns.mjs +7 -2
- 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]` |
|
|
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
|
+
}
|
package/bin/lib/agentPlan.mjs
CHANGED
|
@@ -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
|
|
52
|
-
if (!
|
|
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
|
-
'
|
|
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
|
-
|
|
68
|
-
|
|
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
|
package/bin/lib/claudeStream.mjs
CHANGED
|
@@ -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')
|
|
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
|
-
|
|
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
|
/**
|