karajan-code 2.5.0 → 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 (109) 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 +29 -3
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/resume.js +1 -1
  32. package/src/commands/run.js +2 -1
  33. package/src/commands/skills.js +49 -0
  34. package/src/config/schema.js +222 -0
  35. package/src/config.js +32 -3
  36. package/src/guards/policy-resolver.js +8 -0
  37. package/src/hu/store.js +9 -0
  38. package/src/infrastructure/command-runner.js +44 -0
  39. package/src/infrastructure/environment.js +38 -0
  40. package/src/infrastructure/file-system-service.js +63 -0
  41. package/src/infrastructure/mocks.js +182 -0
  42. package/src/mcp/handlers/management-handlers.js +7 -2
  43. package/src/mcp/tools.js +10 -3
  44. package/src/orchestrator/config-init.js +16 -1
  45. package/src/orchestrator/flow-runner.js +2085 -0
  46. package/src/orchestrator/pipeline-context.js +17 -0
  47. package/src/orchestrator/preflight-checks.js +78 -4
  48. package/src/orchestrator/solomon-escalation.js +21 -0
  49. package/src/orchestrator/stages/stage-executor.js +155 -0
  50. package/src/orchestrator.js +19 -1923
  51. package/src/prompts/architect.js +2 -2
  52. package/src/prompts/coder.js +2 -2
  53. package/src/prompts/prompt-resolver.js +127 -0
  54. package/src/prompts/reviewer.js +2 -2
  55. package/src/roles/architect-role.js +3 -1
  56. package/src/roles/base-role.js +23 -2
  57. package/src/roles/coder-role.js +3 -1
  58. package/src/roles/security-role.js +12 -0
  59. package/src/roles/tester-role.js +11 -0
  60. package/src/session/journal/decisions-writer.js +200 -0
  61. package/src/session/journal/iteration-logger.js +194 -0
  62. package/src/session/journal/summary-writer.js +176 -0
  63. package/src/session/journal/tree-writer.js +164 -0
  64. package/src/skills/semantic-detector.js +147 -0
  65. package/src/skills/skill-detector.js +186 -8
  66. package/src/skills/skill-loader.js +131 -1
  67. package/src/skills/skills-cache.js +128 -0
  68. package/src/types/agent.js +55 -0
  69. package/src/types/config.js +114 -0
  70. package/src/types/hu.js +47 -0
  71. package/src/types/index.js +34 -0
  72. package/src/types/policy.js +43 -0
  73. package/src/types/session.js +74 -0
  74. package/src/types/stage.js +68 -0
  75. package/src/utils/display/event-handlers.js +389 -0
  76. package/src/utils/display/formatters.js +132 -0
  77. package/src/utils/display/header.js +48 -0
  78. package/src/utils/display/session-summary.js +108 -0
  79. package/src/utils/display/solomon.js +42 -0
  80. package/src/utils/port-check.js +45 -0
  81. package/src/utils/port-occupant.js +95 -0
  82. package/src/utils/provider-env.js +116 -0
  83. package/src/utils/with-timeout.js +42 -0
  84. package/templates/roles/architect/anthropic.md +65 -0
  85. package/templates/roles/architect/default.md +63 -0
  86. package/templates/roles/architect/google.md +52 -0
  87. package/templates/roles/architect/openai.md +51 -0
  88. package/templates/roles/architect/opencode.md +50 -0
  89. package/templates/roles/coder/anthropic.md +72 -0
  90. package/templates/roles/coder/default.md +69 -0
  91. package/templates/roles/coder/google.md +78 -0
  92. package/templates/roles/coder/openai.md +59 -0
  93. package/templates/roles/coder/opencode.md +49 -0
  94. package/templates/roles/planner/anthropic.md +50 -0
  95. package/templates/roles/planner/default.md +48 -0
  96. package/templates/roles/planner/google.md +66 -0
  97. package/templates/roles/planner/openai.md +46 -0
  98. package/templates/roles/planner/opencode.md +53 -0
  99. package/templates/roles/reviewer/anthropic.md +61 -0
  100. package/templates/roles/reviewer/default.md +62 -0
  101. package/templates/roles/reviewer/google.md +63 -0
  102. package/templates/roles/reviewer/openai.md +57 -0
  103. package/templates/roles/reviewer/opencode.md +42 -0
  104. package/templates/roles/solomon/anthropic.md +71 -0
  105. package/templates/roles/solomon/default.md +83 -0
  106. package/templates/roles/solomon/google.md +61 -0
  107. package/templates/roles/solomon/openai.md +53 -0
  108. package/templates/roles/solomon/opencode.md +55 -0
  109. package/src/utils/display.js +0 -669
@@ -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 = {
package/src/hu/store.js CHANGED
@@ -1,3 +1,12 @@
1
+ /**
2
+ * HU (Historia de Usuario / User Story) storage. Persists HU batches and
3
+ * their stories to the session directory.
4
+ *
5
+ * @typedef {import("../types/hu.js").HUStory} HUStory
6
+ * @typedef {import("../types/hu.js").HUBatch} HUBatch
7
+ * @typedef {import("../types/hu.js").HUStatus} HUStatus
8
+ */
9
+
1
10
  import fs from "node:fs/promises";
2
11
  import path from "node:path";
3
12
  import { execFile } from "node:child_process";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * CommandRunner — DI-friendly adapter over execa / runCommand.
3
+ *
4
+ * Why: agent adapters (claude, codex, gemini, ...) all end up invoking
5
+ * src/utils/process.js -> runCommand. That makes unit-testing the agent layer
6
+ * slow and flaky — every test either spins up a real child process or stubs
7
+ * execa deep inside Vitest's module graph.
8
+ *
9
+ * This adapter preserves the runCommand API exactly, so current consumers
10
+ * can opt-in to DI without behavioural changes. A matching MockCommandRunner
11
+ * lives in src/infrastructure/mocks.js and lets tests script outputs per
12
+ * invocation.
13
+ *
14
+ * @typedef {Object} CommandRunResult
15
+ * @property {number} exitCode
16
+ * @property {string} stdout
17
+ * @property {string} stderr
18
+ * @property {boolean} [timedOut]
19
+ * @property {string|null} [signal]
20
+ *
21
+ * @typedef {Object} CommandRunOptions
22
+ * @property {number} [timeout]
23
+ * @property {(evt: {stream: string, line: string}) => void} [onOutput]
24
+ * @property {number} [silenceTimeoutMs]
25
+ * @property {number} [partialOutputFlushMs]
26
+ * @property {string|Buffer} [input]
27
+ * @property {string} [cwd]
28
+ * @property {Record<string,string>} [env]
29
+ *
30
+ * @typedef {Object} CommandRunner
31
+ * @property {(command: string, args?: string[], options?: CommandRunOptions) => Promise<CommandRunResult>} run
32
+ */
33
+
34
+ import { runCommand } from "../utils/process.js";
35
+
36
+ /**
37
+ * Default implementation — delegates to runCommand.
38
+ * @type {CommandRunner}
39
+ */
40
+ export const defaultCommandRunner = {
41
+ async run(command, args = [], options = {}) {
42
+ return runCommand(command, args, options);
43
+ }
44
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Environment — the DI bundle passed to services that need filesystem + command
3
+ * execution. Grouping them keeps call sites small: `new BaseAgent(..., env)`
4
+ * instead of `new BaseAgent(..., fs, runner)`.
5
+ *
6
+ * @typedef {import("./file-system-service.js").FileSystemService} FileSystemService
7
+ * @typedef {import("./command-runner.js").CommandRunner} CommandRunner
8
+ *
9
+ * @typedef {Object} Environment
10
+ * @property {FileSystemService} fs
11
+ * @property {CommandRunner} runner
12
+ */
13
+
14
+ import { defaultFileSystem } from "./file-system-service.js";
15
+ import { defaultCommandRunner } from "./command-runner.js";
16
+
17
+ /**
18
+ * Default environment uses native fs + real execa-based runner.
19
+ * Tests should inject a custom environment via buildEnvironment() below.
20
+ * @type {Environment}
21
+ */
22
+ export const defaultEnvironment = Object.freeze({
23
+ fs: defaultFileSystem,
24
+ runner: defaultCommandRunner
25
+ });
26
+
27
+ /**
28
+ * Merge overrides on top of the default environment. Missing properties fall
29
+ * back to defaults, so callers can replace only the pieces they care about.
30
+ * @param {Partial<Environment>} [overrides]
31
+ * @returns {Environment}
32
+ */
33
+ export function buildEnvironment(overrides = {}) {
34
+ return Object.freeze({
35
+ fs: overrides.fs ?? defaultFileSystem,
36
+ runner: overrides.runner ?? defaultCommandRunner
37
+ });
38
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * FileSystemService — thin DI-friendly adapter over node:fs/promises.
3
+ *
4
+ * Why: existing code uses node:fs directly (see src/utils/fs.js and consumers),
5
+ * which makes unit-testing orchestrator/agent paths slow and flaky — every test
6
+ * that exercises a code path touching fs has to stage real files on disk.
7
+ *
8
+ * This adapter preserves the exact native API shapes for the handful of
9
+ * operations actually used in the codebase, so consumers can opt-in to DI
10
+ * without rewriting call sites. A matching MockFileSystem lives in
11
+ * src/infrastructure/mocks.js.
12
+ *
13
+ * Scope for TSK-0316: intentionally minimal. Only the operations already used
14
+ * by orchestrator + agents. Additional methods added on demand.
15
+ *
16
+ * @typedef {Object} FileSystemService
17
+ * @property {(path: string, encoding?: string) => Promise<string|Buffer>} readFile
18
+ * @property {(path: string, data: string|Buffer, encoding?: string) => Promise<void>} writeFile
19
+ * @property {(path: string, data: string|Buffer, encoding?: string) => Promise<void>} appendFile
20
+ * @property {(path: string) => Promise<boolean>} exists
21
+ * @property {(path: string) => Promise<void>} ensureDir
22
+ * @property {(path: string) => Promise<string[]>} readdir
23
+ * @property {(src: string, dest: string) => Promise<void>} copyFile
24
+ * @property {(path: string) => Promise<{size: number, isDirectory: () => boolean, isFile: () => boolean}>} stat
25
+ */
26
+
27
+ import fs from "node:fs/promises";
28
+
29
+ /**
30
+ * Default implementation backed by node:fs/promises.
31
+ * @type {FileSystemService}
32
+ */
33
+ export const defaultFileSystem = {
34
+ async readFile(path, encoding = "utf8") {
35
+ return fs.readFile(path, encoding);
36
+ },
37
+ async writeFile(path, data, encoding = "utf8") {
38
+ await fs.writeFile(path, data, encoding);
39
+ },
40
+ async appendFile(path, data, encoding = "utf8") {
41
+ await fs.appendFile(path, data, encoding);
42
+ },
43
+ async exists(path) {
44
+ try {
45
+ await fs.access(path);
46
+ return true;
47
+ } catch {
48
+ return false;
49
+ }
50
+ },
51
+ async ensureDir(path) {
52
+ await fs.mkdir(path, { recursive: true });
53
+ },
54
+ async readdir(path) {
55
+ return fs.readdir(path);
56
+ },
57
+ async copyFile(src, dest) {
58
+ await fs.copyFile(src, dest);
59
+ },
60
+ async stat(path) {
61
+ return fs.stat(path);
62
+ }
63
+ };
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Test doubles for the DI surfaces. Intentionally dumb — tests stage inputs
3
+ * through simple setters and assert on recorded calls.
4
+ *
5
+ * @typedef {import("./file-system-service.js").FileSystemService} FileSystemService
6
+ * @typedef {import("./command-runner.js").CommandRunner} CommandRunner
7
+ * @typedef {import("./command-runner.js").CommandRunResult} CommandRunResult
8
+ * @typedef {import("./command-runner.js").CommandRunOptions} CommandRunOptions
9
+ */
10
+
11
+ /**
12
+ * In-memory file system. Paths are stored as-is (no normalization) to keep
13
+ * behaviour predictable in tests.
14
+ */
15
+ export class MockFileSystem {
16
+ constructor(initial = {}) {
17
+ /** @type {Map<string, string|Buffer>} */
18
+ this.files = new Map(Object.entries(initial));
19
+ /** @type {Set<string>} */
20
+ this.directories = new Set();
21
+ /** @type {Array<{op: string, path: string, args?: unknown}>} */
22
+ this.calls = [];
23
+ }
24
+
25
+ _record(op, path, args) {
26
+ this.calls.push({ op, path, args });
27
+ }
28
+
29
+ async readFile(path, encoding = "utf8") {
30
+ this._record("readFile", path, { encoding });
31
+ if (!this.files.has(path)) {
32
+ const err = new Error(`ENOENT: no such file or directory, open '${path}'`);
33
+ err.code = "ENOENT";
34
+ throw err;
35
+ }
36
+ const data = this.files.get(path);
37
+ if (Buffer.isBuffer(data) && encoding) return data.toString(encoding);
38
+ return data;
39
+ }
40
+
41
+ async writeFile(path, data, encoding = "utf8") {
42
+ this._record("writeFile", path, { encoding });
43
+ this.files.set(path, data);
44
+ }
45
+
46
+ async appendFile(path, data, encoding = "utf8") {
47
+ this._record("appendFile", path, { encoding });
48
+ const prev = this.files.get(path) ?? "";
49
+ this.files.set(path, String(prev) + String(data));
50
+ }
51
+
52
+ async exists(path) {
53
+ this._record("exists", path);
54
+ return this.files.has(path) || this.directories.has(path);
55
+ }
56
+
57
+ async ensureDir(path) {
58
+ this._record("ensureDir", path);
59
+ this.directories.add(path);
60
+ }
61
+
62
+ async readdir(path) {
63
+ this._record("readdir", path);
64
+ const prefix = path.endsWith("/") ? path : path + "/";
65
+ const entries = new Set();
66
+ for (const p of this.files.keys()) {
67
+ if (p.startsWith(prefix)) {
68
+ const tail = p.slice(prefix.length).split("/")[0];
69
+ if (tail) entries.add(tail);
70
+ }
71
+ }
72
+ if (!entries.size && !this.directories.has(path)) {
73
+ const err = new Error(`ENOENT: no such file or directory, scandir '${path}'`);
74
+ err.code = "ENOENT";
75
+ throw err;
76
+ }
77
+ return [...entries];
78
+ }
79
+
80
+ async copyFile(src, dest) {
81
+ this._record("copyFile", src, { dest });
82
+ if (!this.files.has(src)) {
83
+ const err = new Error(`ENOENT: no such file or directory, copyfile '${src}' -> '${dest}'`);
84
+ err.code = "ENOENT";
85
+ throw err;
86
+ }
87
+ this.files.set(dest, this.files.get(src));
88
+ }
89
+
90
+ async stat(path) {
91
+ this._record("stat", path);
92
+ if (this.files.has(path)) {
93
+ const data = this.files.get(path);
94
+ const size = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(String(data));
95
+ return {
96
+ size,
97
+ isDirectory: () => false,
98
+ isFile: () => true
99
+ };
100
+ }
101
+ if (this.directories.has(path)) {
102
+ return {
103
+ size: 0,
104
+ isDirectory: () => true,
105
+ isFile: () => false
106
+ };
107
+ }
108
+ const err = new Error(`ENOENT: no such file or directory, stat '${path}'`);
109
+ err.code = "ENOENT";
110
+ throw err;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Scripted command runner. Tests call enqueue() to stage responses in order,
116
+ * or setResponder() for a per-invocation decision. Every run() is recorded.
117
+ */
118
+ export class MockCommandRunner {
119
+ constructor() {
120
+ /** @type {Array<CommandRunResult>} */
121
+ this.queue = [];
122
+ /** @type {Array<{command: string, args: string[], options: CommandRunOptions}>} */
123
+ this.calls = [];
124
+ /** @type {null | ((args: {command: string, args: string[], options: CommandRunOptions}) => CommandRunResult|Promise<CommandRunResult>)} */
125
+ this.responder = null;
126
+ /** @type {CommandRunResult} */
127
+ this.defaultResponse = { exitCode: 0, stdout: "", stderr: "" };
128
+ }
129
+
130
+ /**
131
+ * Queue a response. FIFO — first enqueued is returned on the first run().
132
+ * @param {Partial<CommandRunResult>} response
133
+ */
134
+ enqueue(response) {
135
+ this.queue.push({ exitCode: 0, stdout: "", stderr: "", ...response });
136
+ return this;
137
+ }
138
+
139
+ /**
140
+ * Replace FIFO queue with a function that decides per-call.
141
+ * @param {(args: {command: string, args: string[], options: CommandRunOptions}) => CommandRunResult|Promise<CommandRunResult>} fn
142
+ */
143
+ setResponder(fn) {
144
+ this.responder = fn;
145
+ return this;
146
+ }
147
+
148
+ /**
149
+ * @param {string} command
150
+ * @param {string[]} args
151
+ * @param {CommandRunOptions} options
152
+ * @returns {Promise<CommandRunResult>}
153
+ */
154
+ async run(command, args = [], options = {}) {
155
+ this.calls.push({ command, args: [...args], options: { ...options } });
156
+ if (this.responder) return this.responder({ command, args, options });
157
+ if (this.queue.length > 0) return this.queue.shift();
158
+ return { ...this.defaultResponse };
159
+ }
160
+
161
+ /**
162
+ * Convenience: last recorded invocation, or null.
163
+ */
164
+ get lastCall() {
165
+ return this.calls.at(-1) ?? null;
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Build an Environment backed by the mocks above. Useful shorthand in tests:
171
+ *
172
+ * const { env, fs, runner } = buildMockEnvironment();
173
+ * const agent = new BaseAgent(..., env);
174
+ *
175
+ * @param {{files?: Record<string, string|Buffer>}} [opts]
176
+ */
177
+ export function buildMockEnvironment({ files } = {}) {
178
+ const fs = new MockFileSystem(files);
179
+ const runner = new MockCommandRunner();
180
+ const env = Object.freeze({ fs, runner });
181
+ return { env, fs, runner };
182
+ }
@@ -157,8 +157,13 @@ export function handleInit(a) {
157
157
  return runKjCommand({ command: "init", options: a });
158
158
  }
159
159
 
160
- export function handleDoctor(a) {
161
- return runKjCommand({ command: "doctor", options: a });
160
+ export function handleDoctor(a = {}) {
161
+ const commandArgs = [];
162
+ if (a.checkOnly) commandArgs.push("--check-only");
163
+ if (a.yes) commandArgs.push("--yes");
164
+ if (a.json) commandArgs.push("--json");
165
+ if (a.verbose) commandArgs.push("--verbose");
166
+ return runKjCommand({ command: "doctor", commandArgs, options: a });
162
167
  }
163
168
 
164
169
  export function handleConfig(a) {