karajan-code 3.5.1 → 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.
Files changed (42) hide show
  1. package/README.md +1 -1
  2. package/package.json +13 -1
  3. package/packages/ai-trash/README.md +37 -0
  4. package/packages/ai-trash/bin/kj-trash.js +10 -0
  5. package/packages/ai-trash/package.json +43 -0
  6. package/packages/ai-trash/src/cli.js +198 -0
  7. package/packages/ai-trash/src/destructive-parser.js +74 -0
  8. package/packages/ai-trash/src/git-snapshot.js +128 -0
  9. package/packages/ai-trash/src/hook.js +137 -0
  10. package/packages/ai-trash/src/index.js +14 -0
  11. package/packages/ai-trash/src/install.js +52 -0
  12. package/packages/ai-trash/src/logger.js +60 -0
  13. package/packages/ai-trash/src/manifest.js +91 -0
  14. package/packages/ai-trash/src/permissions.js +26 -0
  15. package/packages/ai-trash/src/snapshot.js +66 -0
  16. package/packages/ai-trash/src/ulid.js +66 -0
  17. package/packages/hu-board/src/db.js +11 -0
  18. package/scripts/verify-pack.mjs +14 -1
  19. package/src/autonomy/arbiter-role.js +71 -0
  20. package/src/autonomy/arbiter.js +22 -0
  21. package/src/autonomy/decider.js +18 -0
  22. package/src/autonomy/decision-resolver.js +58 -0
  23. package/src/autonomy/outcome-report.js +55 -0
  24. package/src/autonomy/policy.js +41 -0
  25. package/src/autonomy/stage-ask.js +30 -0
  26. package/src/checks/ai-trash.js +1 -1
  27. package/src/cli/register-meta.js +8 -0
  28. package/src/cli/register-pipeline.js +21 -0
  29. package/src/cli.js +15 -1
  30. package/src/commands/autorun.js +49 -0
  31. package/src/commands/harden.js +57 -2
  32. package/src/commands/init.js +77 -45
  33. package/src/commands/plan/generate.js +3 -2
  34. package/src/commands/run.js +24 -7
  35. package/src/harden/advisory.js +168 -0
  36. package/src/harden/interactive.js +76 -0
  37. package/src/harden/stack-roots.js +34 -4
  38. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -1
  39. package/src/orchestrator/flow-control.js +7 -1
  40. package/src/prompts/arbiter.js +38 -0
  41. package/src/spec-review/run-spec-review.js +38 -10
  42. package/src/utils/squeezr-detect.js +4 -1
