karajan-code 3.7.1 → 3.8.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.
- package/README.md +14 -0
- package/package.json +1 -1
- package/src/agents/availability.js +21 -0
- package/src/audit/audit-fallback.js +66 -0
- package/src/cli/advanced-commands.js +61 -0
- package/src/cli/register-meta.js +49 -0
- package/src/cli.js +17 -4
- package/src/commands/advanced.js +31 -0
- package/src/commands/audit.js +19 -3
- package/src/commands/board.js +7 -4
- package/src/commands/harden.js +2 -1
- package/src/commands/mutate.js +94 -0
- package/src/commands/plan/generate.js +2 -2
- package/src/commands/start.js +117 -0
- package/src/config/defaults.js +2 -1
- package/src/config/schema.js +1 -0
- package/src/guards/secret-redactor.js +71 -0
- package/src/harden/workflow-engine.js +4 -2
- package/src/harden/workflow-templates.js +52 -0
- package/src/mutate/diff-scope.js +101 -0
- package/src/mutate/reviewer-signal.js +76 -0
- package/src/mutate/runner.js +72 -0
- package/src/mutate/tool-registry.js +90 -0
- package/src/orchestrator/drivers/post-loop.js +2 -2
- package/src/orchestrator/stages/reviewer-stage.js +9 -3
- package/src/prompts/start-decision.js +52 -0
- package/src/roles/reviewer-role.js +7 -0
- package/src/start/assessment.js +74 -0
- package/src/start/maturity.js +73 -0
- package/src/start/start-decider-role.js +79 -0
- package/src/start/sweep.js +112 -0
package/README.md
CHANGED
|
@@ -235,8 +235,22 @@ kj board start # Start web dashboard (po
|
|
|
235
235
|
kj board open # Start + open in browser
|
|
236
236
|
kj board status # Check if running
|
|
237
237
|
kj board stop # Stop the board
|
|
238
|
+
|
|
239
|
+
# Quality guardrails
|
|
240
|
+
kj harden # Install hooks, config, CI, guidelines
|
|
241
|
+
kj check # Verify the harness (drift gate)
|
|
242
|
+
kj mutate # Mutation-test the diff (do tests catch mutants?)
|
|
238
243
|
```
|
|
239
244
|
|
|
245
|
+
**Mutation testing** (`kj mutate`) closes the gap coverage leaves: it flips the
|
|
246
|
+
logic of the code you just changed and reruns your suite, so a line that ran but
|
|
247
|
+
isn't really tested shows up as a **survivor**. It is diff-scoped by default
|
|
248
|
+
(vs `HEAD~1`), drives the right runner per stack (Stryker/mutmut/Infection/…
|
|
249
|
+
with no silent fallback), and folds into a workflow three ways — on demand,
|
|
250
|
+
as an opt-in reviewer signal (`KJ_REVIEW_MUTATION=1`), or as a non-blocking
|
|
251
|
+
nightly CI job (`kj harden --mutation`). See
|
|
252
|
+
[GETTING-STARTED](docs/GETTING-STARTED.md#mutation-testing--kj-mutate).
|
|
253
|
+
|
|
240
254
|
### 2. MCP: inside your AI agent
|
|
241
255
|
|
|
242
256
|
This is the primary use case. Karajan runs as an MCP server inside Claude Code, Codex, or Gemini. You ask your AI agent to do something, and it delegates the heavy lifting to Karajan's pipeline.
|
package/package.json
CHANGED
|
@@ -29,3 +29,24 @@ export async function assertAgentsAvailable(agentNames = []) {
|
|
|
29
29
|
}
|
|
30
30
|
throw new Error(lines.join("\n"));
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Non-throwing counterpart to assertAgentsAvailable: probe each agent CLI in
|
|
35
|
+
* parallel and return the subset whose `--version` succeeds. Used by the
|
|
36
|
+
* audit fallback (KJC-BUG-0094) to skip uninstalled providers instead of
|
|
37
|
+
* aborting the whole command when any one is missing.
|
|
38
|
+
* @param {string[]} agentNames
|
|
39
|
+
* @returns {Promise<string[]>}
|
|
40
|
+
*/
|
|
41
|
+
export async function filterAvailableAgents(agentNames = []) {
|
|
42
|
+
const unique = [...new Set(agentNames.filter(Boolean))];
|
|
43
|
+
const probes = await Promise.all(
|
|
44
|
+
unique.map(async (name) => {
|
|
45
|
+
const meta = getAgentMeta(name);
|
|
46
|
+
if (!meta?.bin) return null;
|
|
47
|
+
const res = await runCommand(resolveBin(meta.bin), ["--version"]);
|
|
48
|
+
return res.exitCode === 0 ? name : null;
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
return probes.filter(Boolean);
|
|
52
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Resilient provider/model fallback for the `kj audit` CLI (KJC-BUG-0094).
|
|
2
|
+
// The CLI audit calls AuditRole.executeWithDeterministic directly, bypassing
|
|
3
|
+
// the orchestrator's recovery paths, so a dead configured model (e.g. an
|
|
4
|
+
// inherited "claude-fable-5") killed the command. The LLM phase now tries:
|
|
5
|
+
// configured provider+model → same provider default model → remaining known
|
|
6
|
+
// providers. First success wins; the deterministic context is reused.
|
|
7
|
+
import { resolveRole } from "../config.js";
|
|
8
|
+
import { AuditRole } from "../roles/audit-role.js";
|
|
9
|
+
|
|
10
|
+
const KNOWN_PROVIDERS = ["claude", "codex", "gemini"];
|
|
11
|
+
|
|
12
|
+
/** Ordered, de-duplicated fallback candidates for the audit role. */
|
|
13
|
+
export function buildAuditFallbackCandidates(config) {
|
|
14
|
+
const effective = resolveRole(config, "audit");
|
|
15
|
+
const primary = effective.provider || "claude";
|
|
16
|
+
const candidates = [{ provider: primary, model: effective.model ?? null }];
|
|
17
|
+
if (effective.model) candidates.push({ provider: primary, model: null });
|
|
18
|
+
for (const p of KNOWN_PROVIDERS) {
|
|
19
|
+
if (p !== primary) candidates.push({ provider: p, model: null });
|
|
20
|
+
}
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
return candidates.filter((c) => {
|
|
23
|
+
const key = `${c.provider}::${c.model ?? ""}`;
|
|
24
|
+
return seen.has(key) ? false : (seen.add(key), true);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Pin the audit role to a provider/model. Sets BOTH the per-role override and
|
|
29
|
+
// `coder_options.model` (the audit role inherits its model from the coder
|
|
30
|
+
// bucket); a null model neutralizes both so the provider default is used.
|
|
31
|
+
export function withAuditProvider(config, provider, model) {
|
|
32
|
+
return {
|
|
33
|
+
...config,
|
|
34
|
+
roles: { ...(config?.roles || {}), audit: { ...(config?.roles?.audit || {}), provider, model: model ?? undefined } },
|
|
35
|
+
coder_options: { ...(config?.coder_options || {}), model: model ?? undefined },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Run the audit LLM phase with automatic provider/model fallback. */
|
|
40
|
+
export async function runAuditWithFallback({
|
|
41
|
+
config, logger, roleInput, deterministicCtx, available, candidates, onFallback, createRole,
|
|
42
|
+
}) {
|
|
43
|
+
const makeRole = createRole || ((cfg) => new AuditRole({ config: cfg, logger }));
|
|
44
|
+
const all = candidates || buildAuditFallbackCandidates(config);
|
|
45
|
+
const usable = available ? all.filter((c) => available.includes(c.provider)) : all;
|
|
46
|
+
if (usable.length === 0) {
|
|
47
|
+
throw new Error("No available audit provider — install claude, codex, or gemini, or set roles.audit.provider");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let last = null;
|
|
51
|
+
let attempts = 0;
|
|
52
|
+
for (const cand of usable) {
|
|
53
|
+
attempts += 1;
|
|
54
|
+
if (attempts > 1) onFallback?.(cand, attempts);
|
|
55
|
+
const role = makeRole(withAuditProvider(config, cand.provider, cand.model));
|
|
56
|
+
const meta = { provider: cand.provider, model: cand.model ?? null };
|
|
57
|
+
try {
|
|
58
|
+
const result = await role.executeWithDeterministic(roleInput, deterministicCtx);
|
|
59
|
+
if (result?.ok) return { ...result, ...meta, attempts };
|
|
60
|
+
last = { ...result, ...meta };
|
|
61
|
+
} catch (err) {
|
|
62
|
+
last = { ok: false, result: { error: err.message, provider: cand.provider }, summary: `Audit failed: ${err.message}`, ...meta };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return { ...last, attempts, exhausted: true };
|
|
66
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// KJC-TSK-0582: the flat `kj --help` list had grown to 37 commands, which
|
|
2
|
+
// drowns the handful a newcomer actually needs. This module is the single
|
|
3
|
+
// source of truth for which commands are "core" (always shown in `kj --help`)
|
|
4
|
+
// vs "advanced/specialized" (grouped under `kj advanced`). Nothing is hidden
|
|
5
|
+
// or unregistered — every command stays top-level and invokable for
|
|
6
|
+
// back-compat; this only changes what the help listing surfaces by default.
|
|
7
|
+
|
|
8
|
+
/** The few commands a newcomer needs. Shown in `kj --help`. */
|
|
9
|
+
export const CORE_COMMANDS = [
|
|
10
|
+
"start",
|
|
11
|
+
"init",
|
|
12
|
+
"run",
|
|
13
|
+
"plan",
|
|
14
|
+
"status",
|
|
15
|
+
"doctor",
|
|
16
|
+
"harden",
|
|
17
|
+
"config",
|
|
18
|
+
"update",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
/** Navigation/built-ins that are neither core-basics nor advanced. */
|
|
22
|
+
export const META_COMMANDS = ["advanced", "help"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Advanced commands grouped by area, in display order. Every advanced
|
|
26
|
+
* command MUST live in exactly one group — the parity test fails otherwise,
|
|
27
|
+
* so a newly-registered command can never silently vanish from `kj advanced`.
|
|
28
|
+
*/
|
|
29
|
+
export const ADVANCED_GROUPS = [
|
|
30
|
+
{ title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "scan"] },
|
|
31
|
+
{ title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard"] },
|
|
32
|
+
{ title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
|
|
33
|
+
{ title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar"] },
|
|
34
|
+
{ title: "Sesión / board", commands: ["resume", "report", "board", "undo", "standby"] },
|
|
35
|
+
{ title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents"] },
|
|
36
|
+
{ title: "Mantenimiento", commands: ["clean", "sync", "telemetry"] },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
/** Flat set of every advanced command name (for fast lookup / filtering). */
|
|
40
|
+
export const ADVANCED_COMMANDS = ADVANCED_GROUPS.flatMap((g) => g.commands);
|
|
41
|
+
|
|
42
|
+
const ADVANCED_SET = new Set(ADVANCED_COMMANDS);
|
|
43
|
+
const CORE_SET = new Set(CORE_COMMANDS);
|
|
44
|
+
const META_SET = new Set(META_COMMANDS);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Classify a command name. Returns "core" | "advanced" | "meta" | "unknown".
|
|
48
|
+
* "unknown" means the command is registered but not yet placed in this file —
|
|
49
|
+
* the parity test treats that as a failure so the listing never drifts.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyCommand(name) {
|
|
52
|
+
if (CORE_SET.has(name)) return "core";
|
|
53
|
+
if (ADVANCED_SET.has(name)) return "advanced";
|
|
54
|
+
if (META_SET.has(name)) return "meta";
|
|
55
|
+
return "unknown";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** True when the command should be hidden from the flat `kj --help` list. */
|
|
59
|
+
export function isAdvancedCommand(name) {
|
|
60
|
+
return ADVANCED_SET.has(name);
|
|
61
|
+
}
|
package/src/cli/register-meta.js
CHANGED
|
@@ -3,6 +3,7 @@ import { triageCommand } from "../commands/triage.js";
|
|
|
3
3
|
import { researcherCommand } from "../commands/researcher.js";
|
|
4
4
|
import { architectCommand } from "../commands/architect.js";
|
|
5
5
|
import { onboardCommand } from "../commands/onboard.js";
|
|
6
|
+
import { startCommand } from "../commands/start.js";
|
|
6
7
|
import { ragIndexCommand, ragQueryCommand, ragInstallHooksCommand, ragEvalCommand } from "../commands/rag.js";
|
|
7
8
|
import { qmdQueryCommand } from "../commands/qmd.js";
|
|
8
9
|
import { ragMcpCommand } from "../commands/rag-mcp.js";
|
|
@@ -15,8 +16,10 @@ import { undoCommand } from "../commands/undo.js";
|
|
|
15
16
|
import { syncCommand } from "../commands/sync.js";
|
|
16
17
|
import { cleanCommand } from "../commands/clean.js";
|
|
17
18
|
import { checkCommand } from "../commands/check.js";
|
|
19
|
+
import { mutateCommand } from "../commands/mutate.js";
|
|
18
20
|
import { hardenCommand } from "../commands/harden.js";
|
|
19
21
|
import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
|
|
22
|
+
import { formatAdvancedIndex } from "../commands/advanced.js";
|
|
20
23
|
import { withConfig } from "./_shared.js";
|
|
21
24
|
|
|
22
25
|
/**
|
|
@@ -105,6 +108,19 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
105
108
|
});
|
|
106
109
|
});
|
|
107
110
|
|
|
111
|
+
program
|
|
112
|
+
.command("start")
|
|
113
|
+
.description("Single entry point: assess the project and recommend the next step")
|
|
114
|
+
.argument("[task]", "What you want to do (optional natural-language goal)")
|
|
115
|
+
.option("--maturity <type>", "Declare project maturity: new|existing|legacy")
|
|
116
|
+
.option("--yes", "Non-interactive: emit assessment + recommendation, apply nothing")
|
|
117
|
+
.option("--json", "Output the assessment + decision as JSON")
|
|
118
|
+
.action(async (task, flags) => {
|
|
119
|
+
await withConfig(pkgVersion, "start", flags, async ({ config, logger }) => {
|
|
120
|
+
await startCommand({ task, config, logger, flags });
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
108
124
|
const rag = program.command("rag").description("Retrieval-augmented search over Karajan plans, onboarding briefs and project code");
|
|
109
125
|
rag.command("index")
|
|
110
126
|
.description("Index plans + onboarding (and optionally project sources) into the local vector store")
|
|
@@ -406,6 +422,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
406
422
|
.option("--no-config", "Skip lint/format/commit config files (hooks only)")
|
|
407
423
|
.option("--no-ci", "Skip CI quality workflows")
|
|
408
424
|
.option("--no-guidelines", "Skip AI-agent guideline files (AGENTS.md/CLAUDE.md)")
|
|
425
|
+
.option("--mutation", "Also seed an opt-in nightly/manual mutation CI job (never a PR gate)")
|
|
409
426
|
.option("--only <dirs...>", "Harden only these language roots (e.g. frontend backend)")
|
|
410
427
|
.option("--exclude <globs...>", "Skip language roots matching these globs (e.g. wrappers)")
|
|
411
428
|
.option("--report", "Read-only: show what kj would add/improve, change nothing")
|
|
@@ -419,6 +436,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
419
436
|
config: flags.config !== false,
|
|
420
437
|
ci: flags.ci !== false,
|
|
421
438
|
guidelines: flags.guidelines !== false,
|
|
439
|
+
mutation: Boolean(flags.mutation),
|
|
422
440
|
only: flags.only ?? [],
|
|
423
441
|
exclude: flags.exclude ?? [],
|
|
424
442
|
report: Boolean(flags.report),
|
|
@@ -441,4 +459,35 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
441
459
|
);
|
|
442
460
|
if (Number.isInteger(code)) process.exit(code);
|
|
443
461
|
});
|
|
462
|
+
|
|
463
|
+
program
|
|
464
|
+
.command("mutate")
|
|
465
|
+
.description("Diff-scoped mutation testing (¿cazan tus tests los mutantes de tu cambio?)")
|
|
466
|
+
.argument("[path]", "Target explícito a mutar (omite el diff)")
|
|
467
|
+
.option("--since <ref>", "Ref git contra la que sacar el diff", "HEAD~1")
|
|
468
|
+
.option("--max-survivors <n>", "Supervivientes tolerados antes de exit 1", "0")
|
|
469
|
+
.option("--json", "Emitir el resultado normalizado como JSON")
|
|
470
|
+
.action(async (pathArg, flags) => {
|
|
471
|
+
const code = await withConfig(pkgVersion, "mutate", flags, async ({ logger }) =>
|
|
472
|
+
mutateCommand({
|
|
473
|
+
path: pathArg,
|
|
474
|
+
since: flags.since,
|
|
475
|
+
maxSurvivors: Number(flags.maxSurvivors),
|
|
476
|
+
json: Boolean(flags.json),
|
|
477
|
+
logger,
|
|
478
|
+
})
|
|
479
|
+
);
|
|
480
|
+
if (Number.isInteger(code)) process.exit(code);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// KJC-TSK-0582: index of the advanced/specialized commands kept out of the
|
|
484
|
+
// flat `kj --help` list. Descriptions are read from commander itself so they
|
|
485
|
+
// never drift from each command's own --description.
|
|
486
|
+
program
|
|
487
|
+
.command("advanced")
|
|
488
|
+
.description("List advanced & specialized commands, grouped by area")
|
|
489
|
+
.action(() => {
|
|
490
|
+
const descriptions = Object.fromEntries(program.commands.map((c) => [c.name(), c.description()]));
|
|
491
|
+
console.log(formatAdvancedIndex(descriptions));
|
|
492
|
+
});
|
|
444
493
|
}
|
package/src/cli.js
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { Command } from "commander";
|
|
5
|
+
import { Command, Help } from "commander";
|
|
6
6
|
import { loadConfig } from "./config.js";
|
|
7
|
+
import { isAdvancedCommand } from "./cli/advanced-commands.js";
|
|
7
8
|
import { registerPipeline } from "./cli/register-pipeline.js";
|
|
8
9
|
import { registerPlan } from "./cli/register-plan.js";
|
|
9
10
|
import { registerRolesSkills } from "./cli/register-roles-skills.js";
|
|
@@ -41,8 +42,20 @@ program
|
|
|
41
42
|
.allowUnknownOption(true)
|
|
42
43
|
.allowExcessArguments(true);
|
|
43
44
|
|
|
44
|
-
// KJC-TSK-
|
|
45
|
-
//
|
|
45
|
+
// KJC-TSK-0582: the flat list had grown to 37 commands. Show only the core
|
|
46
|
+
// basics in `kj --help`; the advanced/specialized ones live under
|
|
47
|
+
// `kj advanced`. The filter applies to the ROOT help only — subcommand help
|
|
48
|
+
// keeps listing its own subcommands untouched.
|
|
49
|
+
program.configureHelp({
|
|
50
|
+
visibleCommands(cmd) {
|
|
51
|
+
const cmds = Help.prototype.visibleCommands.call(this, cmd);
|
|
52
|
+
if (cmd !== program) return cmds;
|
|
53
|
+
return cmds.filter((c) => !isAdvancedCommand(c.name()));
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// KJC-TSK-0571/0582: orient a newcomer to the few commands they need and
|
|
58
|
+
// point them at `kj advanced` for everything else.
|
|
46
59
|
program.addHelpText(
|
|
47
60
|
"after",
|
|
48
61
|
`
|
|
@@ -53,7 +66,7 @@ Getting started (the basics — start here):
|
|
|
53
66
|
kj doctor Check your environment is ready
|
|
54
67
|
kj harden Add quality git hooks + CI to any repo
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
kj advanced List every advanced/specialized command, grouped by area`
|
|
57
70
|
);
|
|
58
71
|
|
|
59
72
|
registerPipeline(program, { pkgVersion: PKG_VERSION });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// KJC-TSK-0582: `kj advanced` prints every advanced/specialized command,
|
|
2
|
+
// grouped by area, with the one-line description commander already holds for
|
|
3
|
+
// it. Pure formatter (takes a name→description map) so it's trivially
|
|
4
|
+
// testable without spinning up the whole CLI.
|
|
5
|
+
import { ADVANCED_GROUPS } from "../cli/advanced-commands.js";
|
|
6
|
+
|
|
7
|
+
const NAME_PAD = 14;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Build the `kj advanced` index text.
|
|
11
|
+
* @param {Record<string,string>|Map<string,string>} descriptions name → one-line description
|
|
12
|
+
*/
|
|
13
|
+
export function formatAdvancedIndex(descriptions) {
|
|
14
|
+
const lookup = descriptions instanceof Map ? descriptions : new Map(Object.entries(descriptions || {}));
|
|
15
|
+
const firstLine = (text) => String(text || "").split("\n")[0].trim();
|
|
16
|
+
|
|
17
|
+
const lines = [
|
|
18
|
+
"Comandos avanzados y especializados (agrupados por área).",
|
|
19
|
+
"Todos siguen siendo invocables directamente: kj <comando>.",
|
|
20
|
+
"",
|
|
21
|
+
];
|
|
22
|
+
for (const group of ADVANCED_GROUPS) {
|
|
23
|
+
lines.push(`${group.title}:`);
|
|
24
|
+
for (const name of group.commands) {
|
|
25
|
+
lines.push(` kj ${name.padEnd(NAME_PAD)} ${firstLine(lookup.get(name))}`.trimEnd());
|
|
26
|
+
}
|
|
27
|
+
lines.push("");
|
|
28
|
+
}
|
|
29
|
+
lines.push("Usa 'kj <comando> --help' para los detalles de cualquiera de ellos.");
|
|
30
|
+
return lines.join("\n");
|
|
31
|
+
}
|
package/src/commands/audit.js
CHANGED
|
@@ -3,10 +3,11 @@ import fs from "node:fs/promises";
|
|
|
3
3
|
import readline from "node:readline";
|
|
4
4
|
import { execFile } from "node:child_process";
|
|
5
5
|
import { promisify } from "node:util";
|
|
6
|
-
import { assertAgentsAvailable } from "../agents/availability.js";
|
|
6
|
+
import { assertAgentsAvailable, filterAvailableAgents } from "../agents/availability.js";
|
|
7
7
|
import { resolveRole } from "../config.js";
|
|
8
8
|
import { AUDIT_DIMENSIONS } from "../prompts/audit.js";
|
|
9
9
|
import { AuditRole } from "../roles/audit-role.js";
|
|
10
|
+
import { buildAuditFallbackCandidates, runAuditWithFallback } from "../audit/audit-fallback.js";
|
|
10
11
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
11
12
|
import { createCliProgressReporter } from "../utils/cli-progress.js";
|
|
12
13
|
import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
|
|
@@ -260,7 +261,11 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
|
|
|
260
261
|
|
|
261
262
|
return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
262
263
|
const auditRoleConfig = resolveRole(config, "audit");
|
|
263
|
-
|
|
264
|
+
// KJC-BUG-0094: proceed if any fallback candidate is installed; throw only when NONE are.
|
|
265
|
+
const fallbackCandidates = buildAuditFallbackCandidates(config);
|
|
266
|
+
const candidateProviders = [...new Set(fallbackCandidates.map((c) => c.provider))];
|
|
267
|
+
const available = await filterAvailableAgents(candidateProviders);
|
|
268
|
+
if (available.length === 0) await assertAgentsAvailable([auditRoleConfig.provider]);
|
|
264
269
|
logger.info(`Audit (${auditRoleConfig.provider}) starting...`);
|
|
265
270
|
runLog.logText(`[audit] provider=${auditRoleConfig.provider} dimensions=${dimensions || "all"} deterministicOnly=${deterministicOnly}`);
|
|
266
271
|
|
|
@@ -347,7 +352,18 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
|
|
|
347
352
|
const progress = createCliProgressReporter({ role: "auditor" });
|
|
348
353
|
let roleResult;
|
|
349
354
|
try {
|
|
350
|
-
|
|
355
|
+
// KJC-BUG-0094: resilient fallback — if the configured provider/model
|
|
356
|
+
// is down (e.g. an inherited model like "claude-fable-5" is offline),
|
|
357
|
+
// degrade to the provider default, then to the next installed provider,
|
|
358
|
+
// reusing the deterministic context so no static analysis is re-run.
|
|
359
|
+
roleResult = await runAuditWithFallback({
|
|
360
|
+
config, logger,
|
|
361
|
+
roleInput: { ...roleInput, onOutput: progress.onOutput },
|
|
362
|
+
deterministicCtx,
|
|
363
|
+
available,
|
|
364
|
+
candidates: fallbackCandidates,
|
|
365
|
+
onFallback: (cand) => logger.warn(`Audit provider fallback → ${cand.provider}${cand.model ? ` (${cand.model})` : " (default model)"}`),
|
|
366
|
+
});
|
|
351
367
|
progress.finish(roleResult.ok ? "done" : "failed");
|
|
352
368
|
} catch (err) { progress.finish("failed"); throw err; }
|
|
353
369
|
|
package/src/commands/board.js
CHANGED
|
@@ -77,15 +77,18 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* Build the board URL. When `projectSlug` is provided, the URL points at
|
|
80
|
-
* the per-project view (
|
|
81
|
-
* pre-filtered instead of "All projects".
|
|
80
|
+
* the per-project view via the SPA hash route (`#board/<slug>`) so the
|
|
81
|
+
* caller's project shows up pre-filtered instead of "All projects". The
|
|
82
|
+
* frontend router is hash-based (see public/utils/init-listeners.js); a
|
|
83
|
+
* real `/p/<slug>` path is not a route and silently falls back to the
|
|
84
|
+
* global dashboard. KJC-BUG-0093.
|
|
82
85
|
* @param {number} port
|
|
83
86
|
* @param {string|null} [projectSlug]
|
|
84
87
|
* @returns {string}
|
|
85
88
|
*/
|
|
86
|
-
function buildBoardUrl(port, projectSlug) {
|
|
89
|
+
export function buildBoardUrl(port, projectSlug) {
|
|
87
90
|
const base = `http://localhost:${port}`;
|
|
88
|
-
return projectSlug ? `${base}
|
|
91
|
+
return projectSlug ? `${base}/#board/${projectSlug}` : base;
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
/**
|
package/src/commands/harden.js
CHANGED
|
@@ -76,6 +76,7 @@ export async function hardenCommand({
|
|
|
76
76
|
config = true,
|
|
77
77
|
ci = true,
|
|
78
78
|
guidelines = true,
|
|
79
|
+
mutation = false,
|
|
79
80
|
dryRun = false,
|
|
80
81
|
json = false,
|
|
81
82
|
report = false,
|
|
@@ -134,7 +135,7 @@ export async function hardenCommand({
|
|
|
134
135
|
const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
|
|
135
136
|
const withCi = ci && profile !== "minimal";
|
|
136
137
|
const wf = withCi
|
|
137
|
-
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, dryRun })
|
|
138
|
+
? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
|
|
138
139
|
: null;
|
|
139
140
|
const withGuidelines = guidelines && profile !== "minimal";
|
|
140
141
|
const gl = withGuidelines ? installGuidelines({ projectDir, dryRun }) : null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kj mutate` (KJC-TSK-0587) — diff-scoped mutation testing. Detects language
|
|
3
|
+
* (M-A registry) → changed lines (M-B diff-scope) → runs the tool (M-C runner)
|
|
4
|
+
* only over what you just touched. `--json` + exit code make it a gate. No
|
|
5
|
+
* silent fallback: unsupported language / unreadable report exit non-zero.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
11
|
+
import { getMutationTool } from "../mutate/tool-registry.js";
|
|
12
|
+
import { getDiffScope } from "../mutate/diff-scope.js";
|
|
13
|
+
import { runMutation } from "../mutate/runner.js";
|
|
14
|
+
|
|
15
|
+
// Per-tool launch detail: subcommand + JSON report path. Tools without a JSON
|
|
16
|
+
// report leave `report` empty → the run surfaces the tool's own output.
|
|
17
|
+
const INVOCATION = {
|
|
18
|
+
stryker: { base: ["run"], report: "reports/mutation/mutation.json" },
|
|
19
|
+
mutmut: { base: ["run"], report: "mutmut-report.json" },
|
|
20
|
+
infection: { base: [], report: "infection.json" },
|
|
21
|
+
"go-mutesting": { base: [], report: "" },
|
|
22
|
+
pitest: { base: [], report: "" },
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const DEFAULT_SINCE = "HEAD~1";
|
|
26
|
+
|
|
27
|
+
function makeReadReport(reportFile) {
|
|
28
|
+
if (!reportFile) return undefined;
|
|
29
|
+
return async () => JSON.parse(await fs.readFile(reportFile, "utf8"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* `deps` ({detectStack, diffScope, run}) is injectable so tests stay pure.
|
|
34
|
+
* `since` diffs against a git ref (default HEAD~1); `path` bypasses the diff.
|
|
35
|
+
* @returns {Promise<number>} process exit code
|
|
36
|
+
*/
|
|
37
|
+
export async function mutateCommand({
|
|
38
|
+
projectDir = process.cwd(),
|
|
39
|
+
since,
|
|
40
|
+
path: pathArg,
|
|
41
|
+
json = false,
|
|
42
|
+
maxSurvivors = 0,
|
|
43
|
+
logger = console,
|
|
44
|
+
deps = {},
|
|
45
|
+
} = {}) {
|
|
46
|
+
const detectStack = deps.detectStack ?? detectProjectStack;
|
|
47
|
+
const diffScope = deps.diffScope ?? getDiffScope;
|
|
48
|
+
const run = deps.run ?? runMutation;
|
|
49
|
+
const say = (msg) => logger.info?.(msg);
|
|
50
|
+
|
|
51
|
+
const { language } = await detectStack(projectDir);
|
|
52
|
+
const tool = getMutationTool(language);
|
|
53
|
+
if (!tool.supported) {
|
|
54
|
+
say(`kj mutate: lenguaje no soportado (${language ?? "desconocido"}) — ${tool.reason}`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let scope;
|
|
59
|
+
if (pathArg) {
|
|
60
|
+
const args = tool.scope.flag ? [tool.scope.flag, pathArg] : [pathArg];
|
|
61
|
+
scope = { supported: true, empty: false, args };
|
|
62
|
+
} else {
|
|
63
|
+
scope = await diffScope({ since: since ?? DEFAULT_SINCE, language, projectDir });
|
|
64
|
+
}
|
|
65
|
+
if (scope.empty) {
|
|
66
|
+
say("kj mutate: nada que mutar en el diff.");
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const invocation = INVOCATION[tool.id] ?? { base: [], report: "" };
|
|
71
|
+
const reportFile = invocation.report ? path.join(projectDir, invocation.report) : "";
|
|
72
|
+
const outcome = await run({
|
|
73
|
+
binary: tool.binary,
|
|
74
|
+
args: [...invocation.base, ...scope.args],
|
|
75
|
+
cwd: projectDir,
|
|
76
|
+
readReport: makeReadReport(reportFile),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (!outcome.result) {
|
|
80
|
+
say(`kj mutate: la herramienta ${tool.id} no produjo un informe legible.`);
|
|
81
|
+
if (outcome.stderr) say(outcome.stderr.trim());
|
|
82
|
+
return 1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const { score, killed, total, survived } = outcome.result;
|
|
86
|
+
if (json) {
|
|
87
|
+
say(JSON.stringify(outcome.result));
|
|
88
|
+
} else {
|
|
89
|
+
say(`kj mutate (${tool.id}): score ${score ?? "n/a"}% — ${killed}/${total} mutantes cazados`);
|
|
90
|
+
for (const s of survived) say(` ✗ superviviente ${s.file}:${s.line} (${s.mutator ?? s.status})`);
|
|
91
|
+
if (survived.length === 0) say(" ✓ sin supervivientes");
|
|
92
|
+
}
|
|
93
|
+
return survived.length > maxSurvivors ? 1 : 0;
|
|
94
|
+
}
|
|
@@ -513,8 +513,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
513
513
|
const { projectSlug: slugFor } = await import("../../plan/plan-store.js");
|
|
514
514
|
const boardPort = config?.hu_board?.port ?? 4000;
|
|
515
515
|
// `projectSlug` scopes the board URL to this project's view
|
|
516
|
-
// (
|
|
517
|
-
//
|
|
516
|
+
// (`#board/<slug>`) instead of the global "All projects" dashboard.
|
|
517
|
+
// The hash route opens the board pre-filtered to that project.
|
|
518
518
|
const boardResult = await startBoard(boardPort, { projectSlug: slugFor(projectDir) });
|
|
519
519
|
const status = boardResult.alreadyRunning ? "already running" : "started";
|
|
520
520
|
console.log(renderBoardBanner({ url: boardResult.url, status, projectName: plan.name }));
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// KJC-TSK-0570 (Onboard C) — `kj start`: the single entry point to the Brain.
|
|
2
|
+
// Wires the read-only sweep (0568) → deterministic assessment (0569) → haiku
|
|
3
|
+
// decider (0569), reports the recommended next step, and — interactively only —
|
|
4
|
+
// confirms and dispatches the chosen intent to its saved command
|
|
5
|
+
// (harden/rag/run/plan). --json / --yes / no-TTY stay read-only: emit the
|
|
6
|
+
// assessment + intent and apply nothing. Collaborators are injected for testing.
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { execa } from "execa";
|
|
10
|
+
import { runReadOnlySweep } from "../start/sweep.js";
|
|
11
|
+
import { buildAssessment } from "../start/assessment.js";
|
|
12
|
+
import { StartDecidorRole } from "../start/start-decider-role.js";
|
|
13
|
+
import { createWizard, isTTY } from "../utils/wizard.js";
|
|
14
|
+
|
|
15
|
+
// The recommended next step shown for each intent.
|
|
16
|
+
const NEXT_STEP = {
|
|
17
|
+
ASSESS_ONLY: "Everything looks healthy — nothing to do.",
|
|
18
|
+
RECOMMEND_HARDEN: "Suggested: `kj harden --interactive`",
|
|
19
|
+
RECOMMEND_INDEX: "Suggested: `kj rag index`",
|
|
20
|
+
START_TASK: 'Suggested: `kj run "<task>"`',
|
|
21
|
+
PROPOSE_PLAN: 'Suggested: `kj plan "<goal>"`',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Each writing intent maps to the saved command + args `kj start` dispatches.
|
|
25
|
+
const DISPATCH = {
|
|
26
|
+
RECOMMEND_HARDEN: () => ["harden", "--interactive"],
|
|
27
|
+
RECOMMEND_INDEX: () => ["rag", "index"],
|
|
28
|
+
START_TASK: (task) => ["run", task],
|
|
29
|
+
PROPOSE_PLAN: (task) => ["plan", task],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const ASK_FALLBACK = "What would you like to do with this project?";
|
|
33
|
+
const CLI_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "cli.js");
|
|
34
|
+
|
|
35
|
+
// Run a saved `kj` subcommand as a child, inheriting stdio so its own prompts
|
|
36
|
+
// (e.g. `harden --interactive`) drive the real terminal. Never throws here.
|
|
37
|
+
async function spawnKj(args, { cwd }) {
|
|
38
|
+
await execa("node", [CLI_PATH, ...args.filter(Boolean)], { cwd, stdio: "inherit", reject: false });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The decider has its own quota/parse recovery; a spawn-level failure (CLI not
|
|
42
|
+
// installed) still throws, so we degrade here too — `kj start` never crashes.
|
|
43
|
+
async function decide({ task, text, config, logger, makeDecider }) {
|
|
44
|
+
const decider = makeDecider({ config, logger });
|
|
45
|
+
try {
|
|
46
|
+
const decision = await decider.execute({ userMessage: task, assessment: text });
|
|
47
|
+
return decision.result || {};
|
|
48
|
+
} catch (err) {
|
|
49
|
+
logger?.warn?.(`[start] decider unavailable (${err?.message || err}) — asking the user.`);
|
|
50
|
+
return { intent: "ASK_USER", rationale: "Decider unavailable.", questionToAsk: ASK_FALLBACK, degraded: true };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Ask one yes/no, always closing the readline interface afterwards.
|
|
55
|
+
async function confirmApply(makeWizard) {
|
|
56
|
+
const wizard = makeWizard();
|
|
57
|
+
try {
|
|
58
|
+
return await wizard.confirm("Apply this?", true);
|
|
59
|
+
} finally {
|
|
60
|
+
wizard.close();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Print the rationale + recommended step, or the open question for ASK_USER.
|
|
65
|
+
function render(result) {
|
|
66
|
+
if (result.intent === "ASK_USER") {
|
|
67
|
+
console.log(`❓ ${result.questionToAsk || ASK_FALLBACK}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (result.rationale) console.log(`→ ${result.rationale}`);
|
|
71
|
+
const step = NEXT_STEP[result.intent];
|
|
72
|
+
if (step) console.log(` ${step}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function startCommand({ task = "", config, logger, flags = {}, deps = {} }) {
|
|
76
|
+
const sweep = deps.runReadOnlySweep || runReadOnlySweep;
|
|
77
|
+
const assess = deps.buildAssessment || buildAssessment;
|
|
78
|
+
const makeDecider = deps.makeDecider || ((opts) => new StartDecidorRole(opts));
|
|
79
|
+
const tty = deps.isTTY || isTTY;
|
|
80
|
+
const spawn = deps.spawnKj || spawnKj;
|
|
81
|
+
const makeWizard = deps.makeWizard || (() => createWizard());
|
|
82
|
+
|
|
83
|
+
const bundle = await sweep(config.projectDir, { declared: flags.maturity || null });
|
|
84
|
+
const { text, summary } = assess(bundle);
|
|
85
|
+
let result = await decide({ task, text, config, logger, makeDecider });
|
|
86
|
+
|
|
87
|
+
if (flags.json) {
|
|
88
|
+
console.log(JSON.stringify({ maturity: summary.maturity, assessment: text, ...result }, null, 2));
|
|
89
|
+
return { ok: true, intent: result.intent };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
console.log(`${text}\n`);
|
|
93
|
+
const interactive = tty() && !flags.yes;
|
|
94
|
+
let goal = task;
|
|
95
|
+
|
|
96
|
+
// ASK_USER: one interactive re-route — the open answer becomes the goal.
|
|
97
|
+
if (result.intent === "ASK_USER" && interactive) {
|
|
98
|
+
const wizard = makeWizard();
|
|
99
|
+
try {
|
|
100
|
+
const answer = await wizard.input(`❓ ${result.questionToAsk || ASK_FALLBACK}`);
|
|
101
|
+
if (answer) {
|
|
102
|
+
goal = answer;
|
|
103
|
+
result = await decide({ task: answer, text, config, logger, makeDecider });
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
wizard.close();
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
render(result);
|
|
110
|
+
|
|
111
|
+
// Dispatch the chosen intent to its saved command, confirming first (it writes).
|
|
112
|
+
const toArgs = DISPATCH[result.intent];
|
|
113
|
+
if (interactive && toArgs && (await confirmApply(makeWizard))) {
|
|
114
|
+
await spawn(toArgs(goal), { cwd: config.projectDir });
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, intent: result.intent };
|
|
117
|
+
}
|