karajan-code 2.34.0 → 3.1.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 (69) hide show
  1. package/README.md +120 -38
  2. package/bin/kj-rag-mcp.js +75 -0
  3. package/docs/README.es.md +5 -5
  4. package/package.json +22 -17
  5. package/packages/hu-board/package.json +5 -2
  6. package/packages/hu-board/public/app.js +81 -4025
  7. package/packages/hu-board/public/index.html +20 -0
  8. package/packages/hu-board/public/utils/api.js +139 -0
  9. package/packages/hu-board/public/utils/board-view.js +453 -0
  10. package/packages/hu-board/public/utils/command-launcher.js +213 -0
  11. package/packages/hu-board/public/utils/config-editor.js +181 -0
  12. package/packages/hu-board/public/utils/dashboard-view.js +103 -0
  13. package/packages/hu-board/public/utils/formatters.js +232 -0
  14. package/packages/hu-board/public/utils/graph-view.js +216 -0
  15. package/packages/hu-board/public/utils/hu-actions.js +224 -0
  16. package/packages/hu-board/public/utils/init-listeners.js +180 -0
  17. package/packages/hu-board/public/utils/log-panel.js +222 -0
  18. package/packages/hu-board/public/utils/modals.js +290 -0
  19. package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
  20. package/packages/hu-board/public/utils/preflight-view.js +89 -0
  21. package/packages/hu-board/public/utils/project-actions.js +143 -0
  22. package/packages/hu-board/public/utils/project-picker-view.js +139 -0
  23. package/packages/hu-board/public/utils/run-launcher.js +169 -0
  24. package/packages/hu-board/public/utils/server-push.js +252 -0
  25. package/packages/hu-board/public/utils/sessions-view.js +199 -0
  26. package/packages/hu-board/public/utils/story-detail-view.js +324 -0
  27. package/packages/hu-board/public/utils/story-edit-form.js +271 -0
  28. package/packages/hu-board/src/config-yaml.js +14 -0
  29. package/packages/hu-board/src/db.js +15 -2
  30. package/packages/hu-board/src/routes/api.js +27 -9
  31. package/scripts/audit-test-diet.mjs +151 -0
  32. package/src/audit/deterministic-summary.js +20 -0
  33. package/src/audit/harness-scorecard.js +52 -3
  34. package/src/audit/harness-section.js +6 -2
  35. package/src/audit/injection-findings.js +105 -0
  36. package/src/checks/hardware.js +150 -0
  37. package/src/checks/node.js +9 -10
  38. package/src/checks/runner.js +10 -0
  39. package/src/cli/register-meta.js +48 -2
  40. package/src/cli/register-plan.js +10 -1
  41. package/src/commands/clean.js +62 -0
  42. package/src/commands/doctor.js +2 -0
  43. package/src/commands/init.js +41 -1
  44. package/src/commands/plan/generate.js +9 -3
  45. package/src/commands/rag-mcp.js +18 -0
  46. package/src/commands/sync.js +30 -2
  47. package/src/commands/telemetry.js +70 -0
  48. package/src/config/defaults.js +9 -1
  49. package/src/config/schema.js +2 -0
  50. package/src/orchestrator/config-init.js +1 -0
  51. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
  52. package/src/orchestrator/stages/coder-stage.js +11 -2
  53. package/src/orchestrator/stages/reviewer-stage.js +11 -0
  54. package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
  55. package/src/orchestrator/stages/tool-judge-stage.js +53 -0
  56. package/src/prompts/tool-correctness-judge.js +31 -0
  57. package/src/review/git-stash-helper.js +104 -0
  58. package/src/review/tdd-discipline.js +90 -0
  59. package/src/roles/audit-role.js +9 -1
  60. package/src/roles/tool-correctness-judge-role.js +78 -0
  61. package/src/session/mutators.js +4 -0
  62. package/src/sonar/scanner.js +51 -16
  63. package/src/sync/apply-canvas-patch.js +55 -0
  64. package/src/utils/paths.js +12 -0
  65. package/src/utils/quota-simulator.js +56 -0
  66. package/src/utils/repo-cleaner.js +79 -0
  67. package/src/utils/telemetry.js +34 -12
  68. package/src/utils/tool-call-extractor.js +56 -0
  69. package/src/utils/vector-store-orphans.js +89 -0