@@ -0,0 +1,14 @@
1
+ // @karajan/ai-trash — public entry (skeleton).
2
+ // Real surface (snapshot, restore, list, manifest) lands in KJC-TSK-0388
3
+ // commits 2-6 and KJC-TSK-0389 (git ops). For now we expose a single
4
+ // `version` export so consumers / tests can confirm the package loads.
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
8
+ import { dirname, join } from "node:path";
9
+
10
+ const here = dirname(fileURLToPath(import.meta.url));
11
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
12
+
13
+ export const version = pkg.version;
14
+ export const name = pkg.name;
@@ -0,0 +1,52 @@
1
+ // Claude Code settings patcher (KJC-TSK-0390 commit 3). Idempotently
2
+ // adds a PreToolUse hook entry that pipes Bash commands through
3
+ // `kj-trash hook`. Preserves any unrelated keys and existing hooks so
4
+ // the user's other settings are untouched.
5
+
6
+ import { promises as fs } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+
10
+ export const DEFAULT_SETTINGS_PATH = join(homedir(), ".claude", "settings.json");
11
+ export const HOOK_COMMAND = "kj-trash hook";
12
+ const MATCHER = "Bash";
13
+
14
+ async function readSettings(path) {
15
+ try {
16
+ return JSON.parse(await fs.readFile(path, "utf8"));
17
+ } catch (err) {
18
+ if (err.code === "ENOENT") return {};
19
+ throw err;
20
+ }
21
+ }
22
+
23
+ function hasHookCommand(matcherEntry, command) {
24
+ return (matcherEntry.hooks ?? []).some((h) => h.type === "command" && h.command === command);
25
+ }
26
+
27
+ export function patchSettings(settings, command = HOOK_COMMAND, matcher = MATCHER) {
28
+ const next = { ...settings };
29
+ next.hooks = { ...(next.hooks ?? {}) };
30
+ const preToolUse = Array.isArray(next.hooks.PreToolUse) ? next.hooks.PreToolUse.slice() : [];
31
+ let entry = preToolUse.find((e) => e.matcher === matcher);
32
+ let mutated = false;
33
+ if (!entry) {
34
+ entry = { matcher, hooks: [{ type: "command", command }] };
35
+ preToolUse.push(entry);
36
+ mutated = true;
37
+ } else if (!hasHookCommand(entry, command)) {
38
+ entry.hooks = [...(entry.hooks ?? []), { type: "command", command }];
39
+ mutated = true;
40
+ }
41
+ next.hooks.PreToolUse = preToolUse;
42
+ return { settings: next, mutated };
43
+ }
44
+
45
+ export async function installClaudeCodeHook({ path = DEFAULT_SETTINGS_PATH, command = HOOK_COMMAND } = {}) {
46
+ const settings = await readSettings(path);
47
+ const { settings: next, mutated } = patchSettings(settings, command);
48
+ if (!mutated) return { path, mutated: false };
49
+ await fs.mkdir(dirname(path), { recursive: true });
50
+ await fs.writeFile(path, JSON.stringify(next, null, 2) + "\n", { encoding: "utf8" });
51
+ return { path, mutated: true };
52
+ }
@@ -0,0 +1,60 @@
1
+ // Append-only JSONL audit log for the trash store. Events are written one
2
+ // JSON object per line so the file can be `tail -f`-d and parsed
3
+ // line-by-line without holding the whole log in memory.
4
+
5
+ import { promises as fs } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { lockdownFile } from "./permissions.js";
9
+
10
+ const HOME = homedir();
11
+
12
+ function redactPath(p) {
13
+ if (typeof p !== "string" || !p) return p;
14
+ if (p === HOME) return "~";
15
+ if (p.startsWith(`${HOME}/`)) return `~/${p.slice(HOME.length + 1)}`;
16
+ return p;
17
+ }
18
+
19
+ function redactPayload(payload) {
20
+ if (!payload || typeof payload !== "object") return payload;
21
+ const out = {};
22
+ for (const [k, v] of Object.entries(payload)) {
23
+ if (typeof v === "string" && (k.includes("Path") || k === "cwd")) {
24
+ out[k] = redactPath(v);
25
+ } else {
26
+ out[k] = v;
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ export async function appendLogEntry(rootDir, event, payload = {}) {
33
+ if (!event) throw new Error("ai-trash: log event required");
34
+ const file = join(rootDir, "log.jsonl");
35
+ const line =
36
+ JSON.stringify({
37
+ ts: new Date().toISOString(),
38
+ event,
39
+ ...redactPayload(payload),
40
+ }) + "\n";
41
+ await fs.mkdir(rootDir, { recursive: true });
42
+ await fs.appendFile(file, line, { encoding: "utf8", mode: 0o600 });
43
+ await lockdownFile(file);
44
+ }
45
+
46
+ export async function readLog(rootDir) {
47
+ const file = join(rootDir, "log.jsonl");
48
+ try {
49
+ const raw = await fs.readFile(file, "utf8");
50
+ return raw
51
+ .split("\n")
52
+ .filter(Boolean)
53
+ .map((l) => JSON.parse(l));
54
+ } catch (err) {
55
+ if (err.code === "ENOENT") return [];
56
+ throw err;
57
+ }
58
+ }
59
+
60
+ export { redactPath };
@@ -0,0 +1,91 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { ulid } from "./ulid.js";
4
+
5
+ export const SCHEMA_VERSION = 1;
6
+
7
+ export async function loadManifest(rootDir) {
8
+ const file = join(rootDir, "manifest.json");
9
+ try {
10
+ const raw = await fs.readFile(file, "utf8");
11
+ const parsed = JSON.parse(raw);
12
+ if (parsed?.schemaVersion !== SCHEMA_VERSION) {
13
+ throw new Error(`ai-trash: unsupported manifest schemaVersion ${parsed?.schemaVersion}`);
14
+ }
15
+ if (!Array.isArray(parsed.entries)) parsed.entries = [];
16
+ return parsed;
17
+ } catch (err) {
18
+ if (err.code === "ENOENT") return { schemaVersion: SCHEMA_VERSION, entries: [] };
19
+ throw err;
20
+ }
21
+ }
22
+
23
+ export async function saveManifest(rootDir, manifest) {
24
+ const file = join(rootDir, "manifest.json");
25
+ await fs.mkdir(dirname(file), { recursive: true });
26
+ const tmp = `${file}.${process.pid}.tmp`;
27
+ await fs.writeFile(tmp, JSON.stringify(manifest, null, 2), {
28
+ encoding: "utf8",
29
+ mode: 0o600,
30
+ });
31
+ await fs.rename(tmp, file);
32
+ }
33
+
34
+ export function addEntry(manifest, partial) {
35
+ if (!partial?.sourcePath) throw new Error("ai-trash: sourcePath required");
36
+ if (typeof partial.sizeBytes !== "number" || partial.sizeBytes < 0) {
37
+ throw new Error("ai-trash: sizeBytes must be a non-negative number");
38
+ }
39
+ const now = Date.now();
40
+ const entry = {
41
+ id: ulid(now),
42
+ createdAt: now,
43
+ sourcePath: partial.sourcePath,
44
+ snapshotPath: partial.snapshotPath ?? null,
45
+ sizeBytes: partial.sizeBytes,
46
+ command: partial.command ?? null,
47
+ origin: partial.origin ?? "unknown",
48
+ };
49
+ manifest.entries.push(entry);
50
+ return entry;
51
+ }
52
+
53
+ export function listEntries(manifest) {
54
+ return [...manifest.entries].sort((a, b) => a.id.localeCompare(b.id));
55
+ }
56
+
57
+ export function removeEntry(manifest, id) {
58
+ const idx = manifest.entries.findIndex((e) => e.id === id);
59
+ if (idx < 0) return null;
60
+ return manifest.entries.splice(idx, 1)[0];
61
+ }
62
+
63
+ export function expireBefore(manifest, cutoffMs) {
64
+ const expired = [];
65
+ manifest.entries = manifest.entries.filter((e) => {
66
+ if (e.createdAt < cutoffMs) {
67
+ expired.push(e);
68
+ return false;
69
+ }
70
+ return true;
71
+ });
72
+ return expired;
73
+ }
74
+
75
+ export function enforceLruQuota(manifest, maxBytes) {
76
+ if (typeof maxBytes !== "number" || maxBytes < 0) {
77
+ throw new Error("ai-trash: maxBytes must be a non-negative number");
78
+ }
79
+ const sorted = listEntries(manifest);
80
+ const total = sorted.reduce((sum, e) => sum + e.sizeBytes, 0);
81
+ const evicted = [];
82
+ let running = total;
83
+ for (const entry of sorted) {
84
+ if (running <= maxBytes) break;
85
+ evicted.push(entry);
86
+ running -= entry.sizeBytes;
87
+ }
88
+ const evictedIds = new Set(evicted.map((e) => e.id));
89
+ manifest.entries = manifest.entries.filter((e) => !evictedIds.has(e.id));
90
+ return evicted;
91
+ }
@@ -0,0 +1,26 @@
1
+ import { promises as fs } from "node:fs";
2
+
3
+ const DIR_MODE = 0o700;
4
+ const FILE_MODE = 0o600;
5
+
6
+ export async function ensureSecureDir(path) {
7
+ await fs.mkdir(path, { recursive: true, mode: DIR_MODE });
8
+ await fs.chmod(path, DIR_MODE);
9
+ }
10
+
11
+ export async function lockdownFile(path) {
12
+ await fs.chmod(path, FILE_MODE);
13
+ }
14
+
15
+ export async function assertOwnedByCurrentUser(path) {
16
+ if (process.platform === "win32") return;
17
+ const stats = await fs.stat(path);
18
+ if (typeof process.getuid !== "function") return;
19
+ if (stats.uid !== process.getuid()) {
20
+ throw new Error(
21
+ `ai-trash: ${path} is owned by uid ${stats.uid}, expected ${process.getuid()}`
22
+ );
23
+ }
24
+ }
25
+
26
+ export { DIR_MODE, FILE_MODE };
@@ -0,0 +1,66 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { ulid } from "./ulid.js";
4
+ import { ensureSecureDir, lockdownFile } from "./permissions.js";
5
+ import { appendLogEntry } from "./logger.js";
6
+
7
+ async function statOrNull(path) {
8
+ try {
9
+ return await fs.lstat(path);
10
+ } catch (err) {
11
+ if (err.code === "ENOENT") return null;
12
+ throw err;
13
+ }
14
+ }
15
+
16
+ export async function snapshotFile(rootDir, sourcePath, options = {}) {
17
+ const stat = await statOrNull(sourcePath);
18
+ if (!stat) throw new Error(`ai-trash: source not found: ${sourcePath}`);
19
+ if (stat.isDirectory()) {
20
+ throw new Error("ai-trash: directory snapshots not implemented in MVP");
21
+ }
22
+ const id = ulid();
23
+ const storeDir = join(rootDir, "store", id);
24
+ await ensureSecureDir(storeDir);
25
+ const snapshotPath = join(storeDir, basename(sourcePath));
26
+ await fs.copyFile(sourcePath, snapshotPath);
27
+ await lockdownFile(snapshotPath);
28
+ const entry = {
29
+ id,
30
+ createdAt: Date.now(),
31
+ sourcePath,
32
+ snapshotPath,
33
+ sizeBytes: stat.size,
34
+ command: options.command ?? null,
35
+ origin: options.origin ?? "unknown",
36
+ };
37
+ await appendLogEntry(rootDir, "snapshot.create", {
38
+ id,
39
+ sourcePath,
40
+ snapshotPath,
41
+ sizeBytes: stat.size,
42
+ });
43
+ return entry;
44
+ }
45
+
46
+ export async function restoreSnapshot(rootDir, entry, targetPath) {
47
+ const target = targetPath ?? entry.sourcePath;
48
+ const exists = await statOrNull(target);
49
+ if (exists) throw new Error(`ai-trash: refusing to overwrite ${target}`);
50
+ await fs.mkdir(join(target, ".."), { recursive: true });
51
+ await fs.copyFile(entry.snapshotPath, target);
52
+ await appendLogEntry(rootDir, "snapshot.restore", {
53
+ id: entry.id,
54
+ snapshotPath: entry.snapshotPath,
55
+ targetPath: target,
56
+ });
57
+ return target;
58
+ }
59
+
60
+ export async function purgeSnapshot(rootDir, entry) {
61
+ await fs.rm(join(rootDir, "store", entry.id), { recursive: true, force: true });
62
+ await appendLogEntry(rootDir, "snapshot.purge", {
63
+ id: entry.id,
64
+ sizeBytes: entry.sizeBytes,
65
+ });
66
+ }
@@ -0,0 +1,66 @@
1
+ // Tiny ULID generator (Crockford base32, 26 chars, monotonic within ms).
2
+ // We keep this self-contained to avoid pulling a runtime dep into the
3
+ // safety-net package — fewer transitive deps = fewer supply-chain surfaces
4
+ // that an attacker could pivot through to reach the trash store.
5
+
6
+ import { randomBytes } from "node:crypto";
7
+
8
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
9
+
10
+ let lastMs = 0;
11
+ let lastRandom = new Uint8Array(10);
12
+
13
+ function encodeTime(ms) {
14
+ let out = "";
15
+ for (let i = 9; i >= 0; i--) {
16
+ out = ENCODING[ms % 32] + out;
17
+ ms = Math.floor(ms / 32);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function encodeRandom(bytes) {
23
+ let out = "";
24
+ for (let i = 0; i < 16; i++) {
25
+ const byteIndex = Math.floor((i * 5) / 8);
26
+ const bitOffset = (i * 5) % 8;
27
+ const hi = bytes[byteIndex] ?? 0;
28
+ const lo = bytes[byteIndex + 1] ?? 0;
29
+ const value = ((hi << 8) | lo) >> (11 - bitOffset);
30
+ out += ENCODING[value & 31];
31
+ }
32
+ return out;
33
+ }
34
+
35
+ function bumpRandom(buf) {
36
+ for (let i = buf.length - 1; i >= 0; i--) {
37
+ if (buf[i] === 0xff) {
38
+ buf[i] = 0;
39
+ continue;
40
+ }
41
+ buf[i] += 1;
42
+ return buf;
43
+ }
44
+ throw new Error("ulid: random overflow within the same millisecond");
45
+ }
46
+
47
+ export function ulid(nowMs = Date.now()) {
48
+ if (nowMs === lastMs) {
49
+ lastRandom = bumpRandom(lastRandom);
50
+ } else {
51
+ lastMs = nowMs;
52
+ lastRandom = new Uint8Array(randomBytes(10));
53
+ }
54
+ return encodeTime(nowMs) + encodeRandom(lastRandom);
55
+ }
56
+
57
+ export function decodeTimeFromUlid(id) {
58
+ const slice = id.slice(0, 10);
59
+ let ms = 0;
60
+ for (const ch of slice) {
61
+ const v = ENCODING.indexOf(ch);
62
+ if (v < 0) throw new Error(`ulid: invalid char ${ch}`);
63
+ ms = ms * 32 + v;
64
+ }
65
+ return ms;
66
+ }
@@ -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
@@ -18,7 +18,7 @@
18
18
  * this from prepublishOnly does not recurse.
19
19
  */
20
20
 
21
- import { execFileSync } from "node:child_process";
21
+ import { execFileSync, spawnSync } from "node:child_process";
22
22
  import fs from "node:fs";
23
23
  import os from "node:os";
24
24
  import path from "node:path";
@@ -101,6 +101,19 @@ try {
101
101
  }
102
102
  console.log("verify-pack: sqlite-vec + @karajan/core resolve ✓");
103
103
 
104
+ // 4. kj-trash (ai-trash safety binary) must ship + link onto PATH and boot
105
+ // without a module-resolution crash. It was silently absent from the tarball
106
+ // until KJC-BUG-0089 — every npm user got "kj-trash not found".
107
+ const trashBin = path.join(tmpDir, "node_modules", ".bin", "kj-trash");
108
+ if (!fs.existsSync(trashBin)) fail(`kj-trash binary missing after install: ${trashBin}`);
109
+ const trash = spawnSync(trashBin, ["--help"], { encoding: "utf8", env: childEnv });
110
+ // The CLI exits 2 on --help (usage); only a real crash (module not found, or
111
+ // an unexpected non-0/2 exit) is a failure.
112
+ if (![0, 2].includes(trash.status) || /ERR_MODULE_NOT_FOUND|Cannot find/.test(trash.stderr || "")) {
113
+ fail("`kj-trash --help` crashed on a clean install", trash.stderr || `exit ${trash.status}`);
114
+ }
115
+ console.log("verify-pack: kj-trash ships + boots ✓");
116
+
104
117
  console.log(`\n✓ verify-pack: karajan-code@${expectedVersion} installs clean and runs.`);
105
118
  } finally {
106
119
  if (tgzPath && fs.existsSync(tgzPath)) fs.rmSync(tgzPath, { force: true });
@@ -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
+ }