karajan-code 2.7.3 → 2.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.
Files changed (167) hide show
  1. package/README.md +4 -4
  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/bootstrap.js +26 -1
  10. package/src/brain/decision-tracker.js +3 -1
  11. package/src/brain/decisor.js +1 -1
  12. package/src/canvas/parse-md.js +238 -0
  13. package/src/canvas/schema.js +101 -0
  14. package/src/canvas/to-flat.js +85 -0
  15. package/src/canvas/validate.js +163 -0
  16. package/src/checks/binaries.js +1 -1
  17. package/src/checks/ports.js +97 -27
  18. package/src/checks/runner.js +26 -10
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +1 -1
  21. package/src/checks/tokens.js +77 -24
  22. package/src/cli/_shared.js +30 -0
  23. package/src/cli/register-meta.js +204 -0
  24. package/src/cli/register-pipeline.js +212 -0
  25. package/src/cli/register-plan.js +152 -0
  26. package/src/cli/register-roles-skills.js +38 -0
  27. package/src/cli/register-sonar.js +28 -0
  28. package/src/cli.js +83 -468
  29. package/src/commands/agents.js +18 -6
  30. package/src/commands/architect.js +29 -2
  31. package/src/commands/audit.js +27 -3
  32. package/src/commands/board.js +160 -30
  33. package/src/commands/clean.js +83 -0
  34. package/src/commands/code.js +7 -2
  35. package/src/commands/discover.js +7 -2
  36. package/src/commands/init.js +2 -2
  37. package/src/commands/plan/_shared.js +40 -0
  38. package/src/commands/plan/add-hu.js +21 -0
  39. package/src/commands/plan/delete.js +9 -0
  40. package/src/commands/plan/generate.js +284 -0
  41. package/src/commands/plan/index.js +14 -0
  42. package/src/commands/plan/list.js +21 -0
  43. package/src/commands/plan/ready.js +46 -0
  44. package/src/commands/plan/remove-hu.js +17 -0
  45. package/src/commands/plan/show.js +81 -0
  46. package/src/commands/plan/validate.js +20 -0
  47. package/src/commands/plan.js +14 -291
  48. package/src/commands/researcher.js +7 -2
  49. package/src/commands/resume.js +24 -1
  50. package/src/commands/review.js +7 -2
  51. package/src/commands/run.js +52 -1
  52. package/src/commands/status.js +1 -1
  53. package/src/commands/sync.js +75 -0
  54. package/src/commands/triage.js +7 -2
  55. package/src/commands/undo.js +1 -1
  56. package/src/config/defaults.js +213 -0
  57. package/src/config/loader.js +137 -0
  58. package/src/config/overrides.js +199 -0
  59. package/src/config/role-resolver.js +159 -0
  60. package/src/config/test-harness.js +79 -0
  61. package/src/config.js +30 -609
  62. package/src/git/automation.js +2 -2
  63. package/src/guards/output-guard.js +0 -2
  64. package/src/hu/acceptance-runner.js +52 -10
  65. package/src/hu/store.js +11 -0
  66. package/src/mcp/handlers/direct-handlers.js +2 -5
  67. package/src/mcp/handlers/management-handlers.js +48 -4
  68. package/src/mcp/handlers/run-handler.js +9 -8
  69. package/src/mcp/preflight.js +32 -13
  70. package/src/mcp/server-handlers.js +4 -3
  71. package/src/mcp/suggest-handler.js +3 -2
  72. package/src/mcp/tools.js +15 -0
  73. package/src/orchestrator/agent-fallback.js +1 -1
  74. package/src/orchestrator/ci-integration.js +10 -10
  75. package/src/orchestrator/config-init.js +25 -7
  76. package/src/orchestrator/direct-actions.js +6 -2
  77. package/src/orchestrator/drivers/error-recovery.js +287 -0
  78. package/src/orchestrator/drivers/init-context.js +222 -0
  79. package/src/orchestrator/drivers/iteration-loop.js +311 -0
  80. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  81. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  82. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  83. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  84. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  85. package/src/orchestrator/drivers/post-loop.js +370 -0
  86. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  87. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  88. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  89. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  90. package/src/orchestrator/drivers/pre-loop.js +434 -0
  91. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  92. package/src/orchestrator/flow-control.js +4 -3
  93. package/src/orchestrator/flow-runner.js +121 -2019
  94. package/src/orchestrator/fs-leak-detector.js +156 -0
  95. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  96. package/src/orchestrator/integrations.js +88 -0
  97. package/src/orchestrator/post-loop-stages.js +35 -8
  98. package/src/orchestrator/preflight-checks.js +30 -3
  99. package/src/orchestrator/repair/repair-detector.js +117 -0
  100. package/src/orchestrator/repair/repair-runner.js +142 -0
  101. package/src/orchestrator/reviewer-fallback.js +1 -1
  102. package/src/orchestrator/solomon-escalation.js +3 -2
  103. package/src/orchestrator/solomon-rules.js +7 -3
  104. package/src/orchestrator/stages/architect-stage.js +12 -11
  105. package/src/orchestrator/stages/coder-stage.js +56 -7
  106. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  107. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  108. package/src/orchestrator/stages/planner-stage.js +1 -1
  109. package/src/orchestrator/stages/research-stage.js +1 -1
  110. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  111. package/src/orchestrator/stages/sonar-stage.js +94 -10
  112. package/src/orchestrator/stages/stage-classes.js +86 -0
  113. package/src/orchestrator/stages/stage-executor.js +1 -1
  114. package/src/orchestrator/stages/triage-stage.js +1 -1
  115. package/src/orchestrator/standby.js +3 -2
  116. package/src/orchestrator/verification-gate.js +20 -12
  117. package/src/plan/adr-generator.js +170 -0
  118. package/src/plan/adr-loader.js +60 -0
  119. package/src/plan/plan-executor.js +19 -1
  120. package/src/plan/plan-hu-ops.js +170 -1
  121. package/src/plan/plan-reviewer.js +199 -0
  122. package/src/plan/plan-schema.js +69 -1
  123. package/src/plan/plan-store.js +40 -3
  124. package/src/plan/spec-detector.js +61 -0
  125. package/src/plan/tests-synthesizer.js +116 -0
  126. package/src/planning-game/client.js +3 -14
  127. package/src/planning-game/decomposition.js +1 -1
  128. package/src/planning-game/pipeline-adapter.js +36 -5
  129. package/src/prompts/coder.js +172 -1
  130. package/src/prompts/hu-reviewer.js +0 -1
  131. package/src/prompts/planner.js +81 -4
  132. package/src/review/diff-generator.js +35 -22
  133. package/src/review/snapshot-diff.js +1 -1
  134. package/src/roles/coder-role.js +25 -3
  135. package/src/roles/planner-role.js +42 -1
  136. package/src/roles/repairer-role.js +181 -0
  137. package/src/roles/tester-role.js +70 -4
  138. package/src/session/mutators.js +304 -0
  139. package/src/session/runtime-overrides.js +59 -0
  140. package/src/{session-store.js → session/store.js} +8 -2
  141. package/src/skills/semantic-detector.js +10 -5
  142. package/src/skills/skill-detector.js +1 -1
  143. package/src/sonar/cloud-scanner.js +0 -1
  144. package/src/sonar/credentials.js +14 -6
  145. package/src/sonar/discovery.js +123 -0
  146. package/src/sonar/manager.js +18 -0
  147. package/src/sync/detect-drift.js +113 -0
  148. package/src/tracker-bootstrap.js +44 -0
  149. package/src/utils/agent-detect.js +15 -11
  150. package/src/utils/board-prompt-bridge.js +109 -0
  151. package/src/utils/cli-progress.js +161 -0
  152. package/src/utils/cwd-confirm.js +39 -0
  153. package/src/utils/derive-project-name-from-cwd.js +119 -0
  154. package/src/utils/garbage-collector.js +396 -0
  155. package/src/utils/git.js +16 -4
  156. package/src/utils/locale.js +1 -1
  157. package/src/utils/paths.js +21 -1
  158. package/src/utils/process.js +1 -1
  159. package/src/utils/prompt-project-name.js +35 -0
  160. package/src/utils/run-context.js +75 -0
  161. package/src/utils/run-log.js +12 -0
  162. package/src/utils/task-file.js +1 -1
  163. package/src/utils/telemetry.js +13 -1
  164. package/src/webperf/devtools-detect.js +1 -1
  165. package/templates/coder-rules.md +13 -0
  166. package/templates/roles/coder.md +34 -0
  167. package/templates/roles/repairer.md +94 -0
