aiki-cli 0.3.2 → 0.3.3
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/CHANGELOG.md +68 -0
- package/README.md +64 -4
- package/dist/bench/idea-v3-bench.js +104 -5
- package/dist/bench/idea-v3-rating.js +158 -3
- package/dist/cli/bench.js +5 -5
- package/dist/cli/index.js +12 -2
- package/dist/cli/resume.js +20 -0
- package/dist/cli/run.js +75 -4
- package/dist/cli/serve.js +48 -0
- package/dist/config/config.js +7 -2
- package/dist/council/view.js +13 -4
- package/dist/orchestration/auto-profile.js +97 -0
- package/dist/orchestration/claim-groups.js +56 -0
- package/dist/orchestration/context.js +66 -3
- package/dist/orchestration/decision-dossier.js +42 -10
- package/dist/orchestration/decision-graph.js +2 -2
- package/dist/orchestration/engine.js +8 -4
- package/dist/orchestration/evidence-origin.js +17 -0
- package/dist/orchestration/jsonStage.js +47 -5
- package/dist/orchestration/modes.js +4 -2
- package/dist/orchestration/preflight.js +19 -0
- package/dist/orchestration/quick-analysis.js +8 -3
- package/dist/orchestration/sanitize-paths.js +10 -0
- package/dist/orchestration/stages/s10-render.js +31 -7
- package/dist/orchestration/stages/s4-analyze.js +7 -1
- package/dist/orchestration/stages/s4b-challenge.js +97 -0
- package/dist/orchestration/stages/s5-drift.js +13 -3
- package/dist/orchestration/stages/s8-verify.js +44 -7
- package/dist/orchestration/stages/s9-judge.js +20 -5
- package/dist/orchestration/stages/s9b-plan.js +44 -15
- package/dist/orchestration/url-sources.js +21 -0
- package/dist/providers/adapter-core.js +1 -1
- package/dist/providers/claude.js +18 -0
- package/dist/schemas/index.js +45 -0
- package/dist/serve/flight-deck.js +830 -0
- package/dist/serve/followup.js +50 -0
- package/dist/serve/frames.js +168 -0
- package/dist/serve/gates.js +72 -0
- package/dist/serve/projections.js +283 -0
- package/dist/serve/server.js +219 -0
- package/dist/serve/threads.js +145 -0
- package/dist/serve-ui/Five.png +0 -0
- package/dist/serve-ui/app.js +820 -0
- package/dist/serve-ui/index.html +171 -0
- package/dist/serve-ui/workspace.css +662 -0
- package/dist/storage/runs.js +2 -1
- package/dist/workflows/idea-refinement.js +94 -16
- package/package.json +2 -2
|
@@ -0,0 +1,830 @@
|
|
|
1
|
+
// The only `aiki serve` seam that reaches engine, storage, config, and attachment guards.
|
|
2
|
+
// Browser-facing values leave through the strict projections in this directory.
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { runDoctorChecks } from '../cli/doctor.js';
|
|
7
|
+
import { estimateRun } from '../cli/run.js';
|
|
8
|
+
import { readSmokeCache, isFresh, entryToSmoke } from '../config/smoke-cache.js';
|
|
9
|
+
import { loadConfig, loadLayeredConfig } from '../config/config.js';
|
|
10
|
+
import { run as runEngine } from '../orchestration/engine.js';
|
|
11
|
+
import { buildEvidencePack, EvidencePack } from '../orchestration/evidence-pack.js';
|
|
12
|
+
import { buildReaderProjection, sanitizeReaderText } from '../orchestration/decision-dossier.js';
|
|
13
|
+
import { sanitizeLocalPaths } from '../orchestration/sanitize-paths.js';
|
|
14
|
+
import { defaultBudgetFor, defaultDeadlineFor } from '../orchestration/modes.js';
|
|
15
|
+
import { extractPublicUrls, snapshotUrlSources, validatePublicUrl } from '../orchestration/url-sources.js';
|
|
16
|
+
import { makeRunId, StageError } from '../orchestration/context.js';
|
|
17
|
+
import { IDEA_STAGES } from '../workflows/idea-refinement.js';
|
|
18
|
+
import { homeAikiRoot } from '../storage/paths.js';
|
|
19
|
+
import { buildReplayCache } from '../storage/replay.js';
|
|
20
|
+
import { readJsonArtifact, runDir } from '../storage/runs-read.js';
|
|
21
|
+
import { DISPLAY_NAME } from '../providers/types.js';
|
|
22
|
+
import { RunMeta, UrlSourceSet } from '../schemas/index.js';
|
|
23
|
+
import { WorkspaceSnapshot, SettingsView, SettingsPatch, ThreadDetail, SendInput, SendOutcome, SafeReportProjection, ReceiptView, providerStatusView, quorumView, orderProviders, threadTitle, } from './projections.js';
|
|
24
|
+
import { appendThread, appendTurn, legacyThreads, legacyThreadDetail, readThreads, readTurns } from './threads.js';
|
|
25
|
+
import { allowanceKey, GateTable } from './gates.js';
|
|
26
|
+
import { FrameBus } from './frames.js';
|
|
27
|
+
import { runFollowup } from './followup.js';
|
|
28
|
+
export class DeckError extends Error {
|
|
29
|
+
status;
|
|
30
|
+
constructor(status, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.status = status;
|
|
33
|
+
this.name = 'DeckError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export class FlightDeck {
|
|
37
|
+
opts;
|
|
38
|
+
gates = new GateTable();
|
|
39
|
+
runs = new Map();
|
|
40
|
+
reports = new Map();
|
|
41
|
+
workers = new Set();
|
|
42
|
+
activeRunId;
|
|
43
|
+
constructor(opts) {
|
|
44
|
+
this.opts = opts;
|
|
45
|
+
}
|
|
46
|
+
async bootstrap() {
|
|
47
|
+
const [cfg, local] = await Promise.all([loadLayeredConfig(this.opts.runsRoot), loadConfig(this.opts.runsRoot)]);
|
|
48
|
+
const providers = await this.providerViews(false, cfg);
|
|
49
|
+
return WorkspaceSnapshot.parse({
|
|
50
|
+
version: this.opts.version,
|
|
51
|
+
providers,
|
|
52
|
+
quorum: quorumView(providers),
|
|
53
|
+
threads: await this.threadList(),
|
|
54
|
+
settings: this.settingsView(cfg, local),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async checkProviders(fresh) {
|
|
58
|
+
const cfg = await loadLayeredConfig(this.opts.runsRoot);
|
|
59
|
+
const { rows } = await runDoctorChecks({ smoke: true, fresh });
|
|
60
|
+
return orderProviders(rows.map((row) => providerStatusView(row, modelFor(row.det.id, cfg))));
|
|
61
|
+
}
|
|
62
|
+
async settings() {
|
|
63
|
+
const [cfg, local] = await Promise.all([loadLayeredConfig(this.opts.runsRoot), loadConfig(this.opts.runsRoot)]);
|
|
64
|
+
return this.settingsView(cfg, local);
|
|
65
|
+
}
|
|
66
|
+
async updateSettings(raw) {
|
|
67
|
+
const patch = SettingsPatch.parse(raw);
|
|
68
|
+
const root = this.opts.runsRoot === homeAikiRoot() ? homeAikiRoot() : this.opts.runsRoot;
|
|
69
|
+
await loadLayeredConfig(this.opts.runsRoot); // validate every active layer before touching either file
|
|
70
|
+
const next = { ...(await loadConfig(root)) };
|
|
71
|
+
if (patch.models) {
|
|
72
|
+
const models = { ...next.models };
|
|
73
|
+
for (const id of ['claude', 'codex', 'agy']) {
|
|
74
|
+
const value = patch.models[id];
|
|
75
|
+
if (value === undefined)
|
|
76
|
+
continue;
|
|
77
|
+
if (value === null)
|
|
78
|
+
delete models[id];
|
|
79
|
+
else
|
|
80
|
+
models[id] = value;
|
|
81
|
+
}
|
|
82
|
+
if (Object.keys(models).length)
|
|
83
|
+
next.models = models;
|
|
84
|
+
else
|
|
85
|
+
delete next.models;
|
|
86
|
+
}
|
|
87
|
+
if (patch.roles) {
|
|
88
|
+
const roles = { ...next.roles };
|
|
89
|
+
for (const role of ['analyst', 'judge', 'verifier', 'responder']) {
|
|
90
|
+
const value = patch.roles[role];
|
|
91
|
+
if (value === undefined)
|
|
92
|
+
continue;
|
|
93
|
+
if (value === null)
|
|
94
|
+
delete roles[role];
|
|
95
|
+
else
|
|
96
|
+
roles[role] = value;
|
|
97
|
+
}
|
|
98
|
+
if (patch.roles.s4 !== undefined) {
|
|
99
|
+
if (patch.roles.s4 === null)
|
|
100
|
+
delete roles.s4;
|
|
101
|
+
else
|
|
102
|
+
roles.s4 = patch.roles.s4;
|
|
103
|
+
}
|
|
104
|
+
if (Object.keys(roles).length)
|
|
105
|
+
next.roles = roles;
|
|
106
|
+
else
|
|
107
|
+
delete next.roles;
|
|
108
|
+
}
|
|
109
|
+
await mkdir(root, { recursive: true });
|
|
110
|
+
const path = join(root, 'config.json');
|
|
111
|
+
const tmp = join(root, `config.json.${randomUUID()}.tmp`);
|
|
112
|
+
await writeFile(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
113
|
+
await rename(tmp, path);
|
|
114
|
+
return this.settings();
|
|
115
|
+
}
|
|
116
|
+
async thread(id) {
|
|
117
|
+
const entry = (await readThreads(this.opts.runsRoot)).find((item) => item.id === id);
|
|
118
|
+
if (!entry)
|
|
119
|
+
return legacyThreadDetail(id);
|
|
120
|
+
const turns = [];
|
|
121
|
+
for (const turn of await readTurns(this.opts.runsRoot, id)) {
|
|
122
|
+
if (turn.kind === 'user_message') {
|
|
123
|
+
turns.push({
|
|
124
|
+
kind: 'user_message',
|
|
125
|
+
text: sanitizeLocalPaths(turn.text),
|
|
126
|
+
attachments: turn.attachments.map(attachmentLabel),
|
|
127
|
+
mode: turn.mode,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
else if (turn.kind === 'run_ref') {
|
|
131
|
+
try {
|
|
132
|
+
turns.push({ kind: 'report', report: await this.report(turn.run_id) });
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
turns.push({ kind: 'note', text: 'This council run has not produced a final answer yet.' });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (turn.kind === 'followup') {
|
|
139
|
+
const provider = turn.provider;
|
|
140
|
+
const providerName = DISPLAY_NAME[provider] ?? turn.provider;
|
|
141
|
+
turns.push({ kind: 'user_message', text: sanitizeLocalPaths(turn.question), attachments: [], mode: 'followup' });
|
|
142
|
+
turns.push({
|
|
143
|
+
kind: 'followup', question: sanitizeLocalPaths(turn.question), answer: sanitizeLocalPaths(turn.answer),
|
|
144
|
+
provider, providerName, label: `follow-up · ${providerName} · 1 call · no council`, callMs: turn.call_ms,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else if (turn.kind === 'error') {
|
|
148
|
+
turns.push({ kind: 'note', text: sanitizeLocalPaths(turn.message) });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
let resumeRunId = null;
|
|
152
|
+
const latestRunId = entry.run_ids.at(-1);
|
|
153
|
+
if ((entry.status === 'failed' || entry.status === 'cancelled') && latestRunId) {
|
|
154
|
+
const meta = RunMeta.safeParse(await readJsonArtifact(runDir(latestRunId, this.opts.runsRoot), 'meta.json'));
|
|
155
|
+
if (meta.success && meta.data.workflow === 'idea-refinement' && (await buildReplayCache(runDir(latestRunId, this.opts.runsRoot))).size) {
|
|
156
|
+
resumeRunId = latestRunId;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return ThreadDetail.parse({ id: entry.id, title: entry.title, legacy: false, resumeRunId, turns });
|
|
160
|
+
}
|
|
161
|
+
/** Start a decision worker and return immediately; gates and progress arrive over frames(). */
|
|
162
|
+
async send(raw) {
|
|
163
|
+
const input = SendInput.parse(raw);
|
|
164
|
+
if (this.activeRunId && !this.runs.get(this.activeRunId)?.bus.done) {
|
|
165
|
+
throw new DeckError(409, 'council already in session');
|
|
166
|
+
}
|
|
167
|
+
const cfg = await loadLayeredConfig(this.opts.runsRoot);
|
|
168
|
+
const existing = input.threadId
|
|
169
|
+
? (await readThreads(this.opts.runsRoot)).find((item) => item.id === input.threadId)
|
|
170
|
+
: undefined;
|
|
171
|
+
if (input.threadId && !existing)
|
|
172
|
+
throw new DeckError(404, 'no such thread');
|
|
173
|
+
return input.kind === 'followup'
|
|
174
|
+
? this.startFollowup(input, cfg, existing)
|
|
175
|
+
: this.startDecision(input, cfg, existing);
|
|
176
|
+
}
|
|
177
|
+
async startDecision(input, cfg, existing, resume) {
|
|
178
|
+
const now = (this.opts.now?.() ?? new Date()).toISOString();
|
|
179
|
+
const threadId = existing?.id ?? `thread-${randomUUID()}`;
|
|
180
|
+
const runId = makeRunId('idea-refinement', this.opts.now?.() ?? new Date());
|
|
181
|
+
const budget = cfg.budget ?? defaultBudgetFor('idea-refinement', input.mode);
|
|
182
|
+
const thread = {
|
|
183
|
+
id: threadId,
|
|
184
|
+
title: existing?.title ?? threadTitle(input.text),
|
|
185
|
+
created_at: existing?.created_at ?? now,
|
|
186
|
+
updated_at: now,
|
|
187
|
+
status: 'running',
|
|
188
|
+
run_ids: [...(existing?.run_ids ?? []), runId],
|
|
189
|
+
};
|
|
190
|
+
await appendThread(this.opts.runsRoot, thread);
|
|
191
|
+
if (!resume) {
|
|
192
|
+
await appendTurn(this.opts.runsRoot, threadId, {
|
|
193
|
+
kind: 'user_message', text: input.text,
|
|
194
|
+
attachments: input.attachments.map((item) => item.kind === 'file' ? item.path : item.url),
|
|
195
|
+
mode: input.mode,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
const stages = IDEA_STAGES.map((stage) => ({ id: stage.id, label: stage.label, status: 'pending', seat: null }));
|
|
199
|
+
const active = {
|
|
200
|
+
id: runId, threadId, input, thread, budget, resume,
|
|
201
|
+
bus: new FrameBus(runId, input.mode, stages, budget),
|
|
202
|
+
abort: new AbortController(), startedAt: Date.now(), calls: 0, replays: 0, repairs: 0,
|
|
203
|
+
callMs: {}, callCount: {}, inflight: new Map(),
|
|
204
|
+
};
|
|
205
|
+
this.runs.set(runId, active);
|
|
206
|
+
this.activeRunId = runId;
|
|
207
|
+
if (!resume) {
|
|
208
|
+
active.bus.emit({
|
|
209
|
+
t: 'turn',
|
|
210
|
+
turn: { kind: 'user_message', text: sanitizeLocalPaths(input.text), attachments: input.attachments.map(attachmentLabel), mode: input.mode },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const worker = this.executeDecision(active, cfg).finally(() => {
|
|
214
|
+
this.workers.delete(worker);
|
|
215
|
+
if (this.activeRunId === runId)
|
|
216
|
+
this.activeRunId = undefined;
|
|
217
|
+
});
|
|
218
|
+
active.worker = worker;
|
|
219
|
+
this.workers.add(worker);
|
|
220
|
+
return SendOutcome.parse({ threadId, runId, status: 'gating' });
|
|
221
|
+
}
|
|
222
|
+
async startFollowup(input, cfg, existing) {
|
|
223
|
+
if (!existing)
|
|
224
|
+
throw new DeckError(400, 'Convene a decision first, then ask a follow-up about its answer.');
|
|
225
|
+
if (input.attachments.length)
|
|
226
|
+
throw new DeckError(400, 'Attachments need a new council decision; use Re-convene instead.');
|
|
227
|
+
let report;
|
|
228
|
+
for (const runId of [...existing.run_ids].reverse()) {
|
|
229
|
+
try {
|
|
230
|
+
report = await this.report(runId);
|
|
231
|
+
break;
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
// A partial run has no report; keep looking for the latest completed decision in the thread.
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (!report)
|
|
238
|
+
throw new DeckError(400, 'Convene a decision first, then ask a follow-up about its answer.');
|
|
239
|
+
const now = (this.opts.now?.() ?? new Date()).toISOString();
|
|
240
|
+
const runId = `followup-${randomUUID()}`;
|
|
241
|
+
const thread = { ...existing, updated_at: now, status: 'running' };
|
|
242
|
+
await appendThread(this.opts.runsRoot, thread);
|
|
243
|
+
const active = {
|
|
244
|
+
id: runId, threadId: thread.id, input, thread, budget: 1,
|
|
245
|
+
bus: new FrameBus(runId, 'followup', [], 1),
|
|
246
|
+
abort: new AbortController(), startedAt: Date.now(), calls: 0, replays: 0, repairs: 0,
|
|
247
|
+
callMs: {}, callCount: {}, inflight: new Map(),
|
|
248
|
+
};
|
|
249
|
+
this.runs.set(runId, active);
|
|
250
|
+
this.activeRunId = runId;
|
|
251
|
+
active.bus.emit({
|
|
252
|
+
t: 'turn',
|
|
253
|
+
turn: { kind: 'user_message', text: sanitizeLocalPaths(input.text), attachments: [], mode: 'followup' },
|
|
254
|
+
});
|
|
255
|
+
const worker = this.executeFollowup(active, cfg, report).finally(() => {
|
|
256
|
+
this.workers.delete(worker);
|
|
257
|
+
if (this.activeRunId === runId)
|
|
258
|
+
this.activeRunId = undefined;
|
|
259
|
+
});
|
|
260
|
+
active.worker = worker;
|
|
261
|
+
this.workers.add(worker);
|
|
262
|
+
return SendOutcome.parse({ threadId: thread.id, runId, status: 'gating' });
|
|
263
|
+
}
|
|
264
|
+
/** Reconnect-safe frame source: hello at the requested cursor, then ordered buffered/live frames. */
|
|
265
|
+
async *frames(runId, afterSeq = 0, signal) {
|
|
266
|
+
const active = this.runs.get(runId);
|
|
267
|
+
if (!active)
|
|
268
|
+
throw new DeckError(404, 'no such run');
|
|
269
|
+
const queue = [];
|
|
270
|
+
let wake;
|
|
271
|
+
const notify = () => { const fn = wake; wake = undefined; fn?.(); };
|
|
272
|
+
const off = active.bus.listen((frame) => { queue.push(frame); notify(); });
|
|
273
|
+
const onAbort = () => notify();
|
|
274
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
275
|
+
try {
|
|
276
|
+
let sent = Math.min(Math.max(0, afterSeq), active.bus.latestSeq);
|
|
277
|
+
yield active.bus.helloFrame(sent);
|
|
278
|
+
queue.unshift(...active.bus.replaySince(sent));
|
|
279
|
+
while (true) {
|
|
280
|
+
let frame;
|
|
281
|
+
while ((frame = queue.shift())) {
|
|
282
|
+
if (frame.seq <= sent)
|
|
283
|
+
continue;
|
|
284
|
+
sent = frame.seq;
|
|
285
|
+
yield frame;
|
|
286
|
+
}
|
|
287
|
+
if (active.bus.done || active.bus.isClosed || signal?.aborted)
|
|
288
|
+
return;
|
|
289
|
+
await new Promise((resolve) => {
|
|
290
|
+
wake = resolve;
|
|
291
|
+
if (queue.length || active.bus.done || active.bus.isClosed || signal?.aborted)
|
|
292
|
+
notify();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
off();
|
|
298
|
+
signal?.removeEventListener('abort', onAbort);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
async act(runId, action) {
|
|
302
|
+
if (action.t === 'resume')
|
|
303
|
+
return this.resume(runId);
|
|
304
|
+
const active = this.runs.get(runId);
|
|
305
|
+
if (!active)
|
|
306
|
+
throw new DeckError(404, 'no such run');
|
|
307
|
+
if (action.t === 'cancel') {
|
|
308
|
+
active.abort.abort();
|
|
309
|
+
this.gates.denyAll();
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const gate = this.gates.get(action.gateId);
|
|
313
|
+
if (!gate)
|
|
314
|
+
throw new DeckError(409, 'gate is no longer pending');
|
|
315
|
+
if (action.t === 'gate' && !gate.scopes?.includes(action.decision))
|
|
316
|
+
throw new DeckError(400, 'that gate does not accept a permission decision');
|
|
317
|
+
if (action.t === 'answer' && gate.scopes)
|
|
318
|
+
throw new DeckError(400, 'that gate expects a permission decision');
|
|
319
|
+
const value = action.t === 'gate' ? action.decision : action.value;
|
|
320
|
+
const summary = action.t === 'gate' ? decisionSummary(action.decision) : 'Answer received';
|
|
321
|
+
// Persist the gate receipt BEFORE releasing the gate: resolving it unblocks the run loop, which appends
|
|
322
|
+
// the follow-up answer turn. If we appended the receipt after resolving, the two appendFile writes race
|
|
323
|
+
// and the receipt can land as the thread's terminal turn instead of the reply. ponytail: order the two
|
|
324
|
+
// writes by persisting first, not by adding a per-thread write lock.
|
|
325
|
+
await appendTurn(this.opts.runsRoot, active.threadId, {
|
|
326
|
+
kind: 'gate_receipt', gate_kind: gate.kind, summary, decision: action.t === 'gate' ? action.decision : 'answered',
|
|
327
|
+
});
|
|
328
|
+
if (!this.gates.resolve(action.gateId, value))
|
|
329
|
+
throw new DeckError(409, 'gate is no longer pending');
|
|
330
|
+
this.log(`⏵ ${summary}`);
|
|
331
|
+
active.bus.emit({ t: 'gate_resolved', gateId: action.gateId, summary });
|
|
332
|
+
}
|
|
333
|
+
async resume(oldRunId) {
|
|
334
|
+
if (this.activeRunId && !this.runs.get(this.activeRunId)?.bus.done) {
|
|
335
|
+
throw new DeckError(409, 'council already in session');
|
|
336
|
+
}
|
|
337
|
+
const thread = (await readThreads(this.opts.runsRoot)).find((entry) => entry.run_ids.includes(oldRunId));
|
|
338
|
+
if (!thread)
|
|
339
|
+
throw new DeckError(404, 'no resumable decision found');
|
|
340
|
+
if (thread.status !== 'failed' && thread.status !== 'cancelled') {
|
|
341
|
+
throw new DeckError(400, 'Only a failed or cancelled council run can be resumed.');
|
|
342
|
+
}
|
|
343
|
+
const oldDir = runDir(oldRunId, this.opts.runsRoot);
|
|
344
|
+
const parsedMeta = RunMeta.safeParse(await readJsonArtifact(oldDir, 'meta.json'));
|
|
345
|
+
if (!parsedMeta.success || parsedMeta.data.workflow !== 'idea-refinement') {
|
|
346
|
+
throw new DeckError(400, 'This run does not have valid decision metadata to resume.');
|
|
347
|
+
}
|
|
348
|
+
let input;
|
|
349
|
+
try {
|
|
350
|
+
input = await readFile(join(oldDir, 'inputs', 'idea.md'), 'utf8');
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new DeckError(400, 'This run does not have its original decision input to resume.');
|
|
354
|
+
}
|
|
355
|
+
const replay = await buildReplayCache(oldDir);
|
|
356
|
+
if (!replay.size)
|
|
357
|
+
throw new DeckError(400, 'No completed calls were cached; convene a fresh decision instead.');
|
|
358
|
+
let evidencePack;
|
|
359
|
+
const savedPack = await readJsonArtifact(oldDir, 'inputs/evidence-pack.json');
|
|
360
|
+
if (savedPack) {
|
|
361
|
+
const parsed = EvidencePack.safeParse(savedPack);
|
|
362
|
+
if (!parsed.success)
|
|
363
|
+
throw new DeckError(400, 'The saved evidence manifest is invalid; resume was refused.');
|
|
364
|
+
evidencePack = parsed.data;
|
|
365
|
+
}
|
|
366
|
+
let urlSources;
|
|
367
|
+
const savedSources = await readJsonArtifact(oldDir, '00a-url-sources.json');
|
|
368
|
+
if (savedSources) {
|
|
369
|
+
const parsed = UrlSourceSet.safeParse(savedSources);
|
|
370
|
+
if (!parsed.success)
|
|
371
|
+
throw new DeckError(400, 'The saved URL snapshot is invalid; resume was refused.');
|
|
372
|
+
urlSources = parsed.data;
|
|
373
|
+
}
|
|
374
|
+
const cfg = await loadLayeredConfig(this.opts.runsRoot);
|
|
375
|
+
const mode = parsedMeta.data.mode === 'quick' ? 'quick' : 'council';
|
|
376
|
+
return this.startDecision({ threadId: thread.id, text: input, mode, kind: 'decision', attachments: [] }, cfg, thread, {
|
|
377
|
+
fromRunId: oldRunId,
|
|
378
|
+
replay,
|
|
379
|
+
evidencePack,
|
|
380
|
+
urlSources,
|
|
381
|
+
allowBlockedSources: urlSources?.sources.some((source) => source.status !== 'FETCHED') ?? false,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
async report(runId) {
|
|
385
|
+
const cached = this.reports.get(runId);
|
|
386
|
+
if (cached)
|
|
387
|
+
return cached;
|
|
388
|
+
const raw = await readJsonArtifact(runDir(runId, this.opts.runsRoot), '10-decision-report.json');
|
|
389
|
+
if (!raw?.dossier || !raw.verdict || !raw.receipt)
|
|
390
|
+
throw new DeckError(409, 'report is not ready');
|
|
391
|
+
const clean = (text) => sanitizeLocalPaths(sanitizeReaderText(text));
|
|
392
|
+
const reader = raw.dossier.readerBrief ? buildReaderProjection(raw) : {
|
|
393
|
+
headline: clean(raw.verdict.summary),
|
|
394
|
+
bottomLine: clean(raw.verdict.primaryReason),
|
|
395
|
+
sections: [{ heading: 'Council reasoning', summary: clean(raw.verdict.summary), bullets: raw.keyFindings.map(clean) }],
|
|
396
|
+
featureBacklog: undefined,
|
|
397
|
+
implementationPlan: undefined,
|
|
398
|
+
caveats: raw.criticalUnknowns.map(clean),
|
|
399
|
+
warnings: raw.verdict.criticalWarning ? [{ message: clean(raw.verdict.criticalWarning) }] : [],
|
|
400
|
+
notices: [], snapshot: undefined, sources: [], nextStep: clean(raw.recommendedActions[0]?.action ?? 'Review the decision before acting.'),
|
|
401
|
+
};
|
|
402
|
+
const features = reader.featureBacklog ? [
|
|
403
|
+
...reader.featureBacklog.must.map((item) => featureView('Must', item)),
|
|
404
|
+
...reader.featureBacklog.should.map((item) => featureView('Should', item)),
|
|
405
|
+
...reader.featureBacklog.later.map((item) => featureView('Later', item)),
|
|
406
|
+
...reader.featureBacklog.wont.map((item) => ({ priority: 'Not now', feature: item.feature, userValue: '', rationale: item.reason, effort: '' })),
|
|
407
|
+
] : [];
|
|
408
|
+
const receipt = ReceiptView.parse({
|
|
409
|
+
mode: raw.mode === 'quick' ? 'Quick' : 'Full Council',
|
|
410
|
+
calls: raw.receipt.calls,
|
|
411
|
+
budget: raw.receipt.budget,
|
|
412
|
+
replays: this.runs.get(runId)?.replays ?? 0,
|
|
413
|
+
durationMs: raw.receipt.modelTimeMs,
|
|
414
|
+
repairs: raw.receipt.categories.repair,
|
|
415
|
+
providers: Object.entries(raw.receipt.byProvider)
|
|
416
|
+
.filter(([id]) => id === 'claude' || id === 'codex' || id === 'agy')
|
|
417
|
+
.map(([id, calls]) => ({ name: DISPLAY_NAME[id], calls })),
|
|
418
|
+
warnings: [...reader.warnings.map((item) => item.message), ...reader.notices.map((item) => item.message)],
|
|
419
|
+
});
|
|
420
|
+
const projected = SafeReportProjection.parse({
|
|
421
|
+
runId,
|
|
422
|
+
verdict: verdictView(raw.verdict.status),
|
|
423
|
+
confidence: { score: Math.round((raw.verdict.confidence ?? 0) * 100), label: raw.verdict.confidenceLabel ?? 'Low' },
|
|
424
|
+
headline: clean(reader.headline),
|
|
425
|
+
bottomLine: clean(reader.bottomLine),
|
|
426
|
+
sections: reader.sections.map((section) => ({ heading: clean(section.heading), summary: clean(section.summary), bullets: section.bullets.map(clean) })),
|
|
427
|
+
warnings: receipt.warnings,
|
|
428
|
+
caveats: reader.caveats.map(clean),
|
|
429
|
+
features,
|
|
430
|
+
milestones: reader.implementationPlan?.milestones.map((item) => ({
|
|
431
|
+
order: item.order, timebox: clean(item.timebox), outcome: clean(item.outcome), tasks: item.tasks.map(clean), doneWhen: clean(item.acceptance_test),
|
|
432
|
+
})) ?? [],
|
|
433
|
+
sources: reader.sources.map((source) => ({ label: clean(source.label), ...(source.url ? { url: source.url } : {}), citedFor: source.citedFor.map(clean) })),
|
|
434
|
+
nextStep: clean(reader.nextStep),
|
|
435
|
+
receipt,
|
|
436
|
+
});
|
|
437
|
+
this.reports.set(runId, projected);
|
|
438
|
+
return projected;
|
|
439
|
+
}
|
|
440
|
+
async close() {
|
|
441
|
+
const active = this.activeRunId ? this.runs.get(this.activeRunId) : undefined;
|
|
442
|
+
active?.abort.abort();
|
|
443
|
+
this.gates.denyAll();
|
|
444
|
+
await Promise.allSettled([...this.workers]);
|
|
445
|
+
for (const run of this.runs.values())
|
|
446
|
+
run.bus.close();
|
|
447
|
+
}
|
|
448
|
+
// ── live worker ──────────────────────────────────────────────────────────────────
|
|
449
|
+
async executeDecision(active, cfg) {
|
|
450
|
+
try {
|
|
451
|
+
this.log(`▶ convening ${active.input.mode} council · ${active.id}`);
|
|
452
|
+
const estimate = estimateRun('idea-refinement', { mode: active.input.mode });
|
|
453
|
+
let evidencePack = active.resume?.evidencePack;
|
|
454
|
+
let urlSources = active.resume?.urlSources ?? { sources: [] };
|
|
455
|
+
let allowBlockedSources = active.resume?.allowBlockedSources ?? false;
|
|
456
|
+
if (active.resume) {
|
|
457
|
+
const estimatedNew = Math.max(1, estimate.calls - active.resume.replay.size);
|
|
458
|
+
const decision = await this.permission(active, 'resume', 'Resume this council?', [
|
|
459
|
+
`${active.resume.replay.size} completed call${active.resume.replay.size === 1 ? '' : 's'} cached and replayed free`,
|
|
460
|
+
`estimated new spend: up to ${estimatedNew} provider call${estimatedNew === 1 ? '' : 's'} · budget cap ${active.budget}`,
|
|
461
|
+
], allowanceKey('resume', active.resume.fromRunId));
|
|
462
|
+
if (decision === 'deny')
|
|
463
|
+
return this.finishCancelled(active, 'Resume denied. No provider calls were made.');
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
const packs = [];
|
|
467
|
+
for (const attachment of active.input.attachments) {
|
|
468
|
+
if (attachment.kind !== 'file')
|
|
469
|
+
continue;
|
|
470
|
+
const pack = await (this.opts.buildPack ?? buildEvidencePack)(attachment.path);
|
|
471
|
+
const detail = pack.files.map((file) => `${file.path} · sha256 ${file.sha256.slice(0, 12)}`);
|
|
472
|
+
const decision = await this.permission(active, 'file', 'Read attached material?', detail, allowanceKey('file', `${pack.root}:${pack.files.map((file) => file.sha256).join(',')}`));
|
|
473
|
+
if (decision === 'deny')
|
|
474
|
+
return this.finishCancelled(active, 'File access denied. No provider calls were made.');
|
|
475
|
+
packs.push(pack);
|
|
476
|
+
}
|
|
477
|
+
if (packs.length)
|
|
478
|
+
evidencePack = EvidencePack.parse({
|
|
479
|
+
root: packs.length === 1 ? packs[0].root : 'multiple attached files',
|
|
480
|
+
files: packs.flatMap((pack) => pack.files),
|
|
481
|
+
});
|
|
482
|
+
const urlInput = [active.input.text, ...active.input.attachments.filter((item) => item.kind === 'url').map((item) => item.url)].join('\n');
|
|
483
|
+
const urls = extractPublicUrls(urlInput);
|
|
484
|
+
for (const rawUrl of urls) {
|
|
485
|
+
const url = await (this.opts.validateUrl ?? validatePublicUrl)(rawUrl);
|
|
486
|
+
const decision = await this.permission(active, 'url', 'Fetch attached page?', [url, 'public http(s) URL · guarded fetch · max 500 KB'], allowanceKey('url', url));
|
|
487
|
+
if (decision === 'deny')
|
|
488
|
+
return this.finishCancelled(active, 'URL access denied. No provider calls were made.');
|
|
489
|
+
}
|
|
490
|
+
urlSources = await (this.opts.snapshotUrls ?? snapshotUrlSources)(urlInput);
|
|
491
|
+
const unreadable = urlSources.sources.filter((source) => source.status !== 'FETCHED');
|
|
492
|
+
if (unreadable.length) {
|
|
493
|
+
const decision = await this.permission(active, 'blocked', 'Run without an unreadable page?', unreadable.map((source) => `${source.url} · ${source.error ?? source.status}`), allowanceKey('blocked', unreadable.map((source) => source.url).join('|')));
|
|
494
|
+
if (decision === 'deny')
|
|
495
|
+
return this.finishCancelled(active, 'Blocked source was not waived. No provider calls were made.');
|
|
496
|
+
allowBlockedSources = true;
|
|
497
|
+
}
|
|
498
|
+
const calls = estimate.minCalls && estimate.minCalls !== estimate.calls ? `${estimate.minCalls}–${estimate.calls}` : `${estimate.calls}`;
|
|
499
|
+
const spend = await this.permission(active, 'spend', 'Convene the council?', [
|
|
500
|
+
`${active.input.mode === 'quick' ? 'Quick' : 'Full Council'} · about ${calls} provider calls`,
|
|
501
|
+
`budget cap ${active.budget} · about ${estimate.opus} Claude/Opus`,
|
|
502
|
+
], allowanceKey('spend', `${active.input.mode}:${active.budget}:${calls}`));
|
|
503
|
+
if (spend === 'deny')
|
|
504
|
+
return this.finishCancelled(active, 'Spend denied. No provider calls were made.');
|
|
505
|
+
}
|
|
506
|
+
active.bus.setLifecycle('running');
|
|
507
|
+
await appendTurn(this.opts.runsRoot, active.threadId, { kind: 'run_ref', run_id: active.id, mode: active.input.mode });
|
|
508
|
+
const outcome = await (this.opts.runner ?? runEngine)('idea-refinement', active.input.text, {
|
|
509
|
+
runId: active.id,
|
|
510
|
+
mode: active.input.mode,
|
|
511
|
+
budget: active.budget,
|
|
512
|
+
deadlineMs: cfg.deadlineMs ?? defaultDeadlineFor('idea-refinement', active.input.mode),
|
|
513
|
+
roleOverrides: cfg.roles,
|
|
514
|
+
providerModels: cfg.models,
|
|
515
|
+
runsRoot: this.opts.runsRoot,
|
|
516
|
+
signal: active.abort.signal,
|
|
517
|
+
evidencePack,
|
|
518
|
+
urlSources,
|
|
519
|
+
allowBlockedSources,
|
|
520
|
+
replay: active.resume?.replay,
|
|
521
|
+
resumedFrom: active.resume?.fromRunId,
|
|
522
|
+
events: this.runEvents(active),
|
|
523
|
+
});
|
|
524
|
+
await this.refreshCounters(active);
|
|
525
|
+
if (outcome.ok) {
|
|
526
|
+
const report = await this.report(active.id);
|
|
527
|
+
this.log(`✓ verdict: ${report.verdict.label}${report.confidence ? ` · confidence ${report.confidence.score} (${report.confidence.label})` : ''} · ${active.calls} calls`);
|
|
528
|
+
active.bus.emit({ t: 'report_ready', runId: active.id });
|
|
529
|
+
active.bus.emit({ t: 'receipt', receipt: report.receipt });
|
|
530
|
+
await this.finishThread(active, 'idle');
|
|
531
|
+
active.bus.emit({ t: 'done', status: 'ok', flags: report.warnings });
|
|
532
|
+
}
|
|
533
|
+
else if (outcome.aborted || active.abort.signal.aborted) {
|
|
534
|
+
await this.finishCancelled(active, 'Council cancelled. Partial artifacts were kept.');
|
|
535
|
+
}
|
|
536
|
+
else {
|
|
537
|
+
await this.finishFailed(active, outcome.error?.message ?? 'The council could not complete.');
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (active.abort.signal.aborted)
|
|
542
|
+
await this.finishCancelled(active, 'Council cancelled. Partial artifacts were kept.');
|
|
543
|
+
else
|
|
544
|
+
await this.finishFailed(active, error instanceof Error ? error.message : String(error));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
async executeFollowup(active, cfg, report) {
|
|
548
|
+
try {
|
|
549
|
+
const spend = await this.permission(active, 'spend', 'Answer this follow-up?', [
|
|
550
|
+
'1 provider call · no council',
|
|
551
|
+
'Uses the completed council report as context',
|
|
552
|
+
], allowanceKey('spend', 'followup:1'));
|
|
553
|
+
if (spend === 'deny')
|
|
554
|
+
return this.finishFollowupCancelled(active);
|
|
555
|
+
active.bus.setLifecycle('running');
|
|
556
|
+
let ended = false;
|
|
557
|
+
const onCallStart = (provider) => {
|
|
558
|
+
active.bus.emit({ t: 'call', provider, stage: 'followup', phase: 'start', category: 'planning', replayed: false });
|
|
559
|
+
};
|
|
560
|
+
const onCallEnd = (provider, ms, ok) => {
|
|
561
|
+
ended = true;
|
|
562
|
+
active.calls++;
|
|
563
|
+
active.callCount[provider] = (active.callCount[provider] ?? 0) + 1;
|
|
564
|
+
active.callMs[provider] = (active.callMs[provider] ?? 0) + ms;
|
|
565
|
+
active.bus.emit({ t: 'call', provider, stage: 'followup', phase: 'end', ms, ok, category: 'planning', replayed: false });
|
|
566
|
+
};
|
|
567
|
+
const result = await (this.opts.followupRunner ?? runFollowup)({
|
|
568
|
+
question: active.input.text,
|
|
569
|
+
report,
|
|
570
|
+
config: cfg,
|
|
571
|
+
signal: active.abort.signal,
|
|
572
|
+
onCallStart,
|
|
573
|
+
onCallEnd,
|
|
574
|
+
});
|
|
575
|
+
if (!ended) {
|
|
576
|
+
onCallStart(result.provider);
|
|
577
|
+
onCallEnd(result.provider, result.callMs, true);
|
|
578
|
+
}
|
|
579
|
+
const answer = sanitizeLocalPaths(result.answer);
|
|
580
|
+
const providerName = DISPLAY_NAME[result.provider];
|
|
581
|
+
const turn = {
|
|
582
|
+
kind: 'followup',
|
|
583
|
+
question: sanitizeLocalPaths(active.input.text),
|
|
584
|
+
answer,
|
|
585
|
+
provider: result.provider,
|
|
586
|
+
providerName,
|
|
587
|
+
label: `follow-up · ${providerName} · 1 call · no council`,
|
|
588
|
+
callMs: result.callMs,
|
|
589
|
+
};
|
|
590
|
+
await appendTurn(this.opts.runsRoot, active.threadId, {
|
|
591
|
+
kind: 'followup', question: active.input.text, provider: result.provider, answer, call_ms: result.callMs,
|
|
592
|
+
});
|
|
593
|
+
active.bus.emit({ t: 'turn', turn });
|
|
594
|
+
active.bus.emit({ t: 'receipt', receipt: this.receipt(active) });
|
|
595
|
+
await this.finishThread(active, 'idle');
|
|
596
|
+
active.bus.emit({ t: 'done', status: 'ok', flags: [] });
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
if (active.abort.signal.aborted)
|
|
600
|
+
await this.finishFollowupCancelled(active);
|
|
601
|
+
else
|
|
602
|
+
await this.finishFollowupFailed(active, error instanceof Error ? error.message : String(error));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
/** Human-readable progress line to the serve terminal (no-op unless a log sink is wired). */
|
|
606
|
+
log(line) {
|
|
607
|
+
this.opts.log?.(line);
|
|
608
|
+
}
|
|
609
|
+
runEvents(active) {
|
|
610
|
+
const stage = (id) => IDEA_STAGES.find((item) => item.id === id);
|
|
611
|
+
const name = (provider) => DISPLAY_NAME[provider] ?? provider;
|
|
612
|
+
return {
|
|
613
|
+
onStageStart: (id) => {
|
|
614
|
+
this.log(`⏳ ${stage(id)?.label ?? id}`);
|
|
615
|
+
active.bus.emit({ t: 'stage', id, label: stage(id)?.label ?? id, status: 'running' });
|
|
616
|
+
},
|
|
617
|
+
onStageEnd: (id, status) => {
|
|
618
|
+
active.bus.emit({ t: 'stage', id, label: stage(id)?.label ?? id, status });
|
|
619
|
+
if (id === 'S7')
|
|
620
|
+
void this.refreshCounters(active);
|
|
621
|
+
},
|
|
622
|
+
onCallStart: (provider, callStage, category, replayed) => {
|
|
623
|
+
active.inflight.set(`${provider}:${callStage}`, { category, replayed });
|
|
624
|
+
this.log(` → ${name(provider)} · ${category}${replayed ? ' (replay)' : ''}`);
|
|
625
|
+
active.bus.emit({ t: 'call', provider, stage: callStage, phase: 'start', category, replayed });
|
|
626
|
+
},
|
|
627
|
+
onCallEnd: (provider, callStage, ms, ok, replayed) => {
|
|
628
|
+
const key = `${provider}:${callStage}`;
|
|
629
|
+
const started = active.inflight.get(key) ?? { category: 'discovery', replayed };
|
|
630
|
+
active.inflight.delete(key);
|
|
631
|
+
if (replayed)
|
|
632
|
+
active.replays++;
|
|
633
|
+
else {
|
|
634
|
+
active.calls++;
|
|
635
|
+
active.callCount[provider] = (active.callCount[provider] ?? 0) + 1;
|
|
636
|
+
active.callMs[provider] = (active.callMs[provider] ?? 0) + ms;
|
|
637
|
+
if (started.category === 'repair')
|
|
638
|
+
active.repairs++;
|
|
639
|
+
}
|
|
640
|
+
this.log(` ← ${name(provider)} ${ok ? 'ok' : 'FAILED'} · ${(ms / 1000).toFixed(1)}s${replayed ? ' (replay)' : ''} · ${active.calls} calls`);
|
|
641
|
+
active.bus.emit({ t: 'call', provider, stage: callStage, phase: 'end', ms, ok, category: started.category, replayed });
|
|
642
|
+
if (started.category === 'repair' && !replayed)
|
|
643
|
+
active.bus.emit({ t: 'counters', repairs: active.repairs });
|
|
644
|
+
},
|
|
645
|
+
clarify: async (question, options) => {
|
|
646
|
+
this.log(`⏸ awaiting your input — ${question}`);
|
|
647
|
+
const gate = { id: this.gates.gateId('clarify'), kind: 'clarify', title: 'Choose the intended reading', lines: [], question, options, allowText: true };
|
|
648
|
+
const value = await this.gates.request(gate, undefined, (card) => active.bus.emit({ t: 'gate', gate: card }));
|
|
649
|
+
if (active.abort.signal.aborted || value === 'deny')
|
|
650
|
+
throw new StageError('S0', 'ABORT', 'aborted');
|
|
651
|
+
if (typeof value === 'number')
|
|
652
|
+
return { kind: 'pick', index: Math.max(0, Math.min(options.length - 1, value)) };
|
|
653
|
+
if (value.trim().toLowerCase() === 'both')
|
|
654
|
+
return { kind: 'both' };
|
|
655
|
+
return { kind: 'text', text: value.trim() || options[0] || 'Use the first reading.' };
|
|
656
|
+
},
|
|
657
|
+
grill: async (brief) => {
|
|
658
|
+
const answers = [];
|
|
659
|
+
for (const question of brief.questions) {
|
|
660
|
+
const gate = { id: this.gates.gateId('grill'), kind: 'grill', title: 'One detail before the council starts', lines: [], questions: [{ id: question.id, prompt: question.question }] };
|
|
661
|
+
const value = await this.gates.request(gate, undefined, (card) => active.bus.emit({ t: 'gate', gate: card }));
|
|
662
|
+
if (active.abort.signal.aborted || value === 'deny')
|
|
663
|
+
throw new StageError('S0', 'ABORT', 'aborted');
|
|
664
|
+
answers.push({ question_id: question.id, answer: String(value).trim() || 'Use best judgment.', source: 'user' });
|
|
665
|
+
}
|
|
666
|
+
return answers;
|
|
667
|
+
},
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
permission(active, kind, title, lines, key) {
|
|
671
|
+
const gate = {
|
|
672
|
+
id: this.gates.gateId(kind), kind, title, lines,
|
|
673
|
+
scopes: ['allow_once', 'allow_session', 'deny'],
|
|
674
|
+
};
|
|
675
|
+
return this.gates.request(gate, key, (card) => {
|
|
676
|
+
this.log(`⏸ awaiting your approval — ${title}`);
|
|
677
|
+
active.bus.emit({ t: 'gate', gate: card });
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
async refreshCounters(active) {
|
|
681
|
+
if (active.bus.done)
|
|
682
|
+
return;
|
|
683
|
+
const graph = await readJsonArtifact(runDir(active.id, this.opts.runsRoot), '07-decision-graph.json');
|
|
684
|
+
if (!graph)
|
|
685
|
+
return;
|
|
686
|
+
active.bus.emit({
|
|
687
|
+
t: 'counters',
|
|
688
|
+
positions: graph.positions?.length ?? 0,
|
|
689
|
+
evidence: graph.evidence?.length ?? 0,
|
|
690
|
+
disagreements: graph.claims?.filter((claim) => claim.state === 'DISAGREEMENT').length ?? 0,
|
|
691
|
+
repairs: active.repairs,
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
receipt(active, warnings = []) {
|
|
695
|
+
return ReceiptView.parse({
|
|
696
|
+
mode: active.input.kind === 'followup'
|
|
697
|
+
? 'Follow-up · single call · no council'
|
|
698
|
+
: active.input.mode === 'quick' ? 'Quick' : 'Full Council',
|
|
699
|
+
calls: active.calls,
|
|
700
|
+
budget: active.budget,
|
|
701
|
+
replays: active.replays,
|
|
702
|
+
durationMs: Object.values(active.callMs).reduce((sum, value) => sum + (value ?? 0), 0),
|
|
703
|
+
repairs: active.repairs,
|
|
704
|
+
providers: ['claude', 'codex', 'agy']
|
|
705
|
+
.filter((id) => active.callCount[id])
|
|
706
|
+
.map((id) => ({ name: DISPLAY_NAME[id], calls: active.callCount[id] ?? 0 })),
|
|
707
|
+
warnings,
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
async finishCancelled(active, message) {
|
|
711
|
+
await this.finishThread(active, 'cancelled');
|
|
712
|
+
await appendTurn(this.opts.runsRoot, active.threadId, { kind: 'error', message });
|
|
713
|
+
if (!active.bus.done) {
|
|
714
|
+
active.bus.emit({ t: 'receipt', receipt: this.receipt(active) });
|
|
715
|
+
active.bus.emit({ t: 'done', status: 'aborted', flags: [] });
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
async finishFollowupCancelled(active) {
|
|
719
|
+
await this.finishThread(active, 'idle');
|
|
720
|
+
if (!active.bus.done) {
|
|
721
|
+
active.bus.emit({ t: 'receipt', receipt: this.receipt(active) });
|
|
722
|
+
active.bus.emit({ t: 'done', status: 'aborted', flags: [] });
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
async finishFollowupFailed(active, raw) {
|
|
726
|
+
const message = sanitizeLocalPaths(raw);
|
|
727
|
+
await this.finishThread(active, 'idle');
|
|
728
|
+
await appendTurn(this.opts.runsRoot, active.threadId, { kind: 'error', message });
|
|
729
|
+
if (!active.bus.done) {
|
|
730
|
+
active.bus.emit({ t: 'gate', gate: { id: this.gates.gateId('attention'), kind: 'attention', title: 'Follow-up needs attention', lines: [message], fix: recoveryText(message) } });
|
|
731
|
+
active.bus.emit({ t: 'receipt', receipt: this.receipt(active, [message]) });
|
|
732
|
+
active.bus.emit({ t: 'done', status: 'failed', flags: [message] });
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async finishFailed(active, raw) {
|
|
736
|
+
const message = sanitizeLocalPaths(raw);
|
|
737
|
+
await this.finishThread(active, 'failed');
|
|
738
|
+
await appendTurn(this.opts.runsRoot, active.threadId, { kind: 'error', message });
|
|
739
|
+
if (!active.bus.done) {
|
|
740
|
+
active.bus.emit({ t: 'gate', gate: { id: this.gates.gateId('attention'), kind: 'attention', title: 'Council needs attention', lines: [message], fix: recoveryText(message) } });
|
|
741
|
+
active.bus.emit({ t: 'receipt', receipt: this.receipt(active, [message]) });
|
|
742
|
+
active.bus.emit({ t: 'done', status: 'failed', flags: [message] });
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
async finishThread(active, status) {
|
|
746
|
+
active.thread = { ...active.thread, status, updated_at: (this.opts.now?.() ?? new Date()).toISOString() };
|
|
747
|
+
await appendThread(this.opts.runsRoot, active.thread);
|
|
748
|
+
}
|
|
749
|
+
// ── HD2 reads ──────────────────────────────────────────────────────────────────────
|
|
750
|
+
async providerViews(fresh, cfg) {
|
|
751
|
+
const { rows } = await runDoctorChecks({ smoke: false });
|
|
752
|
+
const cache = fresh ? {} : await readSmokeCache(this.opts.runsRoot);
|
|
753
|
+
const now = Date.now();
|
|
754
|
+
const withCache = rows.map((row) => {
|
|
755
|
+
if (row.det.status !== 'READY')
|
|
756
|
+
return row;
|
|
757
|
+
const entry = cache[row.det.id];
|
|
758
|
+
return entry && isFresh(entry, row.det.version ?? null, now)
|
|
759
|
+
? { ...row, smoke: entryToSmoke(entry), cached: true }
|
|
760
|
+
: row;
|
|
761
|
+
});
|
|
762
|
+
return orderProviders(withCache.map((row) => providerStatusView(row, modelFor(row.det.id, cfg))));
|
|
763
|
+
}
|
|
764
|
+
async threadList() {
|
|
765
|
+
const [live, legacy] = await Promise.all([readThreads(this.opts.runsRoot), legacyThreads()]);
|
|
766
|
+
const liveViews = live.map((thread) => ({
|
|
767
|
+
id: thread.id,
|
|
768
|
+
title: thread.title,
|
|
769
|
+
updatedAt: thread.updated_at,
|
|
770
|
+
status: thread.status === 'running' ? 'running' : thread.status === 'failed' ? 'failed' : thread.status === 'cancelled' ? 'cancelled' : 'complete',
|
|
771
|
+
mode: null,
|
|
772
|
+
legacy: false,
|
|
773
|
+
}));
|
|
774
|
+
const liveIds = new Set(liveViews.map((thread) => thread.id));
|
|
775
|
+
return [...liveViews, ...legacy.filter((thread) => !liveIds.has(thread.id))].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
776
|
+
}
|
|
777
|
+
settingsView(cfg, local) {
|
|
778
|
+
return SettingsView.parse({
|
|
779
|
+
models: { claude: cfg.models?.claude ?? null, codex: cfg.models?.codex ?? null, agy: cfg.models?.agy ?? null },
|
|
780
|
+
roles: {
|
|
781
|
+
...(cfg.roles?.analyst ? { analyst: cfg.roles.analyst } : {}),
|
|
782
|
+
...(cfg.roles?.judge ? { judge: cfg.roles.judge } : {}),
|
|
783
|
+
...(cfg.roles?.verifier ? { verifier: cfg.roles.verifier } : {}),
|
|
784
|
+
...(cfg.roles?.s4 ? { s4: cfg.roles.s4 } : {}),
|
|
785
|
+
...(cfg.roles?.responder ? { responder: cfg.roles.responder } : {}),
|
|
786
|
+
},
|
|
787
|
+
overrides: {
|
|
788
|
+
models: { claude: local.models?.claude ?? null, codex: local.models?.codex ?? null, agy: local.models?.agy ?? null },
|
|
789
|
+
roles: {
|
|
790
|
+
...(local.roles?.analyst ? { analyst: local.roles.analyst } : {}),
|
|
791
|
+
...(local.roles?.judge ? { judge: local.roles.judge } : {}),
|
|
792
|
+
...(local.roles?.verifier ? { verifier: local.roles.verifier } : {}),
|
|
793
|
+
...(local.roles?.s4 ? { s4: local.roles.s4 } : {}),
|
|
794
|
+
...(local.roles?.responder ? { responder: local.roles.responder } : {}),
|
|
795
|
+
},
|
|
796
|
+
},
|
|
797
|
+
scope: this.opts.runsRoot === homeAikiRoot() ? 'global (~/.aiki/config.json)' : 'project (.aiki/config.json)',
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
function modelFor(id, cfg) {
|
|
802
|
+
return cfg.models?.[id] ?? null;
|
|
803
|
+
}
|
|
804
|
+
function attachmentLabel(item) {
|
|
805
|
+
const value = typeof item === 'string' ? item : item.kind === 'file' ? item.path : item.url;
|
|
806
|
+
return /^https?:\/\//i.test(value) ? value : basename(value);
|
|
807
|
+
}
|
|
808
|
+
function decisionSummary(decision) {
|
|
809
|
+
return decision === 'allow_session' ? '✓ Allowed for this session' : decision === 'allow_once' ? '✓ Allowed once' : '✕ Denied';
|
|
810
|
+
}
|
|
811
|
+
function recoveryText(message) {
|
|
812
|
+
if (/auth|login/i.test(message))
|
|
813
|
+
return 'Run the affected provider CLI once to log in, then retry.';
|
|
814
|
+
if (/quota|rate limit/i.test(message))
|
|
815
|
+
return 'Wait for the provider quota to reset, then retry.';
|
|
816
|
+
return 'Review the message, then convene again.';
|
|
817
|
+
}
|
|
818
|
+
function featureView(priority, item) {
|
|
819
|
+
const effort = item.effort === 'S' ? 'Small' : item.effort === 'M' ? 'Medium' : item.effort === 'L' ? 'Large' : item.effort;
|
|
820
|
+
return { priority, feature: item.feature, userValue: item.user_value, rationale: item.rationale, effort };
|
|
821
|
+
}
|
|
822
|
+
function verdictView(status) {
|
|
823
|
+
if (status === 'ACCEPTED')
|
|
824
|
+
return { tone: 'go', label: 'Proceed' };
|
|
825
|
+
if (status === 'ACCEPTED_WITH_CONDITIONS')
|
|
826
|
+
return { tone: 'conditions', label: 'Proceed with conditions' };
|
|
827
|
+
if (status === 'REJECTED')
|
|
828
|
+
return { tone: 'stop', label: 'Stop' };
|
|
829
|
+
return { tone: 'inconclusive', label: 'Inconclusive' };
|
|
830
|
+
}
|