karajan-code 2.7.4 → 2.9.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 (192) hide show
  1. package/README.md +1 -1
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/audit/basal-cost.js +92 -9
  10. package/src/audit/deterministic-summary.js +200 -0
  11. package/src/audit/osv-findings.js +162 -0
  12. package/src/audit/semgrep-findings.js +117 -0
  13. package/src/audit/sonar-findings.js +81 -0
  14. package/src/audit/webperf-input.js +90 -0
  15. package/src/bootstrap.js +26 -1
  16. package/src/brain/decision-tracker.js +3 -1
  17. package/src/brain/decisor.js +1 -1
  18. package/src/canvas/parse-md.js +238 -0
  19. package/src/canvas/schema.js +101 -0
  20. package/src/canvas/to-flat.js +85 -0
  21. package/src/canvas/validate.js +163 -0
  22. package/src/checks/binaries.js +3 -3
  23. package/src/checks/ci.js +2 -2
  24. package/src/checks/config-files.js +6 -6
  25. package/src/checks/dir-setup.js +4 -0
  26. package/src/checks/mcp-health.js +6 -0
  27. package/src/checks/ports.js +91 -29
  28. package/src/checks/rtk.js +1 -1
  29. package/src/checks/skills.js +5 -1
  30. package/src/checks/sonar.js +2 -2
  31. package/src/checks/system.js +3 -3
  32. package/src/checks/tokens.js +1 -1
  33. package/src/checks/types.js +0 -12
  34. package/src/cli/_shared.js +30 -0
  35. package/src/cli/register-meta.js +218 -0
  36. package/src/cli/register-pipeline.js +212 -0
  37. package/src/cli/register-plan.js +152 -0
  38. package/src/cli/register-roles-skills.js +38 -0
  39. package/src/cli/register-sonar.js +28 -0
  40. package/src/cli.js +83 -468
  41. package/src/commands/agents.js +18 -6
  42. package/src/commands/architect.js +29 -2
  43. package/src/commands/audit.js +265 -30
  44. package/src/commands/board.js +160 -30
  45. package/src/commands/clean.js +83 -0
  46. package/src/commands/code.js +7 -2
  47. package/src/commands/discover.js +7 -2
  48. package/src/commands/doctor.js +1 -1
  49. package/src/commands/init.js +2 -2
  50. package/src/commands/plan/_shared.js +40 -0
  51. package/src/commands/plan/add-hu.js +21 -0
  52. package/src/commands/plan/delete.js +9 -0
  53. package/src/commands/plan/generate.js +284 -0
  54. package/src/commands/plan/index.js +14 -0
  55. package/src/commands/plan/list.js +21 -0
  56. package/src/commands/plan/ready.js +46 -0
  57. package/src/commands/plan/remove-hu.js +17 -0
  58. package/src/commands/plan/show.js +81 -0
  59. package/src/commands/plan/validate.js +20 -0
  60. package/src/commands/plan.js +14 -291
  61. package/src/commands/researcher.js +7 -2
  62. package/src/commands/resume.js +24 -1
  63. package/src/commands/review.js +7 -2
  64. package/src/commands/run.js +52 -1
  65. package/src/commands/status.js +1 -1
  66. package/src/commands/sync.js +75 -0
  67. package/src/commands/triage.js +7 -2
  68. package/src/commands/undo.js +1 -1
  69. package/src/config/defaults.js +213 -0
  70. package/src/config/loader.js +137 -0
  71. package/src/config/overrides.js +199 -0
  72. package/src/config/role-resolver.js +159 -0
  73. package/src/config/test-harness.js +78 -0
  74. package/src/config.js +30 -632
  75. package/src/git/automation.js +2 -2
  76. package/src/guards/output-guard.js +0 -2
  77. package/src/hu/acceptance-runner.js +52 -10
  78. package/src/hu/store.js +11 -0
  79. package/src/mcp/handlers/direct-handlers.js +2 -5
  80. package/src/mcp/handlers/management-handlers.js +48 -4
  81. package/src/mcp/handlers/run-handler.js +9 -8
  82. package/src/mcp/preflight.js +32 -13
  83. package/src/mcp/server-handlers.js +4 -3
  84. package/src/mcp/suggest-handler.js +3 -2
  85. package/src/mcp/tools.js +15 -0
  86. package/src/orchestrator/agent-fallback.js +1 -1
  87. package/src/orchestrator/ci-integration.js +11 -11
  88. package/src/orchestrator/config-init.js +25 -7
  89. package/src/orchestrator/direct-actions.js +7 -3
  90. package/src/orchestrator/drivers/error-recovery.js +287 -0
  91. package/src/orchestrator/drivers/init-context.js +222 -0
  92. package/src/orchestrator/drivers/iteration-loop.js +301 -0
  93. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  94. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  95. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  96. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  97. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  98. package/src/orchestrator/drivers/post-loop.js +370 -0
  99. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  100. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  101. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  102. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  103. package/src/orchestrator/drivers/pre-loop.js +431 -0
  104. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  105. package/src/orchestrator/feedback-queue.js +0 -1
  106. package/src/orchestrator/flow-control.js +4 -3
  107. package/src/orchestrator/flow-runner.js +121 -2077
  108. package/src/orchestrator/fs-leak-detector.js +156 -0
  109. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  110. package/src/orchestrator/integrations.js +81 -0
  111. package/src/orchestrator/post-loop-stages.js +35 -8
  112. package/src/orchestrator/preflight-checks.js +20 -7
  113. package/src/orchestrator/repair/repair-detector.js +117 -0
  114. package/src/orchestrator/repair/repair-runner.js +142 -0
  115. package/src/orchestrator/reviewer-fallback.js +1 -1
  116. package/src/orchestrator/role-output-compressor.js +0 -1
  117. package/src/orchestrator/session-journal.js +4 -41
  118. package/src/orchestrator/solomon-escalation.js +3 -2
  119. package/src/orchestrator/solomon-rules.js +7 -3
  120. package/src/orchestrator/stages/architect-stage.js +12 -11
  121. package/src/orchestrator/stages/coder-stage.js +56 -7
  122. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  123. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  124. package/src/orchestrator/stages/planner-stage.js +1 -1
  125. package/src/orchestrator/stages/research-stage.js +1 -1
  126. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  127. package/src/orchestrator/stages/sonar-stage.js +94 -10
  128. package/src/orchestrator/stages/stage-classes.js +84 -0
  129. package/src/orchestrator/stages/stage-executor.js +1 -1
  130. package/src/orchestrator/stages/triage-stage.js +1 -1
  131. package/src/orchestrator/standby.js +3 -2
  132. package/src/orchestrator/verification-gate.js +20 -12
  133. package/src/plan/adr-generator.js +170 -0
  134. package/src/plan/adr-loader.js +60 -0
  135. package/src/plan/plan-executor.js +19 -1
  136. package/src/plan/plan-hu-ops.js +170 -1
  137. package/src/plan/plan-reviewer.js +199 -0
  138. package/src/plan/plan-schema.js +69 -1
  139. package/src/plan/plan-store.js +40 -17
  140. package/src/plan/spec-detector.js +61 -0
  141. package/src/plan/tests-synthesizer.js +116 -0
  142. package/src/planning-game/client.js +3 -28
  143. package/src/planning-game/decomposition.js +1 -1
  144. package/src/planning-game/pipeline-adapter.js +37 -6
  145. package/src/prompts/audit.js +206 -6
  146. package/src/prompts/coder.js +172 -1
  147. package/src/prompts/hu-reviewer.js +0 -1
  148. package/src/prompts/planner.js +81 -4
  149. package/src/prompts/rtk-snippet.js +1 -1
  150. package/src/review/diff-generator.js +35 -22
  151. package/src/review/snapshot-diff.js +1 -1
  152. package/src/roles/audit-role.js +125 -13
  153. package/src/roles/coder-role.js +25 -3
  154. package/src/roles/planner-role.js +42 -1
  155. package/src/roles/repairer-role.js +181 -0
  156. package/src/roles/tester-role.js +70 -4
  157. package/src/session/mutators.js +304 -0
  158. package/src/session/runtime-overrides.js +59 -0
  159. package/src/{session-store.js → session/store.js} +2 -2
  160. package/src/skills/semantic-detector.js +10 -5
  161. package/src/skills/skill-detector.js +1 -1
  162. package/src/sonar/api.js +1 -1
  163. package/src/sonar/cloud-scanner.js +0 -1
  164. package/src/sonar/credentials.js +14 -6
  165. package/src/sonar/discovery.js +123 -0
  166. package/src/sonar/manager.js +18 -0
  167. package/src/sync/detect-drift.js +113 -0
  168. package/src/tracker-bootstrap.js +44 -0
  169. package/src/utils/agent-detect.js +15 -11
  170. package/src/utils/board-prompt-bridge.js +109 -0
  171. package/src/utils/cli-progress.js +161 -0
  172. package/src/utils/cwd-confirm.js +39 -0
  173. package/src/utils/derive-project-name-from-cwd.js +119 -0
  174. package/src/utils/display/event-handlers.js +1 -1
  175. package/src/utils/display/header.js +1 -1
  176. package/src/utils/display/session-summary.js +3 -3
  177. package/src/utils/display/solomon.js +1 -1
  178. package/src/utils/garbage-collector.js +396 -0
  179. package/src/utils/git.js +16 -4
  180. package/src/utils/locale.js +1 -1
  181. package/src/utils/paths.js +21 -1
  182. package/src/utils/process.js +1 -1
  183. package/src/utils/prompt-project-name.js +35 -0
  184. package/src/utils/role-assigner.js +1 -1
  185. package/src/utils/run-context.js +75 -0
  186. package/src/utils/run-log.js +12 -0
  187. package/src/utils/task-file.js +1 -1
  188. package/src/utils/telemetry.js +13 -1
  189. package/src/webperf/devtools-detect.js +1 -1
  190. package/templates/coder-rules.md +13 -0
  191. package/templates/roles/coder.md +34 -0
  192. package/templates/roles/repairer.md +94 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Derive a sensible default project name from the working directory,