@@ -2,6 +2,7 @@ import { createAgent } from "../agents/index.js";
2
2
  import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
4
  import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect.js";
5
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
5
6
 
6
7
  function formatLayers(layers, lines) {
7
8
  lines.push("### Layers");
@@ -67,8 +68,12 @@ export async function architectCommand({ task, config, logger, context, json })
67
68
 
68
69
  const agent = createAgent(architectRole.provider, config, logger);
69
70
  const prompt = await buildArchitectPrompt({ task, researchContext: context });
70
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
71
- const result = await agent.runTask({ prompt, onOutput, role: "architect" });
71
+ const progress = createCliProgressReporter({ role: "architect" });
72
+ let result;
73
+ try {
74
+ result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "architect" });
75
+ progress.finish(result.ok ? "done" : "failed");
76
+ } catch (err) { progress.finish("failed"); throw err; }
72
77
 
73
78
  if (!result.ok) {
74
79
  throw new Error(result.error || result.output || "Architect failed");
@@ -86,6 +91,28 @@ export async function architectCommand({ task, config, logger, context, json })
86
91
  } else {
87
92
  console.log(result.output);
88
93
  }
94
+
95
+ // Persist ADRs from the architect's tradeoffs[] (v2.7.5). One ADR
96
+ // per tradeoff in Nygard format under `~/.kj/plans/_loose/adrs/`.
97
+ // Coder/reviewer in subsequent runs can pull the active ADRs into
98
+ // their prompt for context — done in a follow-up PR.
99
+ if (parsed?.architecture && config?.projectDir) {
100
+ try {
101
+ const { persistAdrsFromArchitecture } = await import("../plan/adr-generator.js");
102
+ const { adrs, paths } = await persistAdrsFromArchitecture({
103
+ projectDir: config.projectDir,
104
+ planId: null, // standalone architect → "_loose" bucket
105
+ architecture: parsed.architecture,
106
+ });
107
+ if (paths.length > 0) {
108
+ console.log(`\n${adrs.length} ADR${adrs.length === 1 ? "" : "s"} written:`);
109
+ for (const p of paths) console.log(` - ${p}`);
110
+ }
111
+ } catch (err) {
112
+ logger.warn(`ADR write skipped: ${err.message}`);
113
+ }
114
+ }
115
+
89
116
  logger.info("Architect completed.");
