karajan-code 2.4.1 → 2.6.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 (115) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
@@ -4,13 +4,11 @@ import { resolveRole } from "../config.js";
4
4
  import { buildPlannerPrompt } from "../prompts/planner.js";
5
5
  import { parseMaybeJsonString } from "../review/parser.js";
6
6
 
7
+ // ---- Formatting helpers ----
8
+
7
9
  function formatPlan(plan) {
8
10
  const lines = [];
9
-
10
- if (plan.approach) {
11
- lines.push("## Approach", plan.approach, "");
12
- }
13
-
11
+ if (plan.approach) lines.push("## Approach", plan.approach, "");
14
12
  if (plan.steps?.length) {
15
13
  lines.push("## Steps");
16
14
  for (let i = 0; i < plan.steps.length; i++) {
@@ -20,27 +18,39 @@ function formatPlan(plan) {
20
18
  }
21
19
  lines.push("");
22
20
  }
23
-
24
21
  if (plan.risks?.length) {
25
22
  lines.push("## Risks");
26
- for (const risk of plan.risks) {
27
- lines.push(`- ${risk}`);
28
- }
23
+ for (const risk of plan.risks) lines.push(`- ${risk}`);
29
24
  lines.push("");
30
25
  }
31
-
32
26
  if (plan.outOfScope?.length) {
33
27
  lines.push("## Out of scope");
34
- for (const item of plan.outOfScope) {
35
- lines.push(`- ${item}`);
36
- }
28
+ for (const item of plan.outOfScope) lines.push(`- ${item}`);
37
29
  lines.push("");
38
30
  }
31
+ return lines.join("\n");
32
+ }
39
33
 
34
+ function formatHuTable(hus) {
35
+ if (!hus?.length) return " (no HUs)";
36
+ const lines = [];
37
+ const maxTitle = Math.min(50, Math.max(...hus.map(h => h.title?.length || 0)));
38
+ lines.push(` ${"ID".padEnd(30)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
39
+ lines.push(` ${"─".repeat(30)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
40
+ for (const hu of hus) {
41
+ const title = (hu.title || "").slice(0, maxTitle).padEnd(maxTitle);
42
+ const deps = (hu.blocked_by || []).join(", ") || "—";
43
+ lines.push(` ${hu.id.padEnd(30)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
44
+ }
40
45
  return lines.join("\n");
41
46
  }
42
47
 
43
- export async function planCommand({ task, config, logger, json, context }) {
48
+ // ---- Subcommand handlers ----
49
+
50
+ /**
51
+ * kj plan "task" — generate plan + HUs
52
+ */
53
+ export async function planGenerateCommand({ task, config, logger, json, context }) {
44
54
  const plannerRole = resolveRole(config, "planner");
45
55
  await assertAgentsAvailable([plannerRole.provider]);
46
56
 
@@ -54,43 +64,217 @@ export async function planCommand({ task, config, logger, json, context }) {
54
64
  : undefined;
55
65
  const result = await planner.runTask({ prompt, role: "planner", silenceTimeoutMs, timeoutMs });
56
66
 
57
- if (!result.ok) {
58
- throw new Error(result.error || result.output || "Planner failed");
59
- }
67
+ if (!result.ok) throw new Error(result.error || result.output || "Planner failed");
60
68
 
61
69
  const parsed = parseMaybeJsonString(result.output);
62
70
 
63
- // Persist plan
64
- let planId = null;
65
- try {
66
- const { savePlan } = await import("../plan/plan-store.js");
67
- const projectDir = config.projectDir || process.cwd();
68
- planId = await savePlan(projectDir, {
69
- task,
70
- researchContext: null,
71
- architectContext: null,
72
- plan: parsed || result.output,
73
- raw: result.output
71
+ // Create v2 plan with HUs from planner steps
72
+ const { createPlanV2 } = await import("../plan/plan-schema.js");
73
+ const { addHu } = await import("../plan/plan-hu-ops.js");
74
+ const { savePlan } = await import("../plan/plan-store.js");
75
+ const { deriveProjectName, classifyTaskType } = await import("../hu/auto-generator.js");
76
+
77
+ const projectDir = config.projectDir || process.cwd();
78
+ const plan = createPlanV2(task);
79
+ plan.name = deriveProjectName(task);
80
+ plan.approach = parsed?.approach || (typeof parsed === "string" ? parsed : (typeof result.output === "string" ? result.output : null));
81
+ plan.risks = parsed?.risks || [];
82
+ plan.outOfScope = parsed?.outOfScope || [];
83
+
84
+ // Convert planner steps → HUs
85
+ const steps = parsed?.steps || [];
86
+ let prevId = null;
87
+ for (const step of steps) {
88
+ const desc = typeof step === "string" ? step : step.description || step.title || JSON.stringify(step);
89
+ const hu = addHu(plan, {
90
+ title: desc.slice(0, 80),
91
+ task_type: classifyTaskType(desc),
92
+ scope: desc,
93
+ blocked_by: prevId ? [prevId] : [],
94
+ acceptance_tests: [
95
+ "npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL"
96
+ ]
74
97
  });
75
- } catch (err) {
76
- logger.warn(`Plan persistence failed: ${err.message}`);
98
+ prevId = hu.id;
77
99
  }
78
100
 
101
+ const planId = await savePlan(projectDir, plan);
102
+
79
103
  if (json) {
80
- const jsonOutput = parsed || result.output;
81
- const outputObj = typeof jsonOutput === "object" ? { ...jsonOutput, planId } : { plan: jsonOutput, planId };
82
- console.log(JSON.stringify(outputObj, null, 2));
104
+ console.log(JSON.stringify(plan, null, 2));
83
105
  return;
84
106
  }
85
107
 
86
108
  if (parsed?.approach) {
87
109
  console.log(formatPlan(parsed));
110
+ } else if (plan.approach) {
111
+ console.log(plan.approach);
112
+ }
113
+ console.log(`\n## HUs (${plan.hus.length})`);
114
+ console.log(formatHuTable(plan.hus));
115
+ console.log(`\nPlan saved: ${planId} (${plan.hus.length} HUs, status: ${plan.status})`);
116
+ console.log(`Review: kj plan show ${planId}`);
117
+ console.log(`Approve: kj plan ready ${planId}`);
118
+ console.log(`Execute: kj run --plan ${planId} "${task.slice(0, 40)}..."`);
119
+ }
120
+
121
+ /**
122
+ * kj plan list
123
+ */
124
+ export async function planListCommand({ config }) {
125
+ const { listPlans } = await import("../plan/plan-store.js");
126
+ const projectDir = config.projectDir || process.cwd();
127
+ const plans = await listPlans(projectDir);
128
+
129
+ if (plans.length === 0) {
130
+ console.log("No plans found. Create one with: kj plan \"your task\"");
131
+ return;
132
+ }
133
+
134
+ console.log(`Plans for ${projectDir}:\n`);
135
+ for (const p of plans) {
136
+ const husInfo = p.huCount > 0 ? ` (${p.huCount} HUs)` : "";
137
+ const status = p.status !== "draft" ? ` [${p.status}]` : "";
138
+ console.log(` ${p.planId} ${(p.name || p.task || "").slice(0, 50)}${husInfo}${status}`);
139
+ console.log(` Created: ${p.createdAt}`);
140
+ }
141
+ }
142
+
143
+ /**
144
+ * kj plan show <planId>
145
+ */
146
+ export async function planShowCommand({ config, planId }) {
147
+ const { loadPlan } = await import("../plan/plan-store.js");
148
+ const projectDir = config.projectDir || process.cwd();
149
+ const plan = await loadPlan(projectDir, planId);
150
+
151
+ if (!plan) {
152
+ console.error(`Plan not found: ${planId}`);
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+
157
+ console.log(`Plan: ${plan.planId}`);
158
+ console.log(`Task: ${plan.task}`);
159
+ console.log(`Name: ${plan.name || "—"}`);
160
+ console.log(`Status: ${plan.status}`);
161
+ console.log(`Version: ${plan.version}`);
162
+ console.log(`Created: ${plan.createdAt}`);
163
+
164
+ if (plan.approach) {
165
+ console.log(`\n## Approach\n${plan.approach}`);
166
+ }
167
+
168
+ if (plan.hus?.length > 0) {
169
+ console.log(`\n## HUs (${plan.hus.length})`);
170
+ console.log(formatHuTable(plan.hus));
88
171
  } else {
89
- console.log(result.output);
172
+ console.log("\nNo HUs defined. Add with: kj plan add-hu " + planId);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * kj plan ready <planId>
178
+ */
179
+ export async function planReadyCommand({ config, planId }) {
180
+ const { loadPlan, savePlan } = await import("../plan/plan-store.js");
181
+ const { certifyAllHus } = await import("../plan/plan-hu-ops.js");
182
+ const { validatePlan } = await import("../plan/plan-schema.js");
183
+ const projectDir = config.projectDir || process.cwd();
184
+
185
+ const plan = await loadPlan(projectDir, planId);
186
+ if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
187
+
188
+ const validation = validatePlan(plan);
189
+ if (!validation.valid) {
190
+ console.error("Plan validation failed:");
191
+ for (const err of validation.errors) console.error(` - ${err}`);
192
+ process.exitCode = 1;
193
+ return;
90
194
  }
91
195
 
92
- if (planId) {
93
- console.log(`\nPlan saved: ${planId}`);
94
- console.log(`Use it with: kj run --plan ${planId} "<task>"`);
196
+ if (plan.hus.length === 0) {
197
+ console.error("Cannot approve a plan with 0 HUs.");
198
+ process.exitCode = 1;
199
+ return;
95
200
  }
201
+
202
+ const count = certifyAllHus(plan);
203
+ await savePlan(projectDir, plan);
204
+ console.log(`✅ ${count} HUs certified. Plan status: ready`);
205
+ console.log(`Execute: kj run --plan ${planId} "${(plan.task || "").slice(0, 40)}..."`);
96
206
  }
207
+
208
+ /**
209
+ * kj plan validate <planId>
210
+ */
211
+ export async function planValidateCommand({ config, planId }) {
212
+ const { loadPlan } = await import("../plan/plan-store.js");
213
+ const { validatePlan } = await import("../plan/plan-schema.js");
214
+ const projectDir = config.projectDir || process.cwd();
215
+
216
+ const plan = await loadPlan(projectDir, planId);
217
+ if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
218
+
219
+ const result = validatePlan(plan);
220
+ if (result.valid) {
221
+ console.log(`✅ Plan ${planId} is valid (${plan.hus.length} HUs, status: ${plan.status})`);
222
+ } else {
223
+ console.error(`❌ Plan ${planId} has ${result.errors.length} error(s):`);
224
+ for (const err of result.errors) console.error(` - ${err}`);
225
+ process.exitCode = 1;
226
+ }
227
+ }
228
+
229
+ /**
230
+ * kj plan delete <planId>
231
+ */
232
+ export async function planDeleteCommand({ config, planId }) {
233
+ const { deletePlan } = await import("../plan/plan-store.js");
234
+ const projectDir = config.projectDir || process.cwd();
235
+ const ok = await deletePlan(projectDir, planId);
236
+ console.log(ok ? `Deleted: ${planId}` : `Not found: ${planId}`);
237
+ }
238
+
239
+ /**
240
+ * kj plan add-hu <planId> --title "..." --type sw --deps hu_xxx
241
+ */
242
+ export async function planAddHuCommand({ config, planId, title, type, deps, scope }) {
243
+ const { loadPlan, savePlan } = await import("../plan/plan-store.js");
244
+ const { addHu } = await import("../plan/plan-hu-ops.js");
245
+ const projectDir = config.projectDir || process.cwd();
246
+
247
+ const plan = await loadPlan(projectDir, planId);
248
+ if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
249
+
250
+ const hu = addHu(plan, {
251
+ title: title || "New HU",
252
+ task_type: type || "sw",
253
+ scope: scope || null,
254
+ blocked_by: deps ? deps.split(",").map(d => d.trim()) : []
255
+ });
256
+
257
+ await savePlan(projectDir, plan);
258
+ console.log(`Added: ${hu.id} — ${hu.title}`);
259
+ }
260
+
261
+ /**
262
+ * kj plan remove-hu <planId> <huId>
263
+ */
264
+ export async function planRemoveHuCommand({ config, planId, huId }) {
265
+ const { loadPlan, savePlan } = await import("../plan/plan-store.js");
266
+ const { removeHu } = await import("../plan/plan-hu-ops.js");
267
+ const projectDir = config.projectDir || process.cwd();
268
+
269
+ const plan = await loadPlan(projectDir, planId);
270
+ if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
271
+
272
+ const ok = removeHu(plan, huId);
273
+ if (!ok) { console.error(`HU not found: ${huId}`); process.exitCode = 1; return; }
274
+
275
+ await savePlan(projectDir, plan);
276
+ console.log(`Removed: ${huId}`);
277
+ }
278
+
279
+ // Keep backward compat — old callers import planCommand
280
+ export const planCommand = planGenerateCommand;
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
2
2
  import readline from "node:readline";
3
3
  import { resumeFlow } from "../orchestrator.js";
4
4
  import { createActivityLog } from "../activity-log.js";
5
- import { printEvent } from "../utils/display.js";
5
+ import { printEvent } from "../utils/display/event-handlers.js";
6
6
 
7
7
  function createCliAskQuestion() {
8
8
  return async (question, context) => {
@@ -3,7 +3,8 @@ import readline from "node:readline";
3
3
  import { runFlow } from "../orchestrator.js";
4
4
  import { assertAgentsAvailable } from "../agents/availability.js";
5
5
  import { createActivityLog } from "../activity-log.js";
6
- import { printHeader, printEvent } from "../utils/display.js";
6
+ import { printHeader } from "../utils/display/header.js";
7
+ import { printEvent } from "../utils/display/event-handlers.js";
7
8
  import { resolveRole } from "../config.js";
8
9
  import { parseCardId } from "../planning-game/adapter.js";
9
10
 
@@ -0,0 +1,49 @@
1
+ /**
2
+ * `kj skills` — manage the local OpenSkills cache.
3
+ *
4
+ * Subcommands:
5
+ * - list : print cached skills with their age and freshness.
6
+ * - clear-cache : delete all cached metadata (forces re-install on next session).
7
+ */
8
+
9
+ import { listCached, clearCache, getCacheRoot, DEFAULT_TTL_MS } from "../skills/skills-cache.js";
10
+
11
+ function humanAge(cachedAt) {
12
+ const ms = Date.now() - Date.parse(cachedAt);
13
+ if (!Number.isFinite(ms) || ms < 0) return "unknown";
14
+ const days = ms / (24 * 60 * 60 * 1000);
15
+ if (days < 1) return `${Math.max(1, Math.round(ms / (60 * 60 * 1000)))}h ago`;
16
+ return `${days.toFixed(1)}d ago`;
17
+ }
18
+
19
+ export async function skillsCommand({ action = "list" } = {}) {
20
+ switch (action) {
21
+ case "list":
22
+ return listAction();
23
+ case "clear-cache":
24
+ return clearAction();
25
+ default:
26
+ console.error(`Unknown action: ${action}. Use: list | clear-cache`);
27
+ return 1;
28
+ }
29
+ }
30
+
31
+ async function listAction() {
32
+ const cache = await listCached();
33
+ if (cache.length === 0) {
34
+ console.log(`No cached skills at ${getCacheRoot()}`);
35
+ return 0;
36
+ }
37
+ console.log(`Cached skills (TTL ${Math.round(DEFAULT_TTL_MS / 86400000)}d) at ${getCacheRoot()}:\n`);
38
+ for (const entry of cache) {
39
+ const flag = entry.fresh ? "FRESH" : "STALE";
40
+ console.log(` ${flag} ${entry.name} (${humanAge(entry.cachedAt)})`);
41
+ }
42
+ return 0;
43
+ }
44
+
45
+ async function clearAction() {
46
+ const { removed } = await clearCache();
47
+ console.log(`Cleared ${removed} cached skill${removed === 1 ? "" : "s"} from ${getCacheRoot()}`);
48
+ return 0;
49
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Central Valibot schema for Karajan's configuration surface (KJC-TSK-0318).
3
+ *
4
+ * Origin: this schema is based on Jorge del Casar's original work in PR #379
5
+ * (closed). The full 650-line mirror-of-DEFAULTS approach he proposed drifted
6
+ * heavily against `main` by the time it was reviewed, so this file takes the
7
+ * same intent — validating Karajan config at load time with Valibot — but
8
+ * narrows the scope to the handful of fields where validation actually
9
+ * catches real bugs (review_mode, methodology, max_iterations, percentage
10
+ * budgets, port numbers). Everything else is defined with `v.looseObject` /
11
+ * `v.unknown()` so existing consumers who touch undeclared keys keep working.
12
+ *
13
+ * The runtime DEFAULTS object still lives in src/config.js — it is the source
14
+ * of truth for default values. This schema exists to reject invalid input
15
+ * early (e.g. `max_iterations: 0`, `review_mode: "yolo"`) with a clear error,
16
+ * and to give downstream consumers a typed handle via `KarajanConfig`.
17
+ *
18
+ * @typedef {import("valibot").InferOutput<typeof ConfigSchema>} KarajanConfig
19
+ */
20
+
21
+ import * as v from "valibot";
22
+
23
+ /**
24
+ * Positive integer (>= 1). Used where 0 / negative would be a clear bug
25
+ * (max_iterations, port, most timeout knobs when they gate the loop itself).
26
+ */
27
+ const PositiveInt = v.pipe(
28
+ v.number("must be a number"),
29
+ v.integer("must be an integer"),
30
+ v.minValue(1, "must be >= 1")
31
+ );
32
+
33
+ /**
34
+ * Non-negative integer (>= 0). Used for retry counts where 0 is a valid
35
+ * "don't retry" signal.
36
+ */
37
+ const NonNegativeInt = v.pipe(
38
+ v.number("must be a number"),
39
+ v.integer("must be an integer"),
40
+ v.minValue(0, "must be >= 0")
41
+ );
42
+
43
+ /**
44
+ * review_mode picklist — anything outside this set is a typo.
45
+ */
46
+ const ReviewMode = v.picklist(
47
+ ["paranoid", "strict", "standard", "relaxed", "custom"],
48
+ "review_mode must be one of: paranoid | strict | standard | relaxed | custom"
49
+ );
50
+
51
+ const Methodology = v.picklist(
52
+ ["tdd", "standard"],
53
+ "development.methodology must be one of: tdd | standard"
54
+ );
55
+
56
+ /**
57
+ * Role entry — provider + model, both nullable. Extra keys allowed.
58
+ */
59
+ const RoleEntry = v.looseObject({
60
+ provider: v.optional(v.nullable(v.string())),
61
+ model: v.optional(v.nullable(v.string())),
62
+ });
63
+
64
+ const RolesMap = v.optional(v.looseObject({
65
+ planner: v.optional(RoleEntry),
66
+ coder: v.optional(RoleEntry),
67
+ reviewer: v.optional(RoleEntry),
68
+ refactorer: v.optional(RoleEntry),
69
+ solomon: v.optional(RoleEntry),
70
+ researcher: v.optional(RoleEntry),
71
+ tester: v.optional(RoleEntry),
72
+ security: v.optional(RoleEntry),
73
+ impeccable: v.optional(RoleEntry),
74
+ triage: v.optional(RoleEntry),
75
+ discover: v.optional(RoleEntry),
76
+ architect: v.optional(RoleEntry),
77
+ hu_reviewer: v.optional(RoleEntry),
78
+ }));
79
+
80
+ const PipelineEntry = v.looseObject({
81
+ enabled: v.optional(v.boolean()),
82
+ });
83
+
84
+ const PipelineMap = v.optional(v.looseObject({
85
+ planner: v.optional(PipelineEntry),
86
+ refactorer: v.optional(PipelineEntry),
87
+ solomon: v.optional(PipelineEntry),
88
+ researcher: v.optional(PipelineEntry),
89
+ tester: v.optional(PipelineEntry),
90
+ security: v.optional(PipelineEntry),
91
+ impeccable: v.optional(PipelineEntry),
92
+ triage: v.optional(PipelineEntry),
93
+ discover: v.optional(PipelineEntry),
94
+ architect: v.optional(PipelineEntry),
95
+ hu_reviewer: v.optional(PipelineEntry),
96
+ auto_simplify: v.optional(v.boolean()),
97
+ }));
98
+
99
+ const Development = v.optional(v.looseObject({
100
+ methodology: v.optional(Methodology),
101
+ require_test_changes: v.optional(v.boolean()),
102
+ test_file_patterns: v.optional(v.array(v.string())),
103
+ source_file_extensions: v.optional(v.array(v.string())),
104
+ }));
105
+
106
+ const Git = v.optional(v.looseObject({
107
+ auto_commit: v.optional(v.boolean()),
108
+ auto_push: v.optional(v.boolean()),
109
+ auto_pr: v.optional(v.boolean()),
110
+ auto_rebase: v.optional(v.boolean()),
111
+ branch_prefix: v.optional(v.string()),
112
+ }));
113
+
114
+ const ReviewerOptions = v.optional(v.looseObject({
115
+ output_format: v.optional(v.string()),
116
+ require_schema: v.optional(v.boolean()),
117
+ model: v.optional(v.nullable(v.string())),
118
+ deterministic: v.optional(v.boolean()),
119
+ retries: v.optional(NonNegativeInt, 1),
120
+ fallback_reviewer: v.optional(v.nullable(v.string())),
121
+ }));
122
+
123
+ const CoderOptions = v.optional(v.looseObject({
124
+ model: v.optional(v.nullable(v.string())),
125
+ auto_approve: v.optional(v.boolean()),
126
+ fallback_coder: v.optional(v.nullable(v.string())),
127
+ }));
128
+
129
+ const HuBoard = v.optional(v.looseObject({
130
+ enabled: v.optional(v.boolean()),
131
+ port: v.optional(v.pipe(
132
+ v.number("hu_board.port must be a number"),
133
+ v.integer(),
134
+ v.minValue(1, "hu_board.port must be >= 1"),
135
+ v.maxValue(65535, "hu_board.port must be <= 65535")
136
+ )),
137
+ auto_start: v.optional(v.boolean()),
138
+ }));
139
+
140
+ /**
141
+ * Budget warn threshold: 0-100 percent. Anything outside is nonsense.
142
+ */
143
+ const Budget = v.optional(v.looseObject({
144
+ warn_threshold_pct: v.optional(v.pipe(
145
+ v.number(),
146
+ v.minValue(0, "budget.warn_threshold_pct must be 0-100"),
147
+ v.maxValue(100, "budget.warn_threshold_pct must be 0-100")
148
+ )),
149
+ currency: v.optional(v.string()),
150
+ exchange_rate_eur: v.optional(v.number()),
151
+ pricing: v.optional(v.unknown()),
152
+ }));
153
+
154
+ /**
155
+ * Main config schema. `looseObject` lets every consumer that reads an
156
+ * undeclared key keep working while the schema catches the bugs we care
157
+ * about.
158
+ */
159
+ export const ConfigSchema = v.looseObject({
160
+ coder: v.optional(v.string()),
161
+ reviewer: v.optional(v.string()),
162
+ roles: RolesMap,
163
+ pipeline: PipelineMap,
164
+ review_mode: v.optional(ReviewMode),
165
+ max_iterations: v.optional(PositiveInt, 5),
166
+ hu_max_iterations: v.optional(PositiveInt, 3),
167
+ max_budget_usd: v.optional(v.nullable(v.pipe(
168
+ v.number(),
169
+ v.minValue(0, "max_budget_usd must be >= 0")
170
+ ))),
171
+ review_rules: v.optional(v.string()),
172
+ coder_rules: v.optional(v.string()),
173
+ base_branch: v.optional(v.string()),
174
+ coder_options: CoderOptions,
175
+ reviewer_options: ReviewerOptions,
176
+ development: Development,
177
+ git: Git,
178
+ hu_board: HuBoard,
179
+ budget: Budget,
180
+ language: v.optional(v.string()),
181
+ hu_language: v.optional(v.string()),
182
+ policies: v.optional(v.record(v.string(), v.unknown())),
183
+ telemetry: v.optional(v.boolean()),
184
+ });
185
+
186
+ /**
187
+ * Parse a raw merged config against the schema. Returns the normalized
188
+ * config or throws a Valibot ValiError. Call sites should wrap this in a
189
+ * try/catch that re-throws with a friendlier prefix.
190
+ *
191
+ * @param {unknown} input
192
+ * @returns {KarajanConfig}
193
+ */
194
+ export function parseConfig(input) {
195
+ return v.parse(ConfigSchema, input);
196
+ }
197
+
198
+ /**
199
+ * Safe variant — returns { success, data } | { success: false, issues } so
200
+ * callers can decide how to report errors without catching ValiError.
201
+ * @param {unknown} input
202
+ */
203
+ export function safeParseConfig(input) {
204
+ const result = v.safeParse(ConfigSchema, input);
205
+ if (result.success) return { success: true, data: result.output };
206
+ return {
207
+ success: false,
208
+ issues: result.issues.map(i => ({
209
+ path: (i.path || []).map(p => p.key).join("."),
210
+ message: i.message,
211
+ })),
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Aggregate Valibot issues into a single readable string. Used by loadConfig
217
+ * to produce actionable error messages instead of the default ValiError dump.
218
+ * @param {Array<{path: string, message: string}>} issues
219
+ */
220
+ export function formatConfigIssues(issues) {
221
+ return issues.map(i => (i.path ? `${i.path}: ${i.message}` : i.message)).join("\n");
222
+ }
package/src/config.js CHANGED
@@ -3,6 +3,11 @@ import path from "node:path";
3
3
  import yaml from "js-yaml";
4
4
  import { ensureDir, exists } from "./utils/fs.js";
5
5
  import { getKarajanHome } from "./utils/paths.js";
6
+ import { safeParseConfig, formatConfigIssues } from "./config/schema.js";
7
+
8
+ /**
9
+ * @typedef {import("./config/schema.js").KarajanConfig} KarajanConfig
10
+ */
6
11
 
7
12
  const DEFAULTS = {
8
13
  coder: "claude",
@@ -266,6 +271,16 @@ export async function loadConfig(projectDir) {
266
271
  merged.budget = mergeDeep(merged.budget || {}, { pricing: projectPricing });
267
272
  }
268
273
 
274
+ // KJC-TSK-0318 — schema-validate the merged config so invalid values
275
+ // (review_mode typos, max_iterations=0, out-of-range ports) surface at
276
+ // load time with a clear error instead of crashing deep inside the
277
+ // orchestrator. safeParse + explicit error prefix keeps the message
278
+ // actionable.
279
+ const validation = safeParseConfig(merged);
280
+ if (!validation.success) {
281
+ throw new Error(`Invalid Karajan config:\n${formatConfigIssues(validation.issues)}`);
282
+ }
283
+
269
284
  return { config: merged, path: configPath, exists: globalExists, hasProjectConfig: !!projectConfig };
270
285
  }
271
286
 
@@ -303,10 +318,13 @@ const PIPELINE_ENABLE_FLAGS = [
303
318
 
304
319
  const AUTO_SIMPLIFY_FLAG = "autoSimplify";
305
320
 
306
- // Scalar flags: [flagName, setter] — truthy check
321
+ // Scalar flags: [flagName, setter] — truthy check.
322
+ // Note: max_iterations was previously here but `0` was silently ignored
323
+ // (truthy check skipped it). It now lives in UNDEF_CHECK_FLAGS so
324
+ // `--max-iterations 0` reaches the schema validator and errors cleanly
325
+ // instead of falling through to the default (KJC-TSK-0318).
307
326
  const SCALAR_FLAGS = [
308
327
  ["mode", (out, v) => { out.review_mode = v; }],
309
- ["maxIterations", (out, v) => { out.max_iterations = Number(v); }],
310
328
  ["maxIterationMinutes", (out, v) => { out.session.max_iteration_minutes = Number(v); }],
311
329
  ["maxTotalMinutes", (out, v) => { out.session.max_total_minutes = Number(v); }],
312
330
  ["checkpointInterval", (out, v) => { out.session.checkpoint_interval_minutes = Number(v); }],
@@ -317,8 +335,11 @@ const SCALAR_FLAGS = [
317
335
  ["branchPrefix", (out, v) => { out.git.branch_prefix = String(v); }]
318
336
  ];
319
337
 
320
- // Boolean/undefined-check flags: [flagName, setter] — !== undefined check
338
+ // Boolean/undefined-check flags: [flagName, setter] — !== undefined check.
339
+ // These preserve falsy values (false, 0) so --no-rebase correctly sets
340
+ // auto_rebase=false and --reviewer-retries 0 correctly disables retries.
321
341
  const UNDEF_CHECK_FLAGS = [
342
+ ["maxIterations", (out, v) => { out.max_iterations = Number(v); }],
322
343
  ["reviewerRetries", (out, v) => { out.reviewer_options.retries = Number(v); }],
323
344
  ["autoCommit", (out, v) => { out.git.auto_commit = Boolean(v); }],
324
345
  ["autoPush", (out, v) => { out.git.auto_push = Boolean(v); }],
@@ -431,6 +452,14 @@ export function applyRunOverrides(config, flags) {
431
452
  applyMiscOverrides(out, flags);
432
453
  applyOutputModeOverrides(out, flags);
433
454
 
455
+ // KJC-TSK-0318 — re-run schema validation after overrides so CLI flags
456
+ // that would produce an invalid config (e.g. --max-iterations 0) fail
457
+ // fast with the same clear message loadConfig would have produced.
458
+ const validation = safeParseConfig(out);
459
+ if (!validation.success) {
460
+ throw new Error(`Invalid Karajan config after CLI overrides:\n${formatConfigIssues(validation.issues)}`);
461
+ }
462
+
434
463
  return out;
435
464
  }
436
465
 
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Policy resolution: map task type + config toggles to concrete pipeline
3
+ * flags (TDD, Sonar, reviewer retries, tests required, coder required, ...).
4
+ *
5
+ * @typedef {import("../types/policy.js").ResolvedPolicies} ResolvedPolicies
6
+ * @typedef {import("../types/config.js").KarajanConfig} KarajanConfig
7
+ */
8
+
1
9
  export const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "audit", "analysis", "no-code"]);
2
10
 
3
11
  export const DEFAULT_POLICIES = {