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.
- package/README.md +12 -4
- package/package.json +6 -1
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/index.html +1 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/board-view.js +21 -1
- package/packages/hu-board/public/utils/formatters.js +68 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/db.js +137 -0
- package/packages/hu-board/src/format.js +124 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +16 -1
- package/packages/hu-board/src/routes/rag.js +1 -1
- package/packages/hu-board/src/routes/wiki.js +43 -0
- package/packages/hu-board/src/run-tracker.js +1 -1
- package/packages/hu-board/src/server.js +4 -2
- package/packages/hu-board/src/sync.js +1 -1
- package/scripts/git-hooks/post-merge +13 -2
- package/src/agents/aider-agent.js +42 -1
- package/src/agents/claude-agent.js +14 -3
- package/src/agents/codex-agent.js +44 -7
- package/src/agents/gemini-agent.js +37 -1
- package/src/agents/opencode-agent.js +34 -1
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/brain/standby-scheduler.js +10 -92
- package/src/brain/standby-store.js +12 -168
- package/src/checks/ai-trash.js +34 -0
- package/src/checks/qmd.js +41 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +21 -0
- package/src/cli/register-pipeline.js +3 -0
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +27 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/flow-runner.js +10 -2
- package/src/orchestrator/hu-sub-pipeline.js +82 -8
- package/src/plan/plan-hu-ops.js +16 -338
- package/src/plan/plan-id.js +4 -45
- package/src/plan/plan-validation.js +4 -82
- package/src/rag/vec-store.js +17 -253
- package/src/roles/audit-role.js +11 -1
- package/src/session/journal/summary-writer.js +55 -1
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- package/src/utils/budget.js +38 -9
- package/src/utils/cli-ask-question.js +76 -0
- package/src/utils/doc-links.js +2 -0
- package/src/utils/os-detect.js +10 -0
- package/src/utils/paths.js +14 -123
- package/src/utils/port-check.js +6 -45
- package/src/utils/process.js +4 -188
- package/src/utils/qmd-collection.js +72 -0
- package/src/utils/qmd-detect.js +18 -0
- package/src/utils/qmd-install.js +37 -0
- package/src/utils/run-registry.js +9 -116
- package/src/utils/shared-paths.js +12 -41
- package/src/utils/squeezr-detect.js +18 -0
- package/src/utils/squeezr-install.js +37 -0
- package/src/utils/telemetry.js +43 -0
package/src/commands/init.js
CHANGED
|
@@ -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
|
|
759
|
-
if (
|
|
760
|
-
logger.info(
|
|
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
|
|
763
|
-
if (
|
|
764
|
-
logger.
|
|
765
|
-
|
|
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
|
+
}
|
package/src/commands/resume.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/commands/run.js
CHANGED
|
@@ -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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
package/src/git/hu-snapshot.js
CHANGED
|
@@ -1,105 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
}
|