90
117
  return { ok: true };
91
118
  });
@@ -1,8 +1,11 @@
1
+ import path from "node:path";
1
2
  import { createAgent } from "../agents/index.js";
2
3
  import { assertAgentsAvailable } from "../agents/availability.js";
3
4
  import { resolveRole } from "../config.js";
4
5
  import { buildAuditPrompt, parseAuditOutput, AUDIT_DIMENSIONS } from "../prompts/audit.js";
5
6
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
8
+ import { runAgentReadiness, formatAgentReadinessReport } from "../audit/agent-readiness.js";
6
9
 
7
10
  function formatFindings(findings) {
8
11
  const lines = [];
@@ -66,7 +69,24 @@ function formatAudit(parsed) {
66
69
  return lines.join("\n");
67
70
  }
68
71
 
69
- export async function auditCommand({ task, config, logger, dimensions, json }) {
72
+ export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg }) {
73
+ // --agent-readiness is a STANDALONE, deterministic, LLM-free audit
74
+ // dimension. It scores any third-party repo for AI-agent readability
75
+ // (llms.txt presence, page token budgets, robots allowlist, etc.).
76
+ // Per addyosmani/agentic-seo. Issue #542.
77
+ if (agentReadiness) {
78
+ const rootDir = path.resolve(pathArg || config?.projectDir || process.cwd());
79
+ logger.info(`Auditing agent-readiness of ${rootDir}`);
80
+ const report = runAgentReadiness(rootDir);
81
+ if (json) {
82
+ console.log(JSON.stringify(report, null, 2));
83
+ } else {
84
+ console.log(formatAgentReadinessReport(report, rootDir));
85
+ }
86
+ // Exit code: 0 always (audit succeeded). The score is the signal.
87
+ return;
88
+ }
89
+
70
90
  return withCliRunLog("audit", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
71
91
  const auditRole = resolveRole(config, "audit");
72
92
  await assertAgentsAvailable([auditRole.provider]);
@@ -79,8 +99,12 @@ export async function auditCommand({ task, config, logger, dimensions, json }) {
79
99
  : null;
80
100
 
81
101
  const prompt = buildAuditPrompt({ task: task || "Analyze the full codebase", dimensions: dimList });
82
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
83
- const result = await agent.runTask({ prompt, onOutput, role: "audit" });
102
+ const progress = createCliProgressReporter({ role: "auditor" });
103
+ let result;
104
+ try {
105
+ result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "audit" });
106
+ progress.finish(result.ok ? "done" : "failed");
107
+ } catch (err) { progress.finish("failed"); throw err; }
84
108
 
85
109
  if (!result.ok) {
86
110
  throw new Error(result.error || result.output || "Audit failed");
@@ -1,8 +1,10 @@
1
1
  import fs from "node:fs";
2
+ import http from "node:http";
2
3
  import path from "node:path";
3
4
  import net from "node:net";
4
5
  import { spawn } from "node:child_process";
5
6
  import { getKarajanHome } from "../utils/paths.js";
7
+ import { getPortOccupant } from "../utils/port-occupant.js";
6
8
 
7
9
  const BOARD_DIR = path.resolve(import.meta.dirname, "../../packages/hu-board");
8
10
  const PID_FILE = path.join(getKarajanHome(), "hu-board.pid");
@@ -25,6 +27,27 @@ function isProcessAlive(pid) {
25
27
  }
26
28
  }
27
29
 
30
+ /**
31
+ * HTTP-probe the board's /api/dashboard. Returns true when it
32
+ * responds with 2xx within ~750ms. Used as a fallback when the PID
33
+ * file is missing/stale to detect "board is up, just untracked".
34
+ */
35
+ async function isBoardReachable(port, timeoutMs = 750) {
36
+ return new Promise((resolve) => {
37
+ const req = http.request(
38
+ { host: '127.0.0.1', port, path: '/api/dashboard', method: 'GET', timeout: timeoutMs },
39
+ (res) => {
40
+ const ok = res.statusCode >= 200 && res.statusCode < 500;
41
+ res.resume(); // drain
42
+ resolve(ok);
43
+ }
44
+ );
45
+ req.on('error', () => resolve(false));
46
+ req.on('timeout', () => { req.destroy(); resolve(false); });
47
+ req.end();
48
+ });
49
+ }
50
+
28
51
  /**
29
52
  * Check if a TCP port is available on localhost.
30
53
  * Returns true when we can successfully bind (port is free).
@@ -52,11 +75,45 @@ async function findAvailablePort(desiredPort, maxTries = 10) {
52
75
  return null;
53
76
  }
54
77
 
55
- export async function startBoard(desiredPort = 4000) {
78
+ /**
79
+ * Build the board URL. When `projectSlug` is provided, the URL points at
80
+ * the per-project view (`/p/<slug>`) so the caller's project shows up
81
+ * pre-filtered instead of "All projects".
82
+ * @param {number} port
83
+ * @param {string|null} [projectSlug]
84
+ * @returns {string}
85
+ */
86
+ function buildBoardUrl(port, projectSlug) {
87
+ const base = `http://localhost:${port}`;
88
+ return projectSlug ? `${base}/p/${projectSlug}` : base;
89
+ }
90
+
91
+ export async function startBoard(desiredPort = 4000, opts = {}) {
92
+ const { projectSlug = null } = opts;
56
93
  const existingPid = readPid();
57
94
  if (existingPid && isProcessAlive(existingPid)) {
58
95
  // Trust the saved PID — port info comes from the PID file if present
59
- return { ok: true, alreadyRunning: true, pid: existingPid, url: `http://localhost:${desiredPort}` };
96
+ return {
97
+ ok: true,
98
+ alreadyRunning: true,
99
+ pid: existingPid,
100
+ url: buildBoardUrl(desiredPort, projectSlug),
101
+ };
102
+ }
103
+
104
+ // Belt-and-suspenders (v2.7.5 fix): even if the PID file is missing
105
+ // or stale, HTTP-probe the configured port before spawning. If
106
+ // /api/dashboard responds, a board is already there — reuse it. The
107
+ // pre-fix scenario: 🔁 self-restart didn't update the PID file
108
+ // (now fixed), so a `kj plan` afterwards saw a stale PID, spawned
109
+ // a duplicate board on port 4001, and confused the user.
110
+ if (await isBoardReachable(desiredPort)) {
111
+ return {
112
+ ok: true,
113
+ alreadyRunning: true,
114
+ pid: existingPid || null,
115
+ url: buildBoardUrl(desiredPort, projectSlug),
116
+ };
60
117
  }
61
118
 
62
119
  // Find a free port starting from desiredPort
@@ -79,34 +136,85 @@ export async function startBoard(desiredPort = 4000) {
79
136
  });
80
137
  // Swallow spawn errors so an HU Board failure never crashes the pipeline.
81
138
  child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
82
- child.unref();
83
-
84
139
  if (!child.pid) {
85
140
  throw new Error("Failed to spawn HU Board server");
86
141
  }
87
142
  fs.writeFileSync(PID_FILE, String(child.pid));
88
- return { ok: true, alreadyRunning: false, pid: child.pid, url: `http://localhost:${port}`, port };
143
+ // Detach for real: `detached: true` only sets up the new session;
144
+ // without `unref()` the parent's event loop still holds a reference
145
+ // to the child handle and `kj board start` hangs at the prompt
146
+ // instead of returning. Pair this with stdio:"ignore" (already set)
147
+ // to ensure the parent isn't waiting on any of the child's pipes.
148
+ child.unref();
149
+ return { ok: true, alreadyRunning: false, pid: child.pid, url: buildBoardUrl(port, projectSlug), port };
89
150
  }
90
151
 
91
- export async function stopBoard() {
152
+ export async function stopBoard(opts = {}) {
153
+ const { port = 4000 } = opts;
154
+ // Path 1: live PID in the file → straight SIGTERM. Pre-v2.7.5
155
+ // this was the only path; if the file was missing or stale (e.g.
156
+ // user ran a board built before the server-writes-own-PID fix in
157
+ // PR #508) we'd return "wasn't running" while the ghost server
158
+ // happily kept serving requests. The fallbacks below close that gap.
92
159
  const pid = readPid();
93
- if (!pid || !isProcessAlive(pid)) {
94
- try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
95
- return { ok: true, wasRunning: false };
160
+ if (pid && isProcessAlive(pid)) {
161
+ try { process.kill(pid, "SIGTERM"); } catch { /* race with dying process */ }
162
+ fs.rmSync(PID_FILE, { force: true });
163
+ return { ok: true, wasRunning: true, pid, via: "pidfile" };
96
164
  }
165
+ fs.rmSync(PID_FILE, { force: true });
97
166
 
98
- try {
99
- process.kill(pid, "SIGTERM");
100
- } catch { /* process already dead */
101
- // already dead
167
+ // Path 2: HTTP-ask the server to exit cleanly. Works for any
168
+ // running server that has the /api/board/shutdown endpoint —
169
+ // even when there's no PID file, no process tree to walk, etc.
170
+ const shutdownUrl = `/api/board/shutdown`;
171
+ const shutdownOk = await postBoardShutdown(port, shutdownUrl);
172
+ if (shutdownOk) {
173
+ return { ok: true, wasRunning: true, pid: null, via: "api" };
102
174
  }
103
- try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
104
- return { ok: true, wasRunning: true, pid };
175
+
176
+ // Path 3: there's something on the port and the API didn't take it
177
+ // down. Find its PID via lsof / ss / netstat and SIGTERM it.
178
+ // Works against truly old servers that don't have the shutdown
179
+ // endpoint, with the cost of one extra subprocess call.
180
+ try {
181
+ const occupant = await getPortOccupant(port);
182
+ if (occupant?.pid) {
183
+ try { process.kill(occupant.pid, "SIGTERM"); } catch { /* gone */ }
184
+ return { ok: true, wasRunning: true, pid: occupant.pid, via: "port-occupant" };
185
+ }
186
+ } catch { /* lsof not installed etc — fall through */ }
187
+
188
+ return { ok: true, wasRunning: false };
189
+ }
190
+
191
+ /**
192
+ * Best-effort POST to the board's shutdown endpoint. Returns true
193
+ * when the server acks (and is presumably exiting); false on any
194
+ * error so the caller falls through to the next strategy.
195
+ */
196
+ function postBoardShutdown(port, urlPath, timeoutMs = 750) {
197
+ return new Promise((resolve) => {
198
+ const req = http.request(
199
+ { host: '127.0.0.1', port, path: urlPath, method: 'POST', timeout: timeoutMs,
200
+ headers: { 'Content-Type': 'application/json', 'Content-Length': '2' } },
201
+ (res) => {
202
+ const ok = res.statusCode >= 200 && res.statusCode < 400;
203
+ res.resume();
204
+ resolve(ok);
205
+ }
206
+ );
207
+ req.on('error', () => resolve(false));
208
+ req.on('timeout', () => { req.destroy(); resolve(false); });
209
+ req.write('{}');
210
+ req.end();
211
+ });
105
212
  }
106
213
 
107
214
  export async function boardStatus(port = 4000) {
108
215
  const pid = readPid();
109
216
  const running = pid !== null && isProcessAlive(pid);
217
+
110
218
  return {
111
219
  ok: true,
112
220
  running,
@@ -115,27 +223,46 @@ export async function boardStatus(port = 4000) {
115
223
  };
116
224
  }
117
225
 
226
+ // Strip ANSI escapes so we can measure the actual on-screen width of a
227
+ // styled string. Used to size the rules around the banner content.
228
+ const ANSI_RE = /\[[0-9;]*m/g;
229
+ const visibleWidth = (s) => s.replace(ANSI_RE, "").length;
230
+
118
231
  /**
119
- * Render a highlighted cyan box with the HU Board URL + optional project name.
120
- * Centralizes the banner used by both the init-time auto-start (flow-runner.js)
121
- * and the post-planner auto-HU generation safety net so they stay in sync.
232
+ * Render a HU Board announcement framed by two horizontal rules of EQUAL
233
+ * length. Pre-v2.7.5 we used a fixed 50-char rounded box drawn with
234
+ * corners + verticals — fine when the URL fit, but a project slug
235
+ * pushed past the right wall and the box looked broken. The user's
236
+ * verdict: "looks worse than no box at all when it overflows".
237
+ *
238
+ * New rule: just an over-and-under rule sized to the longest visible
239
+ * content line. No left/right verticals, no fixed width. Always tidy.
122
240
  *
123
241
  * @param {Object} params
124
242
  * @param {string} params.url - "http://localhost:4000"
125
243
  * @param {string} params.status - "started" | "already running"
126
244
  * @param {string} [params.projectName] - optional project label
127
- * @returns {string} ANSI-escaped box ready for console.log
245
+ * @returns {string} ANSI-coloured banner ready for console.log
128
246
  */
129
247
  export function renderBoardBanner({ url, status, projectName }) {
130
- const title = projectName ? ` Project: \u001b[1m${projectName}\u001b[0m` : null;
131
- const lines = [
132
- "",
133
- "\u001b[36m\u256d\u2500 HU Board \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e",
134
- `\u001b[36m\u2502\u001b[0m \u001b[1m${status}\u001b[0m \u001b[4m${url}\u001b[0m`,
135
- ];
136
- if (title) lines.push(`\u001b[36m\u2502\u001b[0m${title}`);
137
- lines.push("\u001b[36m\u2570\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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m", "");
138
- return lines.join("\n");
248
+ const cyan = (s) => `${s}`;
249
+ const bold = (s) => `${s}`;
250
+ const underline = (s) => `${s}`;
251
+
252
+ const heading = ` ${bold("HU Board")} · ${status} → ${underline(url)}`;
253
+ const content = [heading];
254
+ if (projectName) content.push(` Project: ${bold(projectName)}`);
255
+
256
+ // Width = widest visible content line + 2-cell padding. Cap at the
257
+ // terminal width so a silly-long URL doesn't draw a 200-char rule.
258
+ const termWidth = (process.stdout.columns && process.stdout.columns > 20)
259
+ ? process.stdout.columns
260
+ : 100;
261
+ const longest = Math.max(...content.map(visibleWidth));
262
+ const ruleWidth = Math.min(longest + 2, termWidth);
263
+ const rule = cyan("─".repeat(ruleWidth));
264
+
265
+ return ["", rule, ...content, rule, ""].join("\n");
139
266
  }
140
267
 
141
268
  export async function boardCommand({ action = "start", port = 4000, logger }) {
@@ -150,9 +277,11 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
150
277
  return result;
151
278
  }
152
279
  case "stop": {
153
- const result = await stopBoard();
280
+ const result = await stopBoard({ port });
154
281
  if (result.wasRunning) {
155
- logger.info(`HU Board stopped (PID ${result.pid})`);
282
+ const pidPart = result.pid ? ` (PID ${result.pid})` : "";
283
+ const viaPart = result.via && result.via !== "pidfile" ? ` [via ${result.via}]` : "";
284
+ logger.info(`HU Board stopped${pidPart}${viaPart}`);
156
285
  } else {
157
286
  logger.info("HU Board was not running");
158
287
  }
@@ -174,6 +303,7 @@ export async function boardCommand({ action = "start", port = 4000, logger }) {
174
303
  await startBoard(port);
175
304
  }
176
305
  const url = `http://localhost:${port}`;
306
+ // eslint-disable-next-line import-x/no-unresolved -- optional peer; the .catch handles its absence
177
307
  const { default: open } = await import("open").catch(() => ({ default: null }));
178
308
  if (open) {
179
309
  await open(url);
@@ -0,0 +1,83 @@
1
+ /**
2
+ * `kj clean` — manual garbage collector.
3
+ *
4
+ * Shows or deletes accumulated state under `~/.kj/plans/` and
5
+ * `~/.karajan/` (sessions, HU story batches). Dry-run by default so
6
+ * the user can eyeball the plan before committing to it.
7
+ *
8
+ * Usage:
9
+ * kj clean # dry-run, shows what would be removed
10
+ * kj clean --yes # actually delete
11
+ * kj clean --plan-days 60 # keep finalised plans for 60 days instead of 30
12
+ * kj clean --draft-days 90 # keep drafts for 90 days instead of 60
13
+ * kj clean --session-days 14 # keep finalised sessions for 14 days instead of 7
14
+ * kj clean --hu-days 30 # keep HU batches for 30 days instead of 14
15
+ *
16
+ * The auto-GC that runs at the start of every `kj run` / `kj plan`
17
+ * uses the same retention defaults; tweaking them here lets the user
18
+ * sweep more or less aggressively without editing config.
19
+ */
20
+
21
+ import { runManualGC, summarizeGC, nukeBoardDb } from "../utils/garbage-collector.js";
22
+
23
+ /**
24
+ * @param {Object} opts
25
+ * @param {boolean} [opts.yes=false] - when false, dry-run only
26
+ * @param {boolean} [opts.nuke=false] - retention=0 for every bucket + wipe board DB
27
+ * @param {number} [opts.planDays=30]
28
+ * @param {number} [opts.draftDays=60]
29
+ * @param {number} [opts.sessionDays=7]
30
+ * @param {number} [opts.huDays=14]
31
+ * @param {Object} [opts.logger]
32
+ */
33
+ export async function cleanCommand(opts = {}) {
34
+ const dryRun = !opts.yes;
35
+ // --nuke forces retention=0 everywhere so drafts / sessions / plans of
36
+ // any age are swept. Combined with wipeBoardDb this is the "I want it
37
+ // all gone right now" button.
38
+ const retention = opts.nuke
39
+ ? { planRetentionDays: 0, draftRetentionDays: 0, sessionRetentionDays: 0, huRetentionDays: 0 }
40
+ : {
41
+ planRetentionDays: opts.planDays,
42
+ draftRetentionDays: opts.draftDays,
43
+ sessionRetentionDays: opts.sessionDays,
44
+ huRetentionDays: opts.huDays,
45
+ };
46
+ const result = await runManualGC({ ...retention, dryRun });
47
+
48
+ if (opts.nuke) {
49
+ const boardResult = await nukeBoardDb({ dryRun });
50
+ result.removed.push(...boardResult.removed);
51
+ result.bytesFreed += boardResult.bytesFreed;
52
+ result.errors.push(...boardResult.errors);
53
+ }
54
+
55
+ if (!result.removed.length) {
56
+ console.log("[clean] nothing to clean — ~/.kj/ and ~/.karajan/ are tidy.");
57
+ return { ok: true, removed: 0 };
58
+ }
59
+
60
+ const verb = dryRun ? "would remove" : "removed";
61
+ console.log(`[clean] ${verb} ${result.removed.length} item(s):`);
62
+ for (const r of result.removed) {
63
+ console.log(` - ${r.kind.padEnd(10)} ${r.path}`);
64
+ console.log(` ${r.reason}`);
65
+ }
66
+
67
+ const kb = Math.round(result.bytesFreed / 1024);
68
+ if (kb) console.log(`[clean] ${verb} ${kb} KiB total`);
69
+
70
+ if (result.errors.length) {
71
+ console.log(`[clean] ${result.errors.length} error(s) (non-blocking):`);
72
+ for (const e of result.errors) console.log(` - ${e.path}: ${e.error}`);
73
+ }
74
+
75
+ if (dryRun) {
76
+ console.log("");
77
+ console.log("[clean] DRY-RUN — no files touched. Re-run with --yes to actually delete.");
78
+ } else {
79
+ console.log(summarizeGC(result));
80
+ }
81
+
82
+ return { ok: true, removed: result.removed.length, bytesFreed: result.bytesFreed };
83
+ }
@@ -4,6 +4,7 @@ import { assertAgentsAvailable } from "../agents/availability.js";
4
4
  import { buildCoderPrompt } from "../prompts/coder.js";
5
5
  import { resolveRole } from "../config.js";
6
6
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
7
8
 
8
9
  export async function codeCommand({ task, config, logger }) {
9
10
  return withCliRunLog("code", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
@@ -21,8 +22,12 @@ export async function codeCommand({ task, config, logger }) {
21
22
  }
22
23
  }
23
24
  const prompt = await buildCoderPrompt({ task, coderRules, methodology: config.development?.methodology || "tdd" });
24
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
25
- const result = await coder.runTask({ prompt, onOutput, role: "coder" });
25
+ const progress = createCliProgressReporter({ role: "coder" });
26
+ let result;
27
+ try {
28
+ result = await coder.runTask({ prompt, onOutput: progress.onOutput, role: "coder" });
29
+ progress.finish(result.ok ? "done" : "failed");
30
+ } catch (err) { progress.finish("failed"); throw err; }
26
31
  if (!result.ok) {
27
32
  if (result.error) logger.error(result.error);
28
33
  throw new Error(result.error || result.output || `Coder failed (exit ${result.exitCode})`);
@@ -1,6 +1,7 @@
1
1
  import { createAgent } from "../agents/index.js";
2
2
  import { assertAgentsAvailable } from "../agents/availability.js";
3
3
  import { resolveRole } from "../config.js";
4
+ import { createCliProgressReporter } from "../utils/cli-progress.js";
4
5
  import { buildDiscoverPrompt, parseDiscoverOutput } from "../prompts/discover.js";
5
6
 
6
7
  function formatGaps(gaps, lines) {
@@ -71,8 +72,12 @@ export async function discoverCommand({ task, config, logger, mode, json }) {
71
72
 
72
73
  const agent = createAgent(discoverRole.provider, config, logger);
73
74
  const prompt = buildDiscoverPrompt({ task, mode: mode || "gaps" });
74
- const onOutput = ({ line }) => process.stdout.write(`${line}\n`);
75
- const result = await agent.runTask({ prompt, onOutput, role: "discover" });
75
+ const progress = createCliProgressReporter({ role: "discover" });
76
+ let result;
77
+ try {
78
+ result = await agent.runTask({ prompt, onOutput: progress.onOutput, role: "discover" });
79
+ progress.finish(result.ok ? "done" : "failed");
80
+ } catch (err) { progress.finish("failed"); throw err; }
76
81
 
77
82
  if (!result.ok) {
78
83
  throw new Error(result.error || result.output || "Discover failed");
@@ -408,10 +408,10 @@ export async function initCommand({ logger, flags = {} }) {
408
408
  // Telemetry: anonymous install event (non-blocking)
409
409
  const { sendTelemetryEvent } = await import("../utils/telemetry.js");
410
410
  const { readFileSync } = await import("node:fs");
411
- const { resolve, dirname } = await import("node:path");
412
411
  const { fileURLToPath } = await import("node:url");
413
412
  try {
414
- const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../package.json");
413
+ // TSK-0340: use the already-static `path` default import for resolve/dirname.
414
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../package.json");
415
415
  const version = JSON.parse(readFileSync(pkgPath, "utf8")).version;
416
416
  sendTelemetryEvent("install", { version }, config).catch(() => {});
417
417
  } catch { /* non-blocking */ }
@@ -0,0 +1,40 @@
1
+ // ---- Formatting helpers shared across plan sub-commands ----
2
+
3
+ export function formatPlan(plan) {
4
+ const lines = [];
5
+ if (plan.approach) lines.push("## Approach", plan.approach, "");
6
+ if (plan.steps?.length) {
7
+ lines.push("## Steps");
8
+ for (let i = 0; i < plan.steps.length; i++) {
9
+ const step = plan.steps[i];
10
+ const commit = step.commit ? ` → \`${step.commit}\`` : "";
11
+ lines.push(`${i + 1}. ${step.description}${commit}`);
12
+ }
13
+ lines.push("");
14
+ }
15
+ if (plan.risks?.length) {
16
+ lines.push("## Risks");
17
+ for (const risk of plan.risks) lines.push(`- ${risk}`);
18
+ lines.push("");
19
+ }
20
+ if (plan.outOfScope?.length) {
21
+ lines.push("## Out of scope");
22
+ for (const item of plan.outOfScope) lines.push(`- ${item}`);
23
+ lines.push("");
24
+ }
25
+ return lines.join("\n");
26
+ }
27
+
28
+ export function formatHuTable(hus) {
29
+ if (!hus?.length) return " (no HUs)";
30
+ const lines = [];
31
+ const maxTitle = Math.min(50, Math.max(...hus.map(h => h.title?.length || 0)));
32
+ lines.push(` ${"ID".padEnd(30)} ${"Title".padEnd(maxTitle)} ${"Type".padEnd(10)} ${"Status".padEnd(10)} Deps`);
33
+ lines.push(` ${"─".repeat(30)} ${"─".repeat(maxTitle)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(15)}`);
34
+ for (const hu of hus) {
35
+ const title = (hu.title || "").slice(0, maxTitle).padEnd(maxTitle);
36
+ const deps = (hu.blocked_by || []).join(", ") || "—";
37
+ lines.push(` ${hu.id.padEnd(30)} ${title} ${(hu.task_type || "sw").padEnd(10)} ${(hu.status || "?").padEnd(10)} ${deps}`);
38
+ }
39
+ return lines.join("\n");
40
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * kj plan add-hu <planId> --title "..." --type sw --deps hu_xxx
3
+ */
4
+ export async function planAddHuCommand({ config, planId, title, type, deps, scope }) {
5
+ const { loadPlan, savePlan } = await import("../../plan/plan-store.js");
6
+ const { addHu } = await import("../../plan/plan-hu-ops.js");
7
+ const projectDir = config.projectDir || process.cwd();
8
+
9
+ const plan = await loadPlan(projectDir, planId);
10
+ if (!plan) { console.error(`Plan not found: ${planId}`); process.exitCode = 1; return; }
11
+
12
+ const hu = addHu(plan, {
13
+ title: title || "New HU",
14
+ task_type: type || "sw",
15
+ scope: scope || null,
16
+ blocked_by: deps ? deps.split(",").map(d => d.trim()) : []
17
+ });
18
+
19
+ await savePlan(projectDir, plan);
20
+ console.log(`Added: ${hu.id} — ${hu.title}`);
21
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * kj plan delete <planId>
3
+ */
4
+ export async function planDeleteCommand({ config, planId }) {
5
+ const { deletePlan } = await import("../../plan/plan-store.js");
6
+ const projectDir = config.projectDir || process.cwd();
7
+ const ok = await deletePlan(projectDir, planId);
8
+ console.log(ok ? `Deleted: ${planId}` : `Not found: ${planId}`);
9
+ }