karajan-code 3.1.0 → 3.3.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 (68) 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 +21 -1
  7. package/packages/hu-board/public/utils/formatters.js +68 -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 +137 -0
  11. package/packages/hu-board/src/format.js +124 -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/agents/aider-agent.js +42 -1
  21. package/src/agents/claude-agent.js +14 -3
  22. package/src/agents/codex-agent.js +44 -7
  23. package/src/agents/gemini-agent.js +37 -1
  24. package/src/agents/opencode-agent.js +34 -1
  25. package/src/audit/ai-slop-findings.js +102 -0
  26. package/src/audit/deterministic-summary.js +18 -0
  27. package/src/brain/standby-scheduler.js +10 -92
  28. package/src/brain/standby-store.js +12 -168
  29. package/src/checks/ai-trash.js +34 -0
  30. package/src/checks/qmd.js +41 -0
  31. package/src/checks/squeezr.js +36 -0
  32. package/src/cli/register-meta.js +21 -0
  33. package/src/cli/register-pipeline.js +3 -0
  34. package/src/commands/audit.js +6 -4
  35. package/src/commands/board.js +34 -0
  36. package/src/commands/doctor.js +6 -0
  37. package/src/commands/init.js +73 -8
  38. package/src/commands/qmd.js +86 -0
  39. package/src/commands/resume.js +6 -45
  40. package/src/commands/run.js +6 -50
  41. package/src/git/hu-snapshot.js +10 -105
  42. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
  43. package/src/orchestrator/drivers/run-hu-batch.js +5 -1
  44. package/src/orchestrator/flow-runner.js +10 -2
  45. package/src/orchestrator/hu-sub-pipeline.js +82 -8
  46. package/src/plan/plan-hu-ops.js +16 -338
  47. package/src/plan/plan-id.js +4 -45
  48. package/src/plan/plan-validation.js +4 -82
  49. package/src/rag/vec-store.js +17 -253
  50. package/src/roles/audit-role.js +11 -1
  51. package/src/session/journal/summary-writer.js +55 -1
  52. package/src/utils/ai-trash.js +48 -0
  53. package/src/utils/atomic-write.js +6 -43
  54. package/src/utils/budget.js +38 -9
  55. package/src/utils/cli-ask-question.js +76 -0
  56. package/src/utils/doc-links.js +2 -0
  57. package/src/utils/os-detect.js +10 -0
  58. package/src/utils/paths.js +14 -123
  59. package/src/utils/port-check.js +6 -45
  60. package/src/utils/process.js +4 -188
  61. package/src/utils/qmd-collection.js +72 -0
  62. package/src/utils/qmd-detect.js +18 -0
  63. package/src/utils/qmd-install.js +37 -0
  64. package/src/utils/run-registry.js +9 -116
  65. package/src/utils/shared-paths.js +12 -41
  66. package/src/utils/squeezr-detect.js +18 -0
  67. package/src/utils/squeezr-install.js +37 -0
  68. package/src/utils/telemetry.js +43 -0
@@ -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,37 @@ 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, setStoryCachedTokens } = await import("../../../../packages/hu-board/src/db.js");
320
+ setStoryCost(huId, outcome.cost_usd);
321
+ // Φ0-G (KJC-TSK-0525): cached/in tokens travel in the same
322
+ // outcome blob as cost. Write them together so the board
323
+ // can render the "🎯 N% cached" badge on the same render.
324
+ // Either may be null (older runs, no cache) — setter handles it.
325
+ if (Number.isFinite(outcome.cached_tokens) || Number.isFinite(outcome.tokens_in)) {
326
+ setStoryCachedTokens(
327
+ huId,
328
+ Number.isFinite(outcome.cached_tokens) ? outcome.cached_tokens : null,
329
+ Number.isFinite(outcome.tokens_in) ? outcome.tokens_in : null,
330
+ );
331
+ }
332
+ } catch (err) {
333
+ logger?.warn?.(`Cost write failed for ${huId}: ${err.message}`);
334
+ }
335
+ }
309
336
  } catch (err) {
310
337
  logger?.warn?.(`Live outcome update failed for ${huId}: ${err.message}`);
311
338
  }
@@ -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" }, {
@@ -263,15 +263,23 @@ async function _runFlowInner({ task, config, logger, flags = {}, emitter = null,
263
263
 
264
264
  // --- Telemetry: anonymous pipeline_complete event (non-blocking) ---
265
265
  try {
266
- const { sendTelemetryEvent } = await import("../utils/telemetry.js");
266
+ const { sendTelemetryEvent, computeCachedPct } = await import("../utils/telemetry.js");
267
267
  const durationS = Math.round((Date.now() - ctx.startedAt) / 1000);
268
268
  const sessionStatus = ctx.session?.status || "unknown";
269
+ // Φ0-H (KJC-TSK-0526): aggregate cache-hit ratios per role so we
270
+ // can see on the fleet how much each provider's cache is buying.
271
+ let cachedPct = { cached_pct_coder: null, cached_pct_reviewer: null, cached_pct_total: null };
272
+ try {
273
+ const budgetSummary = ctx.budgetTracker?.summary?.();
274
+ if (budgetSummary) cachedPct = computeCachedPct(budgetSummary);
275
+ } catch { /* non-blocking — telemetry must never break runs */ }
269
276
  sendTelemetryEvent("pipeline_complete", {
270
277
  mode: config.review_mode,
271
278
  agent: ctx.coderRole?.provider || config.coder,
272
279
  duration_s: durationS,
273
280
  success: sessionStatus === "approved",
274
- taskType: ctx.session?.resolved_policies?.taskType || null
281
+ taskType: ctx.session?.resolved_policies?.taskType || null,
282
+ ...cachedPct
275
283
  }, config).catch(() => {});
276
284
  } catch { /* non-blocking */ }
277
285
  }