@@ -0,0 +1,104 @@
1
+ // Surgical stash helper for the TDD discipline gate (KJC-TSK-0398 PR2).
2
+ // Stashes only the source files we want to hide for the "red" run and
3
+ // restores them afterwards. Tracked files use `git stash push -- <paths>`;
4
+ // untracked files (not yet known to git) are moved aside to a private
5
+ // `.karajan/tmp/stash-<id>/` tree because `git stash` would ignore them.
6
+
7
+ import { mkdir, rename, rm } from "node:fs/promises";
8
+ import { existsSync } from "node:fs";
9
+ import { dirname, join, resolve } from "node:path";
10
+ import { runCommand } from "../utils/process.js";
11
+
12
+ function defaultRunner(cmd, args, opts) {
13
+ return runCommand(cmd, args, opts);
14
+ }
15
+
16
+ async function isTracked(runner, projectDir, file) {
17
+ const res = await runner("git", ["ls-files", "--error-unmatch", "--", file], { cwd: projectDir });
18
+ return res.exitCode === 0;
19
+ }
20
+
21
+ async function stashRefForToken(runner, projectDir, marker) {
22
+ const res = await runner("git", ["stash", "list"], { cwd: projectDir });
23
+ if (res.exitCode !== 0) return null;
24
+ for (const line of String(res.stdout || "").split("\n")) {
25
+ if (line.includes(marker)) {
26
+ const m = line.match(/^(stash@\{\d+\})/);
27
+ if (m) return m[1];
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ // Move `files` aside so a test run sees them as missing. Returns a token to
34
+ // pass to `restoreFiles()`. Throws on failure with the partial token attached.
35
+ export async function stashFilesAside(files, { projectDir = process.cwd(), runner = defaultRunner, tmpRoot } = {}) {
36
+ if (!Array.isArray(files) || files.length === 0) {
37
+ return { trackedStashRef: null, untrackedMoves: [], marker: null };
38
+ }
39
+ const tracked = [];
40
+ const untracked = [];
41
+ for (const f of files) {
42
+ if (await isTracked(runner, projectDir, f)) tracked.push(f);
43
+ else if (existsSync(resolve(projectDir, f))) untracked.push(f);
44
+ }
45
+
46
+ const marker = `kj-tdd-discipline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
47
+ const stashRoot = tmpRoot || join(projectDir, ".karajan", "tmp", marker);
48
+ const untrackedMoves = [];
49
+ let trackedStashRef = null;
50
+
51
+ if (tracked.length > 0) {
52
+ const args = ["stash", "push", "--keep-index", "-m", marker, "--", ...tracked];
53
+ const res = await runner("git", args, { cwd: projectDir });
54
+ if (res.exitCode !== 0) {
55
+ throw new Error(`git stash push failed: ${res.stderr || res.stdout}`);
56
+ }
57
+ trackedStashRef = await stashRefForToken(runner, projectDir, marker);
58
+ }
59
+
60
+ for (const f of untracked) {
61
+ const src = resolve(projectDir, f);
62
+ const dst = join(stashRoot, f);
63
+ await mkdir(dirname(dst), { recursive: true });
64
+ await rename(src, dst);
65
+ untrackedMoves.push({ src, dst });
66
+ }
67
+
68
+ return { trackedStashRef, untrackedMoves, marker, stashRoot };
69
+ }
70
+
71
+ // Reverse a `stashFilesAside` token. Idempotent: a restored token becomes a no-op.
72
+ export async function restoreFiles(token, { projectDir = process.cwd(), runner = defaultRunner } = {}) {
73
+ if (!token || (token.trackedStashRef == null && token.untrackedMoves.length === 0)) return;
74
+
75
+ for (const { src, dst } of token.untrackedMoves) {
76
+ if (existsSync(dst)) {
77
+ await mkdir(dirname(src), { recursive: true });
78
+ await rename(dst, src);
79
+ }
80
+ }
81
+ if (token.stashRoot && existsSync(token.stashRoot)) {
82
+ await rm(token.stashRoot, { recursive: true, force: true });
83
+ }
84
+
85
+ if (token.trackedStashRef) {
86
+ const res = await runner("git", ["stash", "pop", token.trackedStashRef], { cwd: projectDir });
87
+ if (res.exitCode !== 0) {
88
+ throw new Error(`git stash pop failed: ${res.stderr || res.stdout}`);
89
+ }
90
+ }
91
+ token.trackedStashRef = null;
92
+ token.untrackedMoves = [];
93
+ }
94
+
95
+ // Run `body` with `files` stashed aside, always restoring them — even when
96
+ // `body` throws. The TDD discipline stage wraps its "red" run with this.
97
+ export async function withStashedFiles(files, body, opts = {}) {
98
+ const token = await stashFilesAside(files, opts);
99
+ try {
100
+ return await body(token);
101
+ } finally {
102
+ await restoreFiles(token, opts).catch(() => { /* swallow: surface body error */ });
103
+ }
104
+ }
@@ -0,0 +1,90 @@
1
+ // TDD discipline gate (KJC-TSK-0398 PR1).
2
+ // PR1 ships the pure decision logic with injected runTests/stashFiles
3
+ // helpers. PR2 ships the real surgical stash. PR3 wires the gate into
4
+ // the pipeline behind `development.require_red_then_green`.
5
+
6
+ const PARSERS = {
7
+ vitest: parseVitestFailures,
8
+ jest: parseVitestFailures,
9
+ pytest: parsePytestFailures,
10
+ };
11
+
12
+ function parseVitestFailures(output) {
13
+ const failures = new Set();
14
+ for (const line of String(output || "").split("\n")) {
15
+ const m = line.match(/^\s*(?:FAIL|×|✖)\s+(\S+)/);
16
+ if (m) failures.add(m[1].trim());
17
+ }
18
+ return failures;
19
+ }
20
+
21
+ function parsePytestFailures(output) {
22
+ const failures = new Set();
23
+ for (const line of String(output || "").split("\n")) {
24
+ const m = line.match(/^FAILED\s+(\S+)/);
25
+ if (m) failures.add(m[1].trim());
26
+ }
27
+ return failures;
28
+ }
29
+
30
+ function anyNewTestIn(failures, testFiles) {
31
+ return [...failures].some((f) => testFiles.some((t) => f.includes(t) || t.includes(f)));
32
+ }
33
+
34
+ export async function verifyTddDiscipline({
35
+ projectDir,
36
+ sourceFiles = [],
37
+ testFiles = [],
38
+ framework = null,
39
+ runTests,
40
+ stashFiles,
41
+ restoreFiles,
42
+ } = {}) {
43
+ if (sourceFiles.length === 0) {
44
+ return { ok: true, reason: "no_source_changes", sourceFiles, testFiles };
45
+ }
46
+ if (testFiles.length === 0) {
47
+ return { ok: true, reason: "no_test_changes", sourceFiles, testFiles };
48
+ }
49
+
50
+ const parser = framework ? PARSERS[framework] : null;
51
+ if (!parser) {
52
+ return {
53
+ ok: true,
54
+ reason: "framework_unsupported",
55
+ sourceFiles,
56
+ testFiles,
57
+ warning: `tdd-discipline: framework "${framework || "unknown"}" has no failure parser; gate skipped`,
58
+ };
59
+ }
60
+
61
+ let stashToken = null;
62
+ try {
63
+ stashToken = await stashFiles(sourceFiles);
64
+ const before = await runTests(projectDir);
65
+ if (!anyNewTestIn(parser(before.output), testFiles)) {
66
+ return {
67
+ ok: false,
68
+ reason: "tests_pass_without_impl",
69
+ sourceFiles,
70
+ testFiles,
71
+ message:
72
+ "TDD discipline violation: new tests pass without the implementation. Tests must fail first (red), then pass (green).",
73
+ };
74
+ }
75
+ } finally {
76
+ if (stashToken) await restoreFiles(stashToken);
77
+ }
78
+
79
+ const after = await runTests(projectDir);
80
+ if (anyNewTestIn(parser(after.output), testFiles)) {
81
+ return {
82
+ ok: false,
83
+ reason: "tests_fail_with_impl",
84
+ sourceFiles,
85
+ testFiles,
86
+ message: "TDD discipline violation: new tests still fail with the implementation present.",
87
+ };
88
+ }
89
+ return { ok: true, reason: "red_then_green_verified", sourceFiles, testFiles };
90
+ }
@@ -8,6 +8,7 @@ import { collectOsvFindings } from "../audit/osv-findings.js";
8
8
  import { collectSemgrepFindings } from "../audit/semgrep-findings.js";
9
9
  import { collectCircularDeps } from "../audit/circular-deps.js";
10
10
  import { collectDeadExports } from "../audit/dead-exports.js";
11
+ import { collectInjectionFindings } from "../audit/injection-findings.js";
11
12
 
12
13
  function parseDimensions(dimensionsStr) {
13
14
  if (!dimensionsStr || dimensionsStr === "all") return null;
@@ -51,6 +52,7 @@ export class AuditRole extends AgentRole {
51
52
  const noSemgrep = typeof input === "object" ? Boolean(input?.noSemgrep) : false;
52
53
  const noMadge = typeof input === "object" ? Boolean(input?.noMadge) : false;
53
54
  const noKnip = typeof input === "object" ? Boolean(input?.noKnip) : false;
55
+ const noInjectionScan = typeof input === "object" ? Boolean(input?.noInjectionScan) : false;
54
56
  const projectDir = this.config?.projectDir || process.cwd();
55
57
  let basalCost = null;
56
58
  let growthDelta = null;
@@ -61,6 +63,7 @@ export class AuditRole extends AgentRole {
61
63
  let semgrepFindings = null;
62
64
  let circularDeps = null;
63
65
  let deadExports = null;
66
+ let injectionFindings = null;
64
67
  try {
65
68
  basalCost = await measureBasalCost(projectDir);
66
69
  const previous = await loadPreviousAudit(projectDir);
@@ -108,7 +111,12 @@ export class AuditRole extends AgentRole {
108
111
  deadExports = await collectDeadExports(projectDir, stack, this.config, this.logger);
109
112
  } catch { /* knip is best-effort */ }
110
113
  }
111
- return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports };
114
+ if (!noInjectionScan) {
115
+ try {
116
+ injectionFindings = await collectInjectionFindings(projectDir, this.logger);
117
+ } catch { /* injection scan is best-effort (KJC-TSK-0498) */ }
118
+ }
119
+ return { projectDir, basalCost, growthDelta, stack, sonarFindings, webperf, osvFindings, semgrepFindings, circularDeps, deadExports, injectionFindings };
112
120
  }
113
121
 
114
122
  /**
@@ -0,0 +1,78 @@
1
+ // KJC-TSK-0375 — ToolCorrectnessJudgeRole.
2
+ // Post-iteration LLM judge scoring how well a coder picked its tool calls.
3
+ // PR1 ships role + tests; wiring lives in PR3.
4
+
5
+ import { AgentRole } from "./agent-role.js";
6
+ import { buildToolCorrectnessJudgePrompt } from "../prompts/tool-correctness-judge.js";
7
+ import { extractFirstJson } from "../utils/json-extract.js";
8
+
9
+ const DEFAULT_THRESHOLD = 70;
10
+
11
+ const clampScore = (n) => Number.isFinite(n) ? Math.max(0, Math.min(100, Math.round(n))) : null;
12
+
13
+ const normalizeBreakdown = (b) => Array.isArray(b) ? b.map((x) => ({
14
+ tool: typeof x?.tool === "string" ? x.tool : "unknown",
15
+ args: x?.args && typeof x.args === "object" ? x.args : {},
16
+ correct: Boolean(x?.correct),
17
+ reason: typeof x?.reason === "string" ? x.reason.trim() : "",
18
+ })) : [];
19
+
20
+ export class ToolCorrectnessJudgeRole extends AgentRole {
21
+ constructor(opts) {
22
+ super({ ...opts, name: "tool-correctness-judge" });
23
+ this.threshold = opts?.threshold ?? this.config?.pipeline?.tool_judge?.threshold ?? DEFAULT_THRESHOLD;
24
+ }
25
+
26
+ resolveProvider() {
27
+ return this.config?.roles?.["tool-correctness-judge"]?.provider
28
+ || this.config?.roles?.reviewer?.provider
29
+ || this.config?.roles?.coder?.provider
30
+ || "claude";
31
+ }
32
+
33
+ extractInput(input) {
34
+ return { ...super.extractInput(input), toolCalls: Array.isArray(input?.toolCalls) ? input.toolCalls : [] };
35
+ }
36
+
37
+ async buildPrompt({ task, toolCalls }) {
38
+ return { prompt: buildToolCorrectnessJudgePrompt({ task, toolCalls, instructions: this.instructions }) };
39
+ }
40
+
41
+ parseOutput(raw) { return extractFirstJson(raw); }
42
+
43
+ buildSuccessResult(parsed, provider) {
44
+ const score = clampScore(parsed?.score);
45
+ return {
46
+ score,
47
+ summary: typeof parsed?.summary === "string" ? parsed.summary.trim() : "",
48
+ breakdown: normalizeBreakdown(parsed?.breakdown),
49
+ threshold: this.threshold,
50
+ lowQuality: score !== null && score < this.threshold,
51
+ provider,
52
+ };
53
+ }
54
+
55
+ buildSummary(parsed) {
56
+ const score = clampScore(parsed?.score);
57
+ if (score === null) return "Tool-judge: unparseable score";
58
+ return `Tool-judge: ${score}/100 (${score < this.threshold ? "LOW" : "OK"})`;
59
+ }
60
+
61
+ // Empty tool-call list = nothing to judge. Skip the LLM and report a
62
+ // neutral score so an absent transcript is never read as a failure.
63
+ async execute(input) {
64
+ const extracted = this.extractInput(input);
65
+ if (extracted.toolCalls.length === 0) {
66
+ return {
67
+ ok: true,
68
+ result: {
69
+ score: null, summary: "no tool calls captured (skipped)", breakdown: [],
70
+ threshold: this.threshold, lowQuality: false, provider: this.resolveProvider(), skipped: true,
71
+ },
72
+ summary: "Tool-judge: skipped (no calls)",
73
+ usage: null,
74
+ };
75
+ }
76
+ return super.execute(extracted);
77
+ }
78
+ }
@@ -109,6 +109,10 @@ export function setSonarSummary(session, text) {
109
109
  session.last_sonar_summary = text;
110
110
  }
111
111
 
112
+ export function setCoderTranscript(session, text) {
113
+ session.last_coder_transcript = text;
114
+ }
115
+
112
116
  export function setSonarIssueSignature(session, sig) {
113
117
  session.last_sonar_issue_signature = sig;
114
118
  }
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import fsPromises from "node:fs/promises";
3
3
  import path from "node:path";
4
+ import os from "node:os";
4
5
  import { runCommand } from "../utils/process.js";
5
6
  import { sonarUp } from "./manager.js";
6
7
  import { resolveSonarProjectKey } from "./project-key.js";
@@ -10,6 +11,32 @@ import {
10
11
  resolveSonarCredentials,
11
12
  } from "./config-resolver.js";
12
13
 
14
+ export async function findNativeSonarScanner() {
15
+ const which = await runCommand("sh", ["-c", "command -v sonar-scanner"]);
16
+ if (which.exitCode === 0) {
17
+ const bin = String(which.stdout || "").trim();
18
+ if (bin) return bin;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export async function pickSonarScanner(scannerConfig = {}, deps = {}) {
24
+ const find = deps.findNativeSonarScanner || findNativeSonarScanner;
25
+ const arch = deps.arch || os.arch();
26
+ const forced = String(scannerConfig.command || "").trim().toLowerCase();
27
+ if (forced === "docker") return { type: "docker" };
28
+ if (forced === "native" || forced === "sonar-scanner" || forced.endsWith("/sonar-scanner")) {
29
+ const bin = (forced === "native" || forced === "sonar-scanner") ? await find() : forced;
30
+ if (bin) return { type: "native", bin };
31
+ return { type: "docker", reason: "native sonar-scanner not found on PATH" };
32
+ }
33
+ if (arch === "arm64") {
34
+ const bin = await find();
35
+ if (bin) return { type: "native", bin, reason: "ARM64 host — preferring native binary" };
36
+ }
37
+ return { type: "docker" };
38
+ }
39
+
13
40
  export function buildScannerOpts(projectKey, scanner = {}) {
14
41
  const opts = [`-Dsonar.projectKey=${projectKey}`];
15
42
  if (scanner.sources) opts.push(`-Dsonar.sources=${scanner.sources}`);
@@ -236,31 +263,39 @@ export async function runSonarScan(config, projectKey = null) {
236
263
  ...coverage.scannerPatch
237
264
  });
238
265
 
239
- const args = [
240
- "run",
241
- "--rm",
242
- "-v",
243
- `${process.cwd()}:/usr/src`,
244
- ...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
245
- ...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
246
- "-e", "SONAR_HOST_URL",
247
- "-e", "SONAR_TOKEN",
248
- "-e", "SONAR_SCANNER_OPTS",
249
- "sonarsource/sonar-scanner-cli"
250
- ];
251
-
252
- // Pass secrets via process env, not CLI args — invisible in `ps aux`
266
+ const pick = await pickSonarScanner(sonarConfig.scanner);
253
267
  const env = {
254
268
  ...process.env,
255
- SONAR_HOST_URL: host,
269
+ SONAR_HOST_URL: pick.type === "native" ? rawHost : host,
256
270
  SONAR_TOKEN: token || "",
257
271
  SONAR_SCANNER_OPTS: buildScannerOpts(effectiveProjectKey, scannerConfig)
258
272
  };
259
273
 
260
- const result = await runCommand("docker", args, { timeout: scannerTimeout, env });
274
+ let cmd, args;
275
+ if (pick.type === "native") {
276
+ cmd = pick.bin;
277
+ args = [];
278
+ } else {
279
+ cmd = "docker";
280
+ args = [
281
+ "run",
282
+ "--rm",
283
+ "-v",
284
+ `${process.cwd()}:/usr/src`,
285
+ ...(isLocalHost ? ["--add-host", "host.docker.internal:host-gateway"] : []),
286
+ ...(isLocalHost || isExternalSonar ? [] : ["--network", sonarNetwork]),
287
+ "-e", "SONAR_HOST_URL",
288
+ "-e", "SONAR_TOKEN",
289
+ "-e", "SONAR_SCANNER_OPTS",
290
+ "sonarsource/sonar-scanner-cli"
291
+ ];
292
+ }
293
+
294
+ const result = await runCommand(cmd, args, { timeout: scannerTimeout, env });
261
295
  return {
262
296
  ok: result.exitCode === 0,
263
297
  projectKey: effectiveProjectKey,
298
+ scanner: pick.type,
264
299
  stdout: result.stdout,
265
300
  stderr: result.stderr,
266
301
  exitCode: result.exitCode
@@ -0,0 +1,55 @@
1
+ /**
2
+ * kj sync --apply — append a drift patch to the Canvas markdown.
3
+ *
4
+ * KJC-TSK-0348. Pure data + I/O: reads the Canvas at `canvasPath`, drops a
5
+ * `.bak` snapshot alongside it, appends an HTML-comment block describing
6
+ * untracked / missing / covered files, and atomically renames a `.tmp`
7
+ * over the target. Comments are inert for the Canvas parser, so the
8
+ * patched file stays structurally valid — the user converts the TODOs
9
+ * into Operations by hand.
10
+ */
11
+
12
+ import { promises as fs } from "node:fs";
13
+
14
+ function tempPath(target) {
15
+ return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
16
+ }
17
+
18
+ export function renderDriftComment(report) {
19
+ const lines = ["", `<!-- kj sync --apply (${new Date().toISOString()}) — review and convert into Operations as needed. -->`];
20
+ let mutations = 0;
21
+ if (report.untracked?.length) {
22
+ lines.push("<!-- Untracked files (modified, no Operation references them — add one): -->");
23
+ for (const u of report.untracked) { lines.push(`<!-- + ${u.path} -->`); mutations += 1; }
24
+ }
25
+ if (report.missing?.length) {
26
+ lines.push("<!-- Missing files (Operation file_hint points at a file that does not exist): -->");
27
+ for (const m of report.missing) { lines.push(`<!-- ! ${m.path} [op #${(m.operationIndex ?? 0) + 1}] -->`); mutations += 1; }
28
+ }
29
+ if (report.covered?.length) {
30
+ lines.push("<!-- Covered (referenced and modified — consider extending the Approach): -->");
31
+ for (const c of report.covered) lines.push(`<!-- ~ ${c.path} [op #${(c.operationIndex ?? 0) + 1}] -->`);
32
+ }
33
+ lines.push("<!-- /kj sync -->", "");
34
+ return { block: lines.join("\n"), mutations };
35
+ }
36
+
37
+ export async function applyCanvasPatch({ canvasPath, report }) {
38
+ if (!canvasPath || typeof canvasPath !== "string") throw new Error("applyCanvasPatch: canvasPath is required");
39
+ if (!report || typeof report !== "object") throw new Error("applyCanvasPatch: report is required");
40
+ const hasDrift = (report.untracked?.length || 0) + (report.missing?.length || 0) > 0;
41
+ if (!hasDrift) return { patchedPath: canvasPath, backupPath: null, mutations: 0, skipped: true };
42
+
43
+ let original;
44
+ try { original = await fs.readFile(canvasPath, "utf-8"); }
45
+ catch (err) { throw new Error(`applyCanvasPatch: cannot read canvas ${canvasPath}: ${err.message}`, { cause: err }); }
46
+
47
+ const backupPath = `${canvasPath}.bak`;
48
+ await fs.writeFile(backupPath, original, "utf-8");
49
+ const { block, mutations } = renderDriftComment(report);
50
+ const trimmed = original.endsWith("\n") ? original : `${original}\n`;
51
+ const tmp = tempPath(canvasPath);
52
+ await fs.writeFile(tmp, `${trimmed}${block}\n`, "utf-8");
53
+ await fs.rename(tmp, canvasPath);
54
+ return { patchedPath: canvasPath, backupPath, mutations };
55
+ }
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import os from "node:os";
2
3
  import path from "node:path";
3
4
 
@@ -12,6 +13,17 @@ function vitestRoot() {
12
13
  os.tmpdir(),
13
14
  `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
14
15
  );
16
+ // KJC-BUG-0075: clean up on clean exit so /tmp doesn't accumulate one
17
+ // dir per fork × run. `process.on('exit')` runs synchronously and only
18
+ // for graceful exits — SIGKILL leaks survive but are caught by the
19
+ // global-setup mtime purge at the start of the next run.
20
+ process.on("exit", () => {
21
+ try {
22
+ fs.rmSync(_vitestRoot, { recursive: true, force: true });
23
+ } catch {
24
+ // best-effort: never crash a test process on cleanup failure
25
+ }
26
+ });
15
27
  return _vitestRoot;
16
28
  }
17
29
 
@@ -0,0 +1,56 @@
1
+ // KJ_SIMULATE_QUOTA — testing hook for the standby flow. See
2
+ // docs/quota-simulation.md. KJC-TSK-0493.
3
+
4
+ let _warned = false;
5
+
6
+ export function parseQuotaSimulationConfig(env = process.env) {
7
+ const trigger = env.KJ_SIMULATE_QUOTA;
8
+ if (!trigger) return null;
9
+ const m = /^after-iter-(\d+)$/i.exec(String(trigger).trim());
10
+ if (!m) {
11
+ if (!_warned) {
12
+ // eslint-disable-next-line no-console
13
+ console.warn(`[KJ_SIMULATE_QUOTA] Ignoring malformed value "${trigger}". Expected "after-iter-<N>".`);
14
+ _warned = true;
15
+ }
16
+ return null;
17
+ }
18
+ return {
19
+ afterIter: Number.parseInt(m[1], 10),
20
+ resetIso: env.KJ_SIMULATE_QUOTA_RESET || new Date(Date.now() + 3600_000).toISOString(),
21
+ agent: String(env.KJ_SIMULATE_QUOTA_AGENT || "claude").toLowerCase(),
22
+ };
23
+ }
24
+
25
+ const SYNTHETIC_MSG = {
26
+ claude: (iso) => `[KJ_SIMULATE_QUOTA] Claude Pro usage limit reached. weekly limit. resets at ${iso}`,
27
+ codex: () => `[KJ_SIMULATE_QUOTA] You have exceeded your current quota. retry after 60 seconds`,
28
+ gemini: () => `[KJ_SIMULATE_QUOTA] Resource exhausted: quota exceeded. retry after 60 seconds`,
29
+ };
30
+
31
+ export function buildSyntheticQuotaMessage(agent, resetIso) {
32
+ return (SYNTHETIC_MSG[agent] || SYNTHETIC_MSG.claude)(resetIso);
33
+ }
34
+
35
+ export function shouldSimulateQuota({ iteration, agent }, env = process.env) {
36
+ const cfg = parseQuotaSimulationConfig(env);
37
+ if (!cfg) return null;
38
+ if (Number(iteration) < cfg.afterIter) return null;
39
+ const a = String(agent || "").toLowerCase();
40
+ if (cfg.agent !== "any" && cfg.agent !== a) return null;
41
+ return { resetIso: cfg.resetIso, agent: a || cfg.agent };
42
+ }
43
+
44
+ // Mutates execResult: forces ok=false and prepends the synthetic quota
45
+ // line to result.error so the existing detectRateLimit branch fires.
46
+ export function applyQuotaSimulation(execResult, { iteration, agent }, env = process.env) {
47
+ const fire = shouldSimulateQuota({ iteration, agent }, env);
48
+ if (!fire || !execResult || typeof execResult !== "object") return false;
49
+ if (!execResult.result || typeof execResult.result !== "object") execResult.result = {};
50
+ execResult.result.error = `${buildSyntheticQuotaMessage(fire.agent, fire.resetIso)}\n${execResult.result.error || ""}`;
51
+ execResult.ok = false;
52
+ return true;
53
+ }
54
+
55
+ // Test seam — reset the one-shot warning between unit tests.
56
+ export function _resetSimulatorWarning() { _warned = false; }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Repo-level cleaner — KJC-TSK-0499 PR1. Read-only collector that
3
+ * surfaces stale local artifacts so the user can decide what to drop.
4
+ * Detects: branches merged into a base ref, branches with `[gone]`
5
+ * upstream, top-level `dist/` `coverage/` `logs/` older than N days,
6
+ * `*.tmp` / `*.bak` older than N days. Never spawns destructive ops.
7
+ */
8
+ import fs from "node:fs/promises";
9
+ import path from "node:path";
10
+ import { execa } from "execa";
11
+
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+ const STALE_DIRS = ["dist", "coverage", "logs"];
14
+ const STALE_FILE_RX = /\.(tmp|bak)$/;
15
+
16
+ async function safeGit(cwd, args) {
17
+ try {
18
+ const { stdout, exitCode } = await execa("git", args, { cwd, reject: false });
19
+ return exitCode === 0 ? stdout : "";
20
+ } catch { return ""; }
21
+ }
22
+
23
+ async function collectBranches(cwd, baseRef) {
24
+ const items = [];
25
+ const baseShort = baseRef.replace(/^origin\//, "");
26
+ const current = (await safeGit(cwd, ["symbolic-ref", "--short", "HEAD"])).trim();
27
+ const merged = await safeGit(cwd, ["branch", "--merged", baseRef, "--format=%(refname:short)"]);
28
+ for (const raw of merged.split("\n")) {
29
+ const name = raw.trim();
30
+ if (!name || name === current || name === baseShort) continue;
31
+ items.push({ kind: "branch", target: name, reason: `merged into ${baseRef}`, command: `git branch -d ${name}` });
32
+ }
33
+ for (const line of (await safeGit(cwd, ["branch", "-vv"])).split("\n")) {
34
+ const m = line.match(/^[\s*]+(\S+)\s+\S+\s+\[[^:]+: gone\]/);
35
+ if (m && m[1] !== current) items.push({ kind: "branch-gone", target: m[1], reason: "upstream gone", command: `git branch -D ${m[1]}` });
36
+ }
37
+ return items;
38
+ }
39
+
40
+ async function ageMs(p) {
41
+ try { return Date.now() - (await fs.stat(p)).mtimeMs; } catch { return -1; }
42
+ }
43
+
44
+ async function collectStale(cwd, maxAgeDays) {
45
+ const cutoff = maxAgeDays * DAY_MS;
46
+ const items = [];
47
+ for (const name of STALE_DIRS) {
48
+ const full = path.join(cwd, name);
49
+ if (await ageMs(full) > cutoff) {
50
+ items.push({ kind: "dir", target: full, reason: `${name}/ older than ${maxAgeDays}d`, command: `rm -rf ${full}` });
51
+ }
52
+ }
53
+ let entries = [];
54
+ try { entries = await fs.readdir(cwd, { withFileTypes: true }); } catch { /* cwd unreadable */ }
55
+ for (const e of entries) {
56
+ if (!e.isFile() || !STALE_FILE_RX.test(e.name)) continue;
57
+ const full = path.join(cwd, e.name);
58
+ if (await ageMs(full) > cutoff) {
59
+ items.push({ kind: "file", target: full, reason: `${path.extname(e.name)} older than ${maxAgeDays}d`, command: `rm -f ${full}` });
60
+ }
61
+ }
62
+ return items;
63
+ }
64
+
65
+ /**
66
+ * Discover repo-level cleanup candidates.
67
+ * @param {object} opts
68
+ * @param {string} [opts.cwd=process.cwd()]
69
+ * @param {number} [opts.maxAgeDays=7]
70
+ * @param {string} [opts.mergedTo="origin/main"]
71
+ * @returns {Promise<{candidates: Array, errors: Array}>}
72
+ */
73
+ export async function collectRepoCandidates({ cwd = process.cwd(), maxAgeDays = 7, mergedTo = "origin/main" } = {}) {
74
+ const candidates = [];
75
+ const errors = [];
76
+ try { candidates.push(...await collectBranches(cwd, mergedTo)); } catch (e) { errors.push({ stage: "branches", error: String(e?.message ?? e) }); }
77
+ try { candidates.push(...await collectStale(cwd, maxAgeDays)); } catch (e) { errors.push({ stage: "stale", error: String(e?.message ?? e) }); }
78
+ return { candidates, errors };
79
+ }