3
+ * trying several signals in order:
4
+ *
5
+ * 1. `package.json` "name" field (most authoritative — that's what
6
+ * the user already chose for their npm/pnpm project).
7
+ * 2. Git remote URL basename (strip protocol, user, `.git` suffix —
8
+ * e.g. `git@github.com:foo/weather-dashboard.git` → `weather-dashboard`).
9
+ * 3. The cwd directory basename.
10
+ *
11
+ * Any of these is then prettified: kebab/snake → spaces, words
12
+ * capitalised. So `weather-dashboard` becomes `Weather Dashboard`.
13
+ *
14
+ * Returns null when no signal yields anything useful — caller can then
15
+ * fall back to its old "derive from task text" heuristic. This keeps
16
+ * the function pure / sync-friendly even though git/fs lookups are
17
+ * sync — they're cheap on a single-shot CLI startup.
18
+ *
19
+ * Why this matters: pre-fix, running `kj plan generate --task-file
20
+ * SPEC.md` inside `~/ws_demo/weather-dashboard` (an initialised git
21
+ * repo) suggested a project name made from the words of the SPEC's
22
+ * first sentence. That ignored both the directory the user was
23
+ * standing in and the repo they had just `git init`-ed. The right
24
+ * default is "the project I'm in", not "the topic of the document
25
+ * I just wrote".
26
+ */
27
+
28
+ import fs from "node:fs";
29
+ import path from "node:path";
30
+ import { execFileSync } from "node:child_process";
31
+
32
+ /**
33
+ * Turn `weather-dashboard` / `weather_dashboard` / `WeatherDashboard`
34
+ * into `Weather Dashboard`. Preserves single-token names as-is when
35
+ * already title-cased.
36
+ */
37
+ function prettifyName(raw) {
38
+ if (!raw || typeof raw !== "string") return null;
39
+ const trimmed = raw.trim();
40
+ if (!trimmed) return null;
41
+ // Insert space at camelCase boundaries, then split on -/_/whitespace.
42
+ const spaced = trimmed
43
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
44
+ .replace(/[-_]+/g, " ")
45
+ .replace(/\s+/g, " ")
46
+ .trim();
47
+ if (!spaced) return null;
48
+ return spaced
49
+ .split(" ")
50
+ .map((w) => (w.length ? w[0].toUpperCase() + w.slice(1) : w))
51
+ .join(" ");
52
+ }
53
+
54
+ function readPackageJsonName(cwd) {
55
+ try {
56
+ const pkgPath = path.join(cwd, "package.json");
57
+ if (!fs.existsSync(pkgPath)) return null;
58
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
59
+ if (typeof pkg.name === "string" && pkg.name.trim()) {
60
+ // Strip npm scope if present: @org/name → name
61
+ const unscoped = pkg.name.includes("/") ? pkg.name.split("/").pop() : pkg.name;
62
+ return unscoped;
63
+ }
64
+ return null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ }
69
+
70
+ function readGitRemoteBasename(cwd) {
71
+ try {
72
+ // -C points git at the cwd without us cd'ing the process.
73
+ // Audit follow-up: was using execSync with template-string interpolation
74
+ // of `cwd`. Switched to execFileSync with arg arrays so no interpolation
75
+ // reaches /bin/sh.
76
+ const out = execFileSync("git", ["-C", String(cwd), "config", "--get", "remote.origin.url"], {
77
+ stdio: ["ignore", "pipe", "ignore"],
78
+ encoding: "utf8",
79
+ timeout: 1500,
80
+ }).trim();
81
+ if (!out) return null;
82
+ // Strip optional protocol + user, .git suffix, trailing slash.
83
+ // Examples:
84
+ // git@github.com:foo/weather-dashboard.git
85
+ // https://github.com/foo/weather-dashboard
86
+ // git@gitlab.com:team/weather-dashboard
87
+ const last = out.replace(/\.git\/?$/, "").split(/[/:]/).pop();
88
+ return last || null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function readCwdBasename(cwd) {
95
+ const base = path.basename(cwd || process.cwd());
96
+ if (!base || base === "/" || base === ".") return null;
97
+ return base;
98
+ }
99
+
100
+ /**
101
+ * Try every signal in priority order and return the first prettified
102
+ * non-empty result. Returns null when nothing useful is available.
103
+ *
104
+ * @param {string} [cwd] — defaults to process.cwd()
105
+ * @returns {string|null}
106
+ */
107
+ export function deriveProjectNameFromCwd(cwd = process.cwd()) {
108
+ const fromPkg = prettifyName(readPackageJsonName(cwd));
109
+ if (fromPkg) return fromPkg;
110
+ const fromGit = prettifyName(readGitRemoteBasename(cwd));
111
+ if (fromGit) return fromGit;
112
+ const fromCwd = prettifyName(readCwdBasename(cwd));
113
+ if (fromCwd) return fromCwd;
114
+ return null;
115
+ }
116
+
117
+ // Exposed for unit tests so they can drive the individual signals
118
+ // without real filesystem / git side-effects.
119
+ export const __test__ = { prettifyName, readPackageJsonName, readGitRemoteBasename, readCwdBasename };
@@ -342,7 +342,7 @@ export const EVENT_HANDLERS = {
342
342
  };
343
343
 
344
344
  /** Event types suppressed in quiet mode (internal/noise events). */
345
- export const QUIET_SUPPRESSED = new Set([
345
+ const QUIET_SUPPRESSED = new Set([
346
346
  "agent:output",
347
347
  "agent:stall",
348
348
  "pipeline:simplify",
@@ -8,7 +8,7 @@ import { ANSI } from "./formatters.js";
8
8
  const DISPLAY_PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../package.json");
9
9
  const DISPLAY_VERSION = JSON.parse(readFileSync(DISPLAY_PKG_PATH, "utf8")).version;
10
10
 
11
- export const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${ANSI.reset}`;
11
+ const BAR = `${ANSI.dim}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${ANSI.reset}`;
12
12
 
13
13
  /**
14
14
  * Print task, configuration and pipeline header
@@ -15,7 +15,7 @@ export function printSessionStages(stages) {
15
15
  printSessionSonar(stages.sonar);
16
16
  }
17
17
 
18
- export function printSessionPlanner(planner) {
18
+ function printSessionPlanner(planner) {
19
19
  if (!planner?.title && !planner?.approach && !planner?.completedSteps?.length) return;
20
20
  const planParts = [];
21
21
  if (planner.title) planParts.push(planner.title);
@@ -26,7 +26,7 @@ export function printSessionPlanner(planner) {
26
26
  }
27
27
  }
28
28
 
29
- export function printSessionSonar(sonar) {
29
+ function printSessionSonar(sonar) {
30
30
  if (!sonar) return;
31
31
  const gateLabel = sonar.gateStatus === "OK" ? ANSI.green : ANSI.red;
32
32
  console.log(` ${ANSI.dim}\ud83d\udd0d Sonar: ${gateLabel}${sonar.gateStatus}${ANSI.reset}${ANSI.dim} (${sonar.openIssues ?? 0} issues)${ANSI.reset}`);
@@ -55,7 +55,7 @@ export function printSessionGit(git) {
55
55
  }
56
56
  }
57
57
 
58
- export function isBudgetUnavailable(budget) {
58
+ function isBudgetUnavailable(budget) {
59
59
  return budget.usage_available === false ||
60
60
  (budget.total_tokens === 0 && budget.total_cost_usd === 0 && Object.keys(budget.breakdown_by_role || {}).length > 0);
61
61
  }
@@ -1,6 +1,6 @@
1
1
  import { ANSI } from "./formatters.js";
2
2
 
3
- export const SOLOMON_RULING_HANDLERS = {
3
+ const SOLOMON_RULING_HANDLERS = {
4
4
  approve(detail, elapsed) {
5
5
  const dismissedCount = detail?.dismissed?.length || 0;
6
6
  const dismissedSuffix = dismissedCount > 0 ? ` (${dismissedCount} dismissed)` : "";
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Karajan garbage collector — keeps `~/.kj/` and `~/.karajan/` from
3
+ * accumulating dead state across runs.
4
+ *
5
+ * Dogfood motivation: after ~1 year the user's `~/.kj/plans/` had 2 464
6
+ * plans in `home_manu_ws_npm-packages_karajan-code/` plus 35 plans from
7
+ * worktrees that no longer exist. Same story for `~/.karajan/sessions/`
8
+ * and `hu-stories/`. None ever got cleaned because no command did it.
9
+ */
10
+
11
+ /**
12
+ * Two entry points:
13
+ * - `runAutoGC(opts)` — silent, called at the top of every `kj run`
14
+ * and `kj plan`. Prints a single one-liner ONLY when something was
15
+ * removed.
16
+ * - `runManualGC(opts)` — backs `kj clean`. Caller decides `dryRun`;
17
+ * returns the full `{ removed, bytesFreed, errors }` record.
18
+ *
19
+ * Retention policy (all adjustable via opts):
20
+ * • Plans whose stamped `projectDir` no longer exists → delete.
21
+ * • Plans with final status (approved/rejected/executed) older than
22
+ * `planRetentionDays` (30) → delete.
23
+ * • Draft plans older than `draftRetentionDays` (60) → delete.
24
+ * • Finalised sessions older than `sessionRetentionDays` (7) → delete.
25
+ * • HU story batches older than `huRetentionDays` (14) → delete.
26
+ *
27
+ * Failures are NEVER fatal — every op is try/catch and non-blocking.
28
+ * Tested against an isolated tmp KJ_HOME so production data is safe.
29
+ */
30
+
31
+ import fs from "node:fs/promises";
32
+ import path from "node:path";
33
+ import os from "node:os";
34
+
35
+ const FINAL_PLAN_STATUSES = new Set([
36
+ // Terminal plan states the user explicitly opted into — retention applies.
37
+ "approved", "rejected", "executed", "completed", "ready",
38
+ ]);
39
+ const KNOWN_PLAN_STATUSES = new Set([...FINAL_PLAN_STATUSES, "draft", "running"]);
40
+ const FINAL_SESSION_STATUSES = new Set(["approved", "failed", "stopped", "rejected", "completed"]);
41
+
42
+ function getKjHome() {
43
+ return process.env.KJ_HOME || path.join(os.homedir(), ".kj");
44
+ }
45
+
46
+ function getKarajanHome() {
47
+ return process.env.KARAJAN_HOME || path.join(os.homedir(), ".karajan");
48
+ }
49
+
50
+ const DAY_MS = 24 * 60 * 60 * 1000;
51
+
52
+ /**
53
+ * @typedef {Object} GCOptions
54
+ * @property {number} [planRetentionDays=30]
55
+ * @property {number} [draftRetentionDays=60]
56
+ * @property {number} [sessionRetentionDays=7]
57
+ * @property {number} [huRetentionDays=14]
58
+ * @property {boolean} [dryRun=false] - only report what would be removed
59
+ * @property {Date} [now] - clock injection for tests
60
+ */
61
+
62
+ /**
63
+ * @typedef {Object} GCResult
64
+ * @property {Array<{path: string, reason: string, kind: string, bytes?: number}>} removed
65
+ * @property {number} bytesFreed
66
+ * @property {Array<{path: string, error: string}>} errors
67
+ */
68
+
69
+ async function tryReadJson(file) {
70
+ try {
71
+ const txt = await fs.readFile(file, "utf8");
72
+ return JSON.parse(txt);
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ async function tryStat(p) {
79
+ try { return await fs.stat(p); } catch { return null; }
80
+ }
81
+
82
+ async function exists(p) {
83
+ return Boolean(await tryStat(p));
84
+ }
85
+
86
+ async function listDir(dir) {
87
+ try { return await fs.readdir(dir, { withFileTypes: true }); } catch { return []; }
88
+ }
89
+
90
+ async function unlinkOrRm(p, dryRun) {
91
+ if (dryRun) return;
92
+ try {
93
+ const st = await fs.stat(p);
94
+ if (st.isDirectory()) await fs.rm(p, { recursive: true, force: true });
95
+ else await fs.unlink(p);
96
+ } catch { /* best-effort */ }
97
+ }
98
+
99
+ function ageInDays(stat, now) {
100
+ return (now.getTime() - stat.mtimeMs) / DAY_MS;
101
+ }
102
+
103
+ // Slugs are lossy — we CAN'T recover a path from one because `_` in
104
+ // the slug could have come from either `/` in the path or a literal
105
+ // `_` in a directory name (e.g. `ws_ai` vs `ws/ai`). That's why plans
106
+ // saved via `savePlan()` now stamp their absolute `projectDir`; the GC
107
+ // reads it from there. Legacy plans without the field fall back to
108
+ // age-only retention — never marked as orphans on the way up.
109
+
110
+ /**
111
+ * Per-plan classifier. Pulled out so the surrounding GC can evaluate
112
+ * candidates in parallel — every plan is independent (no shared state
113
+ * across iterations except the result accumulator, which is folded back
114
+ * after the bursts settle).
115
+ */
116
+ async function classifyPlan(filePath, opts, now) {
117
+ const stat = await tryStat(filePath);
118
+ if (!stat) return { skip: true };
119
+ const data = await tryReadJson(filePath);
120
+ const status = data?.status || "draft";
121
+ const storedDir = typeof data?.projectDir === "string" ? data.projectDir : null;
122
+ const days = ageInDays(stat, now);
123
+
124
+ let reason = null;
125
+ // Orphan check: only when the plan stamped its origin projectDir.
126
+ // Plans older than the savePlan-stamping change don't have it, so
127
+ // we refuse to guess and just apply age-based retention.
128
+ if (storedDir && !(await exists(storedDir))) {
129
+ reason = "orphaned (project dir no longer exists)";
130
+ } else if (FINAL_PLAN_STATUSES.has(status) && days > opts.planRetentionDays) {
131
+ reason = `${status} plan older than ${opts.planRetentionDays}d (${Math.round(days)}d)`;
132
+ } else if (status === "draft" && days > opts.draftRetentionDays) {
133
+ reason = `stale draft older than ${opts.draftRetentionDays}d (${Math.round(days)}d)`;
134
+ } else if (!KNOWN_PLAN_STATUSES.has(status) && days > opts.draftRetentionDays) {
135
+ // Unknown status (corrupt/legacy/plan from a future version) —
136
+ // apply the draft window. Doing nothing would leak them forever.
137
+ reason = `plan with unknown status "${status}" older than ${opts.draftRetentionDays}d (${Math.round(days)}d)`;
138
+ }
139
+ return { skip: false, stat, reason };
140
+ }
141
+
142
+ async function gcPlans(opts) {
143
+ const result = { removed: [], bytesFreed: 0, errors: [] };
144
+ const now = opts.now || new Date();
145
+ const plansRoot = path.join(getKjHome(), "plans");
146
+ if (!(await exists(plansRoot))) return result;
147
+
148
+ // Audit follow-up: was nested sequential for-of with multiple awaits per
149
+ // iteration. Plans are independent files so we now classify them in
150
+ // parallel per project directory. Each project dir's bursts complete
151
+ // before we move to the next so the order of `result.removed` stays
152
+ // deterministic for tests; cross-project parallelism wasn't worth the
153
+ // complexity given KJ_HOME rarely has more than a handful of projects.
154
+ const projEntries = (await listDir(plansRoot)).filter((e) => e.isDirectory());
155
+ for (const projEntry of projEntries) {
156
+ const projDir = path.join(plansRoot, projEntry.name);
157
+ const planEntries = (await listDir(projDir))
158
+ .filter((e) => e.isFile() && e.name.endsWith(".json"));
159
+
160
+ const verdicts = await Promise.all(
161
+ planEntries.map(async (planEntry) => {
162
+ const filePath = path.join(projDir, planEntry.name);
163
+ const verdict = await classifyPlan(filePath, opts, now);
164
+ return { filePath, verdict };
165
+ })
166
+ );
167
+
168
+ let removedCountInDir = 0;
169
+ const totalCountInDir = planEntries.length;
170
+ for (const { filePath, verdict } of verdicts) {
171
+ if (verdict.skip || !verdict.reason) continue;
172
+ try {
173
+ await unlinkOrRm(filePath, opts.dryRun);
174
+ result.removed.push({ path: filePath, reason: verdict.reason, kind: "plan", bytes: verdict.stat.size });
175
+ result.bytesFreed += verdict.stat.size;
176
+ removedCountInDir += 1;
177
+ } catch (err) {
178
+ result.errors.push({ path: filePath, error: err.message });
179
+ }
180
+ }
181
+
182
+ // If we just emptied the project dir, remove it too.
183
+ if (removedCountInDir === totalCountInDir && totalCountInDir > 0) {
184
+ try {
185
+ await unlinkOrRm(projDir, opts.dryRun);
186
+ } catch { /* ignore */ }
187
+ }
188
+ }
189
+ return result;
190
+ }
191
+
192
+ async function gcSessions(opts) {
193
+ const result = { removed: [], bytesFreed: 0, errors: [] };
194
+ const now = opts.now || new Date();
195
+ const sessionsRoot = path.join(getKarajanHome(), "sessions");
196
+ if (!(await exists(sessionsRoot))) return result;
197
+
198
+ // Pipelines that get SIGKILL'd (power off, terminal closed, OOM…) leave
199
+ // their session.json stuck at status="running" forever. The original GC
200
+ // only reclaimed FINAL statuses and these zombies accumulated on the
201
+ // board. After `staleRunningDays` with no activity we assume the run is
202
+ // dead and sweep them. Default mirrors sessionRetentionDays so there is
203
+ // only one knob to reason about.
204
+ const staleRunningDays = opts.staleRunningDays ?? opts.sessionRetentionDays;
205
+
206
+ // Audit follow-up: parallelise the per-session classification (read
207
+ // session.json + stat the dir). Deletion stays sequential so the
208
+ // result.removed order is deterministic for tests.
209
+ const sessEntries = (await listDir(sessionsRoot)).filter((e) => e.isDirectory());
210
+ const verdicts = await Promise.all(
211
+ sessEntries.map(async (sessEntry) => {
212
+ const sessDir = path.join(sessionsRoot, sessEntry.name);
213
+ const sessionFile = path.join(sessDir, "session.json");
214
+ const stat = await tryStat(sessDir);
215
+ if (!stat) return { sessDir, reason: null };
216
+ const data = await tryReadJson(sessionFile);
217
+ const status = data?.status;
218
+ const days = ageInDays(stat, now);
219
+
220
+ let reason = null;
221
+ if (status && FINAL_SESSION_STATUSES.has(status) && days > opts.sessionRetentionDays) {
222
+ reason = `${status} session older than ${opts.sessionRetentionDays}d (${Math.round(days)}d)`;
223
+ } else if ((!status || status === "running") && days > staleRunningDays) {
224
+ reason = `zombie ${status || "unknown-status"} session older than ${staleRunningDays}d (${Math.round(days)}d)`;
225
+ }
226
+ return { sessDir, reason };
227
+ })
228
+ );
229
+
230
+ for (const { sessDir, reason } of verdicts) {
231
+ if (!reason) continue;
232
+ try {
233
+ await unlinkOrRm(sessDir, opts.dryRun);
234
+ result.removed.push({ path: sessDir, reason, kind: "session" });
235
+ } catch (err) {
236
+ result.errors.push({ path: sessDir, error: err.message });
237
+ }
238
+ }
239
+ return result;
240
+ }
241
+
242
+ async function gcHuStories(opts) {
243
+ const result = { removed: [], bytesFreed: 0, errors: [] };
244
+ const now = opts.now || new Date();
245
+ const huRoot = path.join(getKarajanHome(), "hu-stories");
246
+ if (!(await exists(huRoot))) return result;
247
+
248
+ // Audit follow-up: stat-then-classify in parallel; deletion stays
249
+ // sequential for deterministic ordering.
250
+ const batchEntries = (await listDir(huRoot)).filter((e) => e.isDirectory());
251
+ const verdicts = await Promise.all(
252
+ batchEntries.map(async (batchEntry) => {
253
+ const batchDir = path.join(huRoot, batchEntry.name);
254
+ const stat = await tryStat(batchDir);
255
+ if (!stat) return null;
256
+ const days = ageInDays(stat, now);
257
+ if (days <= opts.huRetentionDays) return null;
258
+ return {
259
+ batchDir,
260
+ reason: `HU batch older than ${opts.huRetentionDays}d (${Math.round(days)}d)`,
261
+ };
262
+ })
263
+ );
264
+
265
+ for (const v of verdicts) {
266
+ if (!v) continue;
267
+ try {
268
+ await unlinkOrRm(v.batchDir, opts.dryRun);
269
+ result.removed.push({ path: v.batchDir, reason: v.reason, kind: "hu-batch" });
270
+ } catch (err) {
271
+ result.errors.push({ path: v.batchDir, error: err.message });
272
+ }
273
+ }
274
+ return result;
275
+ }
276
+
277
+ function mergeResults(...parts) {
278
+ const out = { removed: [], bytesFreed: 0, errors: [] };
279
+ for (const r of parts) {
280
+ out.removed.push(...r.removed);
281
+ out.bytesFreed += r.bytesFreed;
282
+ out.errors.push(...r.errors);
283
+ }
284
+ return out;
285
+ }
286
+
287
+ function defaultOpts(opts = {}) {
288
+ return {
289
+ planRetentionDays: opts.planRetentionDays ?? 30,
290
+ draftRetentionDays: opts.draftRetentionDays ?? 60,
291
+ sessionRetentionDays: opts.sessionRetentionDays ?? 7,
292
+ huRetentionDays: opts.huRetentionDays ?? 14,
293
+ dryRun: Boolean(opts.dryRun),
294
+ now: opts.now || new Date(),
295
+ };
296
+ }
297
+
298
+ /**
299
+ * Manual GC — backs `kj clean`. Caller decides dryRun via opts.
300
+ *
301
+ * @param {GCOptions} [opts]
302
+ * @returns {Promise<GCResult>}
303
+ */
304
+ export async function runManualGC(opts = {}) {
305
+ const o = defaultOpts(opts);
306
+ // Audit follow-up: was 3 sequential awaits. Plans, sessions and HU
307
+ // stories live in different directory subtrees and don't share state,
308
+ // so Promise.all is safe and roughly halves end-to-end GC latency on
309
+ // a populated KJ_HOME.
310
+ const [plans, sessions, huStories] = await Promise.all([
311
+ gcPlans(o),
312
+ gcSessions(o),
313
+ gcHuStories(o),
314
+ ]);
315
+ return mergeResults(plans, sessions, huStories);
316
+ }
317
+
318
+ /**
319
+ * Auto GC — silent, called at the top of `kj run` / `kj plan`. Always
320
+ * destructive (dryRun ignored). Returns the same result so the caller
321
+ * can print a one-liner summary if anything was removed.
322
+ *
323
+ * @param {Omit<GCOptions, 'dryRun'>} [opts]
324
+ * @returns {Promise<GCResult>}
325
+ */
326
+ export async function runAutoGC(opts = {}) {
327
+ return runManualGC({ ...opts, dryRun: false });
328
+ }
329
+
330
+ /**
331
+ * Wipe the HU board SQLite database (+WAL + SHM + pidfile). Used by
332
+ * `kj clean --nuke` as the "reset everything" button.
333
+ *
334
+ * When the caller also stops the board process (we do so best-effort
335
+ * here via the pidfile), re-starting the board with `kj board start`
336
+ * or via the post-`kj plan` autostart recreates the DB empty.
337
+ *
338
+ * @param {{ dryRun?: boolean }} [opts]
339
+ * @returns {Promise<GCResult>}
340
+ */
341
+ export async function nukeBoardDb(opts = {}) {
342
+ const result = { removed: [], bytesFreed: 0, errors: [] };
343
+ const dryRun = Boolean(opts.dryRun);
344
+ const kHome = getKarajanHome();
345
+
346
+ // Stop the board if we find a live pidfile — UNLESS we're being
347
+ // invoked from inside the board itself (the user clicked ⚡ →
348
+ // `kj clean --nuke` in the launcher). In that case killing the
349
+ // board would kill the page they're looking at, mid-confirm.
350
+ // command-runner.js sets KJ_INSIDE_BOARD=1 on every spawn so we
351
+ // can detect this. The nuke still wipes the DB; the board picks
352
+ // up the empty DB on its next chokidar tick.
353
+ const insideBoard = process.env.KJ_INSIDE_BOARD === "1";
354
+ const pidFile = path.join(kHome, "hu-board.pid");
355
+ try {
356
+ const pid = Number.parseInt(await fs.readFile(pidFile, "utf8"), 10);
357
+ if (!dryRun && Number.isFinite(pid) && !insideBoard) {
358
+ try { process.kill(pid, 0); process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
359
+ }
360
+ } catch { /* no pidfile */ }
361
+
362
+ // The DB (+ WAL / SHM) can be hundreds of MB on old installs; report
363
+ // the bytes freed so the user sees the reward.
364
+ for (const name of ["hu-board.db", "hu-board.db-wal", "hu-board.db-shm", "hu-board.pid"]) {
365
+ const p = path.join(kHome, name);
366
+ const stat = await tryStat(p);
367
+ if (!stat) continue;
368
+ try {
369
+ await unlinkOrRm(p, dryRun);
370
+ result.removed.push({ path: p, reason: "board db wiped (--nuke)", kind: "board", bytes: stat.size });
371
+ result.bytesFreed += stat.size;
372
+ } catch (err) {
373
+ result.errors.push({ path: p, error: err.message });
374
+ }
375
+ }
376
+ return result;
377
+ }
378
+
379
+ /**
380
+ * Format a GC result as a single human-readable line. Used by the
381
+ * silent auto-GC path so the user only sees one line in the rare case
382
+ * something was actually cleaned.
383
+ *
384
+ * @param {GCResult} result
385
+ * @returns {string|null} null when nothing was removed
386
+ */
387
+ export function summarizeGC(result) {
388
+ if (!result.removed.length) return null;
389
+ const byKind = {};
390
+ for (const r of result.removed) {
391
+ byKind[r.kind] = (byKind[r.kind] || 0) + 1;
392
+ }
393
+ const parts = Object.entries(byKind).map(([k, n]) => `${n} ${k}${n === 1 ? "" : "s"}`);
394
+ const kb = Math.round(result.bytesFreed / 1024);
395
+ return `[gc] removed ${parts.join(", ")}${kb ? ` (${kb} KiB)` : ""}`;
396
+ }
package/src/utils/git.js CHANGED
@@ -1,18 +1,30 @@
1
1
  import { runCommand } from "./process.js";
2
+ import { getRunContext } from "./run-context.js";
2
3
 
3
- /** @type {((command: string, args?: string[], options?: object) => Promise<object>)|null} */
4
+ /**
5
+ * Module-scoped runner — legacy back-compat for callers outside a
6
+ * `withRunContext` scope (CLI bootstrap, checks/system.js). When running
7
+ * inside a pipeline, `getRunContext()?.runner` takes precedence and
8
+ * provides per-run isolation (TSK-0338). When neither is set we fall
9
+ * back to the bare `runCommand`.
10
+ *
11
+ * @type {((command: string, args?: string[], options?: object) => Promise<object>)|null}
12
+ */
4
13
  let _runner = null;
5
14
 
6
15
  /**
7
- * Inject an RTK-aware runner so all git operations in this module benefit from token savings.
8
- * @param {(command: string, args?: string[], options?: object) => Promise<object>} runner
16
+ * Set the module-scope runner. Retained for back-compat: concurrent
17
+ * `runFlow` invocations should rely on `withRunContext` for isolation,
18
+ * not on this setter (the setter is process-wide and would cause the
19
+ * exact cross-run contamination TSK-0338 eliminates).
9
20
  */
10
21
  export function setRunner(runner) {
11
22
  _runner = runner;
12
23
  }
13
24
 
14
25
  function run(command, args, ...rest) {
15
- return (_runner || runCommand)(command, args, ...rest);
26
+ const ctxRunner = getRunContext()?.runner;
27
+ return (ctxRunner || _runner || runCommand)(command, args, ...rest);
16
28
  }
17
29
 
18
30
  function slugifyTask(task) {
@@ -35,7 +35,7 @@ function extractLangCode(locale) {
35
35
  const trimmed = locale.trim();
36
36
  if (!trimmed || trimmed === "C" || trimmed === "POSIX") return null;
37
37
  // Match first 2 lowercase letters before _, ., or end
38
- const match = /^([a-z]{2})(?:[_.\-]|$)/i.exec(trimmed);
38
+ const match = /^([a-z]{2})(?:[_.-]|$)/i.exec(trimmed);
39
39
  if (match) return match[1].toLowerCase();
40
40
  return null;
41
41
  }
@@ -1,11 +1,31 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
+ // Per-process tmp dir for VITEST runs that forget to set KJ_HOME. Same
5
+ // rationale as src/plan/plan-store.js — without this, tests that use
6
+ // real session/board paths leak files into the developer's real
7
+ // `~/.karajan/` and pollute the HU Board's SQLite DB. Memoised so
8
+ // every helper in the same process agrees on the path. The trailing
9
+ // segment is `.karajan` to keep semantic parity with non-VITEST
10
+ // defaults (existing tests assert paths contain ".karajan/...").
11
+ let _vitestKarajanHome = null;
12
+ function vitestTmpKarajanHome() {
13
+ if (_vitestKarajanHome) return _vitestKarajanHome;
14
+ _vitestKarajanHome = path.join(
15
+ os.tmpdir(),
16
+ `karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
17
+ ".karajan"
18
+ );
19
+ return _vitestKarajanHome;
20
+ }
21
+
4
22
  export function getKarajanHome() {
5
23
  if (process.env.KJ_HOME) {
6
24
  return path.resolve(process.env.KJ_HOME);
7
25
  }
8
-
26
+ if (process.env.VITEST) {
27
+ return vitestTmpKarajanHome();
28
+ }
9
29
  return path.join(os.homedir(), ".karajan");
10
30
  }
11
31
 
@@ -149,7 +149,7 @@ export async function runCommand(command, args = [], options = {}) {
149
149
  }
150
150
  }
151
151
 
152
- function enrichResult(result, stdoutAccum, stderrAccum) {
152
+ function enrichResult(result, stdoutAccum, _stderrAccum) {
153
153
  if (result.timedOut) return result;
154
154
 
155
155
  const killed = result.killed || !!result.signal;