karajan-code 4.47.0 → 4.48.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "4.47.0",
3
+ "version": "4.48.1",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -35,7 +35,7 @@ export const ADVANCED_GROUPS = [
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
36
  { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
37
37
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "claims", "steward", "pr-size"] },
38
- { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security"] },
38
+ { title: "Sesión / board", commands: ["resume", "report", "board", "hu", "adr", "worktree", "undo", "standby", "sentinel", "identity", "security", "handoff"] },
39
39
  { title: "Infra / setup", commands: ["install-tools", "ollama", "skills", "roles", "agents", "env"] },
40
40
  { title: "Mantenimiento", commands: ["clean", "sync", "telemetry", "report-issue"] },
41
41
  ];
@@ -9,6 +9,7 @@ import { bootstrapCommand } from "../commands/bootstrap.js";
9
9
  import { PENDING_EXIT_CODE } from "../utils/pending-user-action.js";
10
10
  import { identityCommand } from "../commands/identity.js";
11
11
  import { securityCommand } from "../commands/security.js";
12
+ import { handoffCommand } from "../commands/handoff.js";
12
13
  import { policyCommand } from "../commands/policy.js";
13
14
  import { claimsCommand, claimsGateCommand } from "../commands/claims.js";
14
15
  import { stewardSweepCommand } from "../commands/steward.js";
@@ -193,6 +194,16 @@ export function registerMeta(program, { pkgVersion }) {
193
194
  });
194
195
  }
195
196
 
197
+ // KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through kj.
198
+ const handoffCmd = program.command("handoff").description("Handoffs: what a gate denied the agent, registered with the gate's why, for the human to read and run by id");
199
+ const handoff = (action, flags) => withConfig(pkgVersion, `handoff-${action}`, flags, async ({ config }) => { process.exitCode = await handoffCommand({ action, config, flags }); });
200
+ handoffCmd.command("add").description("Register the EXACT command a gate denied (the why comes from the Sentinel's record, not from you)")
201
+ .argument("<command...>", "the denied command, as it was run").option("--cwd <dir>", "Directory the command runs in (default: the project)")
202
+ .action((parts, flags) => handoff("add", { ...flags, command: parts.join(" ") }));
203
+ handoffCmd.command("list", { isDefault: true }).description("The handoffs registered in this project").action((flags) => handoff("list", flags));
204
+ handoffCmd.command("show").description("A handoff: the exact command, its sha256 and why the gate denied it")
205
+ .argument("<id>", "handoff id (8 hex)").option("--json", "Output the record as JSON").action((id, flags) => handoff("show", { ...flags, id }));
206
+
196
207
  // KJC-TSK-0986 (HUM-F): the invitation to enroll the phone, once per version.
197
208
  identityCmd
198
209
  .command("phone-invite")
@@ -0,0 +1,38 @@
1
+ /**
2
+ * kj handoff add|list|show (KJC-TSK-0968, HUM-E, ADR 0018): the agent registers
3
+ * what a gate denied it, with the gate's why; the human reads it by id.
4
+ */
5
+ import { addHandoff, listHandoffs, readHandoff } from "../harden/handoff.js";
6
+
7
+ const line = (h) => `${h.id} ${h.status.padEnd(8)} ${h.createdAt} ${h.command}`;
8
+
9
+ /** @returns {Promise<0|1>} */
10
+ export async function handoffCommand({ action = "list", config, flags = {}, log = console.log }) {
11
+ const projectDir = config?.projectDir || process.cwd();
12
+ try {
13
+ if (action === "add") {
14
+ const record = addHandoff({ projectDir, command: flags.command, cwd: flags.cwd ?? projectDir });
15
+ log(`encargo ${record.id} registrado: ${record.command}`);
16
+ log(`por qué lo negó el gate (${record.denial.at}):\n${record.denial.why}`);
17
+ log(`tu usuario lo revisa con kj handoff show ${record.id}; nadie copia y pega el comando.`);
18
+ return 0;
19
+ }
20
+ if (action === "show") {
21
+ const h = readHandoff({ projectDir, id: flags.id });
22
+ if (flags.json) { log(JSON.stringify(h)); return 0; }
23
+ log(`encargo ${h.id} (${h.status}, ${h.createdAt})`);
24
+ log(`comando ${h.command}`);
25
+ log(`sha256 ${h.sha256}`);
26
+ log(`cwd ${h.cwd}`);
27
+ log(`gate ${h.denial.tool} negó el ${h.denial.at}:\n${h.denial.why}`);
28
+ return 0;
29
+ }
30
+ const all = listHandoffs({ projectDir });
31
+ if (all.length === 0) log("kj handoff: ningún encargo registrado");
32
+ for (const h of all) log(line(h));
33
+ return 0;
34
+ } catch (err) {
35
+ log(err.message);
36
+ return 1;
37
+ }
38
+ }
@@ -186,7 +186,7 @@ export async function hardenCommand({
186
186
  const cfg = withConfig ? installConfigsForRoots({ projectDir, roots, dryRun }) : null;
187
187
  const withCi = ci && profile !== "minimal";
188
188
  const wf = withCi
189
- ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, profile, mutation, dryRun })
189
+ ? installWorkflows({ projectDir, language: roots[0]?.language ?? null, root: roots[0]?.dir ?? ".", profile, mutation, dryRun })
190
190
  : null;
191
191
  const withGuidelines = guidelines && profile !== "minimal";
192
192
  // KJC-BUG-0199 (issue #1773): the same detected language the workflows and
@@ -254,7 +254,8 @@ const DEFAULTS = {
254
254
  // delta re-index before every `kj run`; `onCommit` is consulted by
255
255
  // `kj rag install-hooks` to decide whether to install the post-merge
256
256
  // hook. Both default ON; flip to false to opt out without uninstalling.
257
- autoUpdate: { onCommit: true, onRun: true },
257
+ // maxFiles (KJC-BUG-0304): past this drift the pre-query refresh answers from the index as it is.
258
+ autoUpdate: { onCommit: true, onRun: true, maxFiles: 25 },
258
259
  // KJC-TSK-0682 — sensitivity of the indexed code. Cloud embedders
259
260
  // (openai/voyage/cohere/mistral) require an explicit "public"; the
260
261
  // safe default blocks them (local ollama/onnx always allowed).
@@ -0,0 +1,60 @@
1
+ /**
2
+ * KJC-TSK-0968 (HUM-E, ADR 0018): the agent's handoff to the human goes through
3
+ * kj. When a gate denies the agent a command, the agent registers a HANDOFF with
4
+ * the exact command; the why is what the gate said, read from the Sentinel's
5
+ * record of denials, never a text of the agent. The human later reads and runs
6
+ * it by id (E2, E3). Handoffs live in .karajan/handoffs/<id>.json, local.
7
+ */
8
+ import { createHash } from "node:crypto";
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ export const HANDOFFS_DIR = ".karajan/handoffs";
13
+ export const DENIALS_FILE = ".karajan/harness/denials.jsonl";
14
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
15
+
16
+ /** The Sentinel's denials, oldest first; a line that is not JSON is skipped. */
17
+ export function readDenials({ projectDir }) {
18
+ let text;
19
+ try { text = readFileSync(join(projectDir, DENIALS_FILE), "utf8"); } catch { return []; }
20
+ return text.split("\n").filter(Boolean).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
21
+ }
22
+
23
+ /** The latest denial of this EXACT Bash command, or null. */
24
+ export function findDenial({ projectDir, command }) {
25
+ return readDenials({ projectDir }).findLast((d) => d?.tool === "Bash" && d?.input?.command === command) ?? null;
26
+ }
27
+
28
+ /**
29
+ * Registers a handoff for a command a gate denied. Without a denial of those
30
+ * exact bytes there is nothing to hand off: the agent does not write the why.
31
+ * @returns {{id: string, command: string, sha256: string, why: string}}
32
+ */
33
+ export function addHandoff({ projectDir, command, cwd = projectDir }) {
34
+ if (typeof command !== "string" || !command.trim()) throw new Error("kj handoff add: falta el comando exacto que el gate negó");
35
+ const denial = findDenial({ projectDir, command });
36
+ if (!denial) throw new Error("kj handoff add: kj no tiene registrado ningún deny para ese comando exacto; el porqué de un encargo lo escribe el gate, no el agente (ADR 0018)");
37
+ const createdAt = new Date().toISOString();
38
+ const digest = sha256(command);
39
+ const id = sha256(`${digest}:${createdAt}`).slice(0, 8);
40
+ const record = { id, command, sha256: digest, cwd, createdAt, status: "pending", denial: { at: denial.at, tool: denial.tool, why: denial.why } };
41
+ mkdirSync(join(projectDir, HANDOFFS_DIR), { recursive: true });
42
+ writeFileSync(join(projectDir, HANDOFFS_DIR, `${id}.json`), `${JSON.stringify(record, null, 2)}\n`, "utf8");
43
+ return record;
44
+ }
45
+
46
+ export function readHandoff({ projectDir, id }) {
47
+ if (!/^[0-9a-f]{8}$/.test(id ?? "")) throw new Error(`kj handoff: "${id}" no es un identificador de encargo (8 hex)`);
48
+ const path = join(projectDir, HANDOFFS_DIR, `${id}.json`);
49
+ if (!existsSync(path)) throw new Error(`kj handoff: no hay ningún encargo ${id}`);
50
+ const record = JSON.parse(readFileSync(path, "utf8"));
51
+ // The bytes the human will run are the bytes that were registered, or nothing.
52
+ if (sha256(record.command ?? "") !== record.sha256) throw new Error(`kj handoff: el encargo ${id} no coincide con su sha256: alguien lo tocó, no se ejecuta`);
53
+ return record;
54
+ }
55
+
56
+ export function listHandoffs({ projectDir }) {
57
+ let names;
58
+ try { names = readdirSync(join(projectDir, HANDOFFS_DIR)).filter((n) => /^[0-9a-f]{8}\.json$/.test(n)); } catch { return []; }
59
+ return names.map((n) => readHandoff({ projectDir, id: n.slice(0, 8) })).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
60
+ }
@@ -135,8 +135,9 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
135
135
  "fi",
136
136
  "# KJC-BUG-0296 (issue #1989): the host agent's runtime and local state never",
137
137
  "# enter a commit, however they were staged (a blanket `git add -A` put a",
138
- "# Claude scheduler lock into main). Checked on the staged list itself.",
139
- "if git diff --cached --name-only | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
138
+ "# Claude scheduler lock into main). Checked on the staged list itself,",
139
+ "# deletions left out: untracking a runtime file is the fix (KJC-BUG-0307).",
140
+ "if git diff --cached --name-only --diff-filter=d | grep -qE '^(\\.claude/[^/]*\\.lock|\\.claude/scheduled_tasks\\.lock|\\.kj/|\\.karajan/identity\\.local\\.yml|\\.karajan/harness/sentinel-state\\.json)'; then",
140
141
  " echo 'kj harden: agent runtime files are staged (.claude/*.lock, .kj/, identity.local.yml, sentinel-state.json) — unstage them: git restore --staged <path>'; exit 1",
141
142
  "fi",
142
143
  "# v4 review gate (ENV-C1, opt-in via `kj review --install-gate`):",
@@ -64,6 +64,28 @@ export const headIndex = (words, heads) => {
64
64
  return k < 0 ? words.length : i + k;
65
65
  };
66
66
 
67
+ /**
68
+ * KJC-BUG-0306: a simple command that runs `gh ... issue create`, read by words
69
+ * (quotes, escapes and newlines as the shell reads them), not by words anywhere.
70
+ * @param {string} cmd
71
+ */
72
+ export const isGhIssueCreate = (cmd) => shellSegments(cmd).some((words) => {
73
+ const i = headIndex(words, ["gh"]);
74
+ if (words[i]?.split("/").at(-1) !== "gh") return false;
75
+ // The subcommand words, with every flag skipped together with a value that may
76
+ // follow it (--repo x, --hostname y): what is left is "issue", then "create".
77
+ const subcommand = [];
78
+ let afterFlag = false;
79
+ for (const w of words.slice(i + 1)) {
80
+ if (w.startsWith("-")) { afterFlag = !w.includes("="); continue; }
81
+ if (afterFlag && !["issue", "create"].includes(w)) { afterFlag = false; continue; }
82
+ afterFlag = false;
83
+ subcommand.push(w);
84
+ if (subcommand.length === 2) break;
85
+ }
86
+ return subcommand[0] === "issue" && subcommand[1] === "create";
87
+ });
88
+
67
89
  // KJC-BUG-0286: a $ expands only when something expandable follows it ($x, ${x},
68
90
  // $(cmd), $1, $@...). A lone "$ " in a grep pattern is a literal dollar sign.
69
91
  const LIVE_EXPANSION = /\$(?=[A-Za-z0-9_{(@*#?$!-])|`/;
@@ -90,11 +112,16 @@ export const stripInertQuotes = (cmd) => {
90
112
  };
91
113
 
92
114
  // Options whose value is prose, never a path (gh, git, kj).
93
- const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position"]);
115
+ // KJC-BUG-0306: --error and --command of kj report-issue, --scope and --tests of kj hu add.
116
+ const TEXT_OPTIONS = new Set(["--title", "--body", "--message", "-m", "--notes", "--description", "--ac", "--criteria", "--reason", "--decision", "--context", "--consequences", "--position", "--error", "--command", "--scope", "--tests"]);
94
117
  const QUOTED_OPTION_VALUE = /(^|\s)(--?[a-z]+)(=|\s+)("[^"$`\\]*"|'[^']*')/g;
118
+ // KJC-BUG-0306: the title kj hu add and kj adr add take as their first argument, at the start of a segment.
119
+ const KJ_TITLE = /(^|[;&|]\s*)(kj\s+(?:hu|adr)\s+add\s+)("[^"$`\\]*"|'[^']*')/g;
95
120
 
96
121
  /** KJC-BUG-0243: blank the inert quoted value of a text option (--title "a/b c"): prose, not a path. */
97
- export const stripTextOptionValues = (cmd) => cmd.replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
122
+ export const stripTextOptionValues = (cmd) => cmd
123
+ .replace(KJ_TITLE, (m, pre, head, val) => `${pre}${head}${val[0]}${val[0]}`)
124
+ .replace(QUOTED_OPTION_VALUE, (m, pre, opt, sep, val) => (TEXT_OPTIONS.has(opt) ? `${pre}${opt}${sep}${val[0]}${val[0]}` : m));
98
125
 
99
126
  /**
100
127
  * KJC-BUG-0245: the values of the named options, read by the shell reader, so a
@@ -467,13 +467,13 @@ const PRETOOL_BODY = `#!/usr/bin/env node
467
467
  import console from "node:console";
468
468
  import process from "node:process";
469
469
  import { dirname, join, relative, resolve } from "node:path";
470
- import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
470
+ import { appendFileSync, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
471
471
  import { spawnSync } from "node:child_process";
472
472
  import { homedir } from "node:os";
473
473
  import { fileURLToPath } from "node:url";
474
- import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
474
+ import { doc, CODE, TESTS, ROOT, BASE_BRANCHES, CARD, STATE, branchOf, foreignLane, load, save, session, violations, boardGate, pendingMoves, pendingText } from "./sentinel-lib.mjs";
475
475
  // KJC-TSK-0915 (ADR 0014): the shell reader is a real, unit-tested module copied here as is.
476
- import { optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
476
+ import { isGhIssueCreate, optionValues, shellSegments, stripInertQuotes, stripTextOptionValues } from "./sentinel-shell.mjs";
477
477
  import { DISCARD_VERBS, discardOf, foreignLost, removedFiles } from "./sentinel-discard.mjs";
478
478
  import { shellWrites, writesRepo } from "./sentinel-bash-write.mjs";
479
479
  const EDIT_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
@@ -495,8 +495,17 @@ process.stdin.on("end", () => {
495
495
  try {
496
496
  const { session_id: sid = "default", tool_name: tool, tool_input: input = {}, transcript_path: transcript = null } = JSON.parse(raw);
497
497
  // KJC-TSK-0920 (SNT-E): every deny ends with the same line, whatever gate it was.
498
+ // KJC-TSK-0968 (HUM-E, ADR 0018): and leaves a record next to the state, with the
499
+ // gate's own words: kj handoff takes the why from here, never from the agent.
500
+ const said = [];
501
+ const errOrig = console.error.bind(console);
502
+ console.error = (m) => { said.push(String(m)); errOrig(m); };
498
503
  process.on("exit", (code) => {
499
- if (code === 2) console.error("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
504
+ if (code !== 2) return;
505
+ errOrig("karajan: Karajan gobierna y se le obedece. No rodees el gate ni cambies la politica para pasarlo; si te parece injusto, diselo a tu usuario o usa kj report-issue.");
506
+ // Only what a handoff needs: the exact Bash command, or the path an edit aimed at; never file contents.
507
+ const what = tool === "Bash" ? { command: input.command } : { file_path: input.file_path || input.notebook_path || null };
508
+ try { appendFileSync(join(dirname(STATE), "denials.jsonl"), JSON.stringify({ at: new Date().toISOString(), sid, tool, input: what, why: said.join("\\n") }) + "\\n"); } catch { /* the deny stands without its record */ }
500
509
  });
501
510
  // KJC-BUG-0238 (#1886): de quien es cada cambio. La primera vez que la
502
511
  // sesion toca un fichero se anota si estaba limpio: solo entonces un
@@ -574,7 +583,9 @@ process.stdin.on("end", () => {
574
583
  // (sanitiza y busca duplicados), nunca por gh issue create a mano.
575
584
  {
576
585
  const ghIssue = String(input.command || "");
577
- if (tool === "Bash" && /(^|[^a-zA-Z])gh([^a-zA-Z]|$)/.test(ghIssue) && /(^|[^a-z])issue([^a-z]|$)/.test(ghIssue) && /(^|[^a-z])create([^a-z]|$)/.test(ghIssue)) {
586
+ // KJC-BUG-0306: the subcommand itself (gh, its flags, issue create), not three words
587
+ // anywhere: "gh pr create --title 'kj report-issue ...'" is not an issue.
588
+ if (tool === "Bash" && isGhIssueCreate(ghIssue)) {
578
589
  // The target: --repo/-R, an inline or inherited GH_REPO, else the
579
590
  // checkout's own origin, which is what gh takes (review catches).
580
591
  const inlineRepo = /(^|[^A-Z_])GH_REPO=([^ ]+)/.exec(ghIssue);
@@ -884,7 +895,7 @@ process.stdin.on("end", () => {
884
895
  // KJC-BUG-0243: inert quoted text is not a substitution, and the prose value
885
896
  // of a text option (--title, -m...) is not a path.
886
897
  if (/\\$\\(|\`/.test(stripInertQuotes(cmd)) || quotedPathWithSpaces(stripTextOptionValues(cmd))) {
887
- console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion." + doc("cross-lane"));
898
+ console.error("karajan sentinel: sustitucion de comandos o ruta entrecomillada con espacios en un comando mutador — no verificable por el guard de carriles (MONO-0); usa valores/rutas LITERALES sin sustitucion. Si es texto (titulo, descripcion, error) con comillas de codigo o dolar, ponlo entre comillas SIMPLES: entre dobles la shell lo ejecuta." + doc("cross-lane"));
888
899
  process.exit(2);
889
900
  }
890
901
  const SAFE_EXP_SEG = /^([A-Za-z_][A-Za-z0-9_]*=[^ \\t]*[ \\t]*)*((npm|pnpm|yarn|vitest|jest|kj|gh|echo|printf|true|test)\\b|git[ \\t](?![^\\n]*(-C[ \\t]|--git-dir|--work-tree)))[^;|&\\n]*$|^[A-Za-z_][A-Za-z0-9_]*=[^;|&\\n]*$/;
@@ -1038,7 +1049,11 @@ process.stdin.on("end", () => {
1038
1049
  // above rules on it) and the ledger only holds in-repo paths.
1039
1050
  // KJC-BUG-0190: with nothing indexed, no query can cover anything —
1040
1051
  // the gate asks for kj rag index instead of denying the impossible.
1041
- const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
1052
+ // KJC-BUG-0308: a barrel only re-exports its folder, so the RAG answers with
1053
+ // what it re-exports and never with the barrel: a hit in its own subtree covers it.
1054
+ const BARRELS = ["index.js", "index.ts", "index.mjs", "index.cjs", "index.jsx", "index.tsx", "__init__.py", "mod.rs"];
1055
+ const barrelCovered = dirOf(rel) !== "" && BARRELS.includes(rel.slice(rel.lastIndexOf("/") + 1)) && hits.some((h) => h.startsWith(dirOf(rel) + "/"));
1056
+ const covered = rel.startsWith("..") || hits.includes(rel) || hits.some((h) => dirOf(h) === dirOf(rel)) || barrelCovered || ((rs.rag_queries || []).length > 0 && fresh()) || rs.rag_index_empty === true;
1042
1057
  // ADR 0015 (KJC-TSK-0925): no escape. The cases it covered (new file, empty
1043
1058
  // index, not indexable) are decided by the gate itself.
1044
1059
  if (!covered) {
@@ -73,10 +73,14 @@ export function installWorkflows({
73
73
  mutation = false,
74
74
  dryRun = false,
75
75
  kjVersion = null,
76
+ root = ".",
76
77
  } = {}) {
77
78
  const dir = join(projectDir, WORKFLOWS_DIR);
78
79
  const pm = detectPackageManager(projectDir);
79
- const quality = qualityWorkflowFor(language, pm, hasLintScript(projectDir));
80
+ // KJC-BUG-0309: the Quality workflow reads the package manager and the lint
81
+ // script where the language root is, and runs its steps there.
82
+ const appDir = join(projectDir, root);
83
+ const quality = qualityWorkflowFor(language, detectPackageManager(appDir), hasLintScript(appDir), root);
80
84
  const extras = extraWorkflowsFor({ profile, publishable: isPublishableNpm(projectDir), pm });
81
85
  const mut = mutation ? mutationWorkflowFor(language, pm) : null;
82
86
  // PL-C: el tier C solo existe donde el proyecto DECLARA policy. Versión a
@@ -166,13 +166,21 @@ const QUALITY_BY_LANGUAGE = {
166
166
  * `lint:false` drops the JS lint step entirely (project has no lint script,
167
167
  * KJC-BUG-0137) — mirroring the local hook, which also omits it.
168
168
  */
169
- export function qualityWorkflowFor(language, pm = "npm", lint = true) {
169
+ export function qualityWorkflowFor(language, pm = "npm", lint = true, root = ".") {
170
170
  const c = PM_COMMANDS[pm] || PM_COMMANDS.npm;
171
171
  const body =
172
172
  language === "javascript" || language === "typescript"
173
173
  ? header([...nodeSetupSteps(pm), ...(lint ? [` - run: ${c.lint}`] : []), ` - run: ${c.test}`])
174
174
  : QUALITY_BY_LANGUAGE[language];
175
- return body ? { file: "kj-quality.yml", blockId: "wf-quality", body } : null;
175
+ return body ? { file: "kj-quality.yml", blockId: "wf-quality", body: inRoot(body, root) } : null;
176
+ }
177
+
178
+ // KJC-BUG-0309 (#2023): a repo whose app lives in a subdirectory runs every step
179
+ // there. The root goes into YAML, so it is a plain relative path or nothing.
180
+ function inRoot(body, root) {
181
+ if (root === "." || root === "") return body;
182
+ if (!/^[\w.-]+(\/[\w.-]+)*$/.test(root) || root.split("/").includes("..")) throw new Error(`kj harden: "${root}" is not a safe directory for the Quality workflow`);
183
+ return body.replace(" runs-on: ubuntu-latest\n", ` runs-on: ubuntu-latest\n defaults:\n run:\n working-directory: ${root}\n`);
176
184
  }
177
185
 
178
186
  // Caps the net LOC delta of a PR (strict profile only — it is opinionated).
@@ -12,6 +12,8 @@ import { ragExclude } from "./indexable.js";
12
12
  import { makeGovernedEmbedder } from "./governed-embedder.js";
13
13
  import { openProjectStore } from "./project-store.js";
14
14
 
15
+ // The pre-query refresh reindexes at most this many changed files (config.rag.autoUpdate.maxFiles).
16
+ export const DEFAULT_MAX_DRIFT_FILES = 25;
15
17
  const HOOK_SRC = resolve(fileURLToPath(import.meta.url), "../../../scripts/git-hooks/post-merge");
16
18
 
17
19
  export async function maybeAutoUpdate({ projectDir, config, logger = console, flags = {} } = {}) {
@@ -26,6 +28,16 @@ export async function maybeAutoUpdate({ projectDir, config, logger = console, fl
26
28
  try {
27
29
  const since = getLastIndexedCommit(db, slug);
28
30
  if (!since || since === head) return { skipped: true, head };
31
+ // KJC-BUG-0304: a drift of hundreds of files (an index weeks old) reindexed the
32
+ // repo for half an hour before the query answered. Past the bound the query
33
+ // answers from the index as it is, and says how to bring it up to date.
34
+ const maxFiles = config?.rag?.autoUpdate?.maxFiles ?? DEFAULT_MAX_DRIFT_FILES;
35
+ const { stdout } = await execa("git", ["-C", projectDir, "diff", "--name-only", since, "HEAD"]);
36
+ const changed = stdout.split("\n").filter(Boolean).length;
37
+ if (changed > maxFiles) {
38
+ logger.warn?.(`[rag] ${changed} file(s) changed since the last index (more than ${maxFiles}): answering from the index as it is; bring it up to date with kj rag index --since auto`);
39
+ return { skipped: true, reason: "drift-too-large", changed, head };
40
+ }
29
41
  logger.info?.(`[rag] drift detected (${since.slice(0, 7)} → ${head.slice(0, 7)}); running delta update`);
30
42
  const totals = await indexProjectDelta(projectDir, { db, embedder: makeGovernedEmbedder(config), since, logger, exclude: ragExclude(config) });
31
43
  if (totals.head) setLastIndexedCommit(db, slug, totals.head);