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,168 @@
1
+ /**
2
+ * advisory — per-artifact comparison engine for `kj harden` advisory mode
3
+ * (KJC-TSK-0565, épica KJC-PCS-0060). For each artifact harden manages it
4
+ * classifies the repo state: MISSING (no file nor equivalent), KJ_MANAGED (a
5
+ * kj:managed block, up to date or outdated vN) or USER_OWNED (the user's own,
6
+ * possibly in another format). Read-only; never writes or prints. The result
7
+ * feeds `--report` (Advisory B) / `--interactive` (Advisory C). Per-type
8
+ * improvement heuristics for USER_OWNED land in the follow-up slice (PR2).
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import { findManagedBlock } from "../utils/managed-markers.js";
15
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
16
+ import { detectStackRoots } from "./stack-roots.js";
17
+
18
+ const BLOCK_VERSION = 1; // current version every markered config ships at
19
+
20
+ // Other filenames/formats for the same artifact, so kj never reports a false
21
+ // "missing". `pkgKey` is a package.json field that can hold inline config.
22
+ const EQUIVALENTS = {
23
+ commitlint: { files: [".commitlintrc", ".commitlintrc.js", ".commitlintrc.json", ".commitlintrc.yaml"], pkgKey: "commitlint" },
24
+ eslint: { files: ["eslint.config.mjs", ".eslintrc.js", ".eslintrc.cjs", ".eslintrc.json", ".eslintrc.yaml"], pkgKey: "eslintConfig" },
25
+ prettier: { files: [".prettierrc", ".prettierrc.js", ".prettierrc.yaml", "prettier.config.js"], pkgKey: "prettier" },
26
+ };
27
+
28
+ // Concrete gains kj's standard would add to a USER_OWNED config, detected by
29
+ // substring probes (no AST parse): each probe absent from the user's content
30
+ // becomes one improvement. Heuristic by design — conservative, never a false
31
+ // "you're missing this" when the signal is clearly present.
32
+ const IMPROVEMENTS = {
33
+ eslint: [
34
+ { needle: "no-var", id: "no-var", detail: "bans var (ES2025: const/let)" },
35
+ { needle: "no-restricted-globals", id: "no-legacy-globals", detail: "bans alert/confirm/prompt, escape/unescape" },
36
+ { needle: "no-restricted-properties", id: "no-legacy-apis", detail: "bans document.write, substr" },
37
+ ],
38
+ commitlint: [
39
+ { needle: "header-max-length", id: "header-max-length", detail: "caps the commit header at 100 chars" },
40
+ { needle: "subject-case", id: "subject-case", detail: "enforces a lowercase subject" },
41
+ ],
42
+ prettier: [{ needle: "printWidth", id: "print-width", detail: "pins printWidth (consistent wrapping)" }],
43
+ editorconfig: [
44
+ { needle: "insert_final_newline", id: "final-newline", detail: "enforces a final newline" },
45
+ { needle: "charset", id: "charset", detail: "pins charset = utf-8" },
46
+ { needle: "trim_trailing_whitespace", id: "trim-trailing", detail: "trims trailing whitespace" },
47
+ ],
48
+ };
49
+
50
+ /** Improvements kj would add to the user's own config for `artifactId`. */
51
+ function improvementsFor(artifactId, content) {
52
+ return (IMPROVEMENTS[artifactId] ?? [])
53
+ .filter((probe) => !content.includes(probe.needle))
54
+ .map(({ id, detail }) => ({ id, detail }));
55
+ }
56
+
57
+ /** Build an artifact descriptor from a config-template entry. */
58
+ function toArtifact(cfg) {
59
+ const id = cfg.blockId ?? "prettier"; // the only marker-less JSON config is prettier
60
+ return {
61
+ id,
62
+ file: cfg.file,
63
+ blockId: cfg.blockId ?? null,
64
+ currentVersion: cfg.blockId ? BLOCK_VERSION : null,
65
+ equivalents: EQUIVALENTS[id]?.files ?? [],
66
+ pkgKey: EQUIVALENTS[id]?.pkgKey ?? null,
67
+ };
68
+ }
69
+
70
+ /** Locate the user's file for an artifact under `searchDir`; null if absent. */
71
+ function findArtifactFile(searchDir, artifact) {
72
+ for (const rel of [artifact.file, ...artifact.equivalents]) {
73
+ if (existsSync(join(searchDir, rel))) return rel;
74
+ }
75
+ if (artifact.pkgKey && existsSync(join(searchDir, "package.json"))) {
76
+ try {
77
+ if (JSON.parse(readFileSync(join(searchDir, "package.json"), "utf8"))[artifact.pkgKey] != null) {
78
+ return `package.json#${artifact.pkgKey}`;
79
+ }
80
+ } catch {
81
+ /* unreadable package.json → not a match */
82
+ }
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** Read the artifact body, or the JSON of an inline package.json key. */
88
+ function readArtifactContent(searchDir, foundAt) {
89
+ const [file, key] = foundAt.split("#");
90
+ const raw = readFileSync(join(searchDir, file), "utf8");
91
+ if (!key) return raw;
92
+ try {
93
+ return JSON.stringify(JSON.parse(raw)[key] ?? null);
94
+ } catch {
95
+ return raw;
96
+ }
97
+ }
98
+
99
+ /** Map a classified state to a recommendation + human rationale. */
100
+ function recommend(state) {
101
+ if (state.status === "MISSING") return { recommendation: "install", rationale: `kj would add ${state.file}`, mergeable: true };
102
+ if (state.status === "USER_OWNED") {
103
+ const n = state.improvements?.length ?? 0;
104
+ return n === 0
105
+ ? { recommendation: "keep", rationale: "your own config — already covers the kj standard", mergeable: false }
106
+ : { recommendation: "review", rationale: `your own config — kj could add ${n} improvement(s)`, mergeable: true };
107
+ }
108
+ if (state.upToDate) return { recommendation: "keep", rationale: `managed by kj (v${state.managedVersion}), up to date`, mergeable: false };
109
+ return { recommendation: "update", rationale: `kj v${state.currentVersion} available (you have v${state.managedVersion})`, mergeable: true };
110
+ }
111
+
112
+ /** Classify a single artifact found (or not) under `searchDir`. */
113
+ export function classifyArtifact(searchDir, artifact) {
114
+ const foundAt = findArtifactFile(searchDir, artifact);
115
+ const base = { foundAt, managedVersion: null, upToDate: null, improvements: [] };
116
+ let state = { ...base, status: "MISSING" };
117
+ if (foundAt) {
118
+ const content = readArtifactContent(searchDir, foundAt);
119
+ if (artifact.blockId && content.includes(`kj:managed:${artifact.blockId}`)) {
120
+ const { version } = findManagedBlock(content, artifact.blockId);
121
+ state = { ...base, status: "KJ_MANAGED", managedVersion: version, upToDate: version != null && version >= artifact.currentVersion };
122
+ } else {
123
+ state = { ...base, status: "USER_OWNED", improvements: improvementsFor(artifact.id, content) };
124
+ }
125
+ }
126
+ const head = { id: artifact.id, file: artifact.file, currentVersion: artifact.currentVersion };
127
+ return { ...head, ...state, ...recommend({ ...state, ...head }) };
128
+ }
129
+
130
+ /**
131
+ * Compare the whole repo against the kj standard, artifact by artifact:
132
+ * universal configs at the root, then each language's lint config inside its
133
+ * own root (monorepo-aware, mirroring installConfigsForRoots). `minimal`
134
+ * profile ships no config, so there is nothing to compare.
135
+ */
136
+ export function compareHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [] } = {}) {
137
+ if (profile === "minimal") return { artifacts: [] };
138
+ const artifacts = UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) }));
139
+ for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
140
+ const searchDir = dir === "." ? projectDir : join(projectDir, dir);
141
+ for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
142
+ const res = classifyArtifact(searchDir, toArtifact(cfg));
143
+ artifacts.push({ dir, ...res, foundAt: res.foundAt && dir !== "." ? join(dir, res.foundAt) : res.foundAt });
144
+ }
145
+ }
146
+ return { artifacts };
147
+ }
148
+
149
+ const MARK = { install: "+", update: "↑", review: "~", keep: "✓" };
150
+
151
+ /**
152
+ * Render a compareHarden() result as human-readable lines (Advisory B). Pure:
153
+ * returns an array of strings, prints nothing. One line per artifact, indented
154
+ * bullets for each improvement, then a one-line tally.
155
+ */
156
+ export function formatAdvisoryReport({ artifacts = [] } = {}) {
157
+ if (artifacts.length === 0) return ["kj harden — nothing to compare (minimal profile or empty repo)."];
158
+ const lines = ["kj harden — advisory report (read-only)", ""];
159
+ const tally = { install: 0, update: 0, review: 0, keep: 0 };
160
+ for (const a of artifacts) {
161
+ tally[a.recommendation] = (tally[a.recommendation] ?? 0) + 1;
162
+ const path = a.foundAt ?? (a.dir === "." ? a.file : join(a.dir, a.file));
163
+ lines.push(` ${MARK[a.recommendation] ?? "·"} ${a.recommendation.padEnd(7)} ${path} — ${a.rationale}`);
164
+ for (const imp of a.improvements ?? []) lines.push(` • ${imp.detail}`);
165
+ }
166
+ lines.push("", `${artifacts.length} artifact(s) · ${tally.install} missing · ${tally.review} to review · ${tally.update} outdated · ${tally.keep} ok`);
167
+ return lines;
168
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * interactive — `kj harden --interactive` (KJC-TSK-0567, épica KJC-PCS-0060).
3
+ *
4
+ * Walks the advisory comparison (Advisory A/B) and asks, artifact by artifact,
5
+ * whether to apply the kj standard — with a safe default of leaving the user's
6
+ * own config untouched. Only the pieces the user says yes to are written:
7
+ *
8
+ * MISSING → add it? (default yes — same as seed-if-absent)
9
+ * KJ_MANAGED outdated → update it? (default yes)
10
+ * USER_OWNED w/ gaps → adopt kj's? (default NO — keep yours)
11
+ *
12
+ * The full merge (user's config + kj rules) is out of scope here (v2): adopt
13
+ * replaces the file with kj's standard. `ask` is injected so the flow is
14
+ * testable without a TTY; the CLI passes a readline-backed confirm.
15
+ */
16
+
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { dirname, join } from "node:path";
19
+
20
+ import { upsertManagedBlock } from "../utils/managed-markers.js";
21
+ import { compareHarden } from "./advisory.js";
22
+ import { CONFIGS_BY_LANGUAGE, UNIVERSAL_CONFIGS } from "./config-templates.js";
23
+
24
+ const BLOCK_VERSION = 1; // mirrors config-engine.js
25
+
26
+ // Artifact id (blockId, or "prettier" for the marker-less JSON) → its template.
27
+ const TEMPLATE_BY_ID = new Map();
28
+ for (const cfg of [...UNIVERSAL_CONFIGS, ...Object.values(CONFIGS_BY_LANGUAGE).flat()]) {
29
+ TEMPLATE_BY_ID.set(cfg.blockId ?? "prettier", cfg);
30
+ }
31
+
32
+ /** What to ask (and do) for one classified artifact; null = nothing to offer. */
33
+ export function decideAction(a) {
34
+ if (a.recommendation === "install") return { action: "add", defaultYes: true, prompt: `Add ${a.file}? (${a.rationale})` };
35
+ if (a.recommendation === "update") return { action: "update", defaultYes: true, prompt: `Update ${a.file} to kj v${a.currentVersion}?` };
36
+ if (a.recommendation === "review") {
37
+ return { action: "adopt", defaultYes: false, prompt: `Adopt kj's ${a.file}? Replaces yours, adds ${a.improvements.length} improvement(s). Default keeps yours.` };
38
+ }
39
+ return null; // keep / up-to-date — nothing to ask
40
+ }
41
+
42
+ /** Write an artifact's kj body. `overwrite` discards the user's file (adopt). */
43
+ function applyArtifact(projectDir, dir, cfg, { overwrite }) {
44
+ const target = join(projectDir, dir === "." ? "" : dir, cfg.file);
45
+ mkdirSync(dirname(target), { recursive: true });
46
+ if (cfg.json) {
47
+ writeFileSync(target, `${cfg.body}\n`);
48
+ return;
49
+ }
50
+ const source = !overwrite && existsSync(target) ? readFileSync(target, "utf8") : "";
51
+ const { content } = upsertManagedBlock({ source, blockId: cfg.blockId, version: BLOCK_VERSION, body: cfg.body, style: cfg.style });
52
+ writeFileSync(target, content);
53
+ }
54
+
55
+ /**
56
+ * Run the interactive adoption flow. `ask(prompt, defaultYes) → Promise<bool>`.
57
+ * Returns the list of applied artifacts. Writes nothing the user declines.
58
+ */
59
+ export async function interactiveHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [], ask, logger = console } = {}) {
60
+ const { artifacts } = compareHarden({ projectDir, profile, only, exclude });
61
+ const applied = [];
62
+ for (const a of artifacts) {
63
+ const plan = decideAction(a);
64
+ if (!plan) continue;
65
+ const yes = await ask(plan.prompt, plan.defaultYes);
66
+ if (!yes) continue;
67
+ const cfg = TEMPLATE_BY_ID.get(a.id);
68
+ if (!cfg) continue;
69
+ applyArtifact(projectDir, a.dir, cfg, { overwrite: plan.action === "adopt" });
70
+ const where = a.dir === "." ? a.file : join(a.dir, a.file);
71
+ applied.push({ file: where, action: plan.action });
72
+ logger.info?.(` ✓ ${plan.action} ${where}`);
73
+ }
74
+ if (applied.length === 0) logger.info?.("kj harden — nothing applied (kept everything as-is).");
75
+ return { ok: true, interactive: true, applied };
76
+ }
@@ -21,9 +21,38 @@ const IGNORE_DIRS = new Set([
21
21
  "target",
22
22
  "__pycache__",
23
23
  "venv",
24
+ // Auxiliary code that isn't the app to harden (KJC-TSK-0564): demo/fixture
25
+ // dirs ship their own throwaway manifests and must not seed lint/CI config.
26
+ "examples",
27
+ "example",
28
+ "fixtures",
29
+ "samples",
30
+ "third_party",
31
+ "testdata",
32
+ "__fixtures__",
24
33
  ]);
