karajan-code 3.6.0 → 3.7.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.
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v3.6.0 released** — Advisory harden: `kj harden --report` shows what the kj standard would add to an existing repo (per artifact: missing / yours / kj-managed, plus the concrete improvements), and `kj harden --interactive` lets you adopt it piece by piece, default-safe. Plus scope control (`--only`/`--exclude`, demo/fixture dirs ignored), a rounder `kj init` first run (consistent English, glossed terms, clean close), and the `kj-trash` safety net now actually ships to npm installs. Full notes in [CHANGELOG.md](CHANGELOG.md).
26
+ > **v3.7.0 released** — Autonomous delivery: `kj autorun <spec>` chains spec → plan → run every user story → outcome in one command, unattended. An **Arbiter** resolves agent conflicts by picking the least-bad call (acceptance tests > must-fix > nice-to-have), no stage blocks for a human, a wall-clock backstop stops runaways, and it ends with an auditable "delivered, with known defects" report. Autonomy is opt-in (`--autonomous` / `--autonomy <level>`); interactive runs are unchanged. Verified live end-to-end. Full notes in [CHANGELOG.md](CHANGELOG.md).
27
27
 
28
28
  You describe what you want to build. Karajan orchestrates multiple AI agents to plan it, implement it, test it, review it with SonarQube, and iterate. No babysitting required.
29
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "3.6.0",
3
+ "version": "3.7.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -361,6 +361,17 @@ export function getDb() {
361
361
  return db;
362
362
  }
363
363
 
