aiki-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/LICENSE +21 -0
  3. package/README.md +275 -0
  4. package/dist/bench/arms.js +104 -0
  5. package/dist/bench/harness.js +251 -0
  6. package/dist/bench/results.js +70 -0
  7. package/dist/bench/scoring/seeded-bugs.js +31 -0
  8. package/dist/cli/bench.js +50 -0
  9. package/dist/cli/config.js +51 -0
  10. package/dist/cli/doctor.js +115 -0
  11. package/dist/cli/index.js +129 -0
  12. package/dist/cli/models.js +51 -0
  13. package/dist/cli/providers.js +31 -0
  14. package/dist/cli/resolve.js +159 -0
  15. package/dist/cli/resume.js +94 -0
  16. package/dist/cli/run.js +155 -0
  17. package/dist/cli/sessions.js +35 -0
  18. package/dist/cli/show.js +73 -0
  19. package/dist/config/config.js +102 -0
  20. package/dist/config/smoke-cache.js +65 -0
  21. package/dist/council/open.js +26 -0
  22. package/dist/council/view.js +873 -0
  23. package/dist/orchestration/cluster.js +83 -0
  24. package/dist/orchestration/context.js +277 -0
  25. package/dist/orchestration/engine.js +92 -0
  26. package/dist/orchestration/git.js +133 -0
  27. package/dist/orchestration/jsonStage.js +32 -0
  28. package/dist/orchestration/skills.js +39 -0
  29. package/dist/orchestration/stages/cr-ladder.js +63 -0
  30. package/dist/orchestration/stages/cr-map.js +62 -0
  31. package/dist/orchestration/stages/cr-report.js +83 -0
  32. package/dist/orchestration/stages/cr-s4-review.js +69 -0
  33. package/dist/orchestration/stages/cr-s8-crossexam.js +104 -0
  34. package/dist/orchestration/stages/cr-s9-judge.js +89 -0
  35. package/dist/orchestration/stages/s0-grill.js +79 -0
  36. package/dist/orchestration/stages/s1-intent.js +25 -0
  37. package/dist/orchestration/stages/s10-render.js +198 -0
  38. package/dist/orchestration/stages/s2-misread.js +76 -0
  39. package/dist/orchestration/stages/s3-prompts.js +55 -0
  40. package/dist/orchestration/stages/s4-analyze.js +50 -0
  41. package/dist/orchestration/stages/s5-drift.js +40 -0
  42. package/dist/orchestration/stages/s6-claims.js +56 -0
  43. package/dist/orchestration/stages/s7-disagreement.js +134 -0
  44. package/dist/orchestration/stages/s8-verify.js +56 -0
  45. package/dist/orchestration/stages/s9-judge.js +152 -0
  46. package/dist/orchestration/stages/s9b-plan.js +192 -0
  47. package/dist/providers/adapter-core.js +131 -0
  48. package/dist/providers/adapters.js +9 -0
  49. package/dist/providers/agy.js +29 -0
  50. package/dist/providers/claude.js +56 -0
  51. package/dist/providers/codex.js +35 -0
  52. package/dist/providers/detect.js +21 -0
  53. package/dist/providers/probe.js +43 -0
  54. package/dist/providers/profiles.js +38 -0
  55. package/dist/providers/profiles.json +5 -0
  56. package/dist/providers/smoke.js +26 -0
  57. package/dist/providers/spawn.js +152 -0
  58. package/dist/providers/types.js +17 -0
  59. package/dist/schemas/index.js +374 -0
  60. package/dist/skills/.gitkeep +0 -0
  61. package/dist/skills/code-review/judge.md +23 -0
  62. package/dist/skills/code-review/reviewer.md +38 -0
  63. package/dist/skills/idea-refinement/analyst.md +45 -0
  64. package/dist/skills/idea-refinement/planner.md +25 -0
  65. package/dist/storage/feedback.js +111 -0
  66. package/dist/storage/paths.js +20 -0
  67. package/dist/storage/replay.js +0 -0
  68. package/dist/storage/runs-read.js +95 -0
  69. package/dist/storage/runs.js +129 -0
  70. package/dist/storage/sessions.js +71 -0
  71. package/dist/tui/app.js +444 -0
  72. package/dist/tui/format.js +27 -0
  73. package/dist/tui/index.js +8 -0
  74. package/dist/tui/smart-entry.js +106 -0
  75. package/dist/tui/timeline.js +91 -0
  76. package/dist/workflows/code-review.js +76 -0
  77. package/dist/workflows/idea-refinement.js +105 -0
  78. package/package.json +64 -0
@@ -0,0 +1,444 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ // The Ink app (T8, §11 screens). Thin/dumb rendering over the pure logic in timeline.ts + format.ts;
3
+ // all the engine work goes through the standard primitives (setupProviders → resolveRoles → RunCtx →
4
+ // executeRun) with an `events` object wired to React state. Ctrl+C aborts via an AbortController.
5
+ import { useEffect, useRef, useState } from 'react';
6
+ import { Box, Text, useApp, useInput } from 'ink';
7
+ import Spinner from 'ink-spinner';
8
+ import TextInput from 'ink-text-input';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { basename, dirname, join, resolve } from 'node:path';
11
+ import { DISPLAY_NAME } from '../providers/types.js';
12
+ import { RunCtx, DEFAULT_BUDGET, makeRunId, resolveRoles, setupProviders, } from '../orchestration/context.js';
13
+ import { executeRun } from '../orchestration/engine.js';
14
+ import { RunWriter } from '../storage/runs.js';
15
+ import { recordSession, updateSessionStatus, readSessions, findSession } from '../storage/sessions.js';
16
+ import { buildReplayCache } from '../storage/replay.js';
17
+ import { loadLayeredConfig, effectiveConfig } from '../config/config.js';
18
+ import { formatModels } from '../cli/models.js';
19
+ import { IDEA_STAGES, runIdeaRefinement } from '../workflows/idea-refinement.js';
20
+ import { CR_STAGES, runCodeReview } from '../workflows/code-review.js';
21
+ import { computeDiff, computeWorkingTreeDiff, detectRepoStatus } from '../orchestration/git.js';
22
+ import { loadCouncilView } from '../council/view.js';
23
+ import { openCouncilHtml } from '../council/open.js';
24
+ import { GLYPH, displayNames, elapsedLabel, initTimeline, markEnd, markStart, progressBar, runningPhrase, totalElapsed } from './timeline.js';
25
+ import { formatCompletion, formatError } from './format.js';
26
+ import { COMMANDS, PRODUCT_LINE, filterCommands, parseCommand, routeInput, scopeRedirect, suggestCommand } from './smart-entry.js';
27
+ async function loadCompletion(dir) {
28
+ try {
29
+ const [judge, map] = await Promise.all([
30
+ readFile(join(dir, '09-judge-report.json'), 'utf8').then((s) => JSON.parse(s)),
31
+ readFile(join(dir, '07-disagreement-map.json'), 'utf8').then((s) => JSON.parse(s)),
32
+ ]);
33
+ return formatCompletion(dir, judge, map);
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
39
+ export function App(props) {
40
+ const { roleOverrides, budget: budgetOverride, runsRoot, providerModels, version } = props;
41
+ const { exit } = useApp();
42
+ const [phase, setPhase] = useState('detecting');
43
+ const [workflow, setWorkflow] = useState('idea-refinement');
44
+ const [handles, setHandles] = useState([]);
45
+ const [repo, setRepo] = useState(null);
46
+ const [idea, setIdea] = useState('');
47
+ const [routerMessage, setRouterMessage] = useState('');
48
+ const [rows, setRows] = useState([]);
49
+ const [now, setNow] = useState(Date.now());
50
+ const [budget, setBudget] = useState(0);
51
+ const [dir, setDir] = useState('');
52
+ const [grill, setGrill] = useState(null);
53
+ const [grillTyping, setGrillTyping] = useState(false);
54
+ const [grillText, setGrillText] = useState('');
55
+ const [clarify, setClarify] = useState(null);
56
+ const [clarifyTyping, setClarifyTyping] = useState(false); // "type your own" sub-mode
57
+ const [clarifyText, setClarifyText] = useState('');
58
+ const [panel, setPanel] = useState(null); // V9: /sessions /models /config /help output
59
+ const [sel, setSel] = useState(0); // V10: command-palette highlight index
60
+ // V10: TextInput only puts the cursor at the end on MOUNT (ink-text-input keeps the old offset on an
61
+ // external value change). Bumping this key remounts it after a Tab-complete so typing continues at the end.
62
+ const [inputEpoch, setInputEpoch] = useState(0);
63
+ const [pendingIdea, setPendingIdea] = useState(null); // V10: confirm gate before a paid run
64
+ const [completion, setCompletion] = useState(null);
65
+ const [councilView, setCouncilView] = useState(null);
66
+ const [errorView, setErrorView] = useState(null);
67
+ const [aborted, setAborted] = useState(false);
68
+ const abortRef = useRef(null);
69
+ const ctxRef = useRef(null);
70
+ // Detect providers + repo context once, up front.
71
+ useEffect(() => {
72
+ let alive = true;
73
+ void Promise.all([setupProviders(providerModels), detectRepoStatus(process.cwd())]).then(([hs, repoStatus]) => {
74
+ if (!alive)
75
+ return;
76
+ setRepo(repoStatus);
77
+ if (hs.length < 2) {
78
+ setErrorView(formatError('QUORUM'));
79
+ setPhase('finished');
80
+ return;
81
+ }
82
+ setHandles(hs);
83
+ setPhase('input');
84
+ });
85
+ return () => {
86
+ alive = false;
87
+ };
88
+ }, []);
89
+ // Live clock for elapsed labels while running.
90
+ useEffect(() => {
91
+ if (phase !== 'running' && phase !== 'grill' && phase !== 'clarify')
92
+ return;
93
+ const t = setInterval(() => setNow(Date.now()), 500);
94
+ return () => clearInterval(t);
95
+ }, [phase]);
96
+ // V10 command palette: matches for what's being typed (only on the input screen, not mid-confirm).
97
+ const paletteMatches = phase === 'input' && pendingIdea === null ? filterCommands(idea) : [];
98
+ const selIdx = Math.min(sel, Math.max(paletteMatches.length - 1, 0));
99
+ const fallbackGrillAnswers = (brief) => brief.questions.map((q) => ({ question_id: q.id, answer: 'Use best judgment from the supplied prompt.', source: 'default' }));
100
+ const submitGrillAnswer = (answer, source) => {
101
+ if (!grill)
102
+ return;
103
+ const q = grill.brief.questions[grill.index];
104
+ if (!q)
105
+ return;
106
+ const next = [...grill.answers, { question_id: q.id, answer: answer.trim(), source }];
107
+ setGrillTyping(false);
108
+ setGrillText('');
109
+ if (next.length >= grill.brief.questions.length) {
110
+ const resolveGrill = grill.resolve;
111
+ setGrill(null);
112
+ setPhase('running');
113
+ resolveGrill(next);
114
+ return;
115
+ }
116
+ setGrill({ ...grill, index: grill.index + 1, answers: next });
117
+ };
118
+ useInput((input, key) => {
119
+ if (key.ctrl && input === 'c') {
120
+ abortRef.current?.abort();
121
+ if (grill) {
122
+ grill.resolve(fallbackGrillAnswers(grill.brief)); // unblock S0 so the run reaches its abort guard
123
+ setGrill(null);
124
+ setGrillTyping(false);
125
+ setGrillText('');
126
+ }
127
+ if (clarify)
128
+ clarify.resolve({ kind: 'pick', index: 0 }); // unblock S2 so the run reaches its abort guard
129
+ if (phase === 'detecting' || phase === 'input' || phase === 'finished')
130
+ exit();
131
+ return;
132
+ }
133
+ // V10 confirm gate: the TextInput is unmounted while pending, so Enter/Esc arrive here only.
134
+ if (phase === 'input' && pendingIdea !== null) {
135
+ if (key.return) {
136
+ const text = pendingIdea;
137
+ setPendingIdea(null);
138
+ startIdea(text);
139
+ }
140
+ else if (key.escape) {
141
+ setPendingIdea(null);
142
+ setRouterMessage('cancelled — nothing was run.');
143
+ }
144
+ return;
145
+ }
146
+ // V10: Esc on the home screen clears everything typed/shown — universal "get me out" key.
147
+ if (phase === 'input' && key.escape) {
148
+ setIdea('');
149
+ setPanel(null);
150
+ setRouterMessage('');
151
+ setSel(0);
152
+ return;
153
+ }
154
+ // V10 palette keys: ↑/↓ move the highlight, Tab completes into the box. (Enter submits via TextInput.)
155
+ if (phase === 'input' && paletteMatches.length > 0) {
156
+ if (key.upArrow)
157
+ return void setSel((selIdx - 1 + paletteMatches.length) % paletteMatches.length);
158
+ if (key.downArrow)
159
+ return void setSel((selIdx + 1) % paletteMatches.length);
160
+ if (key.tab) {
161
+ setIdea(`/${paletteMatches[selIdx].name} `);
162
+ setSel(0);
163
+ setInputEpoch((e) => e + 1); // remount the input → cursor lands after "/command " ready to type
164
+ return;
165
+ }
166
+ }
167
+ // S0 grill key handling — the "type your own" sub-mode lets TextInput capture keys instead.
168
+ if (phase === 'grill' && grill && !grillTyping) {
169
+ const q = grill.brief.questions[grill.index];
170
+ if (!q)
171
+ return;
172
+ const n = Number.parseInt(input, 10);
173
+ const N = q.suggested_answers.length;
174
+ if (!Number.isNaN(n)) {
175
+ if (n >= 1 && n <= N)
176
+ submitGrillAnswer(q.suggested_answers[n - 1], 'suggested');
177
+ else if (n === N + 1)
178
+ setGrillTyping(true);
179
+ else if (n === N + 2)
180
+ submitGrillAnswer('Use best judgment from the supplied prompt.', 'default');
181
+ }
182
+ return;
183
+ }
184
+ // Clarify key handling — the "type your own" sub-mode lets TextInput capture keys instead.
185
+ if (phase === 'clarify' && clarify && !clarifyTyping) {
186
+ const n = Number.parseInt(input, 10);
187
+ const N = clarify.options.length;
188
+ if (!Number.isNaN(n)) {
189
+ if (n >= 1 && n <= N)
190
+ clarify.resolve({ kind: 'pick', index: n - 1 });
191
+ else if (n === N + 1)
192
+ clarify.resolve({ kind: 'both' });
193
+ else if (n === N + 2)
194
+ setClarifyTyping(true);
195
+ }
196
+ return;
197
+ }
198
+ if (phase === 'finished')
199
+ exit();
200
+ });
201
+ const startRun = (wf, text, cwd, runner, stages, replay) => {
202
+ const trimmed = text.trim();
203
+ if (!trimmed)
204
+ return;
205
+ setPanel(null);
206
+ const available = handles.map((h) => h.id);
207
+ const rs = resolveRoles(wf, available, roleOverrides);
208
+ const runId = makeRunId(wf);
209
+ const writer = new RunWriter(runId, runsRoot);
210
+ const controller = new AbortController();
211
+ abortRef.current = controller;
212
+ const events = {
213
+ onStageStart: (id) => setRows((r) => markStart(r, id, Date.now())),
214
+ onStageEnd: (id, st) => setRows((r) => markEnd(r, id, st, Date.now())),
215
+ grill: (brief) => new Promise((res) => {
216
+ setGrill({
217
+ brief,
218
+ index: 0,
219
+ answers: [],
220
+ resolve: (answers) => {
221
+ setGrill(null);
222
+ setGrillTyping(false);
223
+ setGrillText('');
224
+ setPhase('running');
225
+ res(answers);
226
+ },
227
+ });
228
+ setPhase('grill');
229
+ }),
230
+ clarify: (question, options) => new Promise((res) => {
231
+ setClarify({
232
+ question,
233
+ options,
234
+ resolve: (c) => {
235
+ setClarify(null);
236
+ setClarifyTyping(false);
237
+ setClarifyText('');
238
+ setPhase('running');
239
+ res(c);
240
+ },
241
+ });
242
+ setPhase('clarify');
243
+ }),
244
+ };
245
+ const ctx = new RunCtx({ runId, workflow: wf, handles, roles: rs, writer, cwd: cwd ?? writer.dir, budget: budgetOverride, signal: controller.signal, events, replay });
246
+ ctxRef.current = ctx;
247
+ setWorkflow(wf);
248
+ setRows(initTimeline(stages, rs, available));
249
+ setBudget(ctx.budget.limit);
250
+ setDir(writer.dir);
251
+ setCompletion(null);
252
+ setCouncilView(null);
253
+ setErrorView(null);
254
+ setRouterMessage('');
255
+ setPhase('running');
256
+ void recordSession({ id: runId, workflow: wf, cwd: cwd ?? writer.dir, runsRoot: resolve(dirname(dirname(writer.dir))), startedAt: new Date().toISOString(), status: 'running' });
257
+ void executeRun(ctx, trimmed, runner).then(async (o) => {
258
+ const wasAborted = ctx.aborted;
259
+ setAborted(wasAborted);
260
+ void updateSessionStatus(o.runId, wasAborted ? 'aborted' : o.ok ? 'ok' : 'failed');
261
+ if (o.ok) {
262
+ setCouncilView(await loadCouncilView(o.runId, o.dir));
263
+ setCompletion(wf === 'idea-refinement' ? await loadCompletion(o.dir) : null);
264
+ void openCouncilHtml(o.runId, o.dir); // auto-open the readable report in the browser
265
+ }
266
+ else if (!wasAborted)
267
+ setErrorView(formatError(o.error?.code ?? 'CRASH', o.dir || undefined));
268
+ setPhase('finished');
269
+ });
270
+ };
271
+ const startIdea = (text) => startRun('idea-refinement', text, null, runIdeaRefinement, IDEA_STAGES);
272
+ const startCodeReview = async (action) => {
273
+ if (!repo || !repo.defaultBranch) {
274
+ setRouterMessage('code review needs a git repo with a detectable default branch');
275
+ return;
276
+ }
277
+ try {
278
+ const diff = action === 'review-working-tree'
279
+ ? await computeWorkingTreeDiff(repo.defaultBranch, repo.root)
280
+ : await computeDiff(repo.defaultBranch, 'HEAD', repo.root);
281
+ if (!diff.trim()) {
282
+ setRouterMessage('no changes to review');
283
+ return;
284
+ }
285
+ startRun('code-review', diff, repo.root, runCodeReview, CR_STAGES);
286
+ }
287
+ catch (e) {
288
+ setRouterMessage(e instanceof Error ? e.message : String(e));
289
+ }
290
+ };
291
+ const sessionsPanel = async (lead) => {
292
+ const all = (await readSessions()).slice(0, 12);
293
+ if (!all.length)
294
+ return 'no sessions yet.';
295
+ const mark = { running: '●', ok: '✔', failed: '✖', aborted: '⊘' };
296
+ const rows = all.map((s) => ` ${mark[s.status]} ${s.id} ${s.workflow}${s.status === 'failed' || s.status === 'aborted' ? ' /resume ' + s.id : ''}`);
297
+ return [lead ?? 'Recent sessions:', ...rows].join('\n');
298
+ };
299
+ const configPanel = async () => {
300
+ try {
301
+ return 'Effective config:\n' + JSON.stringify(effectiveConfig(await loadLayeredConfig()), null, 2);
302
+ }
303
+ catch (e) {
304
+ return e instanceof Error ? e.message : String(e);
305
+ }
306
+ };
307
+ const helpPanel = () => [
308
+ 'aiki — a local council of your installed AI CLIs (Claude, Codex, Gemini).',
309
+ 'It stress-tests ideas and reviews code by making the models cross-examine',
310
+ 'each other; a judge settles disputes. Artifacts land in .aiki/runs/.',
311
+ '',
312
+ 'Commands:',
313
+ ...COMMANDS.map((c) => ` ${c.usage.padEnd(20)} ${c.help}`),
314
+ '',
315
+ 'Examples:',
316
+ ' /idea a fridge-to-recipe app for students',
317
+ ' /review --branch review this branch vs the default branch',
318
+ ' /resume 20260707-1645 continue a stopped run (finished calls replay free)',
319
+ '',
320
+ 'Plain text works too — it routes to the idea flow, with a confirm step',
321
+ 'so nothing spends model calls until you say so.',
322
+ ].join('\n');
323
+ const resumeInTui = async (idArg) => {
324
+ if (!idArg)
325
+ return void setPanel(await sessionsPanel('Resume which? type /resume <id>'));
326
+ const sess = await findSession(idArg);
327
+ if (!sess)
328
+ return void setRouterMessage(`no session matches "${idArg}" — see /sessions`);
329
+ if ('ambiguous' in sess)
330
+ return void setRouterMessage(`"${idArg}" is ambiguous: ${sess.ambiguous.join(', ')}`);
331
+ const wf = sess.workflow;
332
+ const oldDir = join(sess.runsRoot, 'runs', sess.id);
333
+ let input;
334
+ try {
335
+ input = await readFile(join(oldDir, 'inputs', wf === 'code-review' ? 'diff.patch' : 'idea.md'), 'utf8');
336
+ }
337
+ catch {
338
+ return void setRouterMessage(`can't recover the input for ${sess.id} — nothing to resume`);
339
+ }
340
+ const replay = await buildReplayCache(oldDir);
341
+ if (replay.size === 0)
342
+ return void setRouterMessage(`no completed calls for ${sess.id} — start fresh`);
343
+ const [runner, stages] = wf === 'code-review' ? [runCodeReview, CR_STAGES] : [runIdeaRefinement, IDEA_STAGES];
344
+ startRun(wf, input, wf === 'code-review' ? sess.cwd : null, runner, stages, replay);
345
+ };
346
+ const runCommand = async (p) => {
347
+ setRouterMessage('');
348
+ setPanel(null);
349
+ switch (p.cmd) {
350
+ case 'idea':
351
+ if (p.rest)
352
+ startIdea(p.rest);
353
+ else
354
+ setRouterMessage('type your idea after the command, e.g. /idea a fridge-to-recipe app');
355
+ return;
356
+ case 'review':
357
+ void startCodeReview(p.args.includes('--branch') || p.args.includes('-b') ? 'review-branch' : 'review-working-tree');
358
+ return;
359
+ case 'resume':
360
+ void resumeInTui(p.args[0]);
361
+ return;
362
+ case 'sessions':
363
+ setPanel('loading…');
364
+ setPanel(await sessionsPanel());
365
+ return;
366
+ case 'models':
367
+ setPanel('loading models…');
368
+ setPanel(await formatModels());
369
+ return;
370
+ case 'config':
371
+ setPanel(await configPanel());
372
+ return;
373
+ case 'help':
374
+ case '':
375
+ setPanel(helpPanel());
376
+ return;
377
+ default: {
378
+ const near = suggestCommand(p.cmd);
379
+ setRouterMessage(`unknown command /${p.cmd}${near ? ` — did you mean /${near}?` : ' — type /help'}`);
380
+ }
381
+ }
382
+ };
383
+ const submitInput = (text) => {
384
+ const parsed = parseCommand(text);
385
+ if (parsed) {
386
+ // V10: Enter with the palette open runs the HIGHLIGHTED command when the typed word isn't
387
+ // itself a known command (so "/mo" ⏎ runs /models; "/review" ⏎ still runs review exactly).
388
+ const known = COMMANDS.some((c) => c.name === parsed.cmd);
389
+ setIdea('');
390
+ setSel(0);
391
+ if (!known && !parsed.rest && paletteMatches.length > 0) {
392
+ void runCommand({ cmd: paletteMatches[selIdx].name, rest: '', args: [] });
393
+ return;
394
+ }
395
+ void runCommand(parsed);
396
+ return;
397
+ }
398
+ setPanel(null);
399
+ // V10.2: "explore my codebase / brainstorm features" → a scope-explaining redirect, BEFORE we would
400
+ // otherwise mis-route it into a full (paid) idea-refinement run on the request sentence.
401
+ const redirect = scopeRedirect(text);
402
+ if (redirect) {
403
+ setIdea('');
404
+ setRouterMessage(redirect);
405
+ return;
406
+ }
407
+ const route = routeInput(text);
408
+ if (route === 'question') {
409
+ setIdea('');
410
+ setRouterMessage(`${PRODUCT_LINE} Type /idea if you meant an idea.`);
411
+ return;
412
+ }
413
+ if (route === 'code-review') {
414
+ setIdea('');
415
+ setRouterMessage(repo ? 'That looks code-related. Type /review (working tree) or /review --branch.' : 'That looks code-related. Open aiki inside a git repo, then /review.');
416
+ return;
417
+ }
418
+ // V10 confirm gate: plain text never starts a paid run directly — show what will happen first.
419
+ setIdea('');
420
+ setRouterMessage('');
421
+ setPendingIdea(text);
422
+ };
423
+ // ── render ────────────────────────────────────────────────────────────────
424
+ return (_jsxs(Box, { flexDirection: "column", paddingX: 1, children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, color: "cyan", children: "aiki" }), version ? _jsxs(Text, { dimColor: true, children: [" v", version] }) : null, _jsxs(Text, { dimColor: true, children: [" \u00B7 ", phase === 'input' ? 'local model council — ideas & code review' : workflow] })] }), phase === 'detecting' && (_jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), ' ', "detecting providers\u2026"] })), (phase === 'input' || phase === 'running' || phase === 'grill' || phase === 'clarify' || phase === 'finished') && handles.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [repo && (_jsxs(Text, { children: ["repo: ", repo.name, " \u2014 ", repo.changedFiles, " changed files vs ", repo.defaultBranch ?? 'unknown default branch'] })), _jsxs(Text, { children: [handles.map((h, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: " \u00B7 " }) : null, _jsx(Text, { color: "green", children: "\u2714" }), " ", DISPLAY_NAME[h.id], h.version ? _jsxs(Text, { dimColor: true, children: [" ", h.version] }) : null] }, h.id))), _jsx(Text, { dimColor: true, children: " \u2014 council ready" })] })] })), phase === 'input' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "/idea " }), _jsx(Text, { dimColor: true, children: '<text>' }), ' stress-test an idea'] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "/review " }), _jsx(Text, { dimColor: true, children: "[--branch]" }), ' review your changes', repo ? '' : _jsx(Text, { dimColor: true, children: " (needs a git repo)" })] }), _jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: "/resume " }), _jsx(Text, { dimColor: true, children: '<id>' }), ' continue a stopped run · ', _jsx(Text, { color: "cyan", children: "/sessions" }), ' ', _jsx(Text, { color: "cyan", children: "/models" }), ' ', _jsx(Text, { color: "cyan", children: "/config" }), ' ', _jsx(Text, { color: "cyan", children: "/help" })] })] }), pendingIdea !== null ? (
425
+ /* V10 confirm gate — nothing spends model calls until Enter. */
426
+ _jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [_jsx(Text, { children: "Run the idea council on:" }), _jsxs(Text, { color: "cyan", children: [" \u201C", pendingIdea.length > 100 ? `${pendingIdea.slice(0, 97)}…` : pendingIdea, "\u201D"] }), _jsxs(Text, { dimColor: true, children: [" 12-stage pipeline \u00B7 up to ", budgetOverride ?? DEFAULT_BUDGET, " model calls \u00B7 Ctrl+C aborts mid-run"] }), _jsxs(Text, { children: [_jsx(Text, { color: "green", children: "enter" }), " run \u00B7 ", _jsx(Text, { color: "yellow", children: "esc" }), " cancel"] })] })) : (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "Type a command, or just describe your idea and press Enter:" }), _jsxs(Box, { borderStyle: "round", paddingX: 1, children: [_jsx(Text, { children: "\u25B8 " }), _jsx(TextInput, { value: idea, onChange: (v) => setIdea(v.replace(/\s*[\r\n]+\s*/g, ' ').replace(/\t/g, '')), onSubmit: submitInput }, inputEpoch)] })] })), paletteMatches.length > 0 && (
427
+ /* V10 live command palette — filtered as you type; ↑↓ move, Tab completes, Enter runs. */
428
+ _jsxs(Box, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [paletteMatches.map((m, i) => (_jsxs(Text, { children: [_jsxs(Text, { color: i === selIdx ? 'cyan' : undefined, bold: i === selIdx, children: [i === selIdx ? '▸ ' : ' ', "/", m.name] }), _jsx(Text, { dimColor: true, children: ` ${m.usage.replace(`/${m.name}`, '').trim().padEnd(10)} ${m.help}` })] }, m.name))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 select \u00B7 tab complete \u00B7 enter run" })] })), routerMessage ? _jsx(Text, { color: "yellow", children: routerMessage }) : null, panel ? (_jsx(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", paddingX: 1, children: panel.split('\n').map((l, i) => (_jsx(Text, { children: l }, i))) })) : (_jsx(Text, { dimColor: true, children: "new here? /help explains how aiki works \u00B7 long idea? `aiki run idea-refinement ./idea.md`" }))] })), (phase === 'running' || phase === 'grill' || phase === 'clarify' || phase === 'finished') && rows.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [rows.map((r) => {
429
+ const color = r.status === 'done' ? 'green' : r.status === 'failed' ? 'red' : r.status === 'running' ? 'yellow' : 'gray';
430
+ return (_jsxs(Text, { children: [r.status === 'running' ? (_jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) })) : (_jsx(Text, { color: color, children: GLYPH[r.status] })), ' ', r.id.padEnd(3), " ", r.label.padEnd(24), _jsx(Text, { dimColor: true, children: displayNames(r.providers) || '—' }), " ", ' ', elapsedLabel(r, now)] }, r.id));
431
+ }), phase === 'running' &&
432
+ (() => {
433
+ const running = rows.find((r) => r.status === 'running');
434
+ const p = progressBar(rows);
435
+ const secs = running?.startedAt !== undefined ? Math.floor((now - running.startedAt) / 1000) : 0;
436
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: "cyan", children: p.bar }), _jsxs(Text, { dimColor: true, children: [" ", p.done, "/", p.total] }), running ? _jsxs(Text, { color: "yellow", children: [" ", runningPhrase(running.id, secs), "\u2026"] }) : null] }), _jsxs(Text, { dimColor: true, children: ["calls used ", ctxRef.current?.calls.length ?? 0, "/", budget, " \u00B7 Ctrl+C aborts (artifacts kept)"] })] }));
437
+ })()] })), phase === 'grill' && grill &&
438
+ (() => {
439
+ const q = grill.brief.questions[grill.index];
440
+ if (!q)
441
+ return null;
442
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", paddingX: 1, children: [_jsxs(Text, { bold: true, children: ["Intent preflight ", grill.index + 1, "/", grill.brief.questions.length] }), _jsx(Text, { children: q.question }), _jsx(Text, { dimColor: true, children: q.why_it_matters }), q.suggested_answers.map((o, i) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: i + 1 }), ". ", o] }, i))), _jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: q.suggested_answers.length + 1 }), ". other - type your own"] }), _jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: q.suggested_answers.length + 2 }), ". skip - use best judgment"] }), grillTyping ? (_jsxs(Box, { borderStyle: "round", paddingX: 1, marginTop: 1, children: [_jsx(Text, { children: "\u25B8 " }), _jsx(TextInput, { value: grillText, onChange: (v) => setGrillText(v.replace(/\s*[\r\n]+\s*/g, ' ')), onSubmit: () => grillText.trim() && submitGrillAnswer(grillText, 'user') })] })) : (_jsxs(Text, { dimColor: true, children: ["press 1-", q.suggested_answers.length + 2, " to choose"] }))] }));
443
+ })(), phase === 'clarify' && clarify && (_jsxs(Box, { flexDirection: "column", marginTop: 1, borderStyle: "round", paddingX: 1, children: [_jsx(Text, { bold: true, children: clarify.question }), clarify.options.map((o, i) => (_jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: i + 1 }), ". ", o] }, i))), _jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: clarify.options.length + 1 }), ". ", clarify.options.length === 2 ? 'both readings — combine them' : 'all readings — combine them'] }), _jsxs(Text, { children: [' ', _jsx(Text, { color: "cyan", children: clarify.options.length + 2 }), ". other \u2014 type your own"] }), clarifyTyping ? (_jsxs(Box, { borderStyle: "round", paddingX: 1, marginTop: 1, children: [_jsx(Text, { children: "\u25B8 " }), _jsx(TextInput, { value: clarifyText, onChange: (v) => setClarifyText(v.replace(/\s*[\r\n]+\s*/g, ' ')), onSubmit: () => clarifyText.trim() && clarify.resolve({ kind: 'text', text: clarifyText }) })] })) : (_jsxs(Text, { dimColor: true, children: ["press 1\u2013", clarify.options.length + 2, " to choose"] }))] })), phase === 'finished' && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [aborted && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["\u2298 Run aborted \u2014 partial artifacts at ", dir] }), _jsxs(Text, { dimColor: true, children: [" finished calls replay free: aiki resume ", basename(dir)] })] })), !aborted && errorView && (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1, children: [_jsxs(Text, { color: "red", bold: true, children: ["\u2716 Run failed [", errorView.code, "]"] }), _jsx(Text, { children: errorView.fix }), errorView.partialDir ? _jsxs(Text, { dimColor: true, children: ["partial artifacts: ", errorView.partialDir] }) : null] })), !aborted && !errorView && councilView && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "\u2714 Run complete" }), totalElapsed(rows) ? _jsxs(Text, { dimColor: true, children: [" \u00B7 council adjourned in ", totalElapsed(rows)] }) : null] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Verdict" }), _jsx(Text, { children: councilView.verdict }), _jsxs(Text, { dimColor: true, children: [councilView.calls, " \u00B7 ", councilView.stats.join(' · ')] })] }), _jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Providers" }), councilView.columns.map((c) => (_jsxs(Text, { children: [c.title, ": ", c.lines[0] ?? 'no role output recorded'] }, c.provider)))] }), councilView.rows.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Council map" }), councilView.rows.slice(0, 5).map((r, i) => (_jsxs(Text, { children: [r.kind.toUpperCase(), " \u00B7 ", r.title, r.ruling ? ` · judge: ${r.ruling}` : ''] }, i)))] })), _jsxs(Text, { dimColor: true, children: ['\n', "report: ", dir, "/final-report.md"] }), _jsxs(Text, { dimColor: true, children: ["report opened in your browser \u00B7 reopen: aiki show ", councilView.runId, " --html --open"] })] })), !aborted && !errorView && !councilView && (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { color: "green", bold: true, children: "\u2714 Run complete" }), totalElapsed(rows) ? _jsxs(Text, { dimColor: true, children: [" \u00B7 council adjourned in ", totalElapsed(rows)] }) : null] }), completion ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Verdict" }), _jsx(Text, { children: completion.verdict }), completion.disagreements.length > 0 && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { bold: true, children: "Top disagreements" }), completion.disagreements.map((d, i) => (_jsxs(Text, { children: [' ', d] }, i)))] })), _jsxs(Text, { dimColor: true, children: ['\n', "report: ", completion.reportPath] })] })) : (_jsxs(Text, { dimColor: true, children: ["artifacts: ", dir] }))] })), _jsxs(Text, { dimColor: true, children: ['\n', "press any key to exit"] })] }))] }));
444
+ }
@@ -0,0 +1,27 @@
1
+ // Pure formatters for the completion + error screens (T8, §4.3, §471). No Ink — unit-tested directly.
2
+ /** Completion summary (§4.3): verdict + the top-N contradictions with their adjudication ruling. */
3
+ export function formatCompletion(dir, judge, map, topN = 3) {
4
+ const ruling = new Map(judge.adjudications.map((a) => [a.id, a.ruling]));
5
+ const disagreements = map.contradictions.slice(0, topN).map((d) => {
6
+ const r = ruling.get(d.id) ?? 'UNRESOLVED';
7
+ const arg = d.attacks[0]?.argument ?? '';
8
+ return `${d.id} → ${r}: ${arg}`;
9
+ });
10
+ return { verdict: judge.verdict, disagreements, reportPath: `${dir}/final-report.md`, rawPath: `${dir}/raw/` };
11
+ }
12
+ /** Actionable fix line per classified error (§471 error panel). */
13
+ const FIX = {
14
+ AUTH: "provider needs login — run it once in a terminal (e.g. `claude`)",
15
+ QUOTA: 'provider quota / rate limit hit — wait, or switch the role to another provider',
16
+ NOT_FOUND: 'provider binary not on PATH — run `aiki doctor`',
17
+ TIMEOUT: 'a provider call exceeded its timeout',
18
+ BAD_OUTPUT: 'a provider returned unparseable output even after the repair retry',
19
+ CRASH: 'a provider process exited abnormally',
20
+ QUORUM: 'need ≥2 providers ready — run `aiki doctor`',
21
+ BUDGET: 'call budget exhausted — raise it with `--budget <n>`',
22
+ DEADLINE: 'run exceeded its wall-clock deadline',
23
+ ABORT: 'run aborted',
24
+ };
25
+ export function formatError(code, partialDir) {
26
+ return { code, fix: FIX[code] ?? 'see the run logs under .aiki/', ...(partialDir ? { partialDir } : {}) };
27
+ }
@@ -0,0 +1,8 @@
1
+ // TUI entry (T8). `render` with exitOnCtrlC:false — the app handles Ctrl+C itself (graceful abort).
2
+ // Config (role pins / budget from .aiki/config.json, T9) is loaded by the CLI entry and passed in here.
3
+ import React from 'react';
4
+ import { render } from 'ink';
5
+ import { App } from './app.js';
6
+ export function startTui(opts = {}) {
7
+ render(React.createElement(App, opts), { exitOnCtrlC: false });
8
+ }
@@ -0,0 +1,106 @@
1
+ export const PRODUCT_LINE = 'aiki stress-tests ideas and reviews code; for general questions use a single model — a council adds cost, not accuracy, when there is one right answer.';
2
+ const QUESTION_START = /^(what|why|how|who|when|where|is|are|can|could|should|would|do|does|did|will)\b/i;
3
+ const CODE_MARKER = /(diff --git|^@@|\+\+\+ b\/|--- a\/|```|[A-Za-z0-9_-]+\/[A-Za-z0-9_./-]+\.(ts|tsx|js|jsx|py|go|rs|java|rb|php|css|html|md)\b|\b(function|const|let|class|import|export)\b|[{};])/m;
4
+ // ── Scope redirect (V10.2) — catch "explore my whole codebase / brainstorm features for me" asks and
5
+ // point them at the right door, instead of silently stress-testing the request sentence as an "idea".
6
+ // aiki reviews a DIFF and vets a STATED idea; it does not roam a repo (§3/§22). Pure + deterministic. ──
7
+ // "go through / analyze / review MY|THIS|THE code|codebase|repo|project|files"
8
+ const CODEBASE_SCAN = /\b(go through|read|scan|analy[sz]e|explore|look (?:at|through|into)|review|audit|inspect|check)\b[^.?!]*\b(my|this|the|our|these)\s+(code|code ?base|repo|repository|project|files|source)\b/i;
9
+ // "what|which ... features|improvements|improve|add|build" (interrogative — genuine ideas don't lead with it)
10
+ const BRAINSTORM = /\b(what|which)\b[^.?!]{0,60}\b(features?|improvements?|improve|add|build)\b/i;
11
+ export const SCOPE_REDIRECT_MSG = 'aiki reviews your *changes*, not a whole codebase, and vets a *specific* idea rather than brainstorming for you. Try /review for your current changes, or /idea "<one concrete idea>" to stress-test a specific one.';
12
+ /** A helpful scope message if the text is a codebase-exploration / feature-brainstorm ask, else null. */
13
+ export function scopeRedirect(text) {
14
+ const t = text.trim();
15
+ if (!t)
16
+ return null;
17
+ return CODEBASE_SCAN.test(t) || BRAINSTORM.test(t) ? SCOPE_REDIRECT_MSG : null;
18
+ }
19
+ export function routeInput(text) {
20
+ const trimmed = text.trim();
21
+ if (!trimmed)
22
+ return 'idea';
23
+ const codeLike = CODE_MARKER.test(trimmed);
24
+ if (codeLike)
25
+ return 'code-review';
26
+ const words = trimmed.split(/\s+/).filter(Boolean).length;
27
+ if (words <= 18 && (trimmed.endsWith('?') || QUESTION_START.test(trimmed)))
28
+ return 'question';
29
+ return 'idea';
30
+ }
31
+ /** The home-screen command list (also drives `/help`). */
32
+ export const COMMANDS = [
33
+ { name: 'idea', usage: '/idea <text>', help: 'stress-test an idea with the council' },
34
+ { name: 'review', usage: '/review [--branch]', help: 'review your working-tree changes (or the branch)' },
35
+ { name: 'resume', usage: '/resume <id>', help: 'continue a killed/timed-out run (replays finished work)' },
36
+ { name: 'sessions', usage: '/sessions', help: 'list past runs (newest first)' },
37
+ { name: 'models', usage: '/models', help: 'show/choose the model each provider uses' },
38
+ { name: 'config', usage: '/config', help: 'show the effective config' },
39
+ { name: 'help', usage: '/help', help: 'this list' },
40
+ ];
41
+ /** Live palette filter (V10): while the user is typing the command word (input starts with "/",
42
+ * no space yet), return the commands it matches — prefix matches first, then substring matches
43
+ * (so "/mo" and "/dels" both find models). Bare "/" lists everything. Pure + deterministic. */
44
+ export function filterCommands(input) {
45
+ const t = input.trimStart();
46
+ if (!t.startsWith('/') || /\s/.test(t))
47
+ return [];
48
+ const q = t.slice(1).toLowerCase();
49
+ const prefix = COMMANDS.filter((c) => c.name.startsWith(q));
50
+ const substr = COMMANDS.filter((c) => !c.name.startsWith(q) && c.name.includes(q));
51
+ return [...prefix, ...substr];
52
+ }
53
+ function editDistance(a, b) {
54
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => {
55
+ const row = new Array(b.length + 1).fill(0);
56
+ row[0] = i;
57
+ return row;
58
+ });
59
+ for (let j = 0; j <= b.length; j++)
60
+ dp[0][j] = j;
61
+ for (let i = 1; i <= a.length; i++)
62
+ for (let j = 1; j <= b.length; j++)
63
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
64
+ return dp[a.length][b.length];
65
+ }
66
+ /** Near-miss recovery (V10): the closest command to an unknown one (≤2 edits, e.g. /model → /models),
67
+ * falling back to a substring match; null when nothing is plausibly meant. */
68
+ export function suggestCommand(cmd) {
69
+ const q = cmd.toLowerCase();
70
+ let best = null;
71
+ let bestD = 3; // accept distance ≤ 2
72
+ for (const c of COMMANDS) {
73
+ const d = editDistance(q, c.name);
74
+ if (d < bestD) {
75
+ bestD = d;
76
+ best = c.name;
77
+ }
78
+ }
79
+ if (best)
80
+ return best;
81
+ const sub = COMMANDS.find((c) => c.name.includes(q) || q.includes(c.name));
82
+ return sub ? sub.name : null;
83
+ }
84
+ /** Parse a slash command like "/review --branch" or "/idea build X". Returns null for non-slash input
85
+ * (which then goes through `routeInput`). Pure + deterministic. */
86
+ export function parseCommand(input) {
87
+ const t = input.trim();
88
+ if (!t.startsWith('/'))
89
+ return null;
90
+ const body = t.slice(1);
91
+ const sp = body.search(/\s/);
92
+ const cmd = (sp === -1 ? body : body.slice(0, sp)).toLowerCase();
93
+ const rest = sp === -1 ? '' : body.slice(sp + 1).trim();
94
+ return { cmd, rest, args: rest ? rest.split(/\s+/) : [] };
95
+ }
96
+ export function quickActionReducer(key, hasRepo) {
97
+ const k = key.toLowerCase();
98
+ if (k === 'i')
99
+ return { action: 'idea' };
100
+ if (k === 'r' || k === 'b') {
101
+ if (!hasRepo)
102
+ return { action: null, message: 'not inside a git repo' };
103
+ return { action: k === 'r' ? 'review-working-tree' : 'review-branch' };
104
+ }
105
+ return { action: null };
106
+ }