karajan-code 3.0.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.
- package/README.md +14 -6
- package/docs/README.es.md +1 -1
- package/package.json +8 -2
- package/packages/hu-board/package.json +3 -1
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +21 -0
- package/packages/hu-board/public/styles.css +32 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +462 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +273 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/public/wiki.html +32 -0
- package/packages/hu-board/public/wiki.js +53 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +93 -2
- package/packages/hu-board/src/format.js +79 -0
- package/packages/hu-board/src/plan-mutations.js +4 -4
- package/packages/hu-board/src/routes/api.js +43 -10
- 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/audit-test-diet.mjs +151 -0
- package/scripts/git-hooks/post-merge +13 -2
- package/src/audit/ai-slop-findings.js +102 -0
- package/src/audit/deterministic-summary.js +18 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- 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/runner.js +10 -0
- package/src/checks/squeezr.js +36 -0
- package/src/cli/register-meta.js +36 -2
- package/src/cli/register-pipeline.js +3 -0
- package/src/cli/register-plan.js +10 -1
- package/src/commands/audit.js +6 -4
- package/src/commands/board.js +34 -0
- package/src/commands/clean.js +62 -0
- package/src/commands/doctor.js +6 -0
- package/src/commands/init.js +73 -8
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/qmd.js +86 -0
- package/src/commands/resume.js +6 -45
- package/src/commands/run.js +6 -50
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/git/hu-snapshot.js +10 -105
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +16 -0
- package/src/orchestrator/drivers/run-hu-batch.js +5 -1
- package/src/orchestrator/hu-sub-pipeline.js +52 -8
- package/src/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- 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/prompts/tool-correctness-judge.js +31 -0
- package/src/rag/vec-store.js +17 -253
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/audit-role.js +11 -1
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/ai-trash.js +48 -0
- package/src/utils/atomic-write.js +6 -43
- 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 -111
- 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/repo-cleaner.js +79 -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/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ai-trash detection + Claude Code hook install (KJC-TSK-0391).
|
|
2
|
+
// `kj-trash` is the binary shipped by the @karajan/ai-trash workspace.
|
|
3
|
+
// detectAiTrash() probes the binary; installAiTrashHook() invokes
|
|
4
|
+
// `kj-trash install --claude-code`, which idempotently patches
|
|
5
|
+
// ~/.claude/settings.json to register the PreToolUse hook.
|
|
6
|
+
|
|
7
|
+
import { runCommand } from "./process.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @returns {Promise<{ available: boolean, version: string|null }>}
|
|
11
|
+
*/
|
|
12
|
+
export async function detectAiTrash() {
|
|
13
|
+
try {
|
|
14
|
+
const res = await runCommand("kj-trash", ["--help"]);
|
|
15
|
+
if (res.exitCode === 0 || res.exitCode === 2) {
|
|
16
|
+
return { available: true, version: "0.0.0" };
|
|
17
|
+
}
|
|
18
|
+
return { available: false, version: null };
|
|
19
|
+
} catch {
|
|
20
|
+
return { available: false, version: null };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Register the kj-trash PreToolUse hook in ~/.claude/settings.json.
|
|
26
|
+
* Idempotent: second run reports "already installed" without rewriting.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} logger - logger with info/warn methods
|
|
29
|
+
* @returns {Promise<{ ok: boolean, mutated: boolean, error: string|null }>}
|
|
30
|
+
*/
|
|
31
|
+
export async function installAiTrashHook(logger) {
|
|
32
|
+
logger.info("Registering ai-trash Claude Code hook...");
|
|
33
|
+
try {
|
|
34
|
+
const res = await runCommand("kj-trash", ["install", "--claude-code"], { timeout: 30_000 });
|
|
35
|
+
if (res.exitCode !== 0) {
|
|
36
|
+
const error = (res.stderr || "").trim() || `exit code ${res.exitCode}`;
|
|
37
|
+
logger.warn(`ai-trash hook install failed: ${error}`);
|
|
38
|
+
return { ok: false, mutated: false, error };
|
|
39
|
+
}
|
|
40
|
+
const mutated = !(res.stdout || "").includes("already installed");
|
|
41
|
+
logger.info(mutated ? "ai-trash hook installed." : "ai-trash hook already installed.");
|
|
42
|
+
return { ok: true, mutated, error: null };
|
|
43
|
+
} catch (err) {
|
|
44
|
+
const error = err.message || String(err);
|
|
45
|
+
logger.warn(`ai-trash hook install failed: ${error}`);
|
|
46
|
+
return { ok: false, mutated: false, error };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,43 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
// silently corrupts.
|
|
8
|
-
//
|
|
9
|
-
// write-temp + rename fixes it: `rename` is atomic on the same
|
|
10
|
-
// filesystem, so a reader sees either the old file or the complete new
|
|
11
|
-
// one, never a half-written one. The temp file sits next to the target
|
|
12
|
-
// (same dir → same FS) and is uniquely named so concurrent writers do
|
|
13
|
-
// not clobber each other's temp. A crash before the rename leaves an
|
|
14
|
-
// orphan `.tmp` but never a corrupt target.
|
|
15
|
-
|
|
16
|
-
import { writeFileSync, renameSync } from "node:fs";
|
|
17
|
-
import { writeFile, rename } from "node:fs/promises";
|
|
18
|
-
|
|
19
|
-
function tempPath(target) {
|
|
20
|
-
return `${target}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Atomically write `obj` as pretty JSON to `target` (synchronous).
|
|
25
|
-
* @param {string} target - destination path; its directory must exist
|
|
26
|
-
* @param {unknown} obj
|
|
27
|
-
*/
|
|
28
|
-
export function writeJsonAtomicSync(target, obj) {
|
|
29
|
-
const tmp = tempPath(target);
|
|
30
|
-
writeFileSync(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
31
|
-
renameSync(tmp, target);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Atomically write `obj` as pretty JSON to `target` (async).
|
|
36
|
-
* @param {string} target - destination path; its directory must exist
|
|
37
|
-
* @param {unknown} obj
|
|
38
|
-
*/
|
|
39
|
-
export async function writeJsonAtomic(target, obj) {
|
|
40
|
-
const tmp = tempPath(target);
|
|
41
|
-
await writeFile(tmp, JSON.stringify(obj, null, 2), "utf-8");
|
|
42
|
-
await rename(tmp, target);
|
|
43
|
-
}
|
|
1
|
+
// Shim — the real implementation now lives in @karajan/core/atomic-write.
|
|
2
|
+
// Kept here so CLI callers (src/**/*.js) keep importing
|
|
3
|
+
// "#utils/atomic-write.js" without churn during KJC-TSK-0511 (decouple
|
|
4
|
+
// @karajan/hu-board). Will be retired once every consumer migrates to
|
|
5
|
+
// the @karajan/core import.
|
|
6
|
+
export { writeJsonAtomic, writeJsonAtomicSync } from "@karajan/core/atomic-write";
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import readline from "node:readline";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared askQuestion factory used by `kj run` and `kj resume`.
|
|
5
|
+
*
|
|
6
|
+
* Pre-fix (KJC-BUG-0081 round 1), `src/commands/run.js` got the guard
|
|
7
|
+
* but `src/commands/resume.js` kept its own copy without the guard, so
|
|
8
|
+
* `kj resume --yes` in non-TTY mode still hung on the HU Board bridge.
|
|
9
|
+
* Centralising the three paths here means both commands honour the
|
|
10
|
+
* same contract and a future fix only needs to land in one place.
|
|
11
|
+
*
|
|
12
|
+
* Three paths:
|
|
13
|
+
* 1. Interactive TTY: prompt via readline (developer terminal).
|
|
14
|
+
* 2. No TTY + `flags.yes`: caller asked NOT to be prompted → write
|
|
15
|
+
* question + context to stderr, return null so the run aborts
|
|
16
|
+
* fast and loud (CI / scripted / `kj audit --yes` desatendido).
|
|
17
|
+
* 3. No TTY without `flags.yes` (HU Board ▶ Run plan with
|
|
18
|
+
* `stdio=ignore`): publish through the file-based bridge so the
|
|
19
|
+
* board can surface a modal; block until the user answers.
|
|
20
|
+
*
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {string|null} [opts.sessionId]
|
|
23
|
+
* @param {object} [opts.flags]
|
|
24
|
+
* @returns {(question: string, context?: object) => Promise<string|null>}
|
|
25
|
+
*/
|
|
26
|
+
export function createCliAskQuestion(opts = {}) {
|
|
27
|
+
const { sessionId = null, flags = {} } = opts;
|
|
28
|
+
return async (question, context) => {
|
|
29
|
+
const stdinReadable = process.stdin && process.stdin.readable !== false;
|
|
30
|
+
const isInteractive = Boolean(process.stdin?.isTTY) && stdinReadable;
|
|
31
|
+
if (!isInteractive) {
|
|
32
|
+
if (flags?.yes) {
|
|
33
|
+
process.stderr.write(
|
|
34
|
+
`\n[non-interactive --yes] Pipeline asked a question that cannot be auto-answered:\n`
|
|
35
|
+
+ ` ❓ ${question}\n`
|
|
36
|
+
+ (context?.detail ? ` Context: ${JSON.stringify(context.detail)}\n` : "")
|
|
37
|
+
+ ` Stopping the session. Re-run without --yes (TTY or HU Board) to answer it,\n`
|
|
38
|
+
+ ` or pass --skip-spec-review / a more complete spec so the question never appears.\n`
|
|
39
|
+
);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const { askThroughBoard } = await import("./board-prompt-bridge.js");
|
|
43
|
+
console.log(`\n❓ ${question}`);
|
|
44
|
+
if (context?.detail) {
|
|
45
|
+
console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
|
|
46
|
+
}
|
|
47
|
+
console.log(
|
|
48
|
+
"\n[non-interactive] Routing the prompt to the HU Board.\n"
|
|
49
|
+
+ " Open http://localhost:4000 — a modal will appear asking for your answer.\n"
|
|
50
|
+
+ " This process is now waiting; closing the board does NOT cancel the run."
|
|
51
|
+
);
|
|
52
|
+
try {
|
|
53
|
+
return await askThroughBoard({ sessionId, question, context });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
console.log(`\n[prompt-bridge] ${err.message} — stopping the session.`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
console.log(`\n❓ ${question}`);
|
|
63
|
+
if (context?.detail) {
|
|
64
|
+
console.log(` Context: ${JSON.stringify(context.detail, null, 2)}`);
|
|
65
|
+
}
|
|
66
|
+
rl.question("\n> Your response (or 'stop' to exit): ", (answer) => {
|
|
67
|
+
rl.close();
|
|
68
|
+
if (answer.trim().toLowerCase() === "stop") {
|
|
69
|
+
resolve(null);
|
|
70
|
+
} else {
|
|
71
|
+
resolve(answer.trim());
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
};
|
|
76
|
+
}
|
package/src/utils/doc-links.js
CHANGED
|
@@ -15,6 +15,8 @@ const ERROR_DOCS = {
|
|
|
15
15
|
config_missing: `${BASE_URL}/getting-started/quick-start/`,
|
|
16
16
|
branch_error: `${BASE_URL}/guides/pipeline/#git-workflow`,
|
|
17
17
|
rtk_install: `${BASE_URL}/guides/configuration/#rtk`,
|
|
18
|
+
squeezr_install: `${BASE_URL}/guides/configuration/#squeezr`,
|
|
19
|
+
qmd_install: `${BASE_URL}/guides/configuration/#qmd`,
|
|
18
20
|
};
|
|
19
21
|
|
|
20
22
|
export function docLink(errorType) {
|
package/src/utils/os-detect.js
CHANGED
|
@@ -16,6 +16,16 @@ const INSTALL_COMMANDS = {
|
|
|
16
16
|
linux: "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh && rtk init --global",
|
|
17
17
|
windows: "Download from https://github.com/rtk-ai/rtk/releases and add to PATH"
|
|
18
18
|
},
|
|
19
|
+
squeezr: {
|
|
20
|
+
macos: "npm install -g squeezr-ai",
|
|
21
|
+
linux: "npm install -g squeezr-ai",
|
|
22
|
+
windows: "npm install -g squeezr-ai"
|
|
23
|
+
},
|
|
24
|
+
qmd: {
|
|
25
|
+
macos: "npm install -g @tobilu/qmd",
|
|
26
|
+
linux: "npm install -g @tobilu/qmd",
|
|
27
|
+
windows: "npm install -g @tobilu/qmd"
|
|
28
|
+
},
|
|
19
29
|
claude: {
|
|
20
30
|
macos: "npm install -g @anthropic-ai/claude-code",
|
|
21
31
|
linux: "npm install -g @anthropic-ai/claude-code",
|
package/src/utils/paths.js
CHANGED
|
@@ -1,111 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
return _vitestRoot;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function vitestTmpHome(defaultSegment) {
|
|
19
|
-
return path.join(vitestRoot(), defaultSegment);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// One-shot warning so users with KJ_HOME set in their shell rcfile do
|
|
23
|
-
// not get spammed once per `kj` invocation. Reset across processes,
|
|
24
|
-
// which is the granularity that matters for a humans-reading-stderr
|
|
25
|
-
// audience.
|
|
26
|
-
let _kjHomeWarned = false;
|
|
27
|
-
function emitKjHomeDeprecationWarning() {
|
|
28
|
-
if (_kjHomeWarned) return;
|
|
29
|
-
_kjHomeWarned = true;
|
|
30
|
-
// eslint-disable-next-line no-console
|
|
31
|
-
console.warn(
|
|
32
|
-
"\x1b[33m[warn]\x1b[0m KJ_HOME is deprecated, rename to KARAJAN_HOME (KJ_HOME will be removed in a future release)"
|
|
33
|
-
);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Unified resolver for Karajan's HOME-level storage root. Used by every
|
|
38
|
-
* helper that previously rolled its own `getKjHome()` with subtly
|
|
39
|
-
* different defaults and VITEST handling.
|
|
40
|
-
*
|
|
41
|
-
* Precedence (highest first):
|
|
42
|
-
* 1. KARAJAN_HOME env var — explicit, no warning
|
|
43
|
-
* 2. KJ_HOME env var — explicit, prints deprecation warning once
|
|
44
|
-
* 3. VITEST tmp dir — auto-isolation under `os.tmpdir()/karajan-vitest-<pid>-<rand>/<defaultSegment>`
|
|
45
|
-
* 4. `~/<defaultSegment>` — production default
|
|
46
|
-
*
|
|
47
|
-
* Callers pass `defaultSegment` so we can preserve `.kj` for legacy
|
|
48
|
-
* paths (plan-store, standby-store) and `.karajan` for the canonical
|
|
49
|
-
* root, until PR 3 unifies the defaults too.
|
|
50
|
-
*
|
|
51
|
-
* @param {object} [options]
|
|
52
|
-
* @param {string} [options.defaultSegment=".karajan"] HOME subdir name
|
|
53
|
-
* @returns {string} absolute path
|
|
54
|
-
*/
|
|
55
|
-
export function resolveHome({ defaultSegment = ".karajan" } = {}) {
|
|
56
|
-
if (process.env.KARAJAN_HOME) {
|
|
57
|
-
return path.resolve(process.env.KARAJAN_HOME);
|
|
58
|
-
}
|
|
59
|
-
if (process.env.KJ_HOME) {
|
|
60
|
-
emitKjHomeDeprecationWarning();
|
|
61
|
-
return path.resolve(process.env.KJ_HOME);
|
|
62
|
-
}
|
|
63
|
-
if (process.env.VITEST) {
|
|
64
|
-
return vitestTmpHome(defaultSegment);
|
|
65
|
-
}
|
|
66
|
-
return path.join(os.homedir(), defaultSegment);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Canonical Karajan home (`~/.karajan` by default). Every helper that
|
|
71
|
-
* used to roll its own `getKjHome()` now goes through this one — see
|
|
72
|
-
* KJC-PCS-0047 PR 3 for the consolidation.
|
|
73
|
-
*/
|
|
74
|
-
export function getKarajanHome() {
|
|
75
|
-
return resolveHome({ defaultSegment: ".karajan" });
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Test-only export. Tests that depend on observing the deprecation
|
|
79
|
-
// warning being emitted exactly once need to reset the latch between
|
|
80
|
-
// cases.
|
|
81
|
-
export function __resetKjHomeWarningForTests() {
|
|
82
|
-
_kjHomeWarned = false;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function getSessionRoot() {
|
|
86
|
-
return path.join(getKarajanHome(), "sessions");
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function getSonarComposePath() {
|
|
90
|
-
return path.join(getKarajanHome(), "docker-compose.sonar.yml");
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Ollama RAG embedder compose: `<karajan-home>/docker-compose.ollama.yml` — KJC-TSK-0435. */
|
|
94
|
-
export function getOllamaComposePath() {
|
|
95
|
-
return path.join(getKarajanHome(), "docker-compose.ollama.yml");
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
|
|
99
|
-
export function getWebperfDir() {
|
|
100
|
-
return path.join(getKarajanHome(), "webperf");
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Run-registry: `<karajan-home>/runs/` — KJC-TSK-0420. */
|
|
104
|
-
export function getRunsDir() {
|
|
105
|
-
return path.join(getKarajanHome(), "runs");
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Board prompt bridge: `<karajan-home>/prompts/` — KJC-TSK-0420. */
|
|
109
|
-
export function getPromptsDir() {
|
|
110
|
-
return path.join(getKarajanHome(), "prompts");
|
|
111
|
-
}
|
|
1
|
+
// Shim: paths primitives now live in @karajan/core/paths so the
|
|
2
|
+
// hu-board workspace can consume them without a relative dep on the
|
|
3
|
+
// CLI src tree. KJC-TSK-0511 PR3.
|
|
4
|
+
export {
|
|
5
|
+
resolveHome,
|
|
6
|
+
getKarajanHome,
|
|
7
|
+
__resetKjHomeWarningForTests,
|
|
8
|
+
getSessionRoot,
|
|
9
|
+
getSonarComposePath,
|
|
10
|
+
getOllamaComposePath,
|
|
11
|
+
getWebperfDir,
|
|
12
|
+
getRunsDir,
|
|
13
|
+
getPromptsDir,
|
|
14
|
+
} from "@karajan/core/paths";
|
package/src/utils/port-check.js
CHANGED
|
@@ -1,45 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createServer } from "node:net";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Check whether a TCP port is available to bind on localhost.
|
|
13
|
-
*
|
|
14
|
-
* @param {number} port
|
|
15
|
-
* @returns {Promise<boolean>}
|
|
16
|
-
*/
|
|
17
|
-
export function isPortAvailable(port) {
|
|
18
|
-
return new Promise((resolve) => {
|
|
19
|
-
const server = createServer();
|
|
20
|
-
server.once("error", () => resolve(false));
|
|
21
|
-
server.once("listening", () => {
|
|
22
|
-
server.close();
|
|
23
|
-
resolve(true);
|
|
24
|
-
});
|
|
25
|
-
server.listen(port);
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Find the first available port starting from `startPort`.
|
|
31
|
-
*
|
|
32
|
-
* @param {number} startPort - first port to try
|
|
33
|
-
* @param {number} [maxAttempts=11] - how many consecutive ports to probe
|
|
34
|
-
* @param {(port: number) => void} [onBusy] - called for each busy port seen
|
|
35
|
-
* @returns {Promise<number>} - resolves with the first free port found
|
|
36
|
-
* @throws {Error} if no port is free within the range
|
|
37
|
-
*/
|
|
38
|
-
export async function findAvailablePort(startPort, maxAttempts = 11, onBusy) {
|
|
39
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
40
|
-
const port = startPort + i;
|
|
41
|
-
if (await isPortAvailable(port)) return port;
|
|
42
|
-
if (onBusy) onBusy(port);
|
|
43
|
-
}
|
|
44
|
-
throw new Error(`No available port found between ${startPort} and ${startPort + maxAttempts - 1}`);
|
|
45
|
-
}
|
|
1
|
+
// Shim — the real implementation now lives in @karajan/core/port-check.
|
|
2
|
+
// Kept here so CLI callers (src/**/*.js) keep importing
|
|
3
|
+
// "../utils/port-check.js" without churn during KJC-TSK-0511 (decouple
|
|
4
|
+
// @karajan/hu-board). Will be retired once every consumer migrates to
|
|
5
|
+
// the @karajan/core import.
|
|
6
|
+
export { isPortAvailable, findAvailablePort } from "@karajan/core/port-check";
|
package/src/utils/process.js
CHANGED
|
@@ -1,188 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
function buildSilenceKilledResult(silenceTimeoutMs, stdout, signal) {
|
|
7
|
-
return {
|
|
8
|
-
exitCode: 143,
|
|
9
|
-
stdout,
|
|
10
|
-
stderr: `Command killed after ${Number(silenceTimeoutMs)}ms without output`,
|
|
11
|
-
timedOut: true,
|
|
12
|
-
signal: signal || KILL_SIGNAL
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs) {
|
|
17
|
-
const flushMs = Number(partialOutputFlushMs) > 0 ? Number(partialOutputFlushMs) : 2000;
|
|
18
|
-
const streams = {};
|
|
19
|
-
const makeHandler = (stream) => {
|
|
20
|
-
const state = { partial: "", dirty: false };
|
|
21
|
-
streams[stream] = state;
|
|
22
|
-
return (chunk) => {
|
|
23
|
-
state.partial += chunk.toString();
|
|
24
|
-
const lines = state.partial.split(/\r\n|\n|\r/);
|
|
25
|
-
state.partial = lines.pop() ?? "";
|
|
26
|
-
state.dirty = state.partial.length > 0;
|
|
27
|
-
for (const line of lines) {
|
|
28
|
-
if (line) onOutput({ stream, line });
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const flushPartials = () => {
|
|
34
|
-
for (const [stream, state] of Object.entries(streams)) {
|
|
35
|
-
if (!state.dirty || !state.partial) continue;
|
|
36
|
-
onOutput({ stream, line: state.partial });
|
|
37
|
-
state.partial = "";
|
|
38
|
-
state.dirty = false;
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
if (subprocess.stdout) subprocess.stdout.on("data", makeHandler("stdout"));
|
|
43
|
-
if (subprocess.stderr) subprocess.stderr.on("data", makeHandler("stderr"));
|
|
44
|
-
const flushInterval = setInterval(flushPartials, flushMs);
|
|
45
|
-
flushInterval.unref?.();
|
|
46
|
-
|
|
47
|
-
subprocess.finally(() => {
|
|
48
|
-
flushPartials();
|
|
49
|
-
clearInterval(flushInterval);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
async function awaitWithTimeout(subprocess, timeout, getStdout) {
|
|
54
|
-
let timer = null;
|
|
55
|
-
const timeoutResult = new Promise((resolve) => {
|
|
56
|
-
timer = setTimeout(() => {
|
|
57
|
-
try {
|
|
58
|
-
subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
|
|
59
|
-
} catch {
|
|
60
|
-
// no-op
|
|
61
|
-
}
|
|
62
|
-
resolve({
|
|
63
|
-
exitCode: 143,
|
|
64
|
-
stdout: getStdout(),
|
|
65
|
-
stderr: `Command timed out after ${timeout}ms`,
|
|
66
|
-
timedOut: true,
|
|
67
|
-
signal: KILL_SIGNAL
|
|
68
|
-
});
|
|
69
|
-
}, timeout);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const result = await Promise.race([subprocess, timeoutResult]);
|
|
73
|
-
if (timer) clearTimeout(timer);
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export async function runCommand(command, args = [], options = {}) {
|
|
78
|
-
const { timeout, onOutput, silenceTimeoutMs, partialOutputFlushMs, ...rest } = options;
|
|
79
|
-
// Detach stdin unless the caller provides input (e.g. Codex uses input: prompt).
|
|
80
|
-
// Without this, a subprocess prompting for input hangs forever since there is no TTY.
|
|
81
|
-
if (!rest.stdin && !rest.input) rest.stdin = "ignore";
|
|
82
|
-
const subprocess = execa(command, args, { reject: false, ...rest });
|
|
83
|
-
|
|
84
|
-
let stdoutAccum = "";
|
|
85
|
-
let stderrAccum = "";
|
|
86
|
-
let outputSilenceTimer = null;
|
|
87
|
-
let silenceTimedOut = false;
|
|
88
|
-
|
|
89
|
-
function clearSilenceTimer() {
|
|
90
|
-
if (outputSilenceTimer) {
|
|
91
|
-
clearTimeout(outputSilenceTimer);
|
|
92
|
-
outputSilenceTimer = null;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function armSilenceTimer() {
|
|
97
|
-
const ms = Number(silenceTimeoutMs);
|
|
98
|
-
if (!Number.isFinite(ms) || ms <= 0 || silenceTimedOut) return;
|
|
99
|
-
clearSilenceTimer();
|
|
100
|
-
outputSilenceTimer = setTimeout(() => {
|
|
101
|
-
silenceTimedOut = true;
|
|
102
|
-
try {
|
|
103
|
-
subprocess.kill(KILL_SIGNAL, { forceKillAfterDelay: 1000 });
|
|
104
|
-
} catch {
|
|
105
|
-
// no-op
|
|
106
|
-
}
|
|
107
|
-
}, ms);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (subprocess.stdout) {
|
|
111
|
-
subprocess.stdout.on("data", (chunk) => {
|
|
112
|
-
stdoutAccum += chunk.toString();
|
|
113
|
-
armSilenceTimer();
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
if (subprocess.stderr) {
|
|
117
|
-
subprocess.stderr.on("data", (chunk) => {
|
|
118
|
-
stderrAccum += chunk.toString();
|
|
119
|
-
armSilenceTimer();
|
|
120
|
-
});
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
if (onOutput) {
|
|
124
|
-
setupOutputStreaming(subprocess, onOutput, partialOutputFlushMs);
|
|
125
|
-
}
|
|
126
|
-
armSilenceTimer();
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
const result = timeout
|
|
130
|
-
? await awaitWithTimeout(subprocess, timeout, () => stdoutAccum)
|
|
131
|
-
: await subprocess;
|
|
132
|
-
clearSilenceTimer();
|
|
133
|
-
if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, stdoutAccum);
|
|
134
|
-
return enrichResult(result, stdoutAccum, stderrAccum);
|
|
135
|
-
} catch (error) {
|
|
136
|
-
clearSilenceTimer();
|
|
137
|
-
if (silenceTimedOut) return buildSilenceKilledResult(silenceTimeoutMs, error?.stdout || stdoutAccum, error?.signal);
|
|
138
|
-
|
|
139
|
-
const details = [error?.shortMessage, error?.originalMessage, error?.stderr, error?.stdout, error?.message]
|
|
140
|
-
.filter(Boolean)
|
|
141
|
-
.join("\n");
|
|
142
|
-
|
|
143
|
-
return {
|
|
144
|
-
exitCode: 1,
|
|
145
|
-
stdout: error?.stdout || stdoutAccum,
|
|
146
|
-
stderr: details || String(error),
|
|
147
|
-
signal: error?.signal || null
|
|
148
|
-
};
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function enrichResult(result, stdoutAccum, _stderrAccum) {
|
|
153
|
-
if (result.timedOut) return result;
|
|
154
|
-
|
|
155
|
-
// execa with reject:false RESOLVES (does not throw) on a spawn
|
|
156
|
-
// failure — ENOENT (binary not in PATH), EACCES, etc. The result
|
|
157
|
-
// then carries exitCode null/undefined, an empty stderr, and the
|
|
158
|
-
// real cause only in `code` / `shortMessage`. Surface it: a missing
|
|
159
|
-
// agent CLI used to fail with a completely empty error message,
|
|
160
|
-
// leaving the user with "coder failed" and no idea what to install.
|
|
161
|
-
if (result.failed && result.exitCode == null && !result.signal) {
|
|
162
|
-
const reason = result.shortMessage || result.message
|
|
163
|
-
|| `spawn failed${result.code ? ` (${result.code})` : ""}`;
|
|
164
|
-
return {
|
|
165
|
-
...result,
|
|
166
|
-
exitCode: 1,
|
|
167
|
-
stdout: result.stdout || stdoutAccum,
|
|
168
|
-
stderr: result.stderr || reason,
|
|
169
|
-
spawnError: result.code || true,
|
|
170
|
-
};
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const killed = result.killed || !!result.signal;
|
|
174
|
-
const signal = result.signal || null;
|
|
175
|
-
|
|
176
|
-
if (killed && !result.stderr) {
|
|
177
|
-
return {
|
|
178
|
-
...result,
|
|
179
|
-
stdout: result.stdout || stdoutAccum,
|
|
180
|
-
stderr: signal
|
|
181
|
-
? `Process killed by signal ${signal}`
|
|
182
|
-
: "Process was killed externally",
|
|
183
|
-
signal
|
|
184
|
-
};
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
return result;
|
|
188
|
-
}
|
|
1
|
+
// Shim: process runner now lives in @karajan/core/process so the
|
|
2
|
+
// hu-board workspace can consume it without a relative dep on the CLI
|
|
3
|
+
// src tree. KJC-TSK-0511 PR6.
|
|
4
|
+
export { runCommand } from "@karajan/core/process";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { runCommand } from "./process.js";
|
|
4
|
+
|
|
5
|
+
export const QMD_DEFAULT_PATHS = ["docs", ".reviews", ".karajan/plans"];
|
|
6
|
+
|
|
7
|
+
export function projectSlug(projectDir) {
|
|
8
|
+
const base = path.basename(path.resolve(projectDir));
|
|
9
|
+
return base.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function planQmdCollections(projectDir) {
|
|
13
|
+
const slug = projectSlug(projectDir);
|
|
14
|
+
const result = [];
|
|
15
|
+
for (const rel of QMD_DEFAULT_PATHS) {
|
|
16
|
+
const abs = path.join(projectDir, rel);
|
|
17
|
+
if (!existsSync(abs)) continue;
|
|
18
|
+
try { if (!statSync(abs).isDirectory()) continue; } catch { continue; }
|
|
19
|
+
const suffix = rel.replace(/^\./, "").replace(/[/\\]+/g, "-");
|
|
20
|
+
result.push({ name: `${slug}-${suffix}`, path: abs });
|
|
21
|
+
}
|
|
22
|
+
return result;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function listExistingCollections() {
|
|
26
|
+
try {
|
|
27
|
+
const res = await runCommand("qmd", ["collection", "list"]);
|
|
28
|
+
if (res.exitCode !== 0) return new Set();
|
|
29
|
+
const names = new Set();
|
|
30
|
+
for (const line of (res.stdout || "").split("\n")) {
|
|
31
|
+
const m = line.match(/^([\w.-]+)\s+\(qmd:\/\//);
|
|
32
|
+
if (m) names.add(m[1]);
|
|
33
|
+
}
|
|
34
|
+
return names;
|
|
35
|
+
} catch { return new Set(); }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Register all default QMD collections for the project. Idempotent: skips
|
|
40
|
+
* collections already present in `qmd collection list`. qmd's CLI only
|
|
41
|
+
* accepts one path per `collection add`, so we register up to 3 sibling
|
|
42
|
+
* collections (`<slug>-docs`, `<slug>-reviews`, `<slug>-karajan-plans`).
|
|
43
|
+
*/
|
|
44
|
+
export async function registerQmdCollections(projectDir, logger) {
|
|
45
|
+
const planned = planQmdCollections(projectDir);
|
|
46
|
+
const result = { ok: true, added: [], skipped: [], errors: [] };
|
|
47
|
+
if (planned.length === 0) {
|
|
48
|
+
logger.info("QMD: no indexable folders yet (docs/, .reviews/, .karajan/plans/ all missing).");
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
const existing = await listExistingCollections();
|
|
52
|
+
for (const { name, path: abs } of planned) {
|
|
53
|
+
if (existing.has(name)) { result.skipped.push(name); continue; }
|
|
54
|
+
try {
|
|
55
|
+
const res = await runCommand("qmd", ["collection", "add", abs, "--name", name], { timeout: 60_000 });
|
|
56
|
+
if (res.exitCode === 0) {
|
|
57
|
+
result.added.push(name);
|
|
58
|
+
} else {
|
|
59
|
+
const err = (res.stderr || res.stdout || "").trim() || `exit code ${res.exitCode}`;
|
|
60
|
+
result.errors.push(`${name}: ${err}`);
|
|
61
|
+
result.ok = false;
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
result.errors.push(`${name}: ${err.message || String(err)}`);
|
|
65
|
+
result.ok = false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (result.added.length) logger.info(`QMD: registered ${result.added.length} collection(s): ${result.added.join(", ")}.`);
|
|
69
|
+
if (result.skipped.length) logger.info(`QMD: ${result.skipped.length} collection(s) already present, skipped.`);
|
|
70
|
+
for (const err of result.errors) logger.warn(`QMD: collection registration failed — ${err}`);
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCommand } from "./process.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detect whether QMD is installed and available.
|
|
5
|
+
* @returns {Promise<{ available: boolean, version: string|null }>}
|
|
6
|
+
*/
|
|
7
|
+
export async function detectQmd() {
|
|
8
|
+
try {
|
|
9
|
+
const result = await runCommand("qmd", ["--version"]);
|
|
10
|
+
if (result.exitCode === 0) {
|
|
11
|
+
const version = (result.stdout || "").trim() || null;
|
|
12
|
+
return { available: true, version };
|
|
13
|
+
}
|
|
14
|
+
return { available: false, version: null };
|
|
15
|
+
} catch { /* qmd binary not found */
|
|
16
|
+
return { available: false, version: null };
|
|
17
|
+
}
|
|
18
|
+
}
|