364
+ /**
365
+ * Lazily initialise the DB if it isn't open yet (KJC-BUG-0090). The cost
366
+ * writers run from `kj run`/`kj autorun` paths that may never start the board
367
+ * (which is what normally calls initDb), so cost writes failed with
368
+ * "Database not initialized". This makes those writers self-sufficient.
369
+ */
370
+ export function ensureDb() {
371
+ if (!db) initDb();
372
+ return db;
373
+ }
374
+
364
375
  /**
365
376
  * Inserts or updates a project record.
366
377
  * @param {{ id: string, name?: string, first_seen?: string, last_activity?: string, total_stories?: number }} project
@@ -0,0 +1,71 @@
1
+ /**
2
+ * ArbiterRole — the autonomous decision authority that resolves agent conflicts
3
+ * by picking the least-bad verdict (KJC-TSK-0573, épica KJC-PCS-0062).
4
+ *
5
+ * Extends AgentRole (so it inherits Brain recovery / standby) following the
6
+ * TriageRole pattern: a cheap decisor model emits a closed-set verdict, and any
7
+ * parse failure degrades to BLOCK_AND_CONTINUE — the pipeline never crashes and
8
+ * never blocks on the Arbiter.
9
+ */
10
+
11
+ import { AgentRole } from "../roles/agent-role.js";
12
+ import { extractFirstJson } from "../utils/json-extract.js";
13
+ import { buildArbiterPrompt, VERDICTS } from "../prompts/arbiter.js";
14
+
15
+ const CONSERVATIVE = "BLOCK_AND_CONTINUE";
16
+ const VERDICT_SET = new Set(VERDICTS);
17
+
18
+ function clampConfidence(value) {
19
+ const n = Number(value);
20
+ return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0;
21
+ }
22
+
23
+ export class ArbiterRole extends AgentRole {
24
+ constructor(opts) {
25
+ super({ ...opts, name: "arbiter" });
26
+ }
27
+
28
+ async buildPrompt(input) {
29
+ return { prompt: buildArbiterPrompt(input) };
30
+ }
31
+
32
+ parseOutput(raw) {
33
+ return extractFirstJson(raw);
34
+ }
35
+
36
+ buildSuccessResult(parsed, provider) {
37
+ const verdict = VERDICT_SET.has(parsed?.verdict) ? parsed.verdict : CONSERVATIVE;
38
+ return {
39
+ verdict,
40
+ confidence: clampConfidence(parsed?.confidence),
41
+ rationale: String(parsed?.rationale || "").trim() || "no rationale",
42
+ defect: parsed?.defect ? String(parsed.defect).trim() : null,
43
+ directive: parsed?.directive ? String(parsed.directive).trim() : null,
44
+ provider,
45
+ };
46
+ }
47
+
48
+ buildSummary(parsed) {
49
+ return `Arbiter: ${VERDICT_SET.has(parsed?.verdict) ? parsed.verdict : CONSERVATIVE}`;
50
+ }
51
+
52
+ // Never crash on garbage: degrade to the conservative verdict so the pipeline
53
+ // moves on instead of aborting (ok:true keeps the run alive).
54
+ handleParseNull(agentResult, provider) {
55
+ return this.degraded(agentResult, provider, "unstructured output");
56
+ }
57
+
58
+ handleParseError(err, agentResult, provider) {
59
+ return this.degraded(agentResult, provider, err?.message || "parse error");
60
+ }
61
+
62
+ degraded(agentResult, provider, why) {
63
+ this.logger?.warn?.(`[arbiter] ${why} → ${CONSERVATIVE} (conservative)`);
64
+ return {
65
+ ok: true,
66
+ result: { verdict: CONSERVATIVE, confidence: 0, rationale: `degraded: ${why}`, defect: null, directive: null, provider },
67
+ summary: `Arbiter: ${CONSERVATIVE} (degraded)`,
68
+ usage: agentResult.usage,
69
+ };
70
+ }
71
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * createArbiter — adapt ArbiterRole to the function the decision-resolver
3
+ * injects (KJC-TSK-0573). The resolver calls `arbiter(req)` and expects
4
+ * `{ choice, rationale, ... }`; here `choice` is the Arbiter's verdict, which
5
+ * the resolver then validates against the caller's options (AUTO-A).
6
+ */
7
+
8
+ import { ArbiterRole } from "./arbiter-role.js";
9
+
10
+ export function createArbiter({ config = {}, logger = console, createAgentFn = null } = {}) {
11
+ return async function arbiter(req) {
12
+ const role = new ArbiterRole({ config, logger, createAgentFn });
13
+ const { result } = await role.execute(req);
14
+ return {
15
+ choice: result.verdict,
16
+ rationale: result.rationale,
17
+ confidence: result.confidence,
18
+ defect: result.defect,
19
+ directive: result.directive,
20
+ };
21
+ };
22
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * buildDecider — wire the active autonomy level to a ready decision resolver
3
+ * (KJC-TSK-0572, design in docs/specs/autonomous-delivery.md §5).
4
+ *
5
+ * interactive → the human `ask`; assisted/autonomous → the Arbiter (AUTO-B).
6
+ * Consumers (spec-review, reviewer, checkpoint…) call `resolve(...)` instead of
7
+ * prompting directly, so the autonomy level decides who answers in one place.
8
+ */
9
+
10
+ import { createDecisionResolver } from "./decision-resolver.js";
11
+ import { resolveAutonomy } from "./policy.js";
12
+ import { createArbiter } from "./arbiter.js";
13
+
14
+ export function buildDecider({ config = {}, flags = {}, ask = null, env = process.env, logger = console, createAgentFn = null } = {}) {
15
+ const autonomy = resolveAutonomy({ config, flags, env });
16
+ const arbiter = autonomy === "interactive" ? null : createArbiter({ config, logger, createAgentFn });
17
+ return { autonomy, resolve: createDecisionResolver({ autonomy, ask, arbiter, logger }) };
18
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * autonomy/decision-resolver — the single choke point for every pipeline
3
+ * question (KJC-TSK-0572, design in docs/specs/autonomous-delivery.md §5).
4
+ *
5
+ * Today the "ask a human" logic is scattered (spec-review, checkpoint,
6
+ * reviewer, board-bridge). This unifies it: callers ask `resolveDecision`
7
+ * instead of prompting directly, and the autonomy level decides who answers —
8
+ * the human (`ask`) or the Arbiter (`arbiter`, injected by AUTO-B).
9
+ *
10
+ * INVARIANT: in `autonomous` mode the resolver NEVER returns a null choice and
11
+ * NEVER blocks — if the Arbiter can't decide it degrades to the option flagged
12
+ * `conservative` (or the first option). Decisions are always traced.
13
+ */
14
+
15
+ import { RISK, usesArbiter } from "./policy.js";
16
+
17
+ /** Pick the safe fallback: the option flagged conservative, else the first. */
18
+ function conservativeOf(options) {
19
+ return options.find((o) => o.conservative) ?? options[0] ?? null;
20
+ }
21
+
22
+ function decision(choice, rationale, source) {
23
+ return { choice: choice ?? null, rationale, source };
24
+ }
25
+
26
+ /**
27
+ * Build a resolver bound to an autonomy level and its two answer sources.
28
+ *
29
+ * @param {object} opts
30
+ * @param {string} opts.autonomy - interactive | assisted | autonomous
31
+ * @param {(question: string, context?: object) => Promise<string|null>} [opts.ask] - human prompt
32
+ * @param {(input: object) => Promise<{choice: string, rationale?: string}>} [opts.arbiter] - AUTO-B
33
+ * @returns {(req: object) => Promise<{choice, rationale, source}>}
34
+ */
35
+ export function createDecisionResolver({ autonomy = "interactive", ask = null, arbiter = null, logger = console } = {}) {
36
+ return async function resolveDecision({ question, options = [], context = null, risk = RISK.LOW } = {}) {
37
+ const fallback = conservativeOf(options);
38
+
39
+ // Human path: interactive, or assisted with a high-risk decision.
40
+ if (!usesArbiter(autonomy, risk)) {
41
+ const answer = ask ? await ask(question, context) : null;
42
+ return decision(answer, "human", "human");
43
+ }
44
+
45
+ // Arbiter path: must always yield a valid choice, never block.
46
+ if (!arbiter) return decision(fallback?.value, "no arbiter available → conservative", "fallback");
47
+ let verdict;
48
+ try {
49
+ verdict = await arbiter({ question, options, context, risk });
50
+ } catch (err) {
51
+ logger.warn?.(`decision-resolver: arbiter failed (${err.message}) → conservative`);
52
+ return decision(fallback?.value, "arbiter error → conservative", "fallback");
53
+ }
54
+ const valid = verdict && options.some((o) => o.value === verdict.choice);
55
+ if (!valid) return decision(fallback?.value, "arbiter gave no valid choice → conservative", "fallback");
56
+ return decision(verdict.choice, verdict.rationale ?? "arbiter", "arbiter");
57
+ };
58
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * outcome-report — the auditable "meets the ask, with known defects" report
3
+ * for an autonomous run (KJC-TSK-0577, design in docs/specs/autonomous-delivery.md §10).
4
+ *
5
+ * Pure: builds a serializable outcome from the run results + Arbiter decisions,
6
+ * and renders it as lines (or the structure is emitted as --json). The point is
7
+ * that an imperfect-but-delivered result is explicit and auditable: which HUs
8
+ * met the ask, what the Arbiter decided, and what debt remains.
9
+ */
10
+
11
+ /**
12
+ * @param {object} opts
13
+ * @param {string} [opts.planRef]
14
+ * @param {boolean} opts.delivered - the run met the minimum ask
15
+ * @param {Array<{id, title?, met: boolean, note?}>} [opts.hus]
16
+ * @param {Array<{verdict, rationale, hu?, defect?}>} [opts.decisions] - Arbiter log
17
+ */
18
+ export function buildOutcome({ planRef = null, delivered = false, hus = [], decisions = [] } = {}) {
19
+ const met = hus.filter((h) => h.met).length;
20
+ const residualDefects = [
21
+ ...hus.filter((h) => !h.met).map((h) => ({ hu: h.id, detail: h.note || "acceptance criteria not fully met" })),
22
+ ...decisions.filter((d) => d.defect).map((d) => ({ hu: d.hu ?? null, detail: d.defect })),
23
+ ];
24
+ return {
25
+ verdict: delivered ? "DELIVERED" : "INCOMPLETE",
26
+ planRef,
27
+ hus: { total: hus.length, met, unmet: hus.length - met },
28
+ decisions,
29
+ residualDefects,
30
+ };
31
+ }
32
+
33
+ /** Render an outcome (from buildOutcome) as human-readable lines. */
34
+ export function formatOutcomeReport(outcome) {
35
+ const lines = [
36
+ `kj autorun — outcome: ${outcome.verdict}`,
37
+ ` plan: ${outcome.planRef ?? "?"}`,
38
+ ` HUs: ${outcome.hus.met}/${outcome.hus.total} met the ask`,
39
+ ];
40
+ if (outcome.decisions?.length) {
41
+ lines.push(" Decisions (Arbiter — least-bad calls):");
42
+ for (const d of outcome.decisions) {
43
+ lines.push(` • ${d.verdict}${d.hu ? ` [${d.hu}]` : ""} — ${d.rationale}`);
44
+ }
45
+ }
46
+ if (outcome.residualDefects?.length) {
47
+ lines.push(" Known defects (delivered with debt):");
48
+ for (const def of outcome.residualDefects) {
49
+ lines.push(` • ${def.hu ? `[${def.hu}] ` : ""}${def.detail}`);
50
+ }
51
+ } else {
52
+ lines.push(" No residual defects recorded.");
53
+ }
54
+ return lines;
55
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * autonomy/policy — autonomy levels for Autonomous Delivery (KJC-TSK-0572,
3
+ * épica KJC-PCS-0062, design in docs/specs/autonomous-delivery.md §4).
4
+ *
5
+ * One configurable axis decides who resolves a pipeline question:
6
+ * interactive — the human (current behaviour, default)
7
+ * assisted — the human only for high-risk decisions, the Arbiter otherwise
8
+ * autonomous — always the Arbiter; never asks a human, never blocks
9
+ *
10
+ * Precedence (most specific wins): CLI flags → env → config → default.
11
+ */
12
+
13
+ export const AUTONOMY_LEVELS = Object.freeze(["interactive", "assisted", "autonomous"]);
14
+
15
+ /** Risk of a decision — drives whether `assisted` escalates to the human. */
16
+ export const RISK = Object.freeze({ LOW: "low", HIGH: "high" });
17
+
18
+ const DEFAULT_LEVEL = "interactive";
19
+
20
+ /**
21
+ * Resolve the active autonomy level. `--autonomous` is sugar for the
22
+ * `autonomous` level; `--autonomy <level>` sets it explicitly. An unknown
23
+ * value degrades to the safe default rather than throwing.
24
+ */
25
+ export function resolveAutonomy({ config = {}, flags = {}, env = {} } = {}) {
26
+ const raw =
27
+ flags.autonomy ??
28
+ (flags.autonomous ? "autonomous" : null) ??
29
+ env.KJ_AUTONOMY ??
30
+ config.autonomy ??
31
+ DEFAULT_LEVEL;
32
+ const level = String(raw).toLowerCase();
33
+ return AUTONOMY_LEVELS.includes(level) ? level : DEFAULT_LEVEL;
34
+ }
35
+
36
+ /** True when the level should route decisions to the Arbiter for `risk`. */
37
+ export function usesArbiter(level, risk = RISK.LOW) {
38
+ if (level === "autonomous") return true;
39
+ if (level === "assisted") return risk !== RISK.HIGH;
40
+ return false; // interactive
41
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * createAutonomyAskQuestion — make the stages' question function non-blocking in
3
+ * autonomous mode (KJC-TSK-0576, AUTO-E, design in docs/specs/autonomous-delivery.md §9).
4
+ *
5
+ * In `autonomous` no stage asks a human or routes to the HU Board: the wrapper
6
+ * answers in-process with the least-bad "keep going" choice, so the run never
7
+ * pauses or aborts mid-loop. interactive and assisted keep the human prompts
8
+ * (baseAsk) unchanged.
9
+ *
10
+ * The answer is chosen per stage so the EXISTING branches proceed (verified
11
+ * against the stage code — no stage logic is touched):
12
+ * - architect clarification: null → the stage continues best-effort (a truthy
13
+ * answer would force a wasteful architect re-run).
14
+ * - everything else (Solomon escalation, checkpoint, …): "continue" → parsed
15
+ * as free-text guidance → the stage continues instead of pausing/aborting.
16
+ */
17
+ export function createAutonomyAskQuestion({ baseAsk = null, autonomy = "interactive", logger = console } = {}) {
18
+ if (autonomy !== "autonomous") return baseAsk;
19
+ const auto = async (_question, context = {}) => {
20
+ const stage = context?.stage ?? null;
21
+ logger?.info?.(`[autonomy] ${stage ? `${stage}: ` : ""}auto-continue (no human in the loop)`);
22
+ return stage === "architect" ? null : "continue";
23
+ };
24
+ auto.interactive = false;
25
+ // Marker so the orchestrator can tell an autonomous (unattended) run from an
26
+ // interactive one even though both carry a truthy askQuestion — the wall-clock
27
+ // backstop relies on it (KJC-TSK-0575, AUTO-D).
28
+ auto.unattended = true;
29
+ return auto;
30
+ }
@@ -114,6 +114,8 @@ export function registerPipeline(program, { pkgVersion }) {
114
114
  .option("--skip-role <role...>", "Force one or more roles OFF regardless of triage (e.g. --skip-role tester security)")
115
115
  .option("--force-role <role...>", "Force one or more roles ON regardless of triage")
116
116
  .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
117
+ .option("--autonomy <level>", "Autonomy level: interactive | assisted | autonomous (default: interactive)")
118
+ .option("--autonomous", "Shortcut for --autonomy autonomous: the Arbiter resolves every gate, no human prompts")
117
119
  .option("--no-rag-update", "Skip the pre-run RAG drift check (KJC-TSK-0455). Same as config.rag.autoUpdate.onRun=false.")
118
120
  .option("--dry-run", "Show what would be executed without running anything")
119
121
  .option("--json", "Output JSON only (no styled display)")
@@ -143,6 +145,25 @@ export function registerPipeline(program, { pkgVersion }) {
143
145
  });
144
146
  });
145
147
 
148
+ program
149
+ .command("autorun")
150
+ .description("Unattended delivery: spec → plan → run all HUs → outcome (defaults to --autonomous)")
151
+ .argument("[task]", "Spec/task description (or use --task-file)")
152
+ .option("--task-file <path>", "Read the spec from a file (e.g. spec.md)")
153
+ .option("--autonomy <level>", "interactive | assisted | autonomous (default: autonomous)")
154
+ .option("--autonomous", "Shortcut for --autonomy autonomous")
155
+ .option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
156
+ .action(async (task, flags) => {
157
+ const code = await withConfig(pkgVersion, "autorun", flags, async ({ config, logger }) => {
158
+ const { resolveTaskInput } = await import("../utils/task-file.js");
159
+ const { autorunCommand } = await import("../commands/autorun.js");
160
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
161
+ const res = await autorunCommand({ task: resolvedTask, config, logger, flags });
162
+ return res?.ok ? 0 : 1;
163
+ });
164
+ if (Number.isInteger(code)) process.exit(code);
165
+ });
166
+
146
167
  program
147
168
  .command("code")
148
169
  .description("Run only coder")
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `kj autorun <spec>` — the unattended delivery chain (KJC-TSK-0574, épica
3
+ * KJC-PCS-0062, design in docs/specs/autonomous-delivery.md §7).
4
+ *
5
+ * One command: spec → plan → run all HUs → outcome. It reuses `kj plan` and
6
+ * `kj run --plan` rather than reimplementing them, and defaults to the
7
+ * `autonomous` level so the Arbiter resolves every gate (no human in the loop).
8
+ * The rich outcome report lands in AUTO-F; here we chain + propagate exit code.
9
+ */
10
+
11
+ import { planGenerateCommand } from "./plan/generate.js";
12
+ import { runCommandHandler } from "./run.js";
13
+ import { buildOutcome, formatOutcomeReport } from "../autonomy/outcome-report.js";
14
+
15
+ export async function autorunCommand({
16
+ task,
17
+ config,
18
+ logger = console,
19
+ flags = {},
20
+ planFn = planGenerateCommand,
21
+ runFn = runCommandHandler,
22
+ } = {}) {
23
+ // autorun implies unattended: default to autonomous unless the user picked a
24
+ // level explicitly (so `kj autorun --autonomy assisted` still works).
25
+ const autoFlags = flags.autonomy || flags.autonomous ? flags : { ...flags, autonomous: true };
26
+
27
+ logger.info?.("kj autorun — planning…");
28
+ const plan = await planFn({ task, config, logger, flags: autoFlags });
29
+ if (!plan?.ok || !plan.planId) {
30
+ logger.error?.(`kj autorun: planning failed${plan?.reason ? ` (${plan.reason})` : ""}.`);
31
+ return { ok: false, stage: "plan", reason: plan?.reason ?? "plan-failed" };
32
+ }
33
+
34
+ logger.info?.(`kj autorun — running plan ${plan.ref ?? plan.planId} (${plan.huCount ?? "?"} HUs)…`);
35
+ const run = await runFn({ config, logger, flags: { ...autoFlags, plan: plan.planId } });
36
+
37
+ const ok = run?.ok === true;
38
+ // AUTO-F: emit the auditable outcome — delivered/incomplete, HUs that met the
39
+ // ask, Arbiter decisions and residual defects (enriched as the run surfaces
40
+ // them; baseline today is delivered + plan ref).
41
+ const outcome = buildOutcome({
42
+ planRef: plan.ref ?? plan.planId,
43
+ delivered: ok,
44
+ hus: run?.hus ?? [],
45
+ decisions: run?.decisions ?? [],
46
+ });
47
+ for (const line of formatOutcomeReport(outcome)) logger.info?.(line);
48
+ return { ok, stage: ok ? "done" : "run", planId: plan.planId, ref: plan.ref ?? plan.planId, outcome };
49
+ }
@@ -457,7 +457,7 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
457
457
 
458
458
  if (json) {
459
459
  console.log(JSON.stringify(plan, null, 2));
460
- return { ok: true };
460
+ return { ok: true, planId, ref: plan.alias || planId, huCount: plan.hus.length };
461
461
  }
462
462
 
463
463
  if (parsed?.approach) {
@@ -524,5 +524,6 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
524
524
  console.log(`\nVisualize: kj board start (http://localhost:${config?.hu_board?.port ?? 4000})`);
525
525
  }
526
526
  }
527
- return { ok: true };
527
+ // KJC-TSK-0574: surface planId/ref so `kj autorun` can chain plan → run.
528
+ return { ok: true, planId, ref, huCount: plan.hus.length };
528
529
  }
@@ -17,6 +17,8 @@ import { maybeAutoUpdate } from "../rag/auto-update.js";
17
17
  // keep working. KJC-BUG-0081 round 2 split the body into utils/ because
18
18
  // resume.js also needs the same contract.
19
19
  import { createCliAskQuestion } from "../utils/cli-ask-question.js";
20
+ import { buildDecider } from "../autonomy/decider.js";
21
+ import { createAutonomyAskQuestion } from "../autonomy/stage-ask.js";
20
22
  export { createCliAskQuestion };
21
23
 
22
24
  export async function runCommandHandler({ task, config, logger, flags }) {
@@ -129,12 +131,16 @@ export async function runCommandHandler({ task, config, logger, flags }) {
129
131
 
130
132
  const askQuestion = createCliAskQuestion({ flags });
131
133
 
134
+ // Autonomy (KJC-PCS-0062): wire the level to a resolver. In autonomous mode
135
+ // every gate is decided by the Arbiter instead of the human; interactive
136
+ // keeps the human prompts. The decider is threaded into the gates below.
137
+ const { autonomy, resolve: decide } = buildDecider({ config, flags, ask: askQuestion, logger });
138
+
132
139
  // Spec-reviewer pre-pipeline audit (KJC-PCS-0048). Runs BEFORE
133
- // anything else — surfaces ambiguity / missing scope / missing AC
134
- // and lets the user bail out cheap, before any tokens burn.
135
- // Bypass with --skip-spec-review. Static import — the role runs
136
- // on every kj invocation (modulo bypass), no lazy-load benefit.
137
- const reviewResult = await runSpecReview({ spec: task, config, logger, askQuestion, flags });
140
+ // anything else — surfaces ambiguity / missing scope / missing AC.
141
+ // In autonomous mode the Arbiter decides (never aborts); interactive
142
+ // lets the user bail. Bypass with --skip-spec-review.
143
+ const reviewResult = await runSpecReview({ spec: task, config, logger, askQuestion, flags, resolve: decide, autonomy });
138
144
  if (!reviewResult.proceed) {
139
145
  logger.info("Aborted by user after spec review.");
140
146
  cleanupRegistry();
@@ -145,9 +151,14 @@ export async function runCommandHandler({ task, config, logger, flags }) {
145
151
  // user actually approved.
146
152
  const effectiveTask = reviewResult.refined && reviewResult.finalSpec ? reviewResult.finalSpec : task;
147
153
 
154
+ // AUTO-E: in autonomous mode the stages never ask a human or block on the
155
+ // board — they auto-continue with the least-bad choice. interactive/assisted
156
+ // keep the human prompts.
157
+ const stageAsk = createAutonomyAskQuestion({ baseAsk: askQuestion, autonomy, logger });
158
+
148
159
  let result;
149
160
  try {
150
- result = await runFlow({ task: effectiveTask, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
161
+ result = await runFlow({ task: effectiveTask, config, logger, flags, emitter, askQuestion: stageAsk, pgTaskId: pgCardId || null, pgProject: pgProject || null });
151
162
  } finally {
152
163
  // KJC-TSK-0396: limpia el registro pase lo que pase (éxito,
153
164
  // throw, o paused). Los handlers de SIGINT/SIGTERM/exit son
@@ -163,6 +174,12 @@ export async function runCommandHandler({ task, config, logger, flags }) {
163
174
  // failed), tell the user the exact command to resume it.
164
175
  printResumeHint(result);
165
176
  }
166
- return { ok: !result?.paused && result?.approved !== false };
177
+ // Surface per-HU results so `kj autorun` can build a real outcome report
178
+ // (KJC-BUG-0090: it showed "0/0 met the ask" because only { ok } came back).
179
+ // The plan run returns them as `huResults` (run-hu-batch finalResult).
180
+ const hus = Array.isArray(result?.huResults)
181
+ ? result.huResults.map((r) => ({ id: r.huId, met: r.approved === true }))
182
+ : [];
183
+ return { ok: !result?.paused && result?.approved !== false, hus };
167
184
  });
168
185
  }
@@ -316,7 +316,8 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
316
316
  await savePlanToDisk(projectDir, loadedPlan);
317
317
  if (outcome && Number.isFinite(outcome.cost_usd)) {
318
318
  try {
319
- const { setStoryCost, setStoryCachedTokens } = await import("../../../../packages/hu-board/src/db.js");
319
+ const { ensureDb, setStoryCost, setStoryCachedTokens } = await import("../../../../packages/hu-board/src/db.js");
320
+ ensureDb(); // KJC-BUG-0090: autorun/run may not have started the board
320
321
  setStoryCost(huId, outcome.cost_usd);
321
322
  // Φ0-G (KJC-TSK-0525): cached/in tokens travel in the same
322
323
  // outcome blob as cost. Write them together so the board
@@ -128,7 +128,13 @@ export function parseCheckpointAnswer({ trimmedAnswer, checkpointDisabled, confi
128
128
  }
129
129
 
130
130
  export async function checkSessionTimeout({ askQuestion, elapsedMinutes, config, session, emitter, eventBase, i, budgetSummary }) {
131
- if (askQuestion || elapsedMinutes <= config.session.max_total_minutes) return;
131
+ // Enforce the wall-clock when no human is watching the checkpoint: either a
132
+ // non-interactive run (no askQuestion) OR an autonomous one — where askQuestion
133
+ // is the auto-continue wrapper (truthy) but nobody is there to stop a runaway.
134
+ // Interactive runs keep skipping it (the human decides at the checkpoint).
135
+ // KJC-TSK-0575 (AUTO-D): without this, autonomous runs had NO wall-clock cap.
136
+ const unattended = !askQuestion || askQuestion.unattended === true;
137
+ if (!unattended || elapsedMinutes <= config.session.max_total_minutes) return;
132
138
 
133
139
  await markSessionStatus(session, "failed");
134
140
  emitProgress(
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Arbiter prompt (KJC-TSK-0573, design in docs/specs/autonomous-delivery.md §6).
3
+ *
4
+ * The Arbiter is Karajan's autonomous decision authority: given a conflict, it
5
+ * picks the LEAST-BAD option from a closed set and moves forward — no human.
6
+ */
7
+
8
+ /** The closed set of conflict-resolution verdicts. BLOCK_AND_CONTINUE is safe. */
9
+ export const VERDICTS = Object.freeze([
10
+ "ACCEPT_WITH_DEFECT",
11
+ "RETRY_DIFFERENT_APPROACH",
12
+ "DESCOPE_HU",
13
+ "BLOCK_AND_CONTINUE",
14
+ "PROCEED",
15
+ ]);
16
+
17
+ export function buildArbiterPrompt({ question, options = [], context = null } = {}) {
18
+ const allowed = options.length ? options.map((o) => o.value) : VERDICTS;
19
+ return [
20
+ "You are the Arbiter — Karajan's autonomous decision authority. There is no human to ask.",
21
+ "Resolve the conflict below by choosing the LEAST-BAD option and moving forward.",
22
+ "",
23
+ "Ground-truth order (what wins when signals disagree):",
24
+ " 1. Acceptance tests passing — beats any reviewer opinion.",
25
+ " 2. Must-fix issues (security, correctness, data) — beat style.",
26
+ " 3. Nice-to-have (style, optional refactors) — never block; record as a defect if dropped.",
27
+ "Goal: maximize 'the ask is met', minimize irreversible risk. Imperfect-but-delivered beats blocked.",
28
+ "",
29
+ `Conflict: ${question}`,
30
+ context ? `Context: ${JSON.stringify(context)}` : "",
31
+ "",
32
+ `Choose exactly one verdict from: ${allowed.join(", ")}`,
33
+ "Respond with ONLY this JSON (no prose):",
34
+ '{"verdict":"<one of the allowed>","confidence":0.0,"rationale":"why it is the least-bad option","defect":"residual debt if any, else null","directive":"instruction to the coder when RETRY_DIFFERENT_APPROACH, else null"}',
35
+ ]
36
+ .filter(Boolean)
37
+ .join("\n");
38
+ }
@@ -7,10 +7,30 @@
7
7
  import { SpecReviewerRole } from "../roles/spec-reviewer-role.js";
8
8
  import { printFindings } from "../utils/display/spec-findings.js";
9
9
  import { runRefinePass } from "./refine-loop.js";
10
+ import { RISK } from "../autonomy/policy.js";
10
11
 
11
12
  const MAX_REFINE_ITERATIONS = 5;
12
13
 
13
- export async function runSpecReview({ spec, config, logger, askQuestion, flags = {} }) {
14
+ /**
15
+ * Decide the gate action (`continue` | `refine`) autonomously: the Arbiter
16
+ * picks PROCEED (proceed with the best reading) or RETRY_DIFFERENT_APPROACH
17
+ * (auto-refine). It is never offered `cancel`, so an autonomous run never
18
+ * aborts on spec ambiguity (KJC-TSK-0572, closes gap G1).
19
+ */
20
+ async function decideGateAutonomously(resolve, { severity, findings }) {
21
+ const { choice } = await resolve({
22
+ question: `Spec has ${findings.length} finding(s) at severity ${severity}: proceed with the best reading, or auto-refine?`,
23
+ options: [
24
+ { value: "PROCEED", label: "proceed with the best reading", conservative: true },
25
+ { value: "RETRY_DIFFERENT_APPROACH", label: "auto-refine the spec" },
26
+ ],
27
+ context: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))] },
28
+ risk: severity === "fail" ? RISK.HIGH : RISK.LOW,
29
+ });
30
+ return choice === "RETRY_DIFFERENT_APPROACH" ? "refine" : "continue";
31
+ }
32
+
33
+ export async function runSpecReview({ spec, config, logger, askQuestion, flags = {}, resolve = null, autonomy = "interactive" }) {
14
34
  // VITEST guard — pre-existing test suites (e.g. tests/commands/command-run.test.js)
15
35
  // mock `runFlow` + `assertAgentsAvailable` but predate this role and have
16
36
  // no need to exercise it. Auto-skip in vitest unless the test passes
@@ -40,16 +60,24 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
40
60
  return { proceed: true, severity: "ok", findings: [], finalSpec: currentSpec, refined: iter > 0 };
41
61
  }
42
62
  printFindings(findings, severity);
43
- if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
44
63
 
45
- const answer = await askQuestion(
46
- `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
47
- { detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 } },
48
- );
49
- if (answer === null) return { proceed: false, cancelled: true, severity, findings };
50
- const n = String(answer).trim().toLowerCase();
51
- if (n.startsWith("x") || n === "cancel") return { proceed: false, cancelled: true, severity, findings };
52
- if (n.startsWith("r") || n === "refine") {
64
+ // Autonomous/assisted: the Arbiter decides (continue|refine), never cancels.
65
+ // Interactive (no resolver): the human answers [c]/[r]/[x] as before.
66
+ let action;
67
+ if (resolve && autonomy !== "interactive") {
68
+ action = await decideGateAutonomously(resolve, { severity, findings });
69
+ } else {
70
+ if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
71
+ const answer = await askQuestion(
72
+ `Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
73
+ { detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 } },
74
+ );
75
+ if (answer === null) return { proceed: false, cancelled: true, severity, findings };
76
+ const n = String(answer).trim().toLowerCase();
77
+ action = n.startsWith("x") || n === "cancel" ? "cancel" : n.startsWith("r") || n === "refine" ? "refine" : "continue";
78
+ }
79
+ if (action === "cancel") return { proceed: false, cancelled: true, severity, findings };
80
+ if (action === "refine") {
53
81
  const refine = await runRefinePass({
54
82
  spec: currentSpec, findings, role,
55
83
  projectDir: config?.projectDir, taskFile: flags.taskFile, logger,