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,83 @@
1
+ // Deterministic restatement clustering for S2 (§9). Groups per-provider interpretations by
2
+ // normalized token overlap; a threshold ≥ 0.6 puts two restatements in the same cluster. Pure
3
+ // code (no model call) → directly unit-tested. This — not a model — decides whether the providers
4
+ // agree on what the task is (§19 "deterministic validators decide what enters downstream").
5
+ //
6
+ // The overlap metric is the OVERLAP COEFFICIENT (|A∩B|/min), not Jaccard. §9 says "normalized token
7
+ // overlap ≥ 0.6"; Jaccard was too strict on real prose — two same-meaning 1-sentence readings scored
8
+ // right at ~0.60 and split, spuriously triggering the S2 clarification (observed live at T8). The
9
+ // coefficient isn't penalized by length/filler differences: the two same-meaning readings score 0.76,
10
+ // a genuine divergence scores ~0.50 — so real disagreement still clusters apart. (Fixed 2026-07-03.)
11
+ export const SAME_CLUSTER_THRESHOLD = 0.6; // §9
12
+ // Function words + restatement boilerplate ("the user is asking to…") carry no meaning about WHAT the
13
+ // task is; keeping them dilutes the content-word overlap so two same-meaning readings that phrase the
14
+ // framing differently split spuriously (V7). Dropping them makes the coefficient compare content only.
15
+ // This does NOT loosen the 0.6 threshold — genuinely different readings still have disjoint content
16
+ // words and split (see cluster.test). Deliberately conservative: no stemming (would risk false merges).
17
+ const STOPWORDS = new Set([
18
+ 'a', 'an', 'the', 'and', 'or', 'but', 'of', 'to', 'in', 'on', 'at', 'by', 'for', 'with', 'without',
19
+ 'from', 'into', 'as', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'it', 'its', 'this', 'that',
20
+ 'these', 'those', 'their', 'them', 'they', 'i', 'you', 'we', 'he', 'she', 'his', 'her', 'our', 'your',
21
+ 'my', 'me', 'us', 'do', 'does', 'did', 'will', 'would', 'can', 'could', 'should', 'if', 'then', 'than',
22
+ 'so', 'such', 'not', 'no', 'over', 'under', 'about', 'up', 'down', 'out', 'how', 'what', 'which',
23
+ // restatement framing verbs/nouns
24
+ 'user', 'users', 'wants', 'want', 'wanting', 'asking', 'asks', 'ask', 'request', 'requesting', 'needs',
25
+ 'need', 'wishes', 'wish', 'seeking', 'looking', 'like',
26
+ ]);
27
+ /** Lowercase, split on non-alphanumerics, drop empties + stopwords. Overlap is on the resulting SET. */
28
+ export function tokenize(text) {
29
+ return new Set(text
30
+ .toLowerCase()
31
+ .split(/[^a-z0-9]+/)
32
+ .filter((t) => t.length > 0 && !STOPWORDS.has(t)));
33
+ }
34
+ /** Jaccard overlap of two token sets: |A∩B| / |A∪B|. 0 when both empty. */
35
+ export function overlap(a, b) {
36
+ if (a.size === 0 && b.size === 0)
37
+ return 0;
38
+ let inter = 0;
39
+ for (const t of a)
40
+ if (b.has(t))
41
+ inter++;
42
+ const union = a.size + b.size - inter;
43
+ return union === 0 ? 0 : inter / union;
44
+ }
45
+ /**
46
+ * Overlap coefficient of two token sets: |A∩B| / min(|A|,|B|). 0 when either is empty. Unlike
47
+ * Jaccard, it is not penalized when one text is much longer than the other — the right measure for
48
+ * "is this short restatement a subset of this longer text" (S5 drift: task_echo vs contract.task).
49
+ */
50
+ export function overlapCoefficient(a, b) {
51
+ if (a.size === 0 || b.size === 0)
52
+ return 0;
53
+ let inter = 0;
54
+ for (const t of a)
55
+ if (b.has(t))
56
+ inter++;
57
+ return inter / Math.min(a.size, b.size);
58
+ }
59
+ /**
60
+ * Single-link clustering: an item joins the first existing cluster whose representative it overlaps
61
+ * with ≥ threshold, else it starts a new cluster. Input order is preserved → deterministic output.
62
+ */
63
+ export function clusterInterpretations(items, threshold = SAME_CLUSTER_THRESHOLD) {
64
+ const clusters = [];
65
+ for (const item of items) {
66
+ const tokens = tokenize(item.text);
67
+ const home = clusters.find((c) => overlapCoefficient(tokens, c.repTokens) >= threshold);
68
+ if (home)
69
+ home.members.push(item.key);
70
+ else
71
+ clusters.push({ members: [item.key], representative: item.text, repTokens: tokens });
72
+ }
73
+ return clusters.map((c) => ({ members: c.members, representative: c.representative }));
74
+ }
75
+ /** Index of the largest cluster (ties → earliest). Used by headless S2 to pick the majority. */
76
+ export function majorityClusterIndex(clusters) {
77
+ let best = 0;
78
+ for (let i = 1; i < clusters.length; i++) {
79
+ if (clusters[i].members.length > clusters[best].members.length)
80
+ best = i;
81
+ }
82
+ return best;
83
+ }
@@ -0,0 +1,277 @@
1
+ // Run context + engine primitives (§6). A run is a single-threaded walk through typed stages;
2
+ // `RunCtx` carries everything a stage needs and is the ONLY thing that spends the call budget.
3
+ //
4
+ // Invariants enforced here:
5
+ // - Budget guard (§6, §19): a provider call that would exceed the budget throws `BudgetExceeded`
6
+ // BEFORE spawning anything; the run then fails gracefully with partial artifacts + meta.
7
+ // - Wall-clock deadline (§19): checked before every call and between stages → `DeadlineExceeded`.
8
+ // A single in-flight call is separately bounded by the adapter's per-call timeout (process-tree
9
+ // kill), so the run cannot hang past deadline + one call timeout. (Mid-call abort is post-v1.)
10
+ // - Full audit (§15): every call's exact prompt and raw output are written under `raw/`.
11
+ import { randomBytes } from 'node:crypto';
12
+ import { PROVIDER_IDS } from '../providers/types.js';
13
+ import { ADAPTERS } from '../providers/adapters.js';
14
+ import { extractJson } from '../providers/adapter-core.js';
15
+ import { detect } from '../providers/detect.js';
16
+ import { probeFlags } from '../providers/probe.js';
17
+ import { replayKey } from '../storage/replay.js';
18
+ /** Bracket a stage call with the TUI's start/end events (no-op headless). A thrown StageError marks
19
+ * the row `failed` and re-propagates unchanged — the engine's failure handling is untouched. */
20
+ export async function runStage(ctx, id, fn) {
21
+ ctx.events?.onStageStart?.(id);
22
+ try {
23
+ const result = await fn();
24
+ ctx.events?.onStageEnd?.(id, 'done');
25
+ return result;
26
+ }
27
+ catch (e) {
28
+ ctx.events?.onStageEnd?.(id, 'failed');
29
+ throw e;
30
+ }
31
+ }
32
+ export const DEFAULT_BUDGET = 18; // full idea pipeline min = S0(1)+S1(1)+S2(3)+S3(1)+S4(2)+S7-group(1)+
33
+ // S8(1)+S9(1)+S9b(1) = 12. But S2 runs 3 providers that EACH can repair, plus S9 + S9b anchor repairs —
34
+ // a live run spent 3 repairs and died at 13 before S9b. 18 = 12 + ~6 repair headroom so the validation
35
+ // plan actually renders; a true repair-storm still fails gracefully. Overridable via `--budget`/config.
36
+ // §19 was 10 min; raised to 20 (user-authorized 2026-07-06). Evidence: a real Opus judge (S9) on a
37
+ // 9-dispute idea ran ~360s and the whole run was ~14 min → the 10-min wall-clock aborted a legitimate run.
38
+ export const DEFAULT_DEADLINE_MS = 20 * 60 * 1000; // wall-clock cap
39
+ // §7.1 was 180s; raised to 300 (user-authorized 2026-07-06). The adapter retries a TIMEOUT once, so 180s
40
+ // gave a 360s ceiling and the deep-reasoning judge blew it (observed exactly 360.1s → TIMEOUT). 300s per
41
+ // attempt covers the Opus judge; the wall-clock deadline above remains the outer bound.
42
+ const DEFAULT_CALL_TIMEOUT_MS = 300_000;
43
+ export class BudgetExceeded extends Error {
44
+ constructor(limit) {
45
+ super(`call budget exhausted (limit ${limit})`);
46
+ this.name = 'BudgetExceeded';
47
+ }
48
+ }
49
+ export class DeadlineExceeded extends Error {
50
+ constructor(deadlineMs) {
51
+ super(`run wall-clock deadline exceeded (${deadlineMs}ms)`);
52
+ this.name = 'DeadlineExceeded';
53
+ }
54
+ }
55
+ /** Raised by a stage when a provider call fails unrecoverably (post adapter retry) or output is
56
+ * unusable after the §14 repair retry. Carries the provider taxonomy code for meta/reporting. */
57
+ export class StageError extends Error {
58
+ stage;
59
+ code;
60
+ constructor(stage, code, // ProviderError | 'QUORUM' | 'ABORT'
61
+ message) {
62
+ super(`${stage}: ${message}`);
63
+ this.stage = stage;
64
+ this.code = code;
65
+ this.name = 'StageError';
66
+ }
67
+ }
68
+ export class RunCtx {
69
+ runId;
70
+ workflow;
71
+ roles;
72
+ writer;
73
+ cwd;
74
+ events;
75
+ budget;
76
+ calls = [];
77
+ /** §16 report-header flags accumulated by stages (S4 → low_diversity, S7 → low_diversity,
78
+ * S9 → synthesis_suspect). Folded into meta.json at finalize by `buildMeta`. */
79
+ flags = new Set();
80
+ handles;
81
+ signal;
82
+ deadlineMs;
83
+ deadlineAt;
84
+ now;
85
+ replay; // resume replay cache (V6.3)
86
+ seq = 0; // monotonic per-call counter for raw/ filenames (== budget.used on a fresh run)
87
+ constructor(opts) {
88
+ this.runId = opts.runId;
89
+ this.workflow = opts.workflow;
90
+ this.roles = opts.roles;
91
+ this.writer = opts.writer;
92
+ this.cwd = opts.cwd;
93
+ this.events = opts.events;
94
+ this.budget = { limit: opts.budget ?? DEFAULT_BUDGET, used: 0 };
95
+ this.handles = new Map(opts.handles.map((h) => [h.id, h]));
96
+ this.signal = opts.signal;
97
+ this.deadlineMs = opts.deadlineMs ?? DEFAULT_DEADLINE_MS;
98
+ this.now = opts.now ?? Date.now;
99
+ this.deadlineAt = this.now() + this.deadlineMs;
100
+ this.replay = opts.replay;
101
+ }
102
+ /** Provider ids available this run (READY at setup). */
103
+ available() {
104
+ return [...this.handles.keys()];
105
+ }
106
+ /** True once the run's abort signal has fired (Ctrl+C). Lets the finalizer record `aborted:true`
107
+ * even when the triggering error surfaced as something else (e.g. a killed in-flight call). */
108
+ get aborted() {
109
+ return this.signal?.aborted ?? false;
110
+ }
111
+ handle(id) {
112
+ const h = this.handles.get(id);
113
+ if (!h)
114
+ throw new StageError('setup', 'QUORUM', `provider ${id} not available`);
115
+ return h;
116
+ }
117
+ /** Raise a §16 report-header flag; deduped, folded into meta at finalize. */
118
+ addFlag(flag) {
119
+ this.flags.add(flag);
120
+ }
121
+ /** Guard: throw if aborted or past the wall-clock deadline. Called before every provider call. */
122
+ guard() {
123
+ if (this.signal?.aborted)
124
+ throw new StageError('run', 'ABORT', 'aborted');
125
+ if (this.now() > this.deadlineAt)
126
+ throw new DeadlineExceeded(this.deadlineMs);
127
+ }
128
+ /**
129
+ * Make one budgeted provider call. Decrements budget (throws `BudgetExceeded` if it would go
130
+ * over), records a `CallRecord`, and dumps the exact prompt + raw output under `raw/` (§15).
131
+ */
132
+ async call(handle, req, stage) {
133
+ this.guard();
134
+ const seq = ++this.seq;
135
+ await this.writer.writeRaw(`${stage}-${handle.id}-${seq}.prompt.txt`, req.prompt);
136
+ // Resume (V6.3): if this exact (provider, prompt) already succeeded in the run we're resuming,
137
+ // replay its output — no real call, no budget spend. Only never-completed calls hit the model.
138
+ const cachedOut = this.replay?.get(replayKey(handle.id, req.prompt));
139
+ let res;
140
+ if (cachedOut !== undefined) {
141
+ res = { ok: true, text: cachedOut, json: req.expectJson ? extractJson(cachedOut) : undefined, durationMs: 0 };
142
+ }
143
+ else {
144
+ if (this.budget.used + 1 > this.budget.limit)
145
+ throw new BudgetExceeded(this.budget.limit);
146
+ this.budget.used++;
147
+ res = await handle.adapter.run({
148
+ prompt: req.prompt,
149
+ cwd: req.cwd ?? this.cwd,
150
+ timeoutMs: req.timeoutMs ?? DEFAULT_CALL_TIMEOUT_MS,
151
+ expectJson: req.expectJson,
152
+ readOnly: true,
153
+ signal: this.signal, // Ctrl+C kills the in-flight child (T8); undefined headless
154
+ }, handle.flags);
155
+ // Only REAL calls count toward the ledger/budget; a replayed call is free and already recorded in
156
+ // the run it came from. raw/ below still logs the replayed prompt+output for a full audit.
157
+ this.calls.push({
158
+ provider: handle.id,
159
+ stage,
160
+ durationMs: res.durationMs,
161
+ ...(res.ok ? {} : { error: res.error }),
162
+ });
163
+ }
164
+ await this.writer.writeRaw(`${stage}-${handle.id}-${seq}.out`, res.ok ? res.text : `[${res.error}]\n${res.stderrTail}`);
165
+ return res;
166
+ }
167
+ /** Assemble the run's `meta.json` payload (§15) from the handles + call ledger accumulated so
168
+ * far. Called at finalize (success) and in the failure path (partial artifacts stay valid). */
169
+ buildMeta(exitStatus, aborted, flags) {
170
+ const provider_versions = {};
171
+ const flag_profiles = {};
172
+ const read_only = {};
173
+ for (const h of this.handles.values()) {
174
+ if (h.version)
175
+ provider_versions[h.id] = h.version;
176
+ flag_profiles[h.id] = h.flags;
177
+ read_only[h.id] = h.readOnly;
178
+ }
179
+ // Roles record: the three singular roles plus one `s4_<n>` entry per fan-out seat (T6).
180
+ const roles = {
181
+ analyst: this.roles.analyst,
182
+ judge: this.roles.judge,
183
+ verifier: this.roles.verifier,
184
+ };
185
+ this.roles.s4.forEach((id, i) => (roles[`s4_${i + 1}`] = id));
186
+ // Fold stage-raised flags in with any explicitly passed by the caller; dedupe.
187
+ const allFlags = [...new Set([...(flags ?? []), ...this.flags])];
188
+ return {
189
+ run_id: this.runId,
190
+ workflow: this.workflow,
191
+ provider_versions,
192
+ flag_profiles,
193
+ roles,
194
+ read_only,
195
+ calls: this.calls,
196
+ call_count: this.calls.length,
197
+ budget: { limit: this.budget.limit, used: this.budget.used },
198
+ exit_status: exitStatus,
199
+ aborted,
200
+ ...(allFlags.length ? { flags: allFlags } : {}),
201
+ };
202
+ }
203
+ }
204
+ /** Run-fatal errors abort the whole run; everything else (e.g. a single provider failure in a
205
+ * fan-out) is handled locally by the stage (drop provider, check quorum). */
206
+ export function isFatal(e) {
207
+ return e instanceof BudgetExceeded || e instanceof DeadlineExceeded || (e instanceof StageError && e.code === 'ABORT');
208
+ }
209
+ // ── Provider setup + role assignment ────────────────────────────────────────
210
+ const READONLY_FROM_FLAG = (f) => f.readOnlyFlag;
211
+ /**
212
+ * Build handles for every provider that is READY (detect + flag probe; no model calls). Providers
213
+ * that aren't installed are simply omitted; quorum (§8) is checked by the caller.
214
+ */
215
+ export async function setupProviders(models) {
216
+ const handles = [];
217
+ for (const id of PROVIDER_IDS) {
218
+ const det = await detect(id);
219
+ if (det.status !== 'READY')
220
+ continue;
221
+ const probed = await probeFlags(id);
222
+ const model = models?.[id]; // V8: user-chosen model → passed to the CLI as `--model <id>`
223
+ const flags = model ? { ...probed, model } : probed;
224
+ handles.push({ id, adapter: ADAPTERS[id], flags, readOnly: READONLY_FROM_FLAG(flags), version: det.version ?? null });
225
+ }
226
+ return handles;
227
+ }
228
+ /** Preference order used when a role's default provider is unavailable. */
229
+ const ANALYST_PREF = ['agy', 'codex', 'claude'];
230
+ const JUDGE_PREF = ['claude', 'agy', 'codex'];
231
+ // code-review (§271): reviewers = claude + codex (strongest code-nav, they AUTHOR findings); judge =
232
+ // agy (Gemini) so the judge never adjudicates its own finding. Verifier role is unused (S8 = mutual
233
+ // cross-exam by the two reviewers).
234
+ const CR_REVIEWERS = ['claude', 'codex'];
235
+ const CR_JUDGE_PREF = ['agy', 'claude', 'codex'];
236
+ /**
237
+ * Default role assignment (§10, decided at T5) with graceful degradation and an override seam.
238
+ * `overrides` is the config/flag hook (§10 "config can pin roles") — config loading itself is T9.
239
+ *
240
+ * idea-refinement default (3 providers): analyst=agy, judge=claude, verifier=codex, S4=[agy,codex].
241
+ * The one hard rule kept even under degradation: the judge must not be the sole S4 author it would
242
+ * adjudicate — with ≥2 S4 seats and judge picked outside them, that holds. Full §8/§10 fallbacks
243
+ * (2- and 1-provider self-consistency) are firmed at T6 when S4 lands.
244
+ */
245
+ export function resolveRoles(workflow, available, overrides) {
246
+ const has = (id) => available.includes(id);
247
+ const pick = (pref, avoid = []) => {
248
+ const chosen = pref.find((id) => has(id) && !avoid.includes(id)) ?? pref.find((id) => has(id)) ?? available[0];
249
+ if (!chosen)
250
+ throw new StageError('setup', 'QUORUM', 'no providers available for role assignment');
251
+ return chosen;
252
+ };
253
+ // code-review (§271): reviewers author findings, judge must not be one of them. Verifier unused.
254
+ if (workflow === 'code-review') {
255
+ const reviewers = CR_REVIEWERS.filter(has);
256
+ const s4 = overrides?.s4 ?? (reviewers.length ? reviewers : available);
257
+ const judge = overrides?.judge ?? pick(CR_JUDGE_PREF, s4); // prefer agy, avoid the reviewers
258
+ return {
259
+ analyst: overrides?.analyst ?? s4[0] ?? judge, // unused in code-review (S1/S3 are deterministic)
260
+ judge,
261
+ verifier: overrides?.verifier ?? judge, // unused (S8 is mutual cross-exam) — kept type-valid
262
+ s4,
263
+ };
264
+ }
265
+ // idea-refinement: S4 seats = the two non-judge providers so the judge stays a non-author (§10).
266
+ const judge = overrides?.judge ?? pick(JUDGE_PREF);
267
+ const analyst = overrides?.analyst ?? pick(ANALYST_PREF);
268
+ const s4 = overrides?.s4 ?? available.filter((id) => id !== judge);
269
+ const verifier = overrides?.verifier ?? (has('codex') ? 'codex' : pick(ANALYST_PREF, [judge]));
270
+ return { analyst, judge, verifier, s4 };
271
+ }
272
+ /** Generate a run id: `<yyyymmdd>-<hhmm>-<workflow>-<rand4>` (§15). */
273
+ export function makeRunId(workflow, at = new Date()) {
274
+ const p = (n, w = 2) => String(n).padStart(w, '0');
275
+ const stamp = `${at.getFullYear()}${p(at.getMonth() + 1)}${p(at.getDate())}-${p(at.getHours())}${p(at.getMinutes())}`;
276
+ return `${stamp}-${workflow}-${randomBytes(2).toString('hex')}`;
277
+ }
@@ -0,0 +1,92 @@
1
+ // Engine run wrapper (§6). Sets up the run folder, walks a workflow's stage composition, and — no
2
+ // matter how the run ends — finalizes a valid `meta.json` with the exit status. A fatal error
3
+ // (budget/deadline/abort/bad-output/quorum) becomes a graceful failure: partial artifacts on disk
4
+ // stay valid (RunWriter atomic writes) and meta records what happened (§6, §19).
5
+ import { dirname, resolve } from 'node:path';
6
+ import { BudgetExceeded, DeadlineExceeded, StageError, makeRunId, resolveRoles, setupProviders, RunCtx } from './context.js';
7
+ import { RunWriter } from '../storage/runs.js';
8
+ import { recordSession, updateSessionStatus } from '../storage/sessions.js';
9
+ import { runIdeaRefinement } from '../workflows/idea-refinement.js';
10
+ import { runCodeReview } from '../workflows/code-review.js';
11
+ const WORKFLOWS = {
12
+ 'idea-refinement': runIdeaRefinement,
13
+ 'code-review': runCodeReview,
14
+ };
15
+ function classifyError(e) {
16
+ if (e instanceof BudgetExceeded)
17
+ return { code: 'BUDGET', aborted: false };
18
+ if (e instanceof DeadlineExceeded)
19
+ return { code: 'DEADLINE', aborted: false };
20
+ if (e instanceof StageError)
21
+ return { code: e.code, aborted: e.code === 'ABORT' };
22
+ return { code: 'CRASH', aborted: false };
23
+ }
24
+ /** Execute a workflow within an already-built RunCtx. Writes 00-original.md, runs the stages, and
25
+ * finalizes meta.json in both the success and failure paths. */
26
+ export async function executeRun(ctx, input, fn) {
27
+ await ctx.writer.init();
28
+ await ctx.writer.writeText('original', input);
29
+ ctx.events?.onStart?.(ctx.runId, ctx.writer.dir);
30
+ const base = { runId: ctx.runId, dir: ctx.writer.dir };
31
+ try {
32
+ await fn(ctx, input);
33
+ await ctx.writer.writeMeta(ctx.buildMeta('ok', false));
34
+ return { ok: true, ...base, callCount: ctx.calls.length };
35
+ }
36
+ catch (e) {
37
+ const classified = classifyError(e);
38
+ // A fired abort signal wins: record `aborted` even if the surfacing error was e.g. a killed
39
+ // in-flight call classified as CRASH/QUORUM (§472/§603 — Ctrl+C leaves aborted:true meta).
40
+ const aborted = ctx.aborted || classified.aborted;
41
+ // Best-effort finalize: never let a meta-write failure mask the original error.
42
+ await ctx.writer.writeMeta(ctx.buildMeta(aborted ? 'aborted' : 'failed', aborted)).catch(() => { });
43
+ return { ok: false, ...base, callCount: ctx.calls.length, error: { code: classified.code, message: e instanceof Error ? e.message : String(e) } };
44
+ }
45
+ }
46
+ /**
47
+ * Top-level headless entry (backs `aiki run`): detect+probe providers, check quorum (§8), assign
48
+ * roles (§10), build the RunCtx, and execute the workflow. No smoke test here — a dead provider
49
+ * surfaces as a call failure handled by the stage's quorum logic.
50
+ */
51
+ export async function run(workflow, input, opts = {}) {
52
+ const handles = await setupProviders(opts.providerModels);
53
+ if (handles.length < 2) {
54
+ return {
55
+ ok: false,
56
+ runId: '(none)',
57
+ dir: '',
58
+ callCount: 0,
59
+ error: { code: 'QUORUM', message: `need ≥2 providers, found ${handles.length} — run \`aiki doctor\`` },
60
+ };
61
+ }
62
+ const runId = makeRunId(workflow);
63
+ const roles = resolveRoles(workflow, handles.map((h) => h.id), opts.roleOverrides);
64
+ const writer = new RunWriter(runId, opts.runsRoot);
65
+ const ctx = new RunCtx({
66
+ runId,
67
+ workflow,
68
+ handles,
69
+ roles,
70
+ writer,
71
+ cwd: opts.cwd ?? writer.dir, // code-review passes the repo root so reviewers can read the tree
72
+ budget: opts.budget,
73
+ deadlineMs: opts.deadlineMs,
74
+ signal: opts.signal,
75
+ events: opts.events,
76
+ replay: opts.replay,
77
+ });
78
+ // Register the session (V6.3) so `aiki sessions`/`resume` can find it from anywhere. run() is a
79
+ // real-CLI entry (setupProviders); tests use executeRun directly and never touch the global registry.
80
+ await recordSession({
81
+ id: runId,
82
+ workflow,
83
+ cwd: opts.cwd ?? writer.dir,
84
+ runsRoot: resolve(dirname(dirname(writer.dir))),
85
+ startedAt: new Date().toISOString(),
86
+ status: 'running',
87
+ ...(opts.resumedFrom ? { resumedFrom: opts.resumedFrom } : {}),
88
+ });
89
+ const outcome = await executeRun(ctx, input, WORKFLOWS[workflow]);
90
+ await updateSessionStatus(runId, outcome.ok ? 'ok' : 'failed');
91
+ return outcome;
92
+ }
@@ -0,0 +1,133 @@
1
+ // Git plumbing for the code-review workflow (§12.2, T10). This is aiki's OWN read-only git usage
2
+ // (compute the diff to review) — not a provider spawn, so it doesn't go through the adapter machinery.
3
+ //
4
+ // Range semantics: THREE-DOT (`base...head`) = the merge-base diff, i.e. only what HEAD added since it
5
+ // diverged from base. That's what "review this branch/PR" means and avoids noise when base moved ahead
6
+ // (grilled 2026-07-04).
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ const exec = promisify(execFile);
10
+ export class GitError extends Error {
11
+ constructor(message) {
12
+ super(message);
13
+ this.name = 'GitError';
14
+ }
15
+ }
16
+ /** Absolute path of the repo root containing `cwd`, or null if `cwd` isn't inside a git work tree. */
17
+ export async function repoToplevel(cwd) {
18
+ try {
19
+ const { stdout } = await exec('git', ['rev-parse', '--show-toplevel'], { cwd });
20
+ return stdout.trim() || null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ async function gitStdout(args, cwd) {
27
+ try {
28
+ const { stdout } = await exec('git', args, { cwd, maxBuffer: 32 * 1024 * 1024 });
29
+ return stdout.trim();
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
35
+ export function chooseDefaultBranch(originHead, refs) {
36
+ const trimmed = originHead?.trim();
37
+ if (trimmed)
38
+ return trimmed;
39
+ for (const name of ['main', 'master']) {
40
+ if (refs.includes(name))
41
+ return name;
42
+ if (refs.includes(`origin/${name}`))
43
+ return `origin/${name}`;
44
+ }
45
+ return null;
46
+ }
47
+ export async function detectDefaultBranch(cwd) {
48
+ const originHead = await gitStdout(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'], cwd);
49
+ const refsRaw = await gitStdout(['for-each-ref', '--format=%(refname:short)', 'refs/heads', 'refs/remotes'], cwd);
50
+ return chooseDefaultBranch(originHead, refsRaw ? refsRaw.split('\n').filter(Boolean) : []);
51
+ }
52
+ /** Compute the unified diff to review: `git diff --unified=3 <base>...<head>` (three-dot merge-base). */
53
+ export async function computeDiff(base, head, cwd) {
54
+ try {
55
+ const { stdout } = await exec('git', ['diff', '--unified=3', `${base}...${head}`], { cwd, maxBuffer: 32 * 1024 * 1024 });
56
+ return stdout;
57
+ }
58
+ catch (e) {
59
+ throw new GitError(`git diff ${base}...${head} failed: ${e.message.split('\n')[0]}`);
60
+ }
61
+ }
62
+ async function mergeBase(base, head, cwd) {
63
+ const mb = await gitStdout(['merge-base', base, head], cwd);
64
+ if (!mb)
65
+ throw new GitError(`git merge-base ${base} ${head} failed`);
66
+ return mb;
67
+ }
68
+ async function untrackedFiles(cwd) {
69
+ const raw = await gitStdout(['ls-files', '--others', '--exclude-standard'], cwd);
70
+ return raw ? raw.split('\n').filter(Boolean) : [];
71
+ }
72
+ async function diffUntrackedFile(file, cwd) {
73
+ try {
74
+ const { stdout } = await exec('git', ['diff', '--no-index', '--', '/dev/null', file], { cwd, maxBuffer: 32 * 1024 * 1024 });
75
+ return stdout;
76
+ }
77
+ catch (e) {
78
+ const err = e;
79
+ if (err.stdout)
80
+ return err.stdout;
81
+ throw e;
82
+ }
83
+ }
84
+ /** Diff from the merge-base to the current working tree, including staged, unstaged, and untracked files. */
85
+ export async function computeWorkingTreeDiff(base, cwd) {
86
+ try {
87
+ const mb = await mergeBase(base, 'HEAD', cwd);
88
+ const { stdout } = await exec('git', ['diff', '--unified=3', mb], { cwd, maxBuffer: 32 * 1024 * 1024 });
89
+ const extra = await Promise.all((await untrackedFiles(cwd)).map((f) => diffUntrackedFile(f, cwd)));
90
+ return [stdout, ...extra].filter(Boolean).join('\n');
91
+ }
92
+ catch (e) {
93
+ if (e instanceof GitError)
94
+ throw e;
95
+ throw new GitError(`git diff working tree against ${base} failed: ${e.message.split('\n')[0]}`);
96
+ }
97
+ }
98
+ export async function changedFilesSinceDefault(base, cwd) {
99
+ try {
100
+ const mb = await mergeBase(base, 'HEAD', cwd);
101
+ const { stdout } = await exec('git', ['diff', '--name-only', mb], { cwd, maxBuffer: 32 * 1024 * 1024 });
102
+ const files = new Set([...stdout.split('\n').filter(Boolean), ...await untrackedFiles(cwd)]);
103
+ return files.size;
104
+ }
105
+ catch {
106
+ return 0;
107
+ }
108
+ }
109
+ export async function detectRepoStatus(cwd) {
110
+ const root = await repoToplevel(cwd);
111
+ if (!root)
112
+ return null;
113
+ const defaultBranch = await detectDefaultBranch(root);
114
+ const changedFiles = defaultBranch ? await changedFilesSinceDefault(defaultBranch, root) : 0;
115
+ return { root, name: root.split('/').filter(Boolean).at(-1) ?? root, defaultBranch, changedFiles };
116
+ }
117
+ /**
118
+ * Pure: the set of files present at HEAD that the diff touches — parsed from `+++ b/<path>` lines
119
+ * (a `+++ /dev/null` marks a deletion, which has no HEAD file, so it is skipped). These are exactly
120
+ * the files a finding is allowed to reference (§12.2 "file appears in the diff").
121
+ */
122
+ export function parseDiffFiles(diff) {
123
+ const files = new Set();
124
+ for (const line of diff.split('\n')) {
125
+ if (!line.startsWith('+++ '))
126
+ continue;
127
+ const path = line.slice(4).trim();
128
+ if (path === '/dev/null')
129
+ continue;
130
+ files.add(path.startsWith('b/') ? path.slice(2) : path);
131
+ }
132
+ return [...files];
133
+ }
@@ -0,0 +1,32 @@
1
+ // Shared helper for stages whose provider output is schema-validated JSON (§14).
2
+ //
3
+ // Flow: call provider (expectJson) → the adapter already did its ONE transient retry + §14
4
+ // extraction → here we zod-validate the extracted JSON. On a *validation* failure we do the §14
5
+ // repair retry: resend to the SAME provider with the zod error and a "corrected JSON only"
6
+ // instruction, exactly once. A second failure → StageError('BAD_OUTPUT'), which the stage turns
7
+ // into its §9 failure handling (S1/S3 abort the run).
8
+ import { StageError } from './context.js';
9
+ export async function jsonCall(ctx, handle, stage, prompt, schema, opts = {}) {
10
+ const first = await ctx.call(handle, { prompt, expectJson: true, cwd: opts.cwd }, stage);
11
+ if (!first.ok) {
12
+ // AUTH/QUOTA/NOT_FOUND fail fast; TIMEOUT/CRASH/BAD_OUTPUT were already retried once by the adapter.
13
+ throw new StageError(stage, first.error, `provider ${handle.id} call failed (${first.error})`);
14
+ }
15
+ const parsed = schema.safeParse(first.json);
16
+ if (parsed.success)
17
+ return parsed.data;
18
+ // §14 repair retry — one attempt, same provider.
19
+ const repairPrompt = `${prompt}\n\n---\nYour previous output failed validation:\n${zodMessage(parsed.error)}\n` +
20
+ `Output ONLY the corrected JSON, nothing else.`;
21
+ const second = await ctx.call(handle, { prompt: repairPrompt, expectJson: true, cwd: opts.cwd }, `${stage}-repair`);
22
+ if (!second.ok) {
23
+ throw new StageError(stage, second.error, `repair retry failed (${second.error})`);
24
+ }
25
+ const reparsed = schema.safeParse(second.json);
26
+ if (reparsed.success)
27
+ return reparsed.data;
28
+ throw new StageError(stage, 'BAD_OUTPUT', `output failed validation after repair: ${zodMessage(reparsed.error)}`);
29
+ }
30
+ function zodMessage(err) {
31
+ return err.issues.map((i) => `- ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n');
32
+ }
@@ -0,0 +1,39 @@
1
+ // Skills — per-role playbooks that sharpen a stage prompt without changing code. A "skill" is a
2
+ // markdown file under src/skills/<workflow>/<role>.md (copied to dist/skills/ at build; the path
3
+ // resolves the same in src and dist because the tree mirrors). A missing playbook returns '' so
4
+ // callers stay backward-compatible: the stage prompt is byte-for-byte unchanged when no skill exists.
5
+ //
6
+ // §19 skill-injection boundary: playbooks load ONLY from the repo skills dir (never remote / user
7
+ // paths), AND their text is scanned for exfiltration patterns. A playbook that trips the lint is
8
+ // rejected (treated as absent) — fail-closed to the safe no-skill baseline; a bad skill file never
9
+ // crashes the run, it just doesn't apply.
10
+ import { readFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
12
+ /** Exfiltration patterns a skill playbook must not contain (§19). Repo files are author-controlled;
13
+ * this is defense-in-depth against a compromised playbook smuggling a "leak this" instruction. */
14
+ const EXFIL_PATTERNS = [
15
+ { name: 'url', re: /\b(?:https?|ftp):\/\//i },
16
+ { name: 'upload', re: /\bupload\b/i },
17
+ { name: 'send-to', re: /\bsend\b[^\n]{0,30}\bto\b/i },
18
+ { name: 'base64-blob', re: /[A-Za-z0-9+/=]{50,}/ },
19
+ ];
20
+ /** The name of the first exfiltration pattern `text` trips, or null if it is clean. */
21
+ export function lintSkill(text) {
22
+ for (const { name, re } of EXFIL_PATTERNS)
23
+ if (re.test(text))
24
+ return name;
25
+ return null;
26
+ }
27
+ /** Load the playbook for a role in a workflow, or '' if none exists OR it fails the §19 lint.
28
+ * Role-keyed, provider-agnostic. */
29
+ export function loadSkill(workflow, role) {
30
+ const url = new URL(`../skills/${workflow}/${role}.md`, import.meta.url);
31
+ let text;
32
+ try {
33
+ text = readFileSync(fileURLToPath(url), 'utf8').trim();
34
+ }
35
+ catch {
36
+ return ''; // no playbook for this role → prompt unchanged
37
+ }
38
+ return lintSkill(text) ? '' : text; // §19: reject a playbook that trips the exfil lint
39
+ }