25
34
  const DEFAULT_MAX_DEPTH = 2;
26
35
 
36
+ /**
37
+ * Minimal glob for harden scope flags — no dynamic RegExp (eslint security).
38
+ * Matches on exact equality, directory prefix (`wrappers` ⊇ `wrappers/python`)
39
+ * and `*` wildcards anchored at both ends unless the `*` is leading/trailing.
40
+ */
41
+ export function matchesGlob(value, pattern) {
42
+ if (value === pattern || value.startsWith(`${pattern}/`)) return true;
43
+ if (!pattern.includes("*")) return false;
44
+ const pieces = pattern.split("*");
45
+ let idx = 0;
46
+ for (let i = 0; i < pieces.length; i += 1) {
47
+ const piece = pieces[i];
48
+ if (!piece) continue;
49
+ const at = value.indexOf(piece, idx);
50
+ if (at === -1 || (i === 0 && at !== 0)) return false;
51
+ idx = at + piece.length;
52
+ }
53
+ return pattern.endsWith("*") || value.length === idx;
54
+ }
55
+
27
56
  /** Identify the language of a single directory by its manifest files. */
28
57
  export function languageAt(dir) {
29
58
  if (existsSync(join(dir, "package.json"))) {
@@ -43,7 +72,7 @@ export function languageAt(dir) {
43
72
  return null;
44
73
  }
45
74
 
46
- export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } = {}) {
75
+ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH, only = [], exclude = [] } = {}) {
47
76
  const found = [];
48
77
  function walk(dir, depth) {
49
78
  const language = languageAt(dir);
@@ -69,7 +98,8 @@ export function detectStackRoots(projectDir, { maxDepth = DEFAULT_MAX_DEPTH } =
69
98
  const cur = byLanguage.get(f.language);
70
99
  if (!cur || f.depth < cur.depth) byLanguage.set(f.language, f);
71
100
  }
72
- return [...byLanguage.values()]
73
- .map(({ dir, language }) => ({ dir, language }))
74
- .sort((a, b) => a.dir.localeCompare(b.dir));
101
+ let roots = [...byLanguage.values()].map(({ dir, language }) => ({ dir, language }));
102
+ if (only.length) roots = roots.filter((r) => only.some((o) => matchesGlob(r.dir, o)));
103
+ if (exclude.length) roots = roots.filter((r) => !exclude.some((g) => matchesGlob(r.dir, g)));
104
+ return roots.sort((a, b) => a.dir.localeCompare(b.dir));
75
105
  }
@@ -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,
@@ -8,7 +8,10 @@ export async function detectSqueezr() {
8
8
  try {
9
9
  const result = await runCommand("squeezr", ["--version"]);
10
10
  if (result.exitCode === 0) {
11
- const version = (result.stdout || "").trim() || null;
11
+ // `squeezr --version` may append an "update available" banner box; keep
12
+ // just the semver so callers never render that noise (KJC-BUG-0088).
13
+ const raw = (result.stdout || "").trim();
14
+ const version = raw.match(/\d+\.\d+\.\d+[\w.-]*/)?.[0] || raw.split("\n")[0].trim() || null;
12
15
  return { available: true, version };
13
16
  }
14
17
  return { available: false, version: null };