karajan-code 3.1.0 → 3.2.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 (59) hide show
  1. package/README.md +12 -4
  2. package/package.json +6 -1
  3. package/packages/hu-board/package.json +3 -1
  4. package/packages/hu-board/public/index.html +1 -0
  5. package/packages/hu-board/public/styles.css +32 -0
  6. package/packages/hu-board/public/utils/board-view.js +10 -1
  7. package/packages/hu-board/public/utils/formatters.js +41 -0
  8. package/packages/hu-board/public/wiki.html +32 -0
  9. package/packages/hu-board/public/wiki.js +53 -0
  10. package/packages/hu-board/src/db.js +78 -0
  11. package/packages/hu-board/src/format.js +79 -0
  12. package/packages/hu-board/src/plan-mutations.js +4 -4
  13. package/packages/hu-board/src/routes/api.js +16 -1
  14. package/packages/hu-board/src/routes/rag.js +1 -1
  15. package/packages/hu-board/src/routes/wiki.js +43 -0
  16. package/packages/hu-board/src/run-tracker.js +1 -1
  17. package/packages/hu-board/src/server.js +4 -2
  18. package/packages/hu-board/src/sync.js +1 -1
  19. package/scripts/git-hooks/post-merge +13 -2
  20. package/src/audit/ai-slop-findings.js +102 -0
  21. package/src/audit/deterministic-summary.js +18 -0
  22. package/src/brain/standby-scheduler.js +10 -92
  23. package/src/brain/standby-store.js +12 -168
  24. package/src/checks/ai-trash.js +34 -0
  25. package/src/checks/qmd.js +41 -0
  26. package/src/checks/squeezr.js +36 -0
  27. package/src/cli/register-meta.js +21 -0
  28. package/src/cli/register-pipeline.js +3 -0
  29. package/src/commands/audit.js +6 -4
  30. package/src/commands/board.js +34 -0
  31. package/src/commands/doctor.js +6 -0
  32. package/src/commands/init.js +73 -8
  33. package/src/commands/qmd.js +86 -0
  34. package/src/commands/resume.js +6 -45
  35. package/src/commands/run.js +6 -50
  36. package/src/git/hu-snapshot.js +10 -105
  37. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
  38. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  39. package/src/orchestrator/hu-sub-pipeline.js +52 -8
  40. package/src/plan/plan-hu-ops.js +16 -338
  41. package/src/plan/plan-id.js +4 -45
  42. package/src/plan/plan-validation.js +4 -82
  43. package/src/rag/vec-store.js +17 -253
  44. package/src/roles/audit-role.js +11 -1
  45. package/src/utils/ai-trash.js +48 -0
  46. package/src/utils/atomic-write.js +6 -43
  47. package/src/utils/cli-ask-question.js +76 -0
  48. package/src/utils/doc-links.js +2 -0
  49. package/src/utils/os-detect.js +10 -0
  50. package/src/utils/paths.js +14 -123
  51. package/src/utils/port-check.js +6 -45
  52. package/src/utils/process.js +4 -188
  53. package/src/utils/qmd-collection.js +72 -0
  54. package/src/utils/qmd-detect.js +18 -0
  55. package/src/utils/qmd-install.js +37 -0
  56. package/src/utils/run-registry.js +9 -116
  57. package/src/utils/shared-paths.js +12 -41
  58. package/src/utils/squeezr-detect.js +18 -0
  59. package/src/utils/squeezr-install.js +37 -0
@@ -216,7 +216,7 @@ function loadAuditHistoryContext(projectDir, currentHarness, { showTrend = false
216
216
  finally { try { db?.close(); } catch { /* noop */ } }
217
217
  }
218
218
 
219
- function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }) {
219
+ function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, noAiSlop, json, deterministicOnly, yes }) {
220
220
  const parts = ["kj audit"];
221
221
  if (task && task !== "Analyze the full codebase") parts.push(JSON.stringify(task));
222
222
  if (dimensions && dimensions !== "all") parts.push(`--dimensions=${dimensions}`);
@@ -224,13 +224,14 @@ function describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHar
224
224
  if (noOsv) parts.push("--no-osv");
225
225
  if (noSemgrep) parts.push("--no-semgrep");
226
226
  if (noHarness) parts.push("--no-harness");
227
+ if (noAiSlop) parts.push("--no-ai-slop");
227
228
  if (deterministicOnly) parts.push("--deterministic-only");
228
229
  if (yes) parts.push("--yes");
229
230
  if (json) parts.push("--json");
230
231
  return parts.join(" ");
231
232
  }
232
233
 
233
- export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, noHarness = false, reportFile = null, deterministicOnly = false, yes = false, trend = false, promptFn = null }) {
234
+ export async function auditCommand({ task, config, logger, dimensions, json, agentReadiness, path: pathArg, noSonar = false, noOsv = false, noSemgrep = false, noHarness = false, noAiSlop = false, reportFile = null, deterministicOnly = false, yes = false, trend = false, promptFn = null }) {
234
235
  // --agent-readiness is a STANDALONE, deterministic, LLM-free audit
235
236
  // dimension. It scores any third-party repo for AI-agent readability
236
237
  // (llms.txt presence, page token budgets, robots allowlist, etc.).
@@ -269,6 +270,7 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
269
270
  noSonar,
270
271
  noOsv,
271
272
  noSemgrep,
273
+ noAiSlop,
272
274
  };
273
275
  const deterministicCtx = await role.collectDeterministic(roleInput);
274
276
  const deterministicMd = formatDeterministicSummary(deterministicCtx);
@@ -324,7 +326,7 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
324
326
  if (reportPath.endsWith(".json")) {
325
327
  payload = JSON.stringify({ deterministic: deterministicCtx, harnessScore: harnessJson, harnessScoreDiff: history.diff, mode: "deterministic-only" }, null, 2);
326
328
  } else {
327
- const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }));
329
+ const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, noAiSlop, json, deterministicOnly, yes }));
328
330
  payload = `${header}${deterministicMd}\n${harnessMd}${historyMd}`;
329
331
  }
330
332
  await fs.writeFile(reportPath, payload, "utf8");
@@ -385,7 +387,7 @@ export async function auditCommand({ task, config, logger, dimensions, json, age
385
387
  : { ...roleResult, harnessScore: harnessJson, harnessScoreDiff: history.diff, usage: usage || undefined };
386
388
  payload = JSON.stringify(jsonPayload, null, 2);
387
389
  } else {
388
- const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, json, deterministicOnly, yes }));
390
+ const header = await buildReportHeader(config?.projectDir, describeInvocation({ task, dimensions, noSonar, noOsv, noSemgrep, noHarness, noAiSlop, json, deterministicOnly, yes }));
389
391
  payload = header + stdoutContent + "\n";
390
392
  }
391
393
  await fs.writeFile(reportPath, payload, "utf8");
@@ -122,6 +122,29 @@ export function waitForEarlyExit(child, logPath) {
122
122
  });
123
123
  }
124
124
 
125
+ // KJC-BUG-0080: waitForEarlyExit only catches an exit, not a hung
126
+ // daemon that survived the window without ever bind()ing the port.
127
+ // Poll /api/dashboard until it answers or the readiness budget runs
128
+ // out, so we never announce "HU Board started" for an unreachable
129
+ // server. Overridable with KJ_BOARD_READY_TIMEOUT_MS (tests set it
130
+ // low to stay fast).
131
+ export async function waitForBoardReachable(port, totalTimeoutMs) {
132
+ const envTimeout = Number(process.env.KJ_BOARD_READY_TIMEOUT_MS);
133
+ const budget = Number.isFinite(envTimeout) && envTimeout >= 0
134
+ ? envTimeout
135
+ : (Number.isFinite(totalTimeoutMs) ? totalTimeoutMs : 5000);
136
+ const intervalMs = budget <= 200 ? Math.max(10, Math.floor(budget / 5)) : 200;
137
+ const deadline = Date.now() + budget;
138
+ while (Date.now() < deadline) {
139
+ const remaining = deadline - Date.now();
140
+ const probeTimeout = Math.min(750, Math.max(50, remaining));
141
+ if (await isBoardReachable(port, probeTimeout)) return true;
142
+ const wait = Math.min(intervalMs, deadline - Date.now());
143
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
144
+ }
145
+ return false;
146
+ }
147
+
125
148
  export async function startBoard(desiredPort = 4000, opts = {}) {
126
149
  const { projectSlug = null, bind = "127.0.0.1" } = opts;
127
150
  const existingPid = readPid();
@@ -199,6 +222,17 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
199
222
  if (failure) {
200
223
  throw new Error(failure);
201
224
  }
225
+ // KJC-BUG-0080: even after a clean window, the server may have
226
+ // failed to bind (e.g. port race vs the parent's findAvailablePort)
227
+ // or be still booting. Probe /api/dashboard before announcing
228
+ // success — without this the CLI used to print "HU Board started
229
+ // at http://localhost:4000" while :4000 actually returned nothing.
230
+ const reachable = await waitForBoardReachable(port);
231
+ if (!reachable) {
232
+ throw new Error(
233
+ `HU Board daemon spawned (PID ${child.pid}) but did not answer on http://localhost:${port} in time. See ${logPath} for the cause.`
234
+ );
235
+ }
202
236
  fs.writeFileSync(PID_FILE, String(child.pid));
203
237
  // Detach for real: `detached: true` only sets up the new session;
204
238
  // without `unref()` the parent's event loop still holds a reference
@@ -22,6 +22,9 @@ import { getOllamaChecks } from "../checks/ollama.js";
22
22
  import { getHarnessScorecardChecks } from "../checks/harness-scorecard.js";
23
23
  import { getCiChecks } from "../checks/ci.js";
24
24
  import { getRtkChecks } from "../checks/rtk.js";
25
+ import { getSqueezrChecks } from "../checks/squeezr.js";
26
+ import { getAiTrashChecks } from "../checks/ai-trash.js";
27
+ import { getQmdChecks } from "../checks/qmd.js";
25
28
  import { getNodeChecks } from "../checks/node.js";
26
29
  import { getPortChecks } from "../checks/ports.js";
27
30
  import { getTokenChecks } from "../checks/tokens.js";
@@ -64,6 +67,9 @@ function buildChecks(config, { projectOnly = false } = {}) {
64
67
  ...getSkillsChecks(),
65
68
  ...getCiChecks(),
66
69
  ...getRtkChecks(),
70
+ ...getSqueezrChecks(),
71
+ ...getAiTrashChecks(),
72
+ ...getQmdChecks(),
67
73
  ...getProjectChecks({ projectDir }),
68
74
  ];
69
75
  }
@@ -15,6 +15,12 @@ import { detectOsLocale, SUPPORTED_LANGUAGES } from "../utils/locale.js";
15
15
  import { buildTelemetryPayload, sendTelemetryEvent } from "../utils/telemetry.js";
16
16
  import { detectRtk } from "../utils/rtk-detect.js";
17
17
  import { installRtk } from "../utils/rtk-install.js";
18
+ import { detectSqueezr } from "../utils/squeezr-detect.js";
19
+ import { installSqueezr } from "../utils/squeezr-install.js";
20
+ import { detectAiTrash, installAiTrashHook } from "../utils/ai-trash.js";
21
+ import { detectQmd } from "../utils/qmd-detect.js";
22
+ import { installQmd } from "../utils/qmd-install.js";
23
+ import { registerQmdCollections } from "../utils/qmd-collection.js";
18
24
  import { detectProjectStack } from "../utils/stack-detect.js";
19
25
  import { bootstrapSonarToken } from "../sonar/token-bootstrap.js";
20
26
 
@@ -754,15 +760,74 @@ export async function initCommand({ logger, flags = {} }) {
754
760
  logger.info("No project stack detected — using default configuration.");
755
761
  }
756
762
 
757
- // Check RTK availability — auto-install if missing
758
- const rtk = await detectRtk();
759
- if (rtk.available) {
760
- logger.info(`RTK ${rtk.version || ""} detected.`);
763
+ // Check RTK availability — auto-install if missing (opt-out: --no-rtk)
764
+ const skipRtk = flags?.noRtk === true || flags?.rtk === false;
765
+ if (skipRtk) {
766
+ logger.info("RTK setup skipped (--no-rtk). Bash outputs will not be token-optimized.");
761
767
  } else {
762
- const installResult = await installRtk(logger);
763
- if (!installResult.ok) {
764
- logger.warn("RTK is optional but recommended for 60-90% token savings.");
765
- logger.warn(` Manual install: ${getInstallCommand("rtk")}`);
768
+ const rtk = await detectRtk();
769
+ if (rtk.available) {
770
+ logger.info(`RTK ${rtk.version || ""} detected.`);
771
+ } else {
772
+ const installResult = await installRtk(logger);
773
+ if (!installResult.ok) {
774
+ logger.warn("RTK install failed — Bash outputs will not be token-optimized.");
775
+ logger.warn(` Manual install: ${getInstallCommand("rtk")}`);
776
+ }
777
+ }
778
+ }
779
+
780
+ // Check Squeezr availability — auto-install if missing (opt-out: --no-squeezr)
781
+ const skipSqueezr = flags?.noSqueezr === true || flags?.squeezr === false;
782
+ if (skipSqueezr) {
783
+ logger.info("Squeezr setup skipped (--no-squeezr). Tool outputs will not be compressed.");
784
+ } else {
785
+ const squeezr = await detectSqueezr();
786
+ if (squeezr.available) {
787
+ logger.info(`Squeezr ${squeezr.version || ""} detected.`);
788
+ } else {
789
+ const installResult = await installSqueezr(logger);
790
+ if (!installResult.ok) {
791
+ logger.warn("Squeezr install failed — tool outputs will not be compressed.");
792
+ logger.warn(` Manual install: ${getInstallCommand("squeezr")}`);
793
+ }
794
+ }
795
+ }
796
+
797
+ // Check ai-trash availability — register PreToolUse hook if binary present
798
+ // (opt-out: --no-ai-trash). The binary itself ships with karajan-code.
799
+ const skipAiTrash = flags?.noAiTrash === true || flags?.aiTrash === false;
800
+ if (skipAiTrash) {
801
+ logger.info("ai-trash setup skipped (--no-ai-trash). Destructive ops will run unprotected.");
802
+ } else {
803
+ const aiTrash = await detectAiTrash();
804
+ if (aiTrash.available) {
805
+ await installAiTrashHook(logger);
806
+ } else {
807
+ logger.warn("ai-trash kj-trash binary not found — destructive ops unprotected.");
808
+ logger.warn(" Reinstall karajan-code so kj-trash is on PATH.");
809
+ }
810
+ }
811
+
812
+ // Check QMD availability — auto-install if missing and register project
813
+ // collections (docs/, .reviews/, .karajan/plans/). Opt-out: --no-qmd.
814
+ const skipQmd = flags?.noQmd === true || flags?.qmd === false;
815
+ if (skipQmd) {
816
+ logger.info("QMD setup skipped (--no-qmd). Semantic wiki indexing disabled.");
817
+ } else {
818
+ const qmd = await detectQmd();
819
+ if (qmd.available) {
820
+ logger.info(`QMD ${qmd.version || ""} detected.`);
821
+ } else {
822
+ const installResult = await installQmd(logger);
823
+ if (!installResult.ok) {
824
+ logger.warn("QMD install failed — semantic wiki unavailable.");
825
+ logger.warn(` Manual install: ${getInstallCommand("qmd")}`);
826
+ }
827
+ }
828
+ const recheck = await detectQmd();
829
+ if (recheck.available) {
830
+ await registerQmdCollections(process.cwd(), logger);
766
831
  }
767
832
  }
768
833
 
@@ -0,0 +1,86 @@
1
+ // KJC-TSK-0509 — `kj qmd query` CLI wrapper. Auto-resolves the project's QMD
2
+ // collection (`<projectSlug>-<suffix>`) so users do not have to remember it.
3
+ // Mirrors `kj rag query`'s friendly-error shape on missing binary/collection.
4
+ // Distinction: rag = agent context (code+plans); qmd = human wiki (docs/*.md).
5
+ import { runCommand } from "../utils/process.js";
6
+ import { projectSlug } from "../utils/qmd-collection.js";
7
+
8
+ const QMD_NOT_INSTALLED = /command not found|ENOENT|not recognized as/i;
9
+ const COLLECTION_MISSING = /no such collection|collection .* not found|unknown collection/i;
10
+ const INSTALL_HINT = "qmd not installed. Install it with: npm install -g @tobilu/qmd";
11
+ const INIT_HINT = "Collection not registered for this project. Run `kj init` to register the wiki collection.";
12
+
13
+ function suffixFor(scope) {
14
+ if (!scope || scope === "docs") return "docs";
15
+ if (scope === "reviews" || scope === ".reviews") return "reviews";
16
+ if (scope === "plans" || scope === ".karajan/plans" || scope === "karajan-plans") return "karajan-plans";
17
+ return scope;
18
+ }
19
+
20
+ function resolveCollection({ flags, projectDir }) {
21
+ if (flags.collection) return String(flags.collection).trim();
22
+ return `${projectSlug(projectDir)}-${suffixFor(flags.scope)}`;
23
+ }
24
+
25
+ function notInstalled(logger) {
26
+ logger.error?.(`[qmd] ${INSTALL_HINT}`);
27
+ process.exitCode = 127;
28
+ return { ok: false, error: "qmd not installed", installable: true };
29
+ }
30
+
31
+ export async function qmdQueryCommand({ text, config, logger, flags = {} }) {
32
+ if (!text || !String(text).trim()) throw new Error("kj qmd query: text argument required");
33
+ const projectDir = config?.projectDir || process.cwd();
34
+ const collection = resolveCollection({ flags, projectDir });
35
+ const limit = Math.min(Math.max(Number(flags.topK) || 10, 1), 50);
36
+ const args = ["query", String(text), "--format", "json", "-c", collection, "-n", String(limit)];
37
+ if (flags.fast) args.push("--no-rerank");
38
+
39
+ let out;
40
+ try {
41
+ out = await runCommand("qmd", args, { timeout: 120_000 });
42
+ } catch (err) {
43
+ if (err?.code === "ENOENT") return notInstalled(logger);
44
+ throw err;
45
+ }
46
+
47
+ const stderr = out.stderr || "";
48
+ if (out.exitCode === 127 || QMD_NOT_INSTALLED.test(stderr)) return notInstalled(logger);
49
+ if (COLLECTION_MISSING.test(stderr)) {
50
+ logger.error?.(`[qmd] ${INIT_HINT}`);
51
+ process.exitCode = 1;
52
+ return { ok: false, error: "collection not registered", collection };
53
+ }
54
+ if (out.exitCode !== 0) {
55
+ const msg = (stderr || out.stdout || `qmd exit ${out.exitCode}`).trim();
56
+ logger.error?.(`[qmd] ${msg}`);
57
+ process.exitCode = out.exitCode || 1;
58
+ return { ok: false, error: msg };
59
+ }
60
+
61
+ let results;
62
+ try { results = JSON.parse(out.stdout || "[]"); }
63
+ catch {
64
+ logger.error?.("[qmd] qmd output is not valid JSON");
65
+ process.exitCode = 1;
66
+ return { ok: false, error: "qmd output is not valid JSON" };
67
+ }
68
+
69
+ if (flags.json) {
70
+ process.stdout.write(`${JSON.stringify(results)}\n`);
71
+ return { ok: true, results };
72
+ }
73
+ if (!Array.isArray(results) || results.length === 0) {
74
+ logger.info(`[qmd] no hits for "${text}" in collection ${collection}`);
75
+ return { ok: true, results: [] };
76
+ }
77
+ logger.info(`[qmd] ${results.length} hit(s) in ${collection}:`);
78
+ for (const h of results) {
79
+ const src = h.source || h.path || h.file || "?";
80
+ const score = typeof h.score === "number" ? ` · score=${h.score.toFixed(4)}` : "";
81
+ const snippet = h.snippet || h.text || h.body || "";
82
+ logger.info(` - ${src}${score}`);
83
+ if (snippet) logger.info(` ${snippet.length > 200 ? `${snippet.slice(0, 200)}…` : snippet}`);
84
+ }
85
+ return { ok: true, results };
86
+ }
@@ -1,53 +1,10 @@
1
1
  import { EventEmitter } from "node:events";
2
- import readline from "node:readline";
3
2
  import { resumeFlow } from "../orchestrator.js";
4
3
  import { createActivityLog } from "../activity-log.js";
5
4
  import { printEvent } from "../utils/display/event-handlers.js";
6
5
  import { withCliRunLog } from "../utils/cli-run-log.js";
7
6
  import { registerRun, unregisterRun } from "../utils/run-registry.js";
8
-
9
- function createCliAskQuestion(opts = {}) {
10
- const { sessionId = null } = opts;
11
- return async (question, context) => {
12
- // Mirror the run.js logic: route to the board via the file-based
13
- // bridge when there's no TTY; otherwise prompt the local terminal.
14
- const stdinReadable = process.stdin && process.stdin.readable !== false;
15
- const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
16
- if (!isInteractive) {
17
- const { askThroughBoard } = await import("../utils/board-prompt-bridge.js");
18
- console.log(`\n\u2753 ${question}`);
19
- if (context?.detail) {
20
- console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
21
- }
22
- console.log(
23
- "\n[non-interactive] Routing the prompt to the HU Board.\n"
24
- + " Open http://localhost:4000 \u2014 a modal will appear asking for your answer."
25
- );
26
- try {
27
- return await askThroughBoard({ sessionId, question, context });
28
- } catch (err) {
29
- console.log(`\n[prompt-bridge] ${err.message} \u2014 stopping the session.`);
30
- return null;
31
- }
32
- }
33
-
34
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
35
- return new Promise((resolve) => {
36
- console.log(`\n\u2753 ${question}`);
37
- if (context?.detail) {
38
- console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
39
- }
40
- rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
41
- rl.close();
42
- if (answer.trim().toLowerCase() === "stop") {
43
- resolve(null);
44
- } else {
45
- resolve(answer.trim());
46
- }
47
- });
48
- });
49
- };
50
- }
7
+ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
51
8
 
52
9
  export async function resumeCommand({ sessionId, answer, config, logger, flags }) {
53
10
  const jsonMode = flags?.json;
@@ -88,7 +45,11 @@ export async function resumeCommand({ sessionId, answer, config, logger, flags }
88
45
  }
89
46
  });
90
47
 
91
- const askQuestion = createCliAskQuestion();
48
+ // KJC-BUG-0081 round 2 — propagate `flags` so the helper honours
49
+ // `--yes` here too. Before this fix, `kj resume <id> --yes` in
50
+ // non-TTY mode still routed every prompt to the HU Board bridge
51
+ // and hung, exactly the bug the original PR #995 fixed for `kj run`.
52
+ const askQuestion = createCliAskQuestion({ sessionId, flags });
92
53
  const result = await resumeFlow({
93
54
  sessionId,
94
55
  answer: answer || null,
@@ -1,5 +1,4 @@
1
1
  import { EventEmitter } from "node:events";
2
- import readline from "node:readline";
3
2
  import { runFlow } from "../orchestrator.js";
4
3
  import { assertAgentsAvailable } from "../agents/availability.js";
5
4
  import { createActivityLog } from "../activity-log.js";
@@ -14,54 +13,11 @@ import { confirmCwd } from "../utils/cwd-confirm.js";
14
13
  import { runSpecReview } from "../spec-review/run-spec-review.js";
15
14
  import { maybeAutoUpdate } from "../rag/auto-update.js";
16
15
 
17
- function createCliAskQuestion(opts = {}) {
18
- const { sessionId = null } = opts;
19
- return async (question, context) => {
20
- // Two paths:
21
- // - Interactive TTY: prompt via readline (the developer's terminal).
22
- // - No TTY (board's \u25b6 Run plan with stdio=ignore, CI, etc.): publish
23
- // the prompt through the file-based bridge so the HU Board can
24
- // surface it as a modal. The runner blocks on the bridge until
25
- // the user answers (or times out / kills the run). Pre-v2.7.5
26
- // this path just bailed; now the question actually gets answered.
27
- const stdinReadable = process.stdin && process.stdin.readable !== false;
28
- const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
29
- if (!isInteractive) {
30
- const { askThroughBoard } = await import("../utils/board-prompt-bridge.js");
31
- console.log(`\n\u2753 ${question}`);
32
- if (context?.detail) {
33
- console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
34
- }
35
- console.log(
36
- "\n[non-interactive] Routing the prompt to the HU Board.\n"
37
- + " Open http://localhost:4000 \u2014 a modal will appear asking for your answer.\n"
38
- + " This process is now waiting; closing the board does NOT cancel the run."
39
- );
40
- try {
41
- return await askThroughBoard({ sessionId, question, context });
42
- } catch (err) {
43
- console.log(`\n[prompt-bridge] ${err.message} \u2014 stopping the session.`);
44
- return null;
45
- }
46
- }
47
-
48
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
49
- return new Promise((resolve) => {
50
- console.log(`\n\u2753 ${question}`);
51
- if (context?.detail) {
52
- console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
53
- }
54
- rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
55
- rl.close();
56
- if (answer.trim().toLowerCase() === "stop") {
57
- resolve(null);
58
- } else {
59
- resolve(answer.trim());
60
- }
61
- });
62
- });
63
- };
64
- }
16
+ // Re-export the shared helper so existing imports (tests, MCP wrappers)
17
+ // keep working. KJC-BUG-0081 round 2 split the body into utils/ because
18
+ // resume.js also needs the same contract.
19
+ import { createCliAskQuestion } from "../utils/cli-ask-question.js";
20
+ export { createCliAskQuestion };
65
21
 
66
22
  export async function runCommandHandler({ task, config, logger, flags }) {
67
23
  // PR-F (cwd confirmation): when launching from an interactive
@@ -171,7 +127,7 @@ export async function runCommandHandler({ task, config, logger, flags }) {
171
127
  printHeader({ task: task, config });
172
128
  }
173
129
 
174
- const askQuestion = createCliAskQuestion();
130
+ const askQuestion = createCliAskQuestion({ flags });
175
131
 
176
132
  // Spec-reviewer pre-pipeline audit (KJC-PCS-0048). Runs BEFORE
177
133
  // anything else — surfaces ambiguity / missing scope / missing AC
@@ -1,105 +1,10 @@
1
- // KJC-TSK-0408: snapshots de ficheros por HU para soportar Undo.
2
- //
3
- // Antes de ejecutar una HU, guardamos un ref git apuntando al HEAD
4
- // actual (`refs/kj-snapshots/<huId>`). Si el usuario hace Undo después,
5
- // hacemos `git reset --hard <ref>` para volver al estado pre-run.
6
- //
7
- // Por qué refs en lugar de stash/tarball:
8
- // - Refs son objetos git nativos. No mueven blobs (todos compartidos
9
- // con HEAD), así que crear el snapshot es O(1).
10
- // - El snapshot persiste a través de checkouts y rebases.
11
- // - Si el ref ya apunta al SHA correcto, restore es no-op.
12
- //
13
- // El runner es inyectable para que los tests no necesiten un repo git real.
14
-
15
- import { runCommand } from "../utils/process.js";
16
-
17
- const REF_PREFIX = "refs/kj-snapshots/";
18
-
19
- /**
20
- * Sanitiza un huId para uso como nombre de ref. Git rechaza:
21
- * - `..`, `:`, `~`, `^`, `?`, `*`, `[`, `\`, espacios, control chars
22
- * - segmentos que empiecen por `.` o terminen en `.lock`
23
- * Reemplazamos cualquier no [a-zA-Z0-9_-] por `_`.
24
- */
25
- export function snapshotRefForHu(huId) {
26
- if (!huId) throw new Error("huId requerido");
27
- const safe = String(huId).replaceAll(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80);
28
- return REF_PREFIX + safe;
29
- }
30
-
31
- async function defaultRunner(args, opts = {}) {
32
- return runCommand("git", args, opts);
33
- }
34
-
35
- /**
36
- * Crea (o actualiza) el ref del snapshot apuntando al HEAD actual.
37
- *
38
- * @param {object} args
39
- * @param {string} args.projectDir
40
- * @param {string} args.huId
41
- * @param {Function} [args.runner] - sustituye git CLI en tests
42
- * @returns {Promise<{ ok: boolean, ref?: string, sha?: string, error?: string }>}
43
- */
44
- export async function createHuSnapshot({ projectDir, huId, runner }) {
45
- const run = runner || defaultRunner;
46
- const ref = snapshotRefForHu(huId);
47
- const head = await run(["rev-parse", "HEAD"], { cwd: projectDir });
48
- if (head.exitCode !== 0) {
49
- return { ok: false, error: `rev-parse HEAD failed: ${(head.stderr || "").trim()}` };
50
- }
51
- const sha = (head.stdout || "").trim();
52
- if (!sha) return { ok: false, error: "HEAD vacío" };
53
- const update = await run(["update-ref", ref, sha], { cwd: projectDir });
54
- if (update.exitCode !== 0) {
55
- return { ok: false, error: `update-ref falló: ${(update.stderr || "").trim()}` };
56
- }
57
- return { ok: true, ref, sha };
58
- }
59
-
60
- /**
61
- * @param {object} args
62
- * @param {string} args.projectDir
63
- * @param {string} args.huId
64
- * @param {Function} [args.runner]
65
- * @returns {Promise<{ ok: boolean, sha?: string }>}
66
- */
67
- export async function hasHuSnapshot({ projectDir, huId, runner }) {
68
- const run = runner || defaultRunner;
69
- const ref = snapshotRefForHu(huId);
70
- const r = await run(["rev-parse", "--verify", "--quiet", ref], { cwd: projectDir });
71
- if (r.exitCode !== 0) return { ok: false };
72
- return { ok: true, sha: (r.stdout || "").trim() };
73
- }
74
-
75
- /**
76
- * Restaura el workspace al snapshot. DESTRUCTIVO: hace `git reset --hard`,
77
- * lo que descarta cambios no committeados. Caller debe avisar al usuario.
78
- *
79
- * @returns {Promise<{ ok: boolean, sha?: string, error?: string }>}
80
- */
81
- export async function restoreHuSnapshot({ projectDir, huId, runner }) {
82
- const run = runner || defaultRunner;
83
- const ref = snapshotRefForHu(huId);
84
- const has = await hasHuSnapshot({ projectDir, huId, runner: run });
85
- if (!has.ok) return { ok: false, error: `snapshot no encontrado para HU ${huId}` };
86
- const r = await run(["reset", "--hard", ref], { cwd: projectDir });
87
- if (r.exitCode !== 0) {
88
- return { ok: false, error: `reset --hard falló: ${(r.stderr || "").trim()}` };
89
- }
90
- return { ok: true, sha: has.sha };
91
- }
92
-
93
- /**
94
- * Borra el ref del snapshot. Idempotente: si no existe, devuelve ok:true.
95
- */
96
- export async function removeHuSnapshot({ projectDir, huId, runner }) {
97
- const run = runner || defaultRunner;
98
- const ref = snapshotRefForHu(huId);
99
- const r = await run(["update-ref", "-d", ref], { cwd: projectDir });
100
- // git update-ref -d devuelve 0 incluso si el ref no existía.
101
- if (r.exitCode !== 0) {
102
- return { ok: false, error: `update-ref -d falló: ${(r.stderr || "").trim()}` };
103
- }
104
- return { ok: true };
105
- }
1
+ // Shim de compatibilidad: hu-snapshot vive ahora en @karajan/core.
2
+ // Mantenemos esta ruta para que callers internos del CLI (orchestrator,
3
+ // runners) sigan funcionando sin tocar imports.
4
+ export {
5
+ snapshotRefForHu,
6
+ createHuSnapshot,
7
+ hasHuSnapshot,
8
+ restoreHuSnapshot,
9
+ removeHuSnapshot,
10
+ } from "@karajan/core/hu-snapshot";
@@ -302,10 +302,26 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
302
302
  // PR3: per-HU outcome. Stamped once per HU at runSingleHu's exit
303
303
  // so the board can show the 📄 indicator as soon as each HU
304
304
  // finishes (iterations / duration / commits / blockers / summary).
305
+ //
306
+ // Cost D (KJC-TSK-0515): the outcome may carry `cost_usd` (USD
307
+ // spent on this HU). When present and finite, also persist it to
308
+ // board.db so the per-card badge (Cost F) and the project total
309
+ // chip (Cost G) have data to show — without this write, the
310
+ // `cost_usd` column would always stay NULL and the UI would render
311
+ // nothing. Lazy-import to keep the pre-loop phase free of board
312
+ // deps until we actually need them.
305
313
  setLiveOutcomeUpdater(session, async (huId, outcome) => {
306
314
  try {
307
315
  if (!setHuOutcomeFn(loadedPlan, huId, outcome)) return;
308
316
  await savePlanToDisk(projectDir, loadedPlan);
317
+ if (outcome && Number.isFinite(outcome.cost_usd)) {
318
+ try {
319
+ const { setStoryCost } = await import("../../../../packages/hu-board/src/db.js");
320
+ setStoryCost(huId, outcome.cost_usd);
321
+ } catch (err) {
322
+ logger?.warn?.(`Cost write failed for ${huId}: ${err.message}`);
323
+ }
324
+ }
309
325
  } catch (err) {
310
326
  logger?.warn?.(`Live outcome update failed for ${huId}: ${err.message}`);
311
327
  }
@@ -360,7 +360,11 @@ export async function runHuBatch({ ctx, task, askQuestion, emitter, logger }) {
360
360
  onStatusChange: ctx.session?._liveStatusUpdater || null,
361
361
  // PR3 (per-HU outcome): same pattern, but for the rich
362
362
  // outcome blob written ONCE per HU at the end of runSingleHu.
363
- onOutcome: ctx.session?._liveOutcomeUpdater || null
363
+ onOutcome: ctx.session?._liveOutcomeUpdater || null,
364
+ // Cost D (KJC-TSK-0515): the sub-pipeline slices BudgetTracker
365
+ // entries per HU and stamps `outcome.cost_usd` so the board can
366
+ // show per-HU spend. Without this the tracker is invisible to it.
367
+ budgetTracker: ctx.budgetTracker || null
364
368
  });
365
369
 
366
370
  emitProgress(emitter, makeEvent("hu:sub-pipeline:end", { ...ctx.eventBase, stage: "hu-sub-pipeline" }, {