karajan-code 3.7.2 → 3.9.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.
@@ -0,0 +1,52 @@
1
+ // KJC-TSK-0569 (Onboard B) — prompt + intent schema for the StartDecidor.
2
+ //
3
+ // The decider only DECIDES (one intent from a closed set); the safe HOW is
4
+ // deterministic (0570 maps the intent to a saved command). Keep this prompt
5
+ // tight: it routes, it never executes.
6
+ const PREAMBLE = [
7
+ "IMPORTANT: You are running as a Karajan sub-agent (the `kj start` decider).",
8
+ "Do NOT mention Karajan, do NOT use MCP tools, do NOT execute anything.",
9
+ "Your only job: read the project assessment and pick ONE next-step intent.",
10
+ ].join(" ");
11
+
12
+ /** Closed set of intents. Anything else degrades to ASK_USER. */
13
+ export const INTENTS = new Set([
14
+ "ASSESS_ONLY",
15
+ "RECOMMEND_HARDEN",
16
+ "RECOMMEND_INDEX",
17
+ "START_TASK",
18
+ "PROPOSE_PLAN",
19
+ "ASK_USER",
20
+ ]);
21
+
22
+ const INTENT_GUIDE = [
23
+ "- ASSESS_ONLY: the project is healthy and the user gave no goal — just report.",
24
+ "- RECOMMEND_HARDEN: quality gaps exist (no tests/CI, drifting checks, missing config).",
25
+ "- RECOMMEND_INDEX: the codebase has no knowledge index yet.",
26
+ "- START_TASK: the user stated a concrete thing to build or fix.",
27
+ "- PROPOSE_PLAN: legacy/large project, or a goal that needs a modernization/feature plan.",
28
+ "- ASK_USER: intent is unclear — ask ONE open question to disambiguate.",
29
+ ].join("\n");
30
+
31
+ /**
32
+ * @param {{userMessage?: string, assessment?: string, instructions?: string}} input
33
+ * @returns {string}
34
+ */
35
+ export function buildStartDecisionPrompt({ userMessage = "", assessment = "", instructions } = {}) {
36
+ const sections = [PREAMBLE];
37
+ if (instructions) sections.push(instructions);
38
+ sections.push(
39
+ "You route the next step for a project the user just opened with `kj start`.",
40
+ "## Project assessment (read-only signals)",
41
+ assessment || "(no assessment available)",
42
+ "## User goal",
43
+ userMessage.trim() || "(none given)",
44
+ "## Intents",
45
+ INTENT_GUIDE,
46
+ "Pick the single best intent. Prefer a concrete next step over ASK_USER when the assessment is clear.",
47
+ "If you pick ASK_USER, put the open question in questionToAsk.",
48
+ "Return a single valid JSON object and nothing else.",
49
+ 'JSON schema: {"intent":"ASSESS_ONLY|RECOMMEND_HARDEN|RECOMMEND_INDEX|START_TASK|PROPOSE_PLAN|ASK_USER","rationale":string,"questionToAsk":string}',
50
+ );
51
+ return sections.join("\n\n");
52
+ }
@@ -2,6 +2,7 @@ import { AgentRole } from "./agent-role.js";
2
2
  import { buildRtkInstructions } from "../prompts/rtk-snippet.js";
3
3
  import { extractFirstJson } from "../utils/json-extract.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "../prompts/prompt-layout.js";
5
+ import { isMutationReviewEnabled, buildReviewerMutationSignal } from "../mutate/reviewer-signal.js";
5
6
 
6
7
  const MAX_DIFF_LENGTH = 12000;
7
8
 
@@ -38,6 +39,11 @@ export class ReviewerRole extends AgentRole {
38
39
  // on every review — last. The buckets ride along so ClaudeAgent can
39
40
  // ship the stable block via --append-system-prompt (Φ1-D).
40
41
  async buildPrompt({ task, diff, reviewRules }) {
42
+ // Opt-in (KJ_REVIEW_MUTATION): null when off/clean → prompt stays identical.
43
+ const mutationSignal = await buildReviewerMutationSignal({
44
+ enabled: isMutationReviewEnabled(),
45
+ projectDir: this.config?.projectDir,
46
+ });
41
47
  const layout = buildPromptLayout([
42
48
  section(SUBAGENT_PREAMBLE, STABLE),
43
49
  section(this.instructions, STABLE),
@@ -51,6 +57,7 @@ export class ReviewerRole extends AgentRole {
51
57
  section(reviewRules ? `Review rules:\n${reviewRules}` : null, STABLE),
52
58
  section(`Task context:\n${task}`, VOLATILE),
53
59
  section(`Git diff:\n${truncateDiff(diff)}`, VOLATILE),
60
+ section(mutationSignal, VOLATILE),
54
61
  ]);
55
62
  return { prompt: joinLayout(layout), stablePrompt: layout.stable, volatilePrompt: layout.volatile };
56
63
  }
@@ -0,0 +1,74 @@
1
+ // KJC-TSK-0569 (Onboard B) — deterministic synthesis of the read-only sweep.
2
+ //
3
+ // Turns the sweep bundle (Onboard A, 0568) into one coherent digest: a
4
+ // human-readable report plus a machine summary the decider (StartDecidorRole)
5
+ // consumes to pick an intent. No LLM here — the costly call is the haiku
6
+ // decider, not a redundant strong-model narration. Reuses the harden advisory
7
+ // formatter so the improvements section reads exactly like `kj harden --report`.
8
+ import { formatAdvisoryReport } from "../harden/advisory.js";
9
+
10
+ const EMPTY_TALLY = { install: 0, update: 0, review: 0, keep: 0 };
11
+
12
+ /** Tally harden advisory artifacts by recommendation. */
13
+ function tallyImprovements(improvements) {
14
+ const tally = { ...EMPTY_TALLY };
15
+ for (const a of improvements?.artifacts ?? []) {
16
+ if (a?.recommendation in tally) tally[a.recommendation] += 1;
17
+ }
18
+ return tally;
19
+ }
20
+
21
+ /** Quality/maintenance gaps as short phrases, derived from signals + drift. */
22
+ function collectGaps(signals = {}, drift) {
23
+ const gaps = [];
24
+ if (!signals.hasTests) gaps.push("no tests");
25
+ if (!signals.hasCI) gaps.push("no CI");
26
+ if (typeof signals.staleDays === "number" && signals.staleDays > 365) {
27
+ gaps.push(`last commit ${signals.staleDays}d old`);
28
+ }
29
+ if (drift && drift.ok === false) {
30
+ const failed = (drift.checks ?? []).filter((c) => c?.ok === false).length;
31
+ if (failed > 0) gaps.push(`${failed} harden check(s) drifting`);
32
+ }
33
+ return gaps;
34
+ }
35
+
36
+ /**
37
+ * @param {object} bundle output of runReadOnlySweep (0568)
38
+ * @returns {{text: string, summary: object}}
39
+ */
40
+ export function buildAssessment(bundle) {
41
+ if (!bundle || typeof bundle !== "object") {
42
+ throw new TypeError("buildAssessment: bundle must be an object");
43
+ }
44
+ const { maturity = {}, signals = {}, drift, improvements, rag, qmd } = bundle;
45
+ const gaps = collectGaps(signals, drift);
46
+ const improvementCounts = tallyImprovements(improvements);
47
+
48
+ const summary = {
49
+ maturity: maturity.maturity ?? "unknown",
50
+ declared: Boolean(maturity.declared),
51
+ deepHealthRecommended: Boolean(maturity.deepHealthRecommended),
52
+ gaps,
53
+ driftOk: drift ? drift.ok !== false : null,
54
+ improvementCounts,
55
+ indexed: rag ? Boolean(rag.indexed) : null,
56
+ qmdAvailable: qmd ? Boolean(qmd.available) : null,
57
+ };
58
+
59
+ const lines = [
60
+ `Project maturity: ${summary.maturity}${summary.declared ? " (declared)" : " (inferred)"}`,
61
+ ...(maturity.reasons ?? []).map((r) => ` - ${r}`),
62
+ "",
63
+ `Tests: ${signals.hasTests ? "yes" : "no"} · CI: ${signals.hasCI ? "yes" : "no"} · commits: ${signals.commitCount ?? 0}`,
64
+ `Quality gaps: ${gaps.length ? gaps.join(", ") : "none detected"}`,
65
+ `Knowledge index: ${summary.indexed === null ? "n/a" : summary.indexed ? "present" : "missing"}` +
66
+ ` · qmd: ${summary.qmdAvailable === null ? "n/a" : summary.qmdAvailable ? "available" : "absent"}`,
67
+ ];
68
+
69
+ if (improvements) {
70
+ lines.push("", ...formatAdvisoryReport(improvements));
71
+ }
72
+
73
+ return { text: lines.join("\n"), summary };
74
+ }
@@ -0,0 +1,73 @@
1
+ // KJC-TSK-0568 (Onboard A) — maturity classifier for the Brain (`kj start`).
2
+ //
3
+ // Pure and deterministic: given a bundle of read-only signals it labels the
4
+ // project new | existing | legacy. No LLM, no I/O — the sweep (sweep.js) does
5
+ // the reading; this only decides. The label drives which read-only subset the
6
+ // orchestrator runs and, downstream (0570), whether a deep health pass is worth
7
+ // the cost. A user-declared maturity always wins over inference.
8
+ //
9
+ // Acceptance criteria (KJC-TSK-0568):
10
+ // - empty repo / scaffolding only -> new
11
+ // - code + config + tests/CI -> existing
12
+ // - code but missing tests/CI, or very stale -> legacy
13
+
14
+ /** Last-commit age (days) beyond which a repo counts as stalled/neglected. */
15
+ export const STALE_DAYS_THRESHOLD = 365;
16
+
17
+ const VALID = new Set(["new", "existing", "legacy"]);
18
+
19
+ /**
20
+ * @param {object} signals
21
+ * @param {("new"|"existing"|"legacy"|null)} [signals.declared] user override
22
+ * @param {boolean} signals.hasSourceCode real source files present
23
+ * @param {boolean} signals.scaffoldingOnly only generator boilerplate, no real code
24
+ * @param {boolean} signals.hasTests a test suite is present
25
+ * @param {boolean} signals.hasCI CI workflows are present
26
+ * @param {number} [signals.commitCount] number of commits (capped upstream)
27
+ * @param {number|null} [signals.staleDays] age of the last commit in days
28
+ * @returns {{maturity: "new"|"existing"|"legacy", reasons: string[], deepHealthRecommended: boolean, declared: boolean}}
29
+ */
30
+ export function classifyMaturity(signals) {
31
+ if (!signals || typeof signals !== "object") {
32
+ throw new TypeError("classifyMaturity: signals must be an object");
33
+ }
34
+
35
+ if (VALID.has(signals.declared)) {
36
+ const maturity = signals.declared;
37
+ return {
38
+ maturity,
39
+ reasons: [`maturity declared by user as "${maturity}"`],
40
+ deepHealthRecommended: maturity === "legacy",
41
+ declared: true,
42
+ };
43
+ }
44
+
45
+ const reasons = [];
46
+ let maturity;
47
+
48
+ if (!signals.hasSourceCode || signals.scaffoldingOnly) {
49
+ maturity = "new";
50
+ reasons.push(signals.scaffoldingOnly ? "only scaffolding present, no real code yet" : "no source code found");
51
+ } else {
52
+ const stalled = typeof signals.staleDays === "number" && signals.staleDays > STALE_DAYS_THRESHOLD;
53
+ const gaps = [];
54
+ if (!signals.hasTests) gaps.push("no tests");
55
+ if (!signals.hasCI) gaps.push("no CI");
56
+ if (stalled) gaps.push(`last commit ${signals.staleDays}d old (stalled)`);
57
+
58
+ if (gaps.length > 0) {
59
+ maturity = "legacy";
60
+ reasons.push(`code present but neglected: ${gaps.join(", ")}`);
61
+ } else {
62
+ maturity = "existing";
63
+ reasons.push("code with tests and CI, actively maintained");
64
+ }
65
+ }
66
+
67
+ return {
68
+ maturity,
69
+ reasons,
70
+ deepHealthRecommended: maturity === "legacy",
71
+ declared: false,
72
+ };
73
+ }
@@ -0,0 +1,79 @@
1
+ // KJC-TSK-0569 (Onboard B) — the StartDecidor: the only LLM in the Brain's
2
+ // onboarding. Reads the deterministic assessment + user goal, picks ONE intent
3
+ // from a closed set, and never executes (0570 maps the intent to a safe command).
4
+ // Provider falls back to the coder, model defaults to haiku. Unparseable/unknown
5
+ // output degrades to ASK_USER so `kj start` always has a sane next step.
6
+ import { AgentRole } from "../roles/agent-role.js";
7
+ import { extractFirstJson } from "../utils/json-extract.js";
8
+ import { buildStartDecisionPrompt, INTENTS } from "../prompts/start-decision.js";
9
+
10
+ const FALLBACK_QUESTION = "What would you like to do with this project?";
11
+
12
+ export class StartDecidorRole extends AgentRole {
13
+ constructor(opts) {
14
+ super({ ...opts, name: "start" });
15
+ }
16
+
17
+ extractInput(input) {
18
+ const src = typeof input === "string" ? { userMessage: input } : input || {};
19
+ return {
20
+ userMessage: src.userMessage || "",
21
+ assessment: src.assessment || "",
22
+ onOutput: src.onOutput || null,
23
+ ...src,
24
+ };
25
+ }
26
+
27
+ async buildPrompt({ userMessage, assessment }) {
28
+ return {
29
+ prompt: buildStartDecisionPrompt({ userMessage, assessment, instructions: this.instructions }),
30
+ };
31
+ }
32
+
33
+ parseOutput(raw) {
34
+ return extractFirstJson(raw);
35
+ }
36
+
37
+ buildSuccessResult(parsed, provider) {
38
+ const intent = INTENTS.has(parsed.intent) ? parsed.intent : "ASK_USER";
39
+ const rationale = String(parsed.rationale || "").trim();
40
+ const questionToAsk = String(parsed.questionToAsk || "").trim();
41
+ const degraded = intent !== parsed.intent;
42
+ return {
43
+ intent,
44
+ rationale: rationale || (degraded ? "Unrecognized intent, asking the user." : "No rationale provided."),
45
+ questionToAsk: intent === "ASK_USER" ? questionToAsk || FALLBACK_QUESTION : "",
46
+ provider,
47
+ };
48
+ }
49
+
50
+ buildSummary(parsed) {
51
+ const intent = INTENTS.has(parsed.intent) ? parsed.intent : "ASK_USER";
52
+ return `Start decision: ${intent}`;
53
+ }
54
+
55
+ handleParseNull(agentResult, provider) {
56
+ return this.degradedDecision({ agentResult, provider, reason: "no JSON found" });
57
+ }
58
+
59
+ handleParseError(err, agentResult, provider) {
60
+ return this.degradedDecision({ agentResult, provider, reason: err?.message || "parse error" });
61
+ }
62
+
63
+ // Never block onboarding: garbage in → ask the user one open question.
64
+ degradedDecision({ agentResult, provider, reason }) {
65
+ this.logger?.warn?.(`[start] decider output unusable (${reason}) — asking the user.`);
66
+ return {
67
+ ok: true,
68
+ result: {
69
+ intent: "ASK_USER",
70
+ rationale: `Could not read a decision (${reason}).`,
71
+ questionToAsk: FALLBACK_QUESTION,
72
+ provider,
73
+ degraded: true,
74
+ },
75
+ summary: "Start decision: ASK_USER (fallback)",
76
+ usage: agentResult.usage,
77
+ };
78
+ }
79
+ }
@@ -0,0 +1,112 @@
1
+ // KJC-TSK-0568 (Onboard A) — read-only sweep orchestrator for the Brain.
2
+ //
3
+ // Runs the READ-ONLY signals fitting the project's maturity and aggregates them
4
+ // into one structured bundle for synthesis (Onboard B, 0569). Writes nothing.
5
+ // No LLM: maturity is deterministic and the costly audit is deferred — legacy
6
+ // only flags `deepHealthRecommended` (0570 decides). Subset by maturity: new =
7
+ // brief+tests; existing/legacy = + drift, harden advisory, rag + qmd status.
8
+ // Collaborators are injected (opts.deps) so it stays trivially testable.
9
+ import { existsSync } from "node:fs";
10
+ import { execFileSync } from "node:child_process";
11
+
12
+ import { collectAll } from "../onboarder/collectors/index.js";
13
+ import { checkHarden } from "../harden/check.js";
14
+ import { compareHarden } from "../harden/advisory.js";
15
+ import { detectTestFramework } from "../utils/project-detect.js";
16
+ import { detectQmd } from "../utils/qmd-detect.js";
17
+ import {
18
+ dbPath, openVecStore, projectSlug, countChunks, getLastIndexedCommit,
19
+ } from "../rag/vec-store.js";
20
+ import { classifyMaturity } from "./maturity.js";
21
+
22
+ const CODE_EXT = new Set([
23
+ ".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx", ".py", ".go",
24
+ ".rs", ".java", ".php", ".rb", ".c", ".cc", ".cpp", ".cs",
25
+ ]);
26
+ const SCAFFOLD_MAX_FILES = 3;
27
+ const CI_MARKERS = [".github/workflows", ".gitlab-ci.yml"];
28
+
29
+ const safe = async (fn) => { try { return await fn(); } catch { return null; } };
30
+
31
+ /** Count real source files in a tree bundle, skipping tests. */
32
+ export function countCodeFiles(tree = []) {
33
+ let n = 0;
34
+ for (const node of tree) {
35
+ if (node.kind === "dir") n += countCodeFiles(node.children);
36
+ else if (node.kind === "file") {
37
+ const ext = node.path.slice(node.path.lastIndexOf("."));
38
+ const isTest = /\.(test|spec)\./.test(node.path) || /(^|\/)(test|tests|__tests__)\//.test(node.path);
39
+ if (CODE_EXT.has(ext) && !isTest) n += 1;
40
+ }
41
+ }
42
+ return n;
43
+ }
44
+
45
+ /** Age of the last commit in days, or null on non-git / no commits. */
46
+ function lastCommitAgeDays(projectDir) {
47
+ try {
48
+ const out = execFileSync("git", ["log", "-1", "--format=%ct"], {
49
+ cwd: projectDir, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
50
+ }).trim();
51
+ if (!out) return null;
52
+ return Math.floor((Date.now() - Number(out) * 1000) / 86_400_000);
53
+ } catch { return null; }
54
+ }
55
+
56
+ /** Read-only RAG index status; null when no index file exists yet. */
57
+ function readRagStatus(projectDir) {
58
+ try {
59
+ if (!existsSync(dbPath())) return { indexed: false, chunks: 0, lastIndexedCommit: null };
60
+ const db = openVecStore();
61
+ return {
62
+ indexed: true,
63
+ chunks: countChunks(db, { kind: "code" }),
64
+ lastIndexedCommit: getLastIndexedCommit(db, projectSlug(projectDir)),
65
+ };
66
+ } catch { return null; }
67
+ }
68
+
69
+ /**
70
+ * @param {string} projectDir
71
+ * @param {{declared?: ("new"|"existing"|"legacy"|null), profile?: string, deps?: object}} [opts]
72
+ * declared = user-declared maturity; deps = injected collaborators (defaults = real collectors).
73
+ */
74
+ export async function runReadOnlySweep(projectDir, { declared = null, profile = "standard", deps = {} } = {}) {
75
+ const d = {
76
+ collectAll, detectTestFramework, checkHarden, compareHarden,
77
+ detectQmd, ragStatus: readRagStatus, gitAgeDays: lastCommitAgeDays, ...deps,
78
+ };
79
+
80
+ const brief = await safe(() => d.collectAll(projectDir));
81
+ const tests = await safe(() => d.detectTestFramework(projectDir));
82
+
83
+ const tree = brief?.tree ?? [];
84
+ const present = brief?.configs?.present ?? [];
85
+ const codeFiles = countCodeFiles(tree);
86
+ const commitCount = brief?.git?.commitCount ?? 0;
87
+
88
+ const signals = {
89
+ hasSourceCode: codeFiles > 0,
90
+ scaffoldingOnly: codeFiles > 0 && codeFiles <= SCAFFOLD_MAX_FILES && commitCount <= 1,
91
+ hasTests: Boolean(tests?.hasTests),
92
+ hasCI: CI_MARKERS.some((m) => present.includes(m)),
93
+ commitCount,
94
+ staleDays: d.gitAgeDays(projectDir),
95
+ };
96
+
97
+ const maturity = classifyMaturity({ declared, ...signals });
98
+ const deep = maturity.maturity !== "new";
99
+
100
+ return {
101
+ projectDir,
102
+ maturity,
103
+ signals,
104
+ brief,
105
+ tests,
106
+ drift: deep ? await safe(() => d.checkHarden({ projectDir, profile })) : null,
107
+ improvements: deep ? await safe(() => d.compareHarden({ projectDir, profile })) : null,
108
+ rag: deep ? await safe(() => d.ragStatus(projectDir)) : null,
109
+ qmd: deep ? await safe(() => d.detectQmd()) : null,
110
+ collectedAt: new Date().toISOString(),
111
+ };
112
+ }
@@ -1,11 +1,47 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { isSea } from "node:sea";
3
4
  import { getKarajanHome } from "./paths.js";
4
5
 
5
6
  const CACHE_FILE = "update-check.json";
6
7
  const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
7
8
  const PACKAGE_NAME = "karajan-code";
8
9
 
10
+ const RAW_BASE = "https://raw.githubusercontent.com/manufosela/karajan-code/main/scripts";
11
+ export const INSTALL_SH_URL = `${RAW_BASE}/install-binary.sh`;
12
+ export const INSTALL_PS1_URL = `${RAW_BASE}/install-binary.ps1`;
13
+
14
+ /**
15
+ * Build the "how to update" line for the detected install channel.
16
+ * Pure and testable — no I/O.
17
+ * @param {{channel: "sea"|"npm"|"unknown", platform?: string}} opts
18
+ */
19
+ export function updateInstruction({ channel, platform = process.platform }) {
20
+ if (channel === "sea") {
21
+ return platform === "win32"
22
+ ? `Re-run the installer: irm ${INSTALL_PS1_URL} | iex`
23
+ : `Re-run the installer: curl -fsSL ${INSTALL_SH_URL} | sh`;
24
+ }
25
+ if (channel === "npm") {
26
+ return `Run: npm install -g ${PACKAGE_NAME}`;
27
+ }
28
+ // Channel unknown — offer both paths, never silently pick a wrong one.
29
+ return `Update: npm install -g ${PACKAGE_NAME} (or re-run the binary installer — see README)`;
30
+ }
31
+
32
+ /**
33
+ * Detect how this kj was installed: "sea" (standalone binary) or "npm"
34
+ * (global install / source tree). Falls back to "npm" if isSea() throws.
35
+ */
36
+ export function detectInstallChannel() {
37
+ try {
38
+ return isSea() ? "sea" : "npm";
39
+ } catch {
40
+ // isSea() unexpectedly threw ⇒ treat as npm / source tree, never block.
41
+ return "npm";
42
+ }
43
+ }
44
+
9
45
  /**
10
46
  * Check npm for a newer version. Non-blocking, cached for 24h.
11
47
  * Returns { updateAvailable, latest, current } or null if check fails/cached.
@@ -61,8 +97,9 @@ export async function checkForUpdate(currentVersion) {
61
97
  export async function printUpdateNotice(currentVersion) {
62
98
  const result = await checkForUpdate(currentVersion);
63
99
  if (result?.updateAvailable) {
100
+ const channel = detectInstallChannel();
64
101
  console.log(`\n Update available: v${result.current} → v${result.latest}`);
65
- console.log(` Run: npm install -g ${PACKAGE_NAME}\n`);
102
+ console.log(` ${updateInstruction({ channel })}\n`);
66
103
  }
67
104
  }
68
105