karajan-code 2.18.1 → 2.19.1
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 +53 -34
- package/docs/README.es.md +13 -2
- package/package.json +9 -1
- package/packages/hu-board/README.md +69 -0
- package/packages/hu-board/package.json +23 -0
- package/packages/hu-board/public/app.js +4038 -0
- package/packages/hu-board/public/index.html +55 -0
- package/packages/hu-board/public/pipeline.css +132 -0
- package/packages/hu-board/public/pipeline.html +48 -0
- package/packages/hu-board/public/pipeline.js +200 -0
- package/packages/hu-board/public/styles.css +918 -0
- package/packages/hu-board/src/auth.js +92 -0
- package/packages/hu-board/src/boot-info.js +16 -0
- package/packages/hu-board/src/cleanup-zombies.js +142 -0
- package/packages/hu-board/src/command-runner.js +134 -0
- package/packages/hu-board/src/config-yaml.js +285 -0
- package/packages/hu-board/src/db.js +739 -0
- package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
- package/packages/hu-board/src/event-bus.js +58 -0
- package/packages/hu-board/src/format.js +83 -0
- package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
- package/packages/hu-board/src/journal-parser.js +180 -0
- package/packages/hu-board/src/plan-mutations.js +422 -0
- package/packages/hu-board/src/preflight.js +285 -0
- package/packages/hu-board/src/routes/api.js +1271 -0
- package/packages/hu-board/src/routes/pipeline.js +45 -0
- package/packages/hu-board/src/run-tracker.js +121 -0
- package/packages/hu-board/src/server.js +435 -0
- package/packages/hu-board/src/sync.js +626 -0
- package/packages/hu-board/src/token-store.js +57 -0
- package/packages/hu-board/src/zombie-reaper.js +207 -0
- package/src/audit/webperf-input.js +4 -7
- package/src/brain/standby-store.js +6 -4
- package/src/checks/dir-setup.js +39 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/cli.js +13 -0
- package/src/commands/plan/generate.js +15 -0
- package/src/commands/run.js +19 -1
- package/src/commands/webperf.js +4 -6
- package/src/config/role-resolver.js +3 -1
- package/src/mcp/handlers/direct-handlers.js +3 -0
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/tools.js +4 -2
- package/src/plan/plan-store.js +7 -27
- package/src/prompts/spec-reviewer.js +66 -0
- package/src/roles/perf-role.js +4 -4
- package/src/roles/spec-reviewer-role.js +106 -0
- package/src/spec-review/refine-loop.js +58 -0
- package/src/spec-review/run-spec-review.js +70 -0
- package/src/utils/board-prompt-bridge.js +4 -5
- package/src/utils/display/spec-findings.js +44 -0
- package/src/utils/garbage-collector.js +8 -6
- package/src/utils/home-migration.js +87 -0
- package/src/utils/paths.js +85 -17
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Spec Reviewer role (KJC-PCS-0048 PR 1). Runs BEFORE intent/triage/planner;
|
|
2
|
+
// audits the user's spec and returns structured findings. Does NOT execute.
|
|
3
|
+
|
|
4
|
+
import { AgentRole } from "./agent-role.js";
|
|
5
|
+
import { buildSpecReviewerPrompt, buildSpecRefinerPrompt, VALID_CATEGORIES } from "../prompts/spec-reviewer.js";
|
|
6
|
+
import { extractFirstJson } from "../utils/json-extract.js";
|
|
7
|
+
import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
8
|
+
|
|
9
|
+
const F_SEVS = new Set(["info", "warn", "fail"]);
|
|
10
|
+
const T_SEVS = new Set(["ok", "info", "warn", "fail"]);
|
|
11
|
+
const CATS = new Set(VALID_CATEGORIES);
|
|
12
|
+
const ORDER = { ok: 0, info: 1, warn: 2, fail: 3 };
|
|
13
|
+
|
|
14
|
+
function normalise(findings) {
|
|
15
|
+
if (!Array.isArray(findings)) return [];
|
|
16
|
+
return findings.map((f, i) => ({
|
|
17
|
+
id: typeof f?.id === "string" && f.id.trim() ? f.id.trim() : `F-${String(i + 1).padStart(3, "0")}`,
|
|
18
|
+
severity: F_SEVS.has(f?.severity) ? f.severity : "warn",
|
|
19
|
+
category: CATS.has(f?.category) ? f.category : "ambiguity",
|
|
20
|
+
message: String(f?.message || "").trim() || "(no description)",
|
|
21
|
+
suggestion: typeof f?.suggestion === "string" && f.suggestion.trim() ? f.suggestion.trim() : null,
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const worstOf = (findings) => findings.reduce((m, f) => (ORDER[f.severity] > ORDER[m] ? f.severity : m), "ok");
|
|
26
|
+
|
|
27
|
+
export class SpecReviewerRole extends AgentRole {
|
|
28
|
+
constructor(opts) { super({ ...opts, name: "spec-reviewer" }); }
|
|
29
|
+
|
|
30
|
+
extractInput(input) {
|
|
31
|
+
if (typeof input === "string") return { spec: input, onOutput: null };
|
|
32
|
+
return {
|
|
33
|
+
spec: input?.spec || input?.task || this.context?.task || "",
|
|
34
|
+
onOutput: input?.onOutput || null,
|
|
35
|
+
sessionState: input?.sessionState || null,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async buildPrompt({ spec }) {
|
|
40
|
+
return { prompt: buildSpecReviewerPrompt({ spec, instructions: this.instructions }) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
parseOutput(raw) { return extractFirstJson(raw); }
|
|
44
|
+
|
|
45
|
+
buildSuccessResult(parsed, provider) {
|
|
46
|
+
const findings = normalise(parsed?.findings);
|
|
47
|
+
const declared = T_SEVS.has(parsed?.severity) ? parsed.severity : null;
|
|
48
|
+
const derived = worstOf(findings);
|
|
49
|
+
// Trust the worse of declared vs derived — agents sometimes report
|
|
50
|
+
// `severity: "ok"` while still listing fail-level findings.
|
|
51
|
+
const severity = !declared || ORDER[derived] > ORDER[declared] ? derived : declared;
|
|
52
|
+
return { severity, findings, reasoning: String(parsed?.reasoning || "").trim() || null, provider };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
buildSummary(parsed) {
|
|
56
|
+
const findings = normalise(parsed?.findings);
|
|
57
|
+
if (findings.length === 0) return "Spec OK (no findings)";
|
|
58
|
+
return `Spec ${worstOf(findings)}: ${findings.length} finding${findings.length === 1 ? "" : "s"}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Parse failures degrade to a soft warning so the pipeline never blocks.
|
|
62
|
+
handleParseNull(r, p) { return this.#degraded(r, p, "parse-null", "LLM output was unstructured"); }
|
|
63
|
+
handleParseError(e, r, p) { return this.#degraded(r, p, "parse-error", e?.message || "unknown"); }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Refine mode (KJC-PCS-0048 PR 3). Bypasses the JSON-only `execute()`
|
|
67
|
+
* flow: returns the LLM output verbatim as the rewritten spec, so the
|
|
68
|
+
* refine loop can persist it as `spec-v2.md`.
|
|
69
|
+
* @returns {Promise<{ ok:boolean, refinedSpec?:string, error?:string, provider:string, usage?:object }>}
|
|
70
|
+
*/
|
|
71
|
+
async refineSpec({ spec, findings }) {
|
|
72
|
+
if (!this._initialized) await this.init({ task: spec });
|
|
73
|
+
const provider = this.resolveProvider();
|
|
74
|
+
const agent = this.createAgentInstance(provider);
|
|
75
|
+
const prompt = buildSpecRefinerPrompt({ originalSpec: spec, findings, instructions: this.instructions });
|
|
76
|
+
const result = await withBrainRecovery({
|
|
77
|
+
agent: { runTask: (a) => agent.runTask(a), provider },
|
|
78
|
+
taskArgs: { prompt, role: this.name },
|
|
79
|
+
role: this.name, provider, emitter: this.emitter, logger: this.logger,
|
|
80
|
+
});
|
|
81
|
+
if (!result.ok) {
|
|
82
|
+
return { ok: false, error: result.error || "refine failed", provider, usage: result.usage };
|
|
83
|
+
}
|
|
84
|
+
const refinedSpec = String(result.output || "").replace(/^```[a-z]*\n?|```$/g, "").trim();
|
|
85
|
+
if (!refinedSpec) return { ok: false, error: "empty refined spec", provider, usage: result.usage };
|
|
86
|
+
return { ok: true, refinedSpec, provider, usage: result.usage };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
#degraded(agentResult, provider, reason, detail) {
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
result: {
|
|
93
|
+
severity: "warn",
|
|
94
|
+
findings: [{
|
|
95
|
+
id: "F-DEG", severity: "warn", category: "assumptions",
|
|
96
|
+
message: `[spec-reviewer] degraded (${reason}): ${detail}`,
|
|
97
|
+
suggestion: "Re-run with --skip-spec-review if this keeps failing.",
|
|
98
|
+
}],
|
|
99
|
+
reasoning: "Spec reviewer could not parse the LLM output; treating as a soft warning.",
|
|
100
|
+
provider, degraded: true,
|
|
101
|
+
},
|
|
102
|
+
summary: `Spec reviewer degraded: ${reason}`,
|
|
103
|
+
usage: agentResult?.usage,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Refine loop for the spec-reviewer (KJC-PCS-0048 PR 3). Called from
|
|
2
|
+
// run-spec-review.js when the user picks [r]efine. Asks the role for a
|
|
3
|
+
// rewritten v2, persists v1.md / v2.md, opens $EDITOR for the user to
|
|
4
|
+
// tweak, uses a SHA-256 hash diff to flag whether the v2 needs a fresh
|
|
5
|
+
// review pass (user modified it) or can proceed as-is.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import crypto from "node:crypto";
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
|
|
12
|
+
const sha256 = (s) => crypto.createHash("sha256").update(String(s || ""), "utf8").digest("hex");
|
|
13
|
+
|
|
14
|
+
function openEditor(filePath, { logger, openEditorFn = null } = {}) {
|
|
15
|
+
if (openEditorFn) return openEditorFn(filePath);
|
|
16
|
+
const raw = process.env.VISUAL || process.env.EDITOR || "vi"; // same precedence as src/commands/config.js
|
|
17
|
+
const parts = raw.split(/\s+/);
|
|
18
|
+
logger?.info?.(`Opening ${parts[0]} on ${filePath}`);
|
|
19
|
+
return spawnSync(parts[0], [...parts.slice(1), filePath], { stdio: "inherit" }).status === 0;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Single refine iteration. The outer loop in run-spec-review.js decides
|
|
24
|
+
* whether to re-invoke us based on the returned `hashChanged` flag
|
|
25
|
+
* (re-review the v2 because the user modified it) or accept the v2 as
|
|
26
|
+
* the final spec (`hashChanged === false`).
|
|
27
|
+
*
|
|
28
|
+
* @returns {Promise<{ok, refinedSpec?, hashChanged, error?, v2Path?}>}
|
|
29
|
+
*/
|
|
30
|
+
export async function runRefinePass({ spec, findings, role, projectDir, taskFile, logger, fs: fsOverride, openEditorFn }) {
|
|
31
|
+
const _fs = fsOverride || fs;
|
|
32
|
+
const refineRes = await role.refineSpec({ spec, findings });
|
|
33
|
+
if (!refineRes.ok) return { ok: false, error: refineRes.error || "refine failed", hashChanged: false };
|
|
34
|
+
|
|
35
|
+
let v2Path;
|
|
36
|
+
try {
|
|
37
|
+
const root = projectDir || process.cwd();
|
|
38
|
+
const ts = new Date().toISOString().replaceAll(/[:.]/g, "-");
|
|
39
|
+
const dir = path.join(root, ".reviews", `spec-review-${ts}`);
|
|
40
|
+
await _fs.mkdir(dir, { recursive: true });
|
|
41
|
+
await _fs.writeFile(path.join(dir, "spec-v1.md"), spec, "utf8");
|
|
42
|
+
v2Path = path.join(dir, "spec-v2.md");
|
|
43
|
+
await _fs.writeFile(v2Path, refineRes.refinedSpec, "utf8");
|
|
44
|
+
if (taskFile) {
|
|
45
|
+
// Mirror the v2 next to the user's --task-file so they find it
|
|
46
|
+
// without diving into .reviews/.
|
|
47
|
+
const p = path.parse(taskFile);
|
|
48
|
+
await _fs.writeFile(path.join(p.dir, `${p.name}.v2${p.ext || ".md"}`), refineRes.refinedSpec, "utf8").catch(() => {});
|
|
49
|
+
}
|
|
50
|
+
} catch (err) { return { ok: false, error: `persist failed: ${err.message}`, hashChanged: false }; }
|
|
51
|
+
|
|
52
|
+
const before = sha256(refineRes.refinedSpec);
|
|
53
|
+
openEditor(v2Path, { logger, openEditorFn });
|
|
54
|
+
let after;
|
|
55
|
+
try { after = await _fs.readFile(v2Path, "utf8"); }
|
|
56
|
+
catch (err) { return { ok: false, error: `read v2 failed: ${err.message}`, hashChanged: false }; }
|
|
57
|
+
return { ok: true, refinedSpec: after, hashChanged: before !== sha256(after), v2Path };
|
|
58
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Pre-pipeline orchestrator for the spec-reviewer (KJC-PCS-0048 PR 2).
|
|
2
|
+
// Called from src/commands/run.js and src/commands/plan/generate.js
|
|
3
|
+
// BEFORE the actual pipeline. Decides whether to proceed based on the
|
|
4
|
+
// role's findings + the user's answer to the interactive prompt.
|
|
5
|
+
// PR 2 only supports [c]ontinue / [x]cancel; refine loop lands in PR 3.
|
|
6
|
+
|
|
7
|
+
import { SpecReviewerRole } from "../roles/spec-reviewer-role.js";
|
|
8
|
+
import { printFindings } from "../utils/display/spec-findings.js";
|
|
9
|
+
import { runRefinePass } from "./refine-loop.js";
|
|
10
|
+
|
|
11
|
+
const MAX_REFINE_ITERATIONS = 5;
|
|
12
|
+
|
|
13
|
+
export async function runSpecReview({ spec, config, logger, askQuestion, flags = {} }) {
|
|
14
|
+
// VITEST guard — pre-existing test suites (e.g. tests/commands/command-run.test.js)
|
|
15
|
+
// mock `runFlow` + `assertAgentsAvailable` but predate this role and have
|
|
16
|
+
// no need to exercise it. Auto-skip in vitest unless the test passes
|
|
17
|
+
// `flags.forceSpecReview: true` (the spec-review's own tests bypass via
|
|
18
|
+
// their vi.doMock of SpecReviewerRole and never reach this guard).
|
|
19
|
+
if (process.env.VITEST && !flags.forceSpecReview) return { proceed: true, skipped: true };
|
|
20
|
+
if (flags.skipSpecReview || config?.spec_reviewer?.enabled === false) {
|
|
21
|
+
return { proceed: true, skipped: true };
|
|
22
|
+
}
|
|
23
|
+
if (!spec || !String(spec).trim()) return { proceed: true, skipped: true };
|
|
24
|
+
|
|
25
|
+
const role = new SpecReviewerRole({ config, logger });
|
|
26
|
+
await role.init({ task: spec });
|
|
27
|
+
|
|
28
|
+
// Outer loop: review → prompt → optional refine → re-review (the
|
|
29
|
+
// refine path replaces `currentSpec` and we go around again).
|
|
30
|
+
let currentSpec = spec;
|
|
31
|
+
for (let iter = 0; iter < MAX_REFINE_ITERATIONS; iter += 1) {
|
|
32
|
+
const res = await role.execute({ spec: currentSpec });
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
logger?.warn?.(`[spec] reviewer failed: ${res.summary} — continuing without review`);
|
|
35
|
+
return { proceed: true, error: res.result?.error, finalSpec: currentSpec };
|
|
36
|
+
}
|
|
37
|
+
const { severity, findings } = res.result || {};
|
|
38
|
+
if (!findings || findings.length === 0 || severity === "ok") {
|
|
39
|
+
logger?.info?.(iter === 0 ? "✓ spec OK" : "✓ refined spec OK");
|
|
40
|
+
return { proceed: true, severity: "ok", findings: [], finalSpec: currentSpec, refined: iter > 0 };
|
|
41
|
+
}
|
|
42
|
+
printFindings(findings, severity);
|
|
43
|
+
if (!askQuestion) return { proceed: true, severity, findings, finalSpec: currentSpec };
|
|
44
|
+
|
|
45
|
+
const answer = await askQuestion(
|
|
46
|
+
`Spec review: ${findings.length} finding${findings.length === 1 ? "" : "s"} at severity ${severity}. [c]ontinue / [r]efine / [x]cancel? (default: continue)`,
|
|
47
|
+
{ detail: { severity, findingCount: findings.length, categories: [...new Set(findings.map((f) => f.category))], iteration: iter + 1 } },
|
|
48
|
+
);
|
|
49
|
+
if (answer === null) return { proceed: false, cancelled: true, severity, findings };
|
|
50
|
+
const n = String(answer).trim().toLowerCase();
|
|
51
|
+
if (n.startsWith("x") || n === "cancel") return { proceed: false, cancelled: true, severity, findings };
|
|
52
|
+
if (n.startsWith("r") || n === "refine") {
|
|
53
|
+
const refine = await runRefinePass({
|
|
54
|
+
spec: currentSpec, findings, role,
|
|
55
|
+
projectDir: config?.projectDir, taskFile: flags.taskFile, logger,
|
|
56
|
+
});
|
|
57
|
+
if (!refine.ok) {
|
|
58
|
+
logger?.warn?.(`[spec] refine failed: ${refine.error} — proceeding with original spec`);
|
|
59
|
+
return { proceed: true, severity, findings, finalSpec: currentSpec };
|
|
60
|
+
}
|
|
61
|
+
currentSpec = refine.refinedSpec;
|
|
62
|
+
if (refine.hashChanged) continue; // user tweaked v2 → re-review
|
|
63
|
+
logger?.info?.("✓ refined spec accepted by user (no edits)");
|
|
64
|
+
return { proceed: true, severity: "ok", findings: [], finalSpec: currentSpec, refined: true };
|
|
65
|
+
}
|
|
66
|
+
return { proceed: true, severity, findings, finalSpec: currentSpec }; // continue
|
|
67
|
+
}
|
|
68
|
+
logger?.warn?.(`[spec] max refine iterations (${MAX_REFINE_ITERATIONS}) reached — proceeding with last spec`);
|
|
69
|
+
return { proceed: true, finalSpec: currentSpec, refined: true };
|
|
70
|
+
}
|
|
@@ -28,16 +28,15 @@
|
|
|
28
28
|
|
|
29
29
|
import { mkdir, writeFile, readFile, unlink, stat } from "node:fs/promises";
|
|
30
30
|
import { join } from "node:path";
|
|
31
|
-
import { homedir } from "node:os";
|
|
32
31
|
import { randomUUID } from "node:crypto";
|
|
33
32
|
|
|
34
33
|
const POLL_INTERVAL_MS = 500;
|
|
35
34
|
const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
36
|
+
// Delegated to src/utils/paths.js so KARAJAN_HOME, KJ_HOME (with
|
|
37
|
+
// deprecation warning), VITEST tmp and the default all share one
|
|
38
|
+
// precedence chain — KJC-TSK-0420.
|
|
39
|
+
import { getPromptsDir } from "./paths.js";
|
|
41
40
|
|
|
42
41
|
function exists(path) {
|
|
43
42
|
return stat(path).then(() => true, () => false);
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Pretty-printer for SpecReviewerRole findings (KJC-PCS-0048 PR 2).
|
|
2
|
+
// Grouped by category, colours by severity, every finding gets its
|
|
3
|
+
// `suggestion` printed below as the actionable fix-it line.
|
|
4
|
+
|
|
5
|
+
const COLOURS = {
|
|
6
|
+
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
7
|
+
cyan: "\x1b[36m", yellow: "\x1b[33m", red: "\x1b[31m", gray: "\x1b[90m",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const SEV_COLOUR = { info: COLOURS.cyan, warn: COLOURS.yellow, fail: COLOURS.red };
|
|
11
|
+
const SEV_LABEL = { info: "INFO", warn: "WARN", fail: "FAIL" };
|
|
12
|
+
|
|
13
|
+
function colour(severity, text) {
|
|
14
|
+
const c = SEV_COLOUR[severity] || COLOURS.gray;
|
|
15
|
+
return `${c}${text}${COLOURS.reset}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<{id,severity,category,message,suggestion?}>} findings
|
|
20
|
+
* @param {"ok"|"info"|"warn"|"fail"} severity
|
|
21
|
+
* @param {object} [opts]
|
|
22
|
+
* @param {(line:string)=>void} [opts.write] Defaults to `console.warn` (stderr).
|
|
23
|
+
*/
|
|
24
|
+
export function printFindings(findings, severity, opts = {}) {
|
|
25
|
+
const write = opts.write || ((line) => process.stderr.write(`${line}\n`));
|
|
26
|
+
if (!Array.isArray(findings) || findings.length === 0) return;
|
|
27
|
+
|
|
28
|
+
write("");
|
|
29
|
+
write(`${COLOURS.bold}Spec review — ${colour(severity, SEV_LABEL[severity] || severity.toUpperCase())}${COLOURS.reset} (${findings.length} finding${findings.length === 1 ? "" : "s"})`);
|
|
30
|
+
|
|
31
|
+
const byCat = new Map();
|
|
32
|
+
for (const f of findings) {
|
|
33
|
+
if (!byCat.has(f.category)) byCat.set(f.category, []);
|
|
34
|
+
byCat.get(f.category).push(f);
|
|
35
|
+
}
|
|
36
|
+
for (const [cat, items] of byCat) {
|
|
37
|
+
write(`${COLOURS.dim}── ${cat}${COLOURS.reset}`);
|
|
38
|
+
for (const f of items) {
|
|
39
|
+
write(` ${colour(f.severity, "●")} ${COLOURS.bold}${f.id}${COLOURS.reset} ${f.message}`);
|
|
40
|
+
if (f.suggestion) write(` ${COLOURS.dim}→${COLOURS.reset} ${f.suggestion}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
write("");
|
|
44
|
+
}
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
|
|
31
31
|
import fs from "node:fs/promises";
|
|
32
32
|
import path from "node:path";
|
|
33
|
-
import
|
|
33
|
+
import { resolveHome } from "./paths.js";
|
|
34
34
|
|
|
35
35
|
const FINAL_PLAN_STATUSES = new Set([
|
|
36
36
|
// Terminal plan states the user explicitly opted into — retention applies.
|
|
@@ -39,13 +39,15 @@ const FINAL_PLAN_STATUSES = new Set([
|
|
|
39
39
|
const KNOWN_PLAN_STATUSES = new Set([...FINAL_PLAN_STATUSES, "draft", "running"]);
|
|
40
40
|
const FINAL_SESSION_STATUSES = new Set(["approved", "failed", "stopped", "rejected", "completed"]);
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
// Post-PR-3 of KJC-PCS-0047 both helpers point at the canonical
|
|
43
|
+
// `~/.karajan/` root. `getKjHome` is kept as an alias of
|
|
44
|
+
// `getKarajanHome` so legacy callers inside the file continue to
|
|
45
|
+
// compile during the consolidation; both names resolve to the same
|
|
46
|
+
// directory now that the layout is unified.
|
|
46
47
|
function getKarajanHome() {
|
|
47
|
-
return
|
|
48
|
+
return resolveHome({ defaultSegment: ".karajan" });
|
|
48
49
|
}
|
|
50
|
+
const getKjHome = getKarajanHome;
|
|
49
51
|
|
|
50
52
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
51
53
|
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// One-shot ~/.kj/ → ~/.karajan/ home consolidation (PR 2 of KJC-PCS-0047).
|
|
2
|
+
// Idempotent via `<target>/.kj-migrated.json`. Tarball backup under
|
|
3
|
+
// `<target>/backup/`. `plans/`, `standby/`, `worktrees/` are moved
|
|
4
|
+
// wholesale; `runs/` is merged with `.karajan/` winning on conflict.
|
|
5
|
+
// Cross-device safe. Non-fatal: CLI bootstrap wraps in try/catch.
|
|
6
|
+
|
|
7
|
+
import fs from "node:fs/promises";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
|
|
12
|
+
const MARKER = ".kj-migrated.json";
|
|
13
|
+
const MOVE_DIRS = ["plans", "standby", "worktrees"];
|
|
14
|
+
|
|
15
|
+
async function readdirOrNull(dir) {
|
|
16
|
+
try { return await fs.readdir(dir); } catch (err) {
|
|
17
|
+
if (err.code === "ENOENT") return null;
|
|
18
|
+
throw err;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function moveChildren(src, dst, { mergeMode = false } = {}) {
|
|
23
|
+
const children = await readdirOrNull(src);
|
|
24
|
+
if (!children?.length) return 0;
|
|
25
|
+
await fs.mkdir(dst, { recursive: true });
|
|
26
|
+
let n = 0;
|
|
27
|
+
for (const c of children) {
|
|
28
|
+
const dstChild = path.join(dst, c);
|
|
29
|
+
if (mergeMode) {
|
|
30
|
+
try { await fs.access(dstChild); continue; } catch { /* no conflict */ }
|
|
31
|
+
}
|
|
32
|
+
const srcChild = path.join(src, c);
|
|
33
|
+
try { await fs.rename(srcChild, dstChild); } catch (err) {
|
|
34
|
+
if (err.code !== "EXDEV") throw err;
|
|
35
|
+
await fs.cp(srcChild, dstChild, { recursive: true });
|
|
36
|
+
await fs.rm(srcChild, { recursive: true, force: true });
|
|
37
|
+
}
|
|
38
|
+
n += 1;
|
|
39
|
+
}
|
|
40
|
+
await fs.rm(src, { recursive: true, force: true }).catch(() => {});
|
|
41
|
+
return n;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function migrateKjToKarajan({ dryRun = false, force = false } = {}) {
|
|
45
|
+
// VITEST guard — tests setting KJ_HOME would otherwise migrate against
|
|
46
|
+
// the developer's real HOME. Migrator's own tests pass `force: true`.
|
|
47
|
+
if (process.env.VITEST && !force) return { migrated: false, reason: "vitest-skip" };
|
|
48
|
+
|
|
49
|
+
const sourceDir = process.env.KJ_HOME || path.join(os.homedir(), ".kj");
|
|
50
|
+
const targetDir = process.env.KARAJAN_HOME || path.join(os.homedir(), ".karajan");
|
|
51
|
+
|
|
52
|
+
if (path.resolve(sourceDir) === path.resolve(targetDir)) {
|
|
53
|
+
return { migrated: false, reason: "same-path" };
|
|
54
|
+
}
|
|
55
|
+
const entries = await readdirOrNull(sourceDir);
|
|
56
|
+
if (!entries) return { migrated: false, reason: "source-missing" };
|
|
57
|
+
if (entries.length === 0) return { migrated: false, reason: "source-empty" };
|
|
58
|
+
|
|
59
|
+
await fs.mkdir(targetDir, { recursive: true });
|
|
60
|
+
const markerPath = path.join(targetDir, MARKER);
|
|
61
|
+
try { await fs.access(markerPath); return { migrated: false, reason: "already-migrated" }; }
|
|
62
|
+
catch { /* not present — proceed */ }
|
|
63
|
+
|
|
64
|
+
if (dryRun) return { migrated: false, reason: "dry-run", sourceDir, targetDir };
|
|
65
|
+
|
|
66
|
+
const backupDir = path.join(targetDir, "backup");
|
|
67
|
+
await fs.mkdir(backupDir, { recursive: true });
|
|
68
|
+
const ts = new Date().toISOString().replaceAll(/[:.]/g, "-");
|
|
69
|
+
const backupPath = path.join(backupDir, `kj-pre-migration-${ts}.tar.gz`);
|
|
70
|
+
await execa("tar", ["-czf", backupPath, "-C", path.dirname(sourceDir), path.basename(sourceDir)]);
|
|
71
|
+
|
|
72
|
+
const counts = {};
|
|
73
|
+
for (const sub of MOVE_DIRS) {
|
|
74
|
+
counts[sub] = await moveChildren(path.join(sourceDir, sub), path.join(targetDir, sub));
|
|
75
|
+
}
|
|
76
|
+
counts.runs = await moveChildren(path.join(sourceDir, "runs"), path.join(targetDir, "runs"), { mergeMode: true });
|
|
77
|
+
|
|
78
|
+
const marker = { version: 1, migrated_at: new Date().toISOString(), items_moved: counts, backup_path: backupPath, source_dir: sourceDir, target_dir: targetDir };
|
|
79
|
+
await fs.writeFile(markerPath, JSON.stringify(marker, null, 2));
|
|
80
|
+
|
|
81
|
+
const summary = Object.entries(counts).filter(([, n]) => n > 0).map(([k, n]) => `${n} ${k}`).join(" + ");
|
|
82
|
+
if (summary) {
|
|
83
|
+
// eslint-disable-next-line no-console
|
|
84
|
+
console.warn(`\x1b[33m[warn]\x1b[0m Migrated ${summary} from ${sourceDir} to ${targetDir} (backup: ${backupPath})`);
|
|
85
|
+
}
|
|
86
|
+
return { migrated: true, counts, backupPath, marker };
|
|
87
|
+
}
|
package/src/utils/paths.js
CHANGED
|
@@ -1,32 +1,85 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
-
// Per-process
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
function vitestTmpKarajanHome() {
|
|
13
|
-
if (_vitestKarajanHome) return _vitestKarajanHome;
|
|
14
|
-
_vitestKarajanHome = path.join(
|
|
4
|
+
// Per-process vitest root. We memoise the *root* (not the suffixed
|
|
5
|
+
// `.karajan` / `.kj` path) so callers with different legacy defaults
|
|
6
|
+
// — plan-store wants `.kj`, db.js wants `.karajan` — can share one
|
|
7
|
+
// random tmp prefix per test run without colliding.
|
|
8
|
+
let _vitestRoot = null;
|
|
9
|
+
function vitestRoot() {
|
|
10
|
+
if (_vitestRoot) return _vitestRoot;
|
|
11
|
+
_vitestRoot = path.join(
|
|
15
12
|
os.tmpdir(),
|
|
16
|
-
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}
|
|
17
|
-
".karajan"
|
|
13
|
+
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`
|
|
18
14
|
);
|
|
19
|
-
return
|
|
15
|
+
return _vitestRoot;
|
|
20
16
|
}
|
|
21
17
|
|
|
22
|
-
|
|
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
|
+
}
|
|
23
59
|
if (process.env.KJ_HOME) {
|
|
60
|
+
emitKjHomeDeprecationWarning();
|
|
24
61
|
return path.resolve(process.env.KJ_HOME);
|
|
25
62
|
}
|
|
26
63
|
if (process.env.VITEST) {
|
|
27
|
-
return
|
|
64
|
+
return vitestTmpHome(defaultSegment);
|
|
28
65
|
}
|
|
29
|
-
return path.join(os.homedir(),
|
|
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;
|
|
30
83
|
}
|
|
31
84
|
|
|
32
85
|
export function getSessionRoot() {
|
|
@@ -36,3 +89,18 @@ export function getSessionRoot() {
|
|
|
36
89
|
export function getSonarComposePath() {
|
|
37
90
|
return path.join(getKarajanHome(), "docker-compose.sonar.yml");
|
|
38
91
|
}
|
|
92
|
+
|
|
93
|
+
/** Webperf cache: `<karajan-home>/webperf/` — KJC-TSK-0420. */
|
|
94
|
+
export function getWebperfDir() {
|
|
95
|
+
return path.join(getKarajanHome(), "webperf");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Run-registry: `<karajan-home>/runs/` — KJC-TSK-0420. */
|
|
99
|
+
export function getRunsDir() {
|
|
100
|
+
return path.join(getKarajanHome(), "runs");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Board prompt bridge: `<karajan-home>/prompts/` — KJC-TSK-0420. */
|
|
104
|
+
export function getPromptsDir() {
|
|
105
|
+
return path.join(getKarajanHome(), "prompts");
|
|
106
|
+
}
|
|
@@ -19,16 +19,16 @@
|
|
|
19
19
|
|
|
20
20
|
import fs from "node:fs";
|
|
21
21
|
import path from "node:path";
|
|
22
|
-
import os from "node:os";
|
|
23
22
|
import { writeJsonAtomicSync } from "./atomic-write.js";
|
|
23
|
+
import { getRunsDir } from "./paths.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Directorio raíz donde viven los registros de run.
|
|
27
|
-
*
|
|
27
|
+
* Delega en `getRunsDir()` (KJC-TSK-0420) — respeta KARAJAN_HOME,
|
|
28
|
+
* KJ_HOME (con warning de deprecación), VITEST tmp y default.
|
|
28
29
|
*/
|
|
29
30
|
export function runsDir() {
|
|
30
|
-
|
|
31
|
-
return path.join(home, "runs");
|
|
31
|
+
return getRunsDir();
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
You are the **Spec Reviewer**. You run BEFORE every other role. You audit the
|
|
2
|
+
user's spec (prompt or `.md`) for deficiencies. You do NOT execute or plan.
|
|
3
|
+
|
|
4
|
+
## Categories (every finding gets exactly one)
|
|
5
|
+
|
|
6
|
+
- **ambiguity** — words without a concrete metric ("better", "clean", "fast").
|
|
7
|
+
- **missing_scope** — no boundary of what should and should not be touched.
|
|
8
|
+
- **missing_ac** — no acceptance criteria; nothing to verify against.
|
|
9
|
+
- **contradiction** — two requirements that cannot both be satisfied.
|
|
10
|
+
- **stack** — no tech stack hint when one is required.
|
|
11
|
+
- **assumptions** — depends on unstated context.
|
|
12
|
+
- **out_of_scope** — asks for things outside what Karajan can do.
|
|
13
|
+
|
|
14
|
+
## Severity
|
|
15
|
+
|
|
16
|
+
`info` (nuance), `warn` (real gap), `fail` (unworkable). Top-level `severity`
|
|
17
|
+
is the WORST of any finding, or `ok` if findings is empty.
|
|
18
|
+
|
|
19
|
+
## Output (JSON, nothing else)
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"severity": "ok|info|warn|fail",
|
|
24
|
+
"findings": [
|
|
25
|
+
{
|
|
26
|
+
"id": "F-001",
|
|
27
|
+
"severity": "info|warn|fail",
|
|
28
|
+
"category": "ambiguity|missing_scope|missing_ac|contradiction|stack|assumptions|out_of_scope",
|
|
29
|
+
"message": "quote the offending phrase verbatim when possible",
|
|
30
|
+
"suggestion": "a sentence the user can paste into the spec"
|
|
31
|
+
}
|
|
32
|
+
],
|
|
33
|
+
"reasoning": "one or two sentences summarising the verdict"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Direct, accionable. No moralising. `suggestion` must be concrete (a paste-able
|
|
38
|
+
sentence), not vague advice.
|