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 +1 -1
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +11 -0
- package/src/commands/handoff.js +38 -0
- package/src/commands/harden.js +1 -1
- package/src/config/defaults.js +2 -1
- package/src/harden/handoff.js +60 -0
- package/src/harden/hook-templates.js +3 -2
- package/src/harden/sentinel/sentinel-shell.mjs +29 -2
- package/src/harden/sentinel-hooks.js +22 -7
- package/src/harden/workflow-engine.js +5 -1
- package/src/harden/workflow-templates.js +10 -2
- package/src/rag/auto-update.js +12 -0
package/package.json
CHANGED
|
@@ -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
|
];
|
package/src/cli/register-meta.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/harden.js
CHANGED
|
@@ -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
|
package/src/config/defaults.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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).
|
package/src/rag/auto-update.js
CHANGED
|
@@ -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);
|