aiki-cli 0.3.2 → 0.3.5
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 +31 -6
- 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 +46 -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
package/dist/cli/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { sessionsCommand } from './sessions.js';
|
|
|
12
12
|
import { config } from './config.js';
|
|
13
13
|
import { modelsCommand } from './models.js';
|
|
14
14
|
import { benchCommand } from './bench.js';
|
|
15
|
+
import { serveCommand } from './serve.js';
|
|
15
16
|
import { VERSION } from './version.js';
|
|
16
17
|
import { ConfigError, loadLayeredConfig } from '../config/config.js';
|
|
17
18
|
import { resolveRunsRoot } from '../storage/paths.js';
|
|
@@ -51,7 +52,8 @@ program
|
|
|
51
52
|
.option('--head <ref>', 'code-review: head git ref to diff to (default HEAD)')
|
|
52
53
|
.option('--diff <file>', 'code-review: review a patch file instead of computing a git diff')
|
|
53
54
|
.option('--evidence <path>', 'idea-refinement: local source file/directory (stores paths + hashes, not copies)')
|
|
54
|
-
.option('--mode <mode>', 'idea-refinement: quick | council (research is an alias for council)')
|
|
55
|
+
.option('--mode <mode>', 'idea-refinement: quick | council | auto (research is an alias for council)')
|
|
56
|
+
.option('--allow-blocked-sources', 'idea-refinement: proceed even when a supplied URL cannot be read (default: stop and ask)')
|
|
55
57
|
.option('--cheap', 'code-review: Gemini+Codex review, Claude judges only disputes (~⅓ the Opus; experimental)')
|
|
56
58
|
.option('--yes', 'skip the run-cost confirmation prompt')
|
|
57
59
|
.action(async (workflow, input, opts) => {
|
|
@@ -93,7 +95,7 @@ program
|
|
|
93
95
|
.command('bench')
|
|
94
96
|
.description('Run code-review, idea lane, or frozen idea-v3 benchmark arms; writes bench/results/*.json.')
|
|
95
97
|
.argument('<workflow>', 'code-review | idea-refinement | idea-v3')
|
|
96
|
-
.option('--arms <list>', 'comma-separated arms to run (code review defaults A,B,C,D; idea-v3 defaults B,C,D2,R
|
|
98
|
+
.option('--arms <list>', 'comma-separated arms to run (code review defaults A,B,C,D; idea-v3 defaults B,C,D2,R; Phase F adds explicit A,B2)')
|
|
97
99
|
.option('--set <name>', 'task set: build | holdout', 'build')
|
|
98
100
|
.option('--resume', 'continue the latest results file: keep already-scored case×arm pairs, retry the rest')
|
|
99
101
|
.option('--yes', 'actually run; without it, print the pre-run Opus-call estimate and exit')
|
|
@@ -123,6 +125,14 @@ program
|
|
|
123
125
|
campaign: opts.campaign,
|
|
124
126
|
}));
|
|
125
127
|
});
|
|
128
|
+
program
|
|
129
|
+
.command('serve')
|
|
130
|
+
.description('Open the chat workspace in the browser (localhost only; no paid calls until you convene a run).')
|
|
131
|
+
.option('--port <n>', 'port to bind (default: first free in 4173–4183)', (v) => parseInt(v, 10))
|
|
132
|
+
.option('--no-open', 'do not open the browser automatically')
|
|
133
|
+
.action(async (opts) => {
|
|
134
|
+
process.exit(await serveCommand({ port: opts.port, open: opts.open }));
|
|
135
|
+
});
|
|
126
136
|
program
|
|
127
137
|
.command('models')
|
|
128
138
|
.description('Show configurable models per provider (lists via the CLI where supported) + your current pins.')
|
package/dist/cli/resume.js
CHANGED
|
@@ -9,6 +9,7 @@ import { resolveRunsRoot } from '../storage/paths.js';
|
|
|
9
9
|
import { buildReplayCache } from '../storage/replay.js';
|
|
10
10
|
import { findSession } from '../storage/sessions.js';
|
|
11
11
|
import { readJsonArtifact, resolveRunId, runDir } from '../storage/runs-read.js';
|
|
12
|
+
import { UrlSourceSet } from '../schemas/index.js';
|
|
12
13
|
import { EvidencePack } from '../orchestration/evidence-pack.js';
|
|
13
14
|
export async function resumeCommand(runArg, opts = {}) {
|
|
14
15
|
if (!runArg) {
|
|
@@ -72,6 +73,22 @@ export async function resumeCommand(runArg, opts = {}) {
|
|
|
72
73
|
}
|
|
73
74
|
evidencePack = parsed.data;
|
|
74
75
|
}
|
|
76
|
+
// v6 T10: the URL snapshot + the user's proceed-past-blocked decision are run inputs, like
|
|
77
|
+
// inputs/idea.md and evidence-pack.json. Reusing the ORIGINAL snapshot keeps every prompt that
|
|
78
|
+
// embeds it byte-identical (replay cache hits, no refetch); and a non-empty replay cache proves
|
|
79
|
+
// the old run cleared the S0 gate, so a blocked source in the snapshot means consent was given
|
|
80
|
+
// (interactive `y` or --allow-blocked-sources) — restore that decision instead of re-asking.
|
|
81
|
+
// (Real failure: resume c46e of 626e died SOURCE_UNREADABLE re-gating a consented run.)
|
|
82
|
+
let urlSources;
|
|
83
|
+
const savedUrlSources = await readJsonArtifact(oldDir, '00a-url-sources.json');
|
|
84
|
+
if (savedUrlSources) {
|
|
85
|
+
const parsedSources = UrlSourceSet.safeParse(savedUrlSources);
|
|
86
|
+
if (!parsedSources.success) {
|
|
87
|
+
process.stderr.write(`cannot validate 00a-url-sources.json for ${oldId} — nothing to resume.\n`);
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
urlSources = parsedSources.data;
|
|
91
|
+
}
|
|
75
92
|
const replay = await buildReplayCache(oldDir);
|
|
76
93
|
if (replay.size === 0) {
|
|
77
94
|
process.stderr.write(`no completed calls found for ${oldId} — start a fresh run instead.\n`);
|
|
@@ -100,6 +117,9 @@ export async function resumeCommand(runArg, opts = {}) {
|
|
|
100
117
|
resumedFrom: oldId,
|
|
101
118
|
providerModels: cfg.models,
|
|
102
119
|
evidencePack,
|
|
120
|
+
urlSources,
|
|
121
|
+
allowBlockedSources: urlSources?.sources.some((s) => s.status !== 'FETCHED') ?? false,
|
|
122
|
+
autoDecision: previousMeta.auto_decision,
|
|
103
123
|
});
|
|
104
124
|
if (outcome.ok) {
|
|
105
125
|
process.stdout.write(`\n ✔ resumed run ${outcome.runId} complete — ${outcome.callCount} new provider call(s)\n artifacts: ${outcome.dir}\n\n`);
|
package/dist/cli/run.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import { readFile } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
+
import { blockedSourceStop, extractPublicUrls, snapshotUrlSources } from '../orchestration/url-sources.js';
|
|
7
|
+
import { buildTaskProfile, resolveAutoMode } from '../orchestration/auto-profile.js';
|
|
8
|
+
import { requestedOutputsFor } from '../orchestration/preflight.js';
|
|
6
9
|
import { renderTerminalSummary } from '../orchestration/stages/s10-render.js';
|
|
7
10
|
import { run as runEngine } from '../orchestration/engine.js';
|
|
8
11
|
import { ConfigError, loadLayeredConfig } from '../config/config.js';
|
|
@@ -16,6 +19,10 @@ const WORKFLOWS = ['idea-refinement', 'code-review'];
|
|
|
16
19
|
export function estimateRun(workflow, opts = {}) {
|
|
17
20
|
if (workflow === 'code-review')
|
|
18
21
|
return { calls: 5, opus: opts.cheap ? 1 : 2 };
|
|
22
|
+
if (opts.auto)
|
|
23
|
+
return { calls: 4, opus: 1, minCalls: opts.fastPath ? 1 : 3, reserved: 0 };
|
|
24
|
+
if (opts.fastPath)
|
|
25
|
+
return { calls: 1, opus: 1, minCalls: 1, reserved: 0 };
|
|
19
26
|
const mode = opts.mode ?? 'council';
|
|
20
27
|
const plan = IDEA_MODE_PLANS[mode];
|
|
21
28
|
return {
|
|
@@ -35,6 +42,19 @@ function confirm(question) {
|
|
|
35
42
|
});
|
|
36
43
|
});
|
|
37
44
|
}
|
|
45
|
+
/** One question, one trimmed answer. Resolve BEFORE close — closing fires the EOF handler, and
|
|
46
|
+
* the live "typed Y, got cancelled" bug was that handler resolving '' first. EOF alone (Ctrl+D)
|
|
47
|
+
* still resolves '' = the safe deny default. Exported (with injectable streams) for the test. */
|
|
48
|
+
export function ask(question, io = { input: process.stdin, output: process.stdout }) {
|
|
49
|
+
return new Promise((resolve) => {
|
|
50
|
+
const rl = createInterface({ input: io.input, output: io.output });
|
|
51
|
+
rl.on('close', () => resolve(''));
|
|
52
|
+
rl.question(question, (a) => {
|
|
53
|
+
resolve(a.trim());
|
|
54
|
+
rl.close();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
}
|
|
38
58
|
/** Resolve an idea-refinement input: an existing file path → its contents, else the arg as inline text. */
|
|
39
59
|
async function resolveInput(arg) {
|
|
40
60
|
if (!arg)
|
|
@@ -90,11 +110,15 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
90
110
|
return 1;
|
|
91
111
|
}
|
|
92
112
|
let mode;
|
|
113
|
+
let autoRequested = false; // v7 Phase B: `--mode auto` → resolved to quick|council once inputs are known
|
|
93
114
|
if (workflow === 'idea-refinement') {
|
|
94
|
-
if (opts.mode
|
|
115
|
+
if (opts.mode === 'auto') {
|
|
116
|
+
autoRequested = true;
|
|
117
|
+
}
|
|
118
|
+
else if (opts.mode !== undefined) {
|
|
95
119
|
const parsed = IdeaModeSchema.safeParse(opts.mode);
|
|
96
120
|
if (!parsed.success) {
|
|
97
|
-
process.stderr.write(`unknown idea mode "${opts.mode}". Available: quick, council, research\n`);
|
|
121
|
+
process.stderr.write(`unknown idea mode "${opts.mode}". Available: quick, council, research, auto\n`);
|
|
98
122
|
return 1;
|
|
99
123
|
}
|
|
100
124
|
mode = parsed.data;
|
|
@@ -120,7 +144,8 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
120
144
|
return 1;
|
|
121
145
|
}
|
|
122
146
|
text = resolved;
|
|
123
|
-
|
|
147
|
+
if (!autoRequested)
|
|
148
|
+
mode ??= inferIdeaMode(text);
|
|
124
149
|
}
|
|
125
150
|
if (opts.evidence) {
|
|
126
151
|
if (workflow !== 'idea-refinement') {
|
|
@@ -135,6 +160,19 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
135
160
|
return 1;
|
|
136
161
|
}
|
|
137
162
|
}
|
|
163
|
+
// v7 Phase B: resolve `--mode auto` here, before any RunCtx/engine work, so meta.mode stays quick|council
|
|
164
|
+
// and the estimate below reflects the resolved mode. urlCount is fetch-free (extract only, no snapshot).
|
|
165
|
+
let autoDecision;
|
|
166
|
+
if (autoRequested) {
|
|
167
|
+
const resolved = resolveAutoMode(buildTaskProfile(text, {
|
|
168
|
+
urlCount: extractPublicUrls(text).length,
|
|
169
|
+
hasEvidencePack: !!evidencePack,
|
|
170
|
+
requestedOutputs: requestedOutputsFor(text),
|
|
171
|
+
}));
|
|
172
|
+
mode = resolved.mode;
|
|
173
|
+
autoDecision = { resolved: resolved.mode, reasons: resolved.reasons, ...(resolved.fastPath ? { fast_path: true } : {}) };
|
|
174
|
+
process.stdout.write(` auto → ${resolved.mode}: ${resolved.reasons.join(', ')}.\n`);
|
|
175
|
+
}
|
|
138
176
|
// Precedence (§10/T9): --budget flag > config.budget > built-in default. roles/deadline/models are
|
|
139
177
|
// config-only (layered: global ~/.aiki base, project .aiki override).
|
|
140
178
|
let cfg;
|
|
@@ -158,7 +196,7 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
158
196
|
process.stderr.write('--cheap only applies to code-review; ignoring for this workflow.\n');
|
|
159
197
|
}
|
|
160
198
|
// Run-cost preview (V5): show the estimate; confirm interactively unless --yes or non-interactive.
|
|
161
|
-
const est = estimateRun(workflow, { cheap: opts.cheap, mode });
|
|
199
|
+
const est = estimateRun(workflow, { cheap: opts.cheap, mode, auto: !!autoDecision, fastPath: autoDecision?.fast_path });
|
|
162
200
|
const callEstimate = est.minCalls !== undefined && est.minCalls !== est.calls ? `${est.minCalls}–${est.calls}` : `${est.calls}`;
|
|
163
201
|
const resolvedBudget = opts.budget ?? cfg.budget ?? defaultBudgetFor(workflow, mode);
|
|
164
202
|
const reservation = est.reserved ? `; ${est.reserved} call(s) reserved for chair + planner` : '';
|
|
@@ -173,6 +211,36 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
173
211
|
else {
|
|
174
212
|
process.stdout.write(`${note}\n`);
|
|
175
213
|
}
|
|
214
|
+
// v6 T10b: check supplied URLs BEFORE any provider spend. A blocked source in a TTY gets a
|
|
215
|
+
// simple permission prompt — Y allow, anything else deny. Headless keeps the fail-fast gate.
|
|
216
|
+
let allowBlockedSources = opts.allowBlockedSources ?? false;
|
|
217
|
+
let urlSources;
|
|
218
|
+
if (workflow === 'idea-refinement') {
|
|
219
|
+
urlSources = await snapshotUrlSources(text);
|
|
220
|
+
const stopMessage = blockedSourceStop(urlSources, mode ?? 'council', allowBlockedSources);
|
|
221
|
+
if (stopMessage && process.stdin.isTTY && process.stdout.isTTY) {
|
|
222
|
+
const unreadable = urlSources.sources.filter((item) => item.status !== 'FETCHED');
|
|
223
|
+
const noun = unreadable.length > 1 ? 'these pages' : 'this page';
|
|
224
|
+
for (const source of unreadable) {
|
|
225
|
+
process.stdout.write(`\n ⚠ Couldn't read a page you attached:\n ${source.url}\n reason: ${source.error ?? source.status}\n`);
|
|
226
|
+
}
|
|
227
|
+
process.stdout.write(`\n Do you want to run without ${noun}?\n`);
|
|
228
|
+
process.stdout.write(` y = yes, run anyway (facts from ${noun} will be marked unverified)\n`);
|
|
229
|
+
process.stdout.write(' n = no, cancel — paste the page text into your prompt and rerun\n\n');
|
|
230
|
+
const answer = await ask(' Choice [y/N]: ');
|
|
231
|
+
if (/^y/i.test(answer)) {
|
|
232
|
+
allowBlockedSources = true;
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
process.stdout.write('\n Cancelled. Paste the page text into your prompt and rerun, or rerun with --allow-blocked-sources.\n');
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (stopMessage) {
|
|
240
|
+
process.stderr.write(` ✖ ${stopMessage}\n`);
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
176
244
|
const outcome = await runEngine(workflow, text, {
|
|
177
245
|
mode,
|
|
178
246
|
budget: resolvedBudget,
|
|
@@ -182,6 +250,9 @@ export async function runCommand(workflow, input, opts = {}) {
|
|
|
182
250
|
runsRoot: await resolveRunsRoot(), // hybrid: repo .aiki when in a repo, else ~/.aiki
|
|
183
251
|
providerModels: cfg.models, // V8: per-provider model → CLI --model
|
|
184
252
|
evidencePack,
|
|
253
|
+
allowBlockedSources,
|
|
254
|
+
urlSources,
|
|
255
|
+
autoDecision,
|
|
185
256
|
});
|
|
186
257
|
if (outcome.ok) {
|
|
187
258
|
process.stdout.write(`\n ✔ run ${outcome.runId} complete — ${outcome.callCount} provider call(s)\n artifacts: ${outcome.dir}\n\n`);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// `aiki serve` — open the chat workspace on localhost. Resolves the built serve-ui assets, builds a
|
|
2
|
+
// FlightDeck over the hybrid runs root, starts the server, and (unless --no-open) opens the browser.
|
|
3
|
+
// The process stays up until Ctrl+C; there are no paid calls until the user convenes a run in the UI.
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { FlightDeck } from '../serve/flight-deck.js';
|
|
8
|
+
import { startServer } from '../serve/server.js';
|
|
9
|
+
import { openInBrowser } from '../council/open.js';
|
|
10
|
+
import { resolveRunsRoot } from '../storage/paths.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
12
|
+
/** Locate the serve-ui assets: dist/serve-ui next to this module (built), else repo-root serve-ui (dev). */
|
|
13
|
+
function resolveStaticDir() {
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url)); // dist/cli
|
|
15
|
+
const built = join(here, '..', 'serve-ui'); // dist/serve-ui
|
|
16
|
+
if (existsSync(join(built, 'index.html')))
|
|
17
|
+
return built;
|
|
18
|
+
return join(here, '..', '..', 'serve-ui'); // repo-root serve-ui (running from src)
|
|
19
|
+
}
|
|
20
|
+
export async function serveCommand(opts = {}) {
|
|
21
|
+
const runsRoot = await resolveRunsRoot();
|
|
22
|
+
const log = (line) => {
|
|
23
|
+
const time = new Date().toLocaleTimeString('en-US', { hour12: false });
|
|
24
|
+
process.stdout.write(` \x1b[2m${time}\x1b[0m ${line}\n`);
|
|
25
|
+
};
|
|
26
|
+
const flightDeck = new FlightDeck({ runsRoot, version: VERSION, log });
|
|
27
|
+
const staticDir = resolveStaticDir();
|
|
28
|
+
let server;
|
|
29
|
+
try {
|
|
30
|
+
server = await startServer({ flightDeck, staticDir, port: opts.port });
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
process.stderr.write(`aiki serve: ${e instanceof Error ? e.message : String(e)}\n`);
|
|
34
|
+
return 1;
|
|
35
|
+
}
|
|
36
|
+
const url = `http://127.0.0.1:${server.port}`;
|
|
37
|
+
process.stdout.write(`\n aiki workspace → ${url}\n (Ctrl+C to stop)\n\n`);
|
|
38
|
+
if (opts.open !== false)
|
|
39
|
+
openInBrowser(url);
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
const shutdown = () => {
|
|
42
|
+
server.close().finally(() => resolve());
|
|
43
|
+
};
|
|
44
|
+
process.on('SIGINT', shutdown);
|
|
45
|
+
process.on('SIGTERM', shutdown);
|
|
46
|
+
});
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -22,6 +22,7 @@ const ConfigRoles = z
|
|
|
22
22
|
judge: ProviderIdSchema.optional(),
|
|
23
23
|
verifier: ProviderIdSchema.optional(),
|
|
24
24
|
s4: z.array(ProviderIdSchema).min(1).optional(),
|
|
25
|
+
responder: ProviderIdSchema.optional(),
|
|
25
26
|
})
|
|
26
27
|
.strict();
|
|
27
28
|
/** Per-provider model override (V8). Each CLI runs its own model families, so model choice is per
|
|
@@ -96,7 +97,11 @@ export function mergeConfig(base, over) {
|
|
|
96
97
|
}
|
|
97
98
|
/** Layered config: global `~/.aiki/config.json` (base) overlaid by the project `.aiki/config.json`. What
|
|
98
99
|
* the CLI actually uses (V8) — a user can set defaults once in the global file and override per project. */
|
|
99
|
-
export async function loadLayeredConfig() {
|
|
100
|
-
const
|
|
100
|
+
export async function loadLayeredConfig(projectRoot = '.aiki') {
|
|
101
|
+
const globalRoot = homeAikiRoot();
|
|
102
|
+
const global = await loadConfig(globalRoot);
|
|
103
|
+
if (projectRoot === globalRoot)
|
|
104
|
+
return global;
|
|
105
|
+
const project = await loadConfig(projectRoot);
|
|
101
106
|
return mergeConfig(global, project);
|
|
102
107
|
}
|
package/dist/council/view.js
CHANGED
|
@@ -3,6 +3,7 @@ import { basename, join } from 'node:path';
|
|
|
3
3
|
import { DISPLAY_NAME } from '../providers/types.js';
|
|
4
4
|
import { RoleOutput as RoleOutputSchema } from '../schemas/index.js';
|
|
5
5
|
import { deriveAudit, deriveScorecard, mergeOpenQuestions } from '../orchestration/stages/s10-render.js';
|
|
6
|
+
import { sanitizeLocalPaths } from '../orchestration/sanitize-paths.js';
|
|
6
7
|
import { buildReaderProjection, claimLookup, readerClaimLabel, readerClaimRefs, renderDecisionDossierMarkdown, stripReaderClaimIds } from '../orchestration/decision-dossier.js';
|
|
7
8
|
import { IDEA_RUBRIC } from '../workflows/idea-refinement.js';
|
|
8
9
|
import { listArtifacts, readJsonArtifact } from '../storage/runs-read.js';
|
|
@@ -356,6 +357,12 @@ function dossierCoverageLabel(value) {
|
|
|
356
357
|
return value >= 0.75 ? 'High' : value >= 0.5 ? 'Medium' : 'Low';
|
|
357
358
|
}
|
|
358
359
|
function dossierCouncilRead(report) {
|
|
360
|
+
if (report.fastPath)
|
|
361
|
+
return 'Single-pass analysis; council escalation was not required.';
|
|
362
|
+
if (report.adaptiveAuto)
|
|
363
|
+
return report.autoEscalationReasons?.length
|
|
364
|
+
? `The primary analysis tripped structural gates; two task readings checked the interpretation${report.autoChallengeUsed ? ', and one targeted challenger checked eligible claims' : '; no challenger could add information'}. No full council was convened.`
|
|
365
|
+
: 'Two task readings informed one primary decision analyst. No full council was convened.';
|
|
359
366
|
if (report.mode === 'quick')
|
|
360
367
|
return 'One structured analyst produced this result; no council, consensus, or independent-verification claim is being made.';
|
|
361
368
|
const scouts = report.models.filter((model) => model.roles.includes('scout')).length;
|
|
@@ -393,7 +400,7 @@ function renderDossierIdeaBody(report) {
|
|
|
393
400
|
const decisiveNumbers = snapshot ? `<div class="decision-numbers">
|
|
394
401
|
<span class="section-eyebrow">Decisive numbers</span>
|
|
395
402
|
<div class="table-wrap"><table class="snapshot-table decisive-table"><thead><tr><th>Metric</th><th>Value</th><th>What it means</th></tr></thead><tbody>${snapshot.decisiveNumbers.map((item) => `<tr><td>${escapeHtml(item.label)}</td><td class="number-value">${escapeHtml(item.value)}</td><td>${escapeHtml(item.meaning)}</td></tr>`).join('')}</tbody></table></div>
|
|
396
|
-
${snapshot.payback ? `<div class="payback-result"><span>Payback · ${escapeHtml(snapshot.payback.status.replaceAll('_', ' '))}</span><strong>${escapeHtml(snapshot.payback.result)}</strong><p>${escapeHtml(snapshot.payback.basis)}</p></div>` : ''}
|
|
403
|
+
${snapshot.payback && snapshot.payback.status !== 'NOT_COMPUTABLE' ? `<div class="payback-result"><span>Payback · ${escapeHtml(snapshot.payback.status.replaceAll('_', ' '))}</span><strong>${escapeHtml(snapshot.payback.result)}</strong><p>${escapeHtml(snapshot.payback.basis)}</p></div>` : ''}
|
|
397
404
|
</div>` : '';
|
|
398
405
|
const optionComparison = snapshot ? `<div class="option-comparison">
|
|
399
406
|
<span class="section-eyebrow">Options at a glance</span>
|
|
@@ -469,6 +476,7 @@ function renderDossierIdeaBody(report) {
|
|
|
469
476
|
const contributions = `${dossierWarning(report, ['verification_skipped', 'single_model', 'low_diversity'])}<p class="lede">Only unique claims that survived independent verification receive credit.</p>${dossierTable(['Provider', 'Verified unique contributions', 'Count'], dossier.contributions.map((item) => [item.name, readerClaimRefs(report, item.verifiedUniqueClaimIds), String(item.verifiedUniqueClaimIds.length)]))}`;
|
|
470
477
|
const receipt = `<div class="receipt">
|
|
471
478
|
<span>mode ${escapeHtml(report.mode)}</span><span>${report.receipt.calls}/${report.receipt.budget} provider calls</span>
|
|
479
|
+
${report.autoEscalationReasons?.length ? `<span>adaptive escalation ${escapeHtml(report.autoEscalationReasons.join('; '))}</span>` : ''}
|
|
472
480
|
<span>discovery ${report.receipt.categories.discovery}</span><span>verification ${report.receipt.categories.verification}</span>
|
|
473
481
|
<span>repair ${report.receipt.categories.repair}</span><span>planning ${report.receipt.categories.planning}</span>
|
|
474
482
|
<span>by provider ${escapeHtml(Object.entries(report.receipt.byProvider).map(([provider, count]) => `${providerName(provider)} ${count}`).join(', ') || 'none')}</span>
|
|
@@ -522,7 +530,7 @@ function renderReaderBriefIdeaBody(report) {
|
|
|
522
530
|
const warnings = projection.warnings.length || projection.notices.length
|
|
523
531
|
? `<div class="warns">${projection.warnings.map((warning) => `<span class="warn">⚑ ${escapeHtml(warning.message)}</span>`).join('')}${projection.notices.map((notice) => `<span class="warn">ⓘ ${escapeHtml(notice.message)}</span>`).join('')}</div>`
|
|
524
532
|
: '';
|
|
525
|
-
const snapshot = projection.snapshot ? section('', 'Decision numbers', `${dossierTable(['Metric', 'Value', 'What it means'], projection.snapshot.decisiveNumbers.map((item) => [item.label, item.value, item.meaning]))}${projection.snapshot.payback ? `<div class="action-callout"><span>Payback · ${escapeHtml(projection.snapshot.payback.status.replaceAll('_', ' '))}</span><p>${escapeHtml(projection.snapshot.payback.result)} — ${escapeHtml(projection.snapshot.payback.basis)}</p></div>` : ''}<h3>Options at a glance</h3>${dossierTable(['Path', 'Commitment', 'Basis', 'Trade-off'], projection.snapshot.options.map((item) => [item.label, item.commitment, item.commitmentKind.replace('_', ' '), item.tradeoff]))}${projection.snapshot.tripwire ? `<div class="action-callout"><span>Go/no-go tripwire</span><p><strong>${escapeHtml(projection.snapshot.tripwire.metric)}: ${escapeHtml(projection.snapshot.tripwire.threshold)}</strong> — ${escapeHtml(projection.snapshot.tripwire.decisionRule)}</p></div>` : ''}`, 80) : '';
|
|
533
|
+
const snapshot = projection.snapshot ? section('', 'Decision numbers', `${dossierTable(['Metric', 'Value', 'What it means'], projection.snapshot.decisiveNumbers.map((item) => [item.label, item.value, item.meaning]))}${projection.snapshot.payback && projection.snapshot.payback.status !== 'NOT_COMPUTABLE' ? `<div class="action-callout"><span>Payback · ${escapeHtml(projection.snapshot.payback.status.replaceAll('_', ' '))}</span><p>${escapeHtml(projection.snapshot.payback.result)} — ${escapeHtml(projection.snapshot.payback.basis)}</p></div>` : ''}<h3>Options at a glance</h3>${dossierTable(['Path', 'Commitment', 'Basis', 'Trade-off'], projection.snapshot.options.map((item) => [item.label, item.commitment, item.commitmentKind.replace('_', ' '), item.tradeoff]))}${projection.snapshot.tripwire ? `<div class="action-callout"><span>Go/no-go tripwire</span><p><strong>${escapeHtml(projection.snapshot.tripwire.metric)}: ${escapeHtml(projection.snapshot.tripwire.threshold)}</strong> — ${escapeHtml(projection.snapshot.tripwire.decisionRule)}</p></div>` : ''}`, 80) : '';
|
|
526
534
|
const editorial = projection.sections.map((item, index) => section(String(index + 1).padStart(2, '0'), item.heading, `<p class="lede">${escapeHtml(item.summary)}</p>${item.bullets.length
|
|
527
535
|
? `<ul class="reasons">${item.bullets.map((bullet) => `<li>${escapeHtml(bullet)}</li>`).join('')}</ul>`
|
|
528
536
|
: ''}`, 100 + index * 40)).join('');
|
|
@@ -947,7 +955,8 @@ export function renderCouncilHtml(view) {
|
|
|
947
955
|
: quick ? renderQuickIdeaBody(view) : isIdea ? renderIdeaBody(view) : renderReviewBody(view);
|
|
948
956
|
// Embed the report as Markdown for the Copy button. Escape "<" so a "</script>" in content can't break out.
|
|
949
957
|
const md = JSON.stringify(councilMarkdown(view)).replace(/</g, '\\u003c');
|
|
950
|
-
|
|
958
|
+
// v6: the whole rendered page is path-sanitized — no local usernames in any artifact (T8).
|
|
959
|
+
return sanitizeLocalPaths(`<!doctype html>
|
|
951
960
|
<html lang="en">
|
|
952
961
|
<head>
|
|
953
962
|
<meta charset="utf-8">
|
|
@@ -1230,7 +1239,7 @@ async function copyReport(btn){
|
|
|
1230
1239
|
document.getElementById('copy-report').addEventListener('click',function(){ copyReport(this); });
|
|
1231
1240
|
</script>
|
|
1232
1241
|
</body>
|
|
1233
|
-
</html
|
|
1242
|
+
</html>`);
|
|
1234
1243
|
}
|
|
1235
1244
|
export async function writeCouncilHtml(runId, dir) {
|
|
1236
1245
|
const view = await loadCouncilView(runId, dir);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// v7 Phase B — `--mode auto`: a deterministic quick-vs-council routing decision made in the CLI
|
|
2
|
+
// BEFORE the engine runs. No model call, no learned routing (§22 forbids that). The rules err toward
|
|
3
|
+
// council: a false council only costs more calls, a false quick under-scrutinizes a risky decision.
|
|
4
|
+
// ponytail: v1 keyword heuristics, not NLP. Upgrade path = a scored intent matcher if precision matters.
|
|
5
|
+
const SENSITIVE = /\b(?:regulat|complian|legal|lawsuit|licens|liabilit|hipaa|gdpr|pci|soc\s?2|financ|fintech|invest|tax(?:es|ation)?|loan|mortgage|insuran|securities|security|vulnerab|exploit|breach|malware|phishing|credential|medical|patient|clinical|diagnos)\w*/i;
|
|
6
|
+
const RESEARCH = /\b(?:research|investigat|competitive analysis|market size|find (?:sources|evidence|data)|look up|up[- ]to[- ]date|latest data|current (?:data|figures|numbers|pricing)|cite sources?)\w*/i;
|
|
7
|
+
// ponytail: v1 decision-question heuristic; upgrade path = the same scored matcher as SENSITIVE/RESEARCH.
|
|
8
|
+
const DECISION_INTENT = /\b(?:should (?:i|we|you)|is it worth|worth (?:it|building|doing|switching|the)|do i (?:need|use)|(?:choose|pick|decide|select) (?:between|whether)|go with|switch(?:ing)? (?:to|from)|migrate (?:to|from)|which\b[^.?!]*\bor\b)/i;
|
|
9
|
+
export function buildTaskProfile(input, opts) {
|
|
10
|
+
const wordCount = input.trim().split(/\s+/).filter(Boolean).length;
|
|
11
|
+
return {
|
|
12
|
+
wordCount,
|
|
13
|
+
urlCount: opts.urlCount,
|
|
14
|
+
hasEvidencePack: opts.hasEvidencePack,
|
|
15
|
+
deliverablesBeyondDecision: opts.requestedOutputs.some((o) => o !== 'DECISION'),
|
|
16
|
+
hasSensitiveKeyword: SENSITIVE.test(input),
|
|
17
|
+
hasResearchWording: RESEARCH.test(input),
|
|
18
|
+
hasDecisionIntent: wordCount >= 4 && DECISION_INTENT.test(input),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Ordered deterministic rules: council if ANY signal fires, else quick. `fastPath` (Phase C, quick-only)
|
|
22
|
+
* is the 1-call single-pass path — eligible when quick AND the input reads as a direct decision question. */
|
|
23
|
+
export function resolveAutoMode(profile) {
|
|
24
|
+
const reasons = [];
|
|
25
|
+
if (profile.urlCount > 0)
|
|
26
|
+
reasons.push('URLs supplied');
|
|
27
|
+
if (profile.hasEvidencePack)
|
|
28
|
+
reasons.push('evidence pack supplied');
|
|
29
|
+
if (profile.hasSensitiveKeyword)
|
|
30
|
+
reasons.push('regulated/financial/security topic');
|
|
31
|
+
if (profile.deliverablesBeyondDecision)
|
|
32
|
+
reasons.push('deliverables beyond a decision requested');
|
|
33
|
+
if (profile.hasResearchWording)
|
|
34
|
+
reasons.push('research wording detected');
|
|
35
|
+
if (profile.wordCount > 120)
|
|
36
|
+
reasons.push('long or complex input (>120 words)');
|
|
37
|
+
if (reasons.length > 0)
|
|
38
|
+
return { mode: 'council', reasons, fastPath: false };
|
|
39
|
+
return { mode: 'quick', reasons: ['plain single-decision prompt'], fastPath: profile.hasDecisionIntent };
|
|
40
|
+
}
|
|
41
|
+
function evidenceForClaim(graph, claimId) {
|
|
42
|
+
const ids = new Set(graph.edges
|
|
43
|
+
.filter((edge) => edge.to === claimId && (edge.type === 'SUPPORTS' || edge.type === 'ATTACKS'))
|
|
44
|
+
.map((edge) => edge.from));
|
|
45
|
+
return graph.evidence.filter((evidence) => ids.has(evidence.id));
|
|
46
|
+
}
|
|
47
|
+
/** Phase D hard gates over validated graph structure; ordered for stable receipts and fixtures. */
|
|
48
|
+
export function structuralEscalationGates(graph) {
|
|
49
|
+
const gates = [];
|
|
50
|
+
const seen = new Set();
|
|
51
|
+
const add = (claimId, kind, reason) => {
|
|
52
|
+
const key = `${claimId}:${kind}`;
|
|
53
|
+
if (seen.has(key))
|
|
54
|
+
return;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
gates.push({ claimId, kind, reason });
|
|
57
|
+
};
|
|
58
|
+
for (const claim of graph.claims) {
|
|
59
|
+
const evidence = evidenceForClaim(graph, claim.id);
|
|
60
|
+
if (claim.sensitivity === 'DECISIVE'
|
|
61
|
+
&& !evidence.some((item) => item.source_kind === 'PRIMARY' || item.source_kind === 'SECONDARY')) {
|
|
62
|
+
add(claim.id, 'NO_INDEPENDENT_EVIDENCE', 'decisive claim has no independent evidence');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
for (const claim of graph.claims) {
|
|
66
|
+
const evidence = evidenceForClaim(graph, claim.id);
|
|
67
|
+
if ((claim.if_false === 'STOP' || claim.if_false === 'PIVOT')
|
|
68
|
+
&& evidence.length > 0
|
|
69
|
+
&& evidence.every((item) => item.source_kind === 'MODEL_KNOWLEDGE')) {
|
|
70
|
+
add(claim.id, 'MODEL_KNOWLEDGE_DECISION', 'STOP/PIVOT claim rests on model knowledge');
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const check of graph.calculation_checks) {
|
|
74
|
+
if (check.status === 'FAIL')
|
|
75
|
+
add(check.claim_id, 'FAILED_CALCULATION', 'deterministic calculation failed');
|
|
76
|
+
}
|
|
77
|
+
for (const claim of graph.claims) {
|
|
78
|
+
if (claim.load_bearing && evidenceForClaim(graph, claim.id)
|
|
79
|
+
.some((item) => item.source_kind === 'USER' && item.support === 'CONTRADICTS')) {
|
|
80
|
+
add(claim.id, 'SUPPLIED_SOURCE_CONTRADICTION', 'user-supplied evidence contradicts a load-bearing claim');
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
for (const claim of graph.claims) {
|
|
84
|
+
if (claim.load_bearing && claim.state === 'DISAGREEMENT') {
|
|
85
|
+
add(claim.id, 'LOAD_BEARING_DISAGREEMENT', 'load-bearing claims are mutually inconsistent');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return gates;
|
|
89
|
+
}
|
|
90
|
+
/** One challenge call is useful only when the stored graph contains something new to inspect. */
|
|
91
|
+
export function canProduceNewInformation(graph, claimId) {
|
|
92
|
+
if (!graph.claims.some((claim) => claim.id === claimId))
|
|
93
|
+
return false;
|
|
94
|
+
return evidenceForClaim(graph, claimId).some((item) => item.source_kind !== 'MODEL_KNOWLEDGE')
|
|
95
|
+
|| graph.calculation_checks.some((check) => check.claim_id === claimId && check.status === 'FAIL')
|
|
96
|
+
|| graph.edges.some((edge) => edge.type === 'DEPENDS_ON' && (edge.from === claimId || edge.to === claimId));
|
|
97
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// v6 semantic claim join (plan/AIKI-v6-council-integrity-plan.md T5). The lexical ≥0.8 grouping in
|
|
2
|
+
// S7 cannot join cross-provider paraphrases, so "0 consensus · 0 disputes" was structural (run
|
|
3
|
+
// f740: codex G3 and agy G13 both said "build `aiki serve`" and sat as two UNIQUE claims). S8 now
|
|
4
|
+
// emits `claim_groups`; this module validates them deterministically and OVERLAYS states onto the
|
|
5
|
+
// compiled graph — claim ids, propositions, edges, and stored artifacts are never mutated, and the
|
|
6
|
+
// state itself is computed by the SAME `classifyClaimState` machine the lexical path uses.
|
|
7
|
+
import { classifyClaimState } from './decision-graph.js';
|
|
8
|
+
/** Drop anything the model got wrong: unknown claim ids, duplicate ids, and groups whose claims
|
|
9
|
+
* all come from ONE provider — a model agreeing with itself is not consensus. */
|
|
10
|
+
export function sanitizeClaimGroups(graph, groups) {
|
|
11
|
+
if (!groups?.length)
|
|
12
|
+
return [];
|
|
13
|
+
const positionById = new Map(graph.positions.map((position) => [position.id, position]));
|
|
14
|
+
const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
|
|
15
|
+
return groups.flatMap((group) => {
|
|
16
|
+
const ids = [...new Set(group.ids)].filter((id) => claimById.has(id));
|
|
17
|
+
if (ids.length < 2)
|
|
18
|
+
return [];
|
|
19
|
+
const providers = new Set(ids.flatMap((id) => claimById.get(id).position_ids.map((positionId) => positionById.get(positionId)?.provider)
|
|
20
|
+
.filter((provider) => provider !== undefined)));
|
|
21
|
+
if (providers.size < 2)
|
|
22
|
+
return [];
|
|
23
|
+
return [{ ids, relation: group.relation }];
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/** Overlay group-derived states onto a copy of the graph. SAME → the union of the member claims'
|
|
27
|
+
* positions is re-classified by the existing state machine (CONSENSUS/SHARED_CONCERN/…);
|
|
28
|
+
* OPPOSES → DISAGREEMENT, which wins over any SAME assignment. Old artifacts (no groups) pass
|
|
29
|
+
* through unchanged, so every pre-v6 run keeps its exact rendering. */
|
|
30
|
+
export function overlayClaimGroups(graph, groups) {
|
|
31
|
+
const sane = sanitizeClaimGroups(graph, groups);
|
|
32
|
+
if (!sane.length)
|
|
33
|
+
return graph;
|
|
34
|
+
const positionById = new Map(graph.positions.map((position) => [position.id, position]));
|
|
35
|
+
const claimById = new Map(graph.claims.map((claim) => [claim.id, claim]));
|
|
36
|
+
const stateById = new Map();
|
|
37
|
+
for (const group of sane) {
|
|
38
|
+
if (group.relation === 'OPPOSES') {
|
|
39
|
+
for (const id of group.ids)
|
|
40
|
+
stateById.set(id, 'DISAGREEMENT');
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const union = group.ids
|
|
44
|
+
.flatMap((id) => claimById.get(id).position_ids)
|
|
45
|
+
.map((id) => positionById.get(id))
|
|
46
|
+
.filter((position) => position !== undefined);
|
|
47
|
+
const state = classifyClaimState(union);
|
|
48
|
+
for (const id of group.ids)
|
|
49
|
+
if (stateById.get(id) !== 'DISAGREEMENT')
|
|
50
|
+
stateById.set(id, state);
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
...graph,
|
|
54
|
+
claims: graph.claims.map((claim) => (stateById.has(claim.id) ? { ...claim, state: stateById.get(claim.id) } : claim)),
|
|
55
|
+
};
|
|
56
|
+
}
|