karajan-code 2.5.0 → 2.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -4
- package/package.json +5 -2
- package/src/agents/aider-agent.js +1 -2
- package/src/agents/base-agent.js +63 -1
- package/src/agents/claude-agent.js +3 -4
- package/src/agents/codex-agent.js +1 -2
- package/src/agents/gemini-agent.js +1 -2
- package/src/agents/index.js +11 -2
- package/src/agents/opencode-agent.js +1 -2
- package/src/brain/decision-tracker.js +123 -0
- package/src/brain/decisor.js +243 -0
- package/src/brain/solomon-consult.js +171 -0
- package/src/budget/comparison.js +96 -0
- package/src/checks/binaries.js +123 -0
- package/src/checks/ci.js +105 -0
- package/src/checks/config-files.js +217 -0
- package/src/checks/dir-setup.js +63 -0
- package/src/checks/mcp-health.js +157 -0
- package/src/checks/node.js +61 -0
- package/src/checks/ports.js +110 -0
- package/src/checks/rtk.js +36 -0
- package/src/checks/runner.js +267 -0
- package/src/checks/skills.js +49 -0
- package/src/checks/sonar.js +67 -0
- package/src/checks/system.js +53 -0
- package/src/checks/tokens.js +96 -0
- package/src/checks/types.js +105 -0
- package/src/cli.js +29 -3
- package/src/commands/board.js +23 -0
- package/src/commands/doctor.js +130 -362
- package/src/commands/resume.js +1 -1
- package/src/commands/run.js +2 -1
- package/src/commands/skills.js +49 -0
- package/src/config/schema.js +222 -0
- package/src/config.js +32 -3
- package/src/guards/policy-resolver.js +8 -0
- package/src/hu/store.js +9 -0
- package/src/infrastructure/command-runner.js +44 -0
- package/src/infrastructure/environment.js +38 -0
- package/src/infrastructure/file-system-service.js +63 -0
- package/src/infrastructure/mocks.js +182 -0
- package/src/mcp/handlers/management-handlers.js +7 -2
- package/src/mcp/tools.js +10 -3
- package/src/orchestrator/config-init.js +16 -1
- package/src/orchestrator/flow-runner.js +2085 -0
- package/src/orchestrator/pipeline-context.js +17 -0
- package/src/orchestrator/preflight-checks.js +78 -4
- package/src/orchestrator/solomon-escalation.js +55 -0
- package/src/orchestrator/stages/stage-executor.js +155 -0
- package/src/orchestrator.js +19 -1923
- package/src/prompts/architect.js +2 -2
- package/src/prompts/coder.js +2 -2
- package/src/prompts/prompt-resolver.js +127 -0
- package/src/prompts/reviewer.js +2 -2
- package/src/roles/agent-role.js +9 -0
- package/src/roles/architect-role.js +3 -1
- package/src/roles/base-role.js +40 -2
- package/src/roles/coder-role.js +3 -1
- package/src/roles/security-role.js +12 -0
- package/src/roles/tester-role.js +11 -0
- package/src/session/journal/decisions-writer.js +200 -0
- package/src/session/journal/iteration-logger.js +194 -0
- package/src/session/journal/summary-writer.js +176 -0
- package/src/session/journal/tree-writer.js +164 -0
- package/src/skills/semantic-detector.js +147 -0
- package/src/skills/skill-detector.js +186 -8
- package/src/skills/skill-loader.js +131 -1
- package/src/skills/skills-cache.js +128 -0
- package/src/types/agent.js +55 -0
- package/src/types/config.js +114 -0
- package/src/types/hu.js +47 -0
- package/src/types/index.js +38 -0
- package/src/types/policy.js +43 -0
- package/src/types/session.js +74 -0
- package/src/types/solomon.js +87 -0
- package/src/types/stage.js +68 -0
- package/src/utils/display/event-handlers.js +389 -0
- package/src/utils/display/formatters.js +132 -0
- package/src/utils/display/header.js +48 -0
- package/src/utils/display/session-summary.js +108 -0
- package/src/utils/display/solomon.js +42 -0
- package/src/utils/port-check.js +45 -0
- package/src/utils/port-occupant.js +95 -0
- package/src/utils/provider-env.js +116 -0
- package/src/utils/with-timeout.js +42 -0
- package/templates/roles/architect/anthropic.md +65 -0
- package/templates/roles/architect/default.md +63 -0
- package/templates/roles/architect/google.md +52 -0
- package/templates/roles/architect/openai.md +51 -0
- package/templates/roles/architect/opencode.md +50 -0
- package/templates/roles/coder/anthropic.md +72 -0
- package/templates/roles/coder/default.md +69 -0
- package/templates/roles/coder/google.md +78 -0
- package/templates/roles/coder/openai.md +59 -0
- package/templates/roles/coder/opencode.md +49 -0
- package/templates/roles/planner/anthropic.md +50 -0
- package/templates/roles/planner/default.md +48 -0
- package/templates/roles/planner/google.md +66 -0
- package/templates/roles/planner/openai.md +46 -0
- package/templates/roles/planner/opencode.md +53 -0
- package/templates/roles/reviewer/anthropic.md +61 -0
- package/templates/roles/reviewer/default.md +62 -0
- package/templates/roles/reviewer/google.md +63 -0
- package/templates/roles/reviewer/openai.md +57 -0
- package/templates/roles/reviewer/opencode.md +42 -0
- package/templates/roles/solomon/anthropic.md +71 -0
- package/templates/roles/solomon/default.md +83 -0
- package/templates/roles/solomon/google.md +61 -0
- package/templates/roles/solomon/openai.md +53 -0
- package/templates/roles/solomon/opencode.md +55 -0
- 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
|
-
|
|
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) {
|