karajan-code 4.35.1 → 4.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/hu-board/public/utils/board-view.js +2 -1
- package/packages/hu-board/public/utils/config-editor.js +5 -2
- package/packages/hu-board/public/utils/terminal-panel.js +2 -1
- package/packages/hu-board/src/config-yaml.js +11 -9
- package/packages/hu-board/src/project-dir.js +42 -0
- package/packages/hu-board/src/routes/api.js +11 -3
- package/packages/hu-board/src/terminal-wire.js +5 -1
- package/packages/hu-board/src/terminal.js +8 -3
- package/src/checks/rag-coverage.js +5 -4
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +26 -4
- package/src/commands/board.js +20 -8
- package/src/commands/bootstrap.js +2 -0
- package/src/commands/env.js +8 -4
- package/src/commands/harden.js +26 -4
- package/src/commands/init.js +2 -1
- package/src/commands/plan/generate.js +4 -6
- package/src/commands/pr-size.js +45 -0
- package/src/commands/rag.js +69 -10
- package/src/commands/review-gate.js +47 -6
- package/src/commands/watch.js +13 -10
- package/src/config/defaults.js +3 -2
- package/src/environment/playbook.js +10 -3
- package/src/harden/guidelines-engine.js +24 -7
- package/src/harden/hook-templates.js +7 -0
- package/src/harden/phone-sign.js +28 -11
- package/src/harden/sentinel-hooks.js +82 -7
- package/src/lang/registry.js +2 -1
- package/src/mcp/handlers/rag-handler.js +14 -4
- package/src/orchestrator/drivers/post-loop.js +13 -11
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
- package/src/orchestrator/stages/rag-context-stage.js +5 -2
- package/src/rag/auto-update.js +5 -3
- package/src/rag/coverage.js +13 -12
- package/src/rag/indexable.js +128 -0
- package/src/rag/indexer.js +31 -13
- package/src/rag/library.js +19 -13
- package/src/rag/migrate.js +73 -0
- package/src/rag/onnx-fallback.js +5 -3
- package/src/rag/project-store.js +48 -0
- package/src/rag/watcher.js +25 -14
- package/src/review/comment-only.js +93 -0
- package/src/review/kj-provenance.js +26 -0
- package/src/review/tests-with-code.js +5 -2
- package/src/review/verdict-store.js +6 -1
- package/src/start/sweep.js +4 -3
- package/src/utils/rulesync.js +61 -0
package/src/commands/rag.js
CHANGED
|
@@ -1,19 +1,49 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join, relative } from "node:path";
|
|
1
3
|
// KJC-PCS-0049 Step 6 — `kj rag` command group. Two subcommands:
|
|
2
4
|
// kj rag index [--project <slug>] [--with-sources]
|
|
3
5
|
// kj rag query <text> [--scope plans|code|onboarding|all] [--top-k N] [--json]
|
|
4
6
|
// Closes the v2.22.0 RAG MVP end-to-end from the terminal.
|
|
5
|
-
import {
|
|
7
|
+
import { countChunks, deleteChunksBySource, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
|
|
6
8
|
import { makeGovernedEmbedder } from "../rag/governed-embedder.js";
|
|
7
|
-
import { indexProject, indexProjectDelta } from "../rag/indexer.js";
|
|
9
|
+
import { indexFile, indexProject, indexProjectDelta, sourceKind } from "../rag/indexer.js";
|
|
10
|
+
import { indexableReason, ragExclude } from "../rag/indexable.js";
|
|
8
11
|
import { fileIndexState } from "../rag/coverage.js";
|
|
9
12
|
import { query } from "../rag/retriever.js";
|
|
10
13
|
import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
|
|
11
14
|
import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
|
|
12
15
|
import { loadGoldenQueries, runEval } from "../rag/eval.js";
|
|
13
16
|
import { getKarajanHome } from "../utils/paths.js";
|
|
17
|
+
import { countProjectChunks, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
|
|
18
|
+
import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
|
|
14
19
|
|
|
20
|
+
// KJC-TSK-0882 (ADR 0011): el indice es del proyecto, no de la maquina.
|
|
15
21
|
function openDb(config) {
|
|
16
|
-
return
|
|
22
|
+
return openProjectStore({ projectDir: config?.projectDir || process.cwd(), dim: config?.rag?.embedder?.dim || 768 });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function indexFiles({ files, projectDir, db, embedder, slug, logger, json, exclude }) {
|
|
26
|
+
const totals = { indexed: 0, failed: 0, files: 0, deleted: 0 };
|
|
27
|
+
for (const file of files) {
|
|
28
|
+
const abs = isAbsolute(file) ? file : join(projectDir, file);
|
|
29
|
+
if (!existsSync(abs)) {
|
|
30
|
+
totals.deleted += deleteChunksBySource(db, abs); // gone from disk: gone from the index
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
// KJC-TSK-0891: the same criterion and kind as the full walk, or the
|
|
34
|
+
// remedy rag-first names would index nothing for a .mdx or a .astro.
|
|
35
|
+
const out = indexableReason(relative(projectDir, abs), abs, { exclude, projectDir });
|
|
36
|
+
if (out) {
|
|
37
|
+
logger.warn?.(`[rag] ${out}: not indexed`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(abs) });
|
|
41
|
+
totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
|
|
42
|
+
}
|
|
43
|
+
const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
|
|
44
|
+
if (json) process.stdout.write(`${JSON.stringify(totals)}\n`);
|
|
45
|
+
else logger.info(`[rag] indexed ${totals.indexed} chunk(s) across ${totals.files} file(s) (${totals.failed} failed${deleted})`);
|
|
46
|
+
return totals;
|
|
17
47
|
}
|
|
18
48
|
|
|
19
49
|
export async function ragIndexCommand({ config, logger, flags = {} }) {
|
|
@@ -22,6 +52,9 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
|
|
|
22
52
|
try {
|
|
23
53
|
const slug = projectSlug(projectDir);
|
|
24
54
|
const embedder = makeGovernedEmbedder(config);
|
|
55
|
+
// KJC-TSK-0891: exactly these files. The remedy rag-first names for a file
|
|
56
|
+
// missing from the index: `--since auto` only reaches what changed.
|
|
57
|
+
if (flags.file?.length) return await indexFiles({ files: flags.file, projectDir, db, embedder, slug, logger, json: flags.json, exclude: ragExclude(config) });
|
|
25
58
|
const sinceFlag = flags.since;
|
|
26
59
|
// KJC-TSK-0455 — `--since auto` resolves to the last commit we indexed;
|
|
27
60
|
// an explicit ref is honoured as-is. Without a baseline (first-time
|
|
@@ -35,7 +68,7 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
|
|
|
35
68
|
let totals;
|
|
36
69
|
if (since) {
|
|
37
70
|
try {
|
|
38
|
-
totals = await indexProjectDelta(projectDir, { db, embedder, since, logger });
|
|
71
|
+
totals = await indexProjectDelta(projectDir, { db, embedder, since, logger, exclude: ragExclude(config) });
|
|
39
72
|
} catch (err) {
|
|
40
73
|
logger.warn?.(`[rag] delta index failed (${err.message}); falling back to full index`);
|
|
41
74
|
totals = null;
|
|
@@ -46,13 +79,17 @@ export async function ragIndexCommand({ config, logger, flags = {} }) {
|
|
|
46
79
|
db, embedder,
|
|
47
80
|
karajanHome: getKarajanHome(), logger,
|
|
48
81
|
withSources: Boolean(flags.withSources),
|
|
82
|
+
exclude: ragExclude(config),
|
|
49
83
|
});
|
|
50
84
|
}
|
|
51
85
|
if (totals.head) setLastIndexedCommit(db, slug, totals.head);
|
|
52
86
|
// KJC-TSK-0697 — the library corpus refreshes on every index run.
|
|
53
87
|
// Best-effort and cheap: a handful of cards, content-hash dedup.
|
|
88
|
+
// KJC-TSK-0883: el canon de la maquina a su base; las fichas del proyecto, a la suya.
|
|
54
89
|
try {
|
|
55
|
-
|
|
90
|
+
const lib = openLibraryStore({ dim: config?.rag?.embedder?.dim || 768 });
|
|
91
|
+
try { await indexLibrary({ db: lib, embedder, logger, scope: "machine" }); } finally { lib.close(); }
|
|
92
|
+
await indexLibrary({ db, embedder, logger, projectDir, scope: "project", project: slug });
|
|
56
93
|
} catch (err) {
|
|
57
94
|
logger.warn?.(`[rag] library index failed: ${err.message}`);
|
|
58
95
|
}
|
|
@@ -87,7 +124,8 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
87
124
|
// (--no-rag-update / config.rag.autoUpdate); failures degrade to a warn
|
|
88
125
|
// inside maybeAutoUpdate and the query proceeds with the current index.
|
|
89
126
|
await maybeAutoUpdate({ projectDir: config?.projectDir || process.cwd(), config, logger, flags });
|
|
90
|
-
|
|
127
|
+
// KJC-TSK-0883: --library abre la base del canon, no la del proyecto.
|
|
128
|
+
const db = flags.library ? openLibraryStore({ dim: config?.rag?.embedder?.dim || 768 }) : openDb(config);
|
|
91
129
|
try {
|
|
92
130
|
const topK = Math.max(1, Number(flags.topK) || 5);
|
|
93
131
|
const scope = flags.scope || "all";
|
|
@@ -107,9 +145,12 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
|
|
|
107
145
|
// deterministic recovery signal. The CLI was emitting just `[]`, which
|
|
108
146
|
// is indistinguishable from "query returned zero hits over a populated
|
|
109
147
|
// store" and forced consumers to parse stderr.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
148
|
+
// KJC-TSK-0882: cuenta lo de ESTE proyecto; chunks ajenos no son respuesta.
|
|
149
|
+
if (countChunks(db) === 0 || (project && countProjectChunks(db, project) === 0)) {
|
|
150
|
+
// La library se rellena en cada `kj rag index`; migrar la llevaria al proyecto.
|
|
151
|
+
const remedy = library ? "kj rag index" : emptyIndexRemedy({ slug: project, legacyPath: join(getKarajanHome(), "rag.db"), dim: config?.rag?.embedder?.dim || 768 });
|
|
152
|
+
logger.warn(`[rag] ${library ? "the library index" : "this project's index"} holds no chunks of ${project || "any project"}: this is not an answer, run ${remedy}`);
|
|
153
|
+
if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope, remedy })}\n`);
|
|
113
154
|
return [];
|
|
114
155
|
}
|
|
115
156
|
const mode = flags.mode || "hybrid";
|
|
@@ -183,7 +224,7 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
|
|
|
183
224
|
const projectDir = config?.projectDir || process.cwd();
|
|
184
225
|
const db = openDb(config);
|
|
185
226
|
try {
|
|
186
|
-
const state = fileIndexState(projectDir, file, { db });
|
|
227
|
+
const state = fileIndexState(projectDir, file, { db, exclude: ragExclude(config) });
|
|
187
228
|
if (flags.json) process.stdout.write(`${JSON.stringify(state)}\n`);
|
|
188
229
|
else process.stdout.write(`${state.state}: ${state.reason}\n`);
|
|
189
230
|
if (!state.canAnswer) process.exitCode = 1;
|
|
@@ -192,3 +233,21 @@ export async function ragCoversCommand({ file, config, flags = {} }) {
|
|
|
192
233
|
db.close();
|
|
193
234
|
}
|
|
194
235
|
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* KJC-TSK-0888 (RAG-P1a, ADR 0011) — `kj rag migrate`: copia los chunks de este
|
|
239
|
+
* proyecto desde la base global a su propio indice, con sus embeddings, sin
|
|
240
|
+
* volver a embeber. La base global no se toca.
|
|
241
|
+
*/
|
|
242
|
+
export async function ragMigrateCommand({ config, flags = {} }) {
|
|
243
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
244
|
+
const res = migrateProjectIndex({
|
|
245
|
+
slug: projectSlug(projectDir),
|
|
246
|
+
legacyPath: join(getKarajanHome(), "rag.db"),
|
|
247
|
+
targetPath: projectDbPath(projectDir),
|
|
248
|
+
dim: config?.rag?.embedder?.dim || 768,
|
|
249
|
+
});
|
|
250
|
+
if (flags.json) process.stdout.write(`${JSON.stringify(res)}\n`);
|
|
251
|
+
else process.stdout.write(`rag migrate: ${res.state} — ${res.reason}\n`);
|
|
252
|
+
return res;
|
|
253
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* verdict tied to the exact diff (verdict-store), so the pre-commit hook
|
|
6
6
|
* (ENV-C) can verify it. Exit code 0 = approved, 1 = rejected/stale.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync } from "node:fs";
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
9
|
import { isAbsolute, join } from "node:path";
|
|
10
10
|
import { runCommand } from "../utils/process.js";
|
|
11
11
|
import { checkVerdict, diffHash, pruneVerdicts } from "../review/verdict-store.js";
|
|
@@ -13,6 +13,7 @@ import { runOneShotReview } from "../review/one-shot-review.js";
|
|
|
13
13
|
import { runSolomonArbitration } from "../review/solomon-arbitration.js";
|
|
14
14
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
15
15
|
import { runSonarPregate, formatSonarFinding, addedLinesByFile } from "../review/sonar-pregate.js";
|
|
16
|
+
import { addedAreCommentsOnly } from "../review/comment-only.js";
|
|
16
17
|
import { checkSonarRequirement, SONAR_RULE_ID } from "../review/sonar-requirement.js";
|
|
17
18
|
import { checkRagRequirement, checkRagVerdict, ragBlock, RAG_RULE_ID } from "../review/rag-requirement.js";
|
|
18
19
|
import { checkUiEvidence, uiBlock } from "../review/ui-evidence.js";
|
|
@@ -59,6 +60,36 @@ async function rawDiff(range, extraArgs = []) {
|
|
|
59
60
|
return res.stdout;
|
|
60
61
|
}
|
|
61
62
|
|
|
63
|
+
/** The file as the reviewed diff leaves it. */
|
|
64
|
+
async function resultingContent(range, file, projectDir) {
|
|
65
|
+
const show = async (spec) => {
|
|
66
|
+
const res = await runCommand("git", ["show", spec]);
|
|
67
|
+
return res.exitCode === 0 ? res.stdout : null;
|
|
68
|
+
};
|
|
69
|
+
// Staged review: the INDEX, never the working tree (unstaged edits are not reviewed).
|
|
70
|
+
if (!range) return show(`:${file}`);
|
|
71
|
+
// `a..b` / `a...b`: the right side (HEAD when omitted).
|
|
72
|
+
if (/\.\./.test(range)) return show(`${range.split(/\.\.\.?/)[1] || "HEAD"}:${file}`);
|
|
73
|
+
// `git diff <ref>` compares the ref with the working tree, so that is the result.
|
|
74
|
+
try { return readFileSync(join(projectDir, file), "utf8"); } catch { return null; }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* KJC-BUG-0235: mark the files whose added lines are all comments, so a
|
|
79
|
+
* cleanup that corrects the comments it left lying is not taken for new code.
|
|
80
|
+
* Anything unreadable or of unknown syntax stays unmarked (counts as code).
|
|
81
|
+
*/
|
|
82
|
+
export async function markCommentOnlyAdditions(numstat, { range, projectDir }) {
|
|
83
|
+
const touched = numstat.filter((n) => n.added > 0);
|
|
84
|
+
if (touched.length === 0) return;
|
|
85
|
+
const added = addedLinesByFile(await rawDiff(range, ["--unified=0"]));
|
|
86
|
+
for (const n of touched) {
|
|
87
|
+
const lines = added.get(n.file);
|
|
88
|
+
const content = lines ? await resultingContent(range, n.file, projectDir) : null;
|
|
89
|
+
n.commentOnly = content !== null && addedAreCommentsOnly(content, lines, n.file);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
62
93
|
// KJC-TSK-0838: a grant is the ONLY way past the sonar requirement, so it is
|
|
63
94
|
// said with who gave it, its scope and until when.
|
|
64
95
|
const formatSonarGrant = (g) => {
|
|
@@ -188,6 +219,14 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
188
219
|
// KJC-TSK-0687 (MG-B): the verifiable half of TDD — sources without a
|
|
189
220
|
// single test change warn (block via method_gates.tests_with_code).
|
|
190
221
|
const changedFiles = (await rawDiff(flags.range, ["--name-only"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
222
|
+
// KJC-BUG-0229: un fichero BORRADO no es una fuente que el scan pueda indexar
|
|
223
|
+
// ni sobre la que el RAG pueda responder, asi que exigirlo era pedir lo
|
|
224
|
+
// imposible: una PR que retiraba 45 ficheros se paro dos veces y acumulo tres
|
|
225
|
+
// excepciones por la misma causa mecanica. Los requisitos de Sonar y de RAG
|
|
226
|
+
// se evaluan sobre lo que sigue VIVO tras el cambio (A, C, M, R); policy, LOC
|
|
227
|
+
// y tests-with-code siguen viendo el diff entero, porque ahi un borrado si
|
|
228
|
+
// cuenta. El borrado danino lo cubre la review cruzada, que lee todo el diff.
|
|
229
|
+
const liveFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=ACMR"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
191
230
|
|
|
192
231
|
// KJC-TSK-0688 (MG-C) + KJC-TSK-0691 (MG-E): oversized-diff policy.
|
|
193
232
|
// Field case: a 522-line PR sailed past the warning with a
|
|
@@ -278,6 +317,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
278
317
|
// exempt — deleting code adds no behavior to test.
|
|
279
318
|
const numstat = (await rawDiff(flags.range, ["--numstat"])).split("\n").map((l) => l.trim()).filter(Boolean)
|
|
280
319
|
.map((l) => { const [a, r, ...f] = l.split(/\s+/); return { file: f.join(" "), added: a === "-" ? 1 : Number(a) || 0, removed: r === "-" ? 0 : Number(r) || 0 }; });
|
|
320
|
+
await markCommentOnlyAdditions(numstat, { range: flags.range, projectDir });
|
|
281
321
|
const tests = checkTestsWithCode({ config, stagedFiles: changedFiles, numstat });
|
|
282
322
|
if (tests.mode === "delete-only") console.log(`⚠ tests-with-code: exempt — ${tests.reason}`);
|
|
283
323
|
if (tests.mode === "warn") console.log(`⚠ tests-with-code: ${tests.reason}`);
|
|
@@ -357,6 +397,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
357
397
|
}
|
|
358
398
|
}
|
|
359
399
|
let res = await checkVerdict(projectDir, diff);
|
|
400
|
+
if (res.note) console.log(`ℹ ${res.note}`);
|
|
360
401
|
// KJC-TSK-0838: a reviewed diff with code enters only if the verdict's
|
|
361
402
|
// sonar block proves the analysis ran and covered every staged source.
|
|
362
403
|
// The headless pipeline stamps its verdict after its own sonar stage and
|
|
@@ -371,7 +412,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
371
412
|
res = { ok: false, verdict: res.verdict, reason: `Sonar is mandatory for code — ${why}` };
|
|
372
413
|
}
|
|
373
414
|
} else if (res.ok) {
|
|
374
|
-
const req = checkSonarRequirement({ config, stagedFiles:
|
|
415
|
+
const req = checkSonarRequirement({ config, stagedFiles: liveFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
|
|
375
416
|
if (!req.ok) res = { ok: false, verdict: res.verdict, reason: req.reason };
|
|
376
417
|
else if (req.mode === "granted") console.log(formatSonarGrant(req.grant));
|
|
377
418
|
}
|
|
@@ -387,7 +428,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
387
428
|
if (!warnIds.includes(RAG_RULE_ID)) warnIds.push(RAG_RULE_ID);
|
|
388
429
|
} else if (res.ok) {
|
|
389
430
|
const { harness, verified, mismatched, drift } = readRagLedger(projectDir);
|
|
390
|
-
const rreq = checkRagVerdict({ config, stagedFiles:
|
|
431
|
+
const rreq = checkRagVerdict({ config, stagedFiles: liveFiles, rag: res.verdict.rag, harness, verified, mismatched, drift, standingExceptions: std.standing });
|
|
391
432
|
if (!rreq.ok) res = { ok: false, verdict: res.verdict, reason: rreq.reason };
|
|
392
433
|
else if (rreq.mode === "granted" && rreq.grant) console.log(formatRagGrant(rreq.grant));
|
|
393
434
|
}
|
|
@@ -414,7 +455,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
414
455
|
if (flags.sonar !== false) {
|
|
415
456
|
// KJC-TSK-0795 AC3: only issues on lines this diff ADDS may veto.
|
|
416
457
|
const touchedLines = addedLinesByFile(await rawDiff(flags.range, ["--unified=0"]));
|
|
417
|
-
const pre = await runSonarPregate({ config, stagedFiles:
|
|
458
|
+
const pre = await runSonarPregate({ config, stagedFiles: liveFiles, touchedLines, logger });
|
|
418
459
|
if (!pre.available) {
|
|
419
460
|
// KJC-BUG-0156 (issue #1543): a scan that FAILED must not read like one
|
|
420
461
|
// more config warning — it hid a dead quality gate for 8 straight PRs.
|
|
@@ -459,7 +500,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
459
500
|
// KJC-TSK-0838: fail-CLOSED for code. Sonar disabled, down, skipped by
|
|
460
501
|
// flag, or blind to a staged source — the diff does not reach the reviewer.
|
|
461
502
|
// Docs-only diffs pass; a live human grant on the rule is the only escape.
|
|
462
|
-
const sonarReq = checkSonarRequirement({ config, stagedFiles:
|
|
503
|
+
const sonarReq = checkSonarRequirement({ config, stagedFiles: liveFiles, sonar: sonarRecord, standingExceptions: std.standing });
|
|
463
504
|
if (!sonarReq.ok) {
|
|
464
505
|
console.log(`✗ ${sonarReq.reason}`);
|
|
465
506
|
process.exitCode = 1;
|
|
@@ -476,7 +517,7 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
476
517
|
// the 16-sep bugs were exactly the twin nobody touched.
|
|
477
518
|
const ledger = readRagLedger(projectDir);
|
|
478
519
|
const newFiles = (await rawDiff(flags.range, ["--name-only", "--diff-filter=A"])).split("\n").map((f) => f.trim()).filter(Boolean);
|
|
479
|
-
const ragReq = checkRagRequirement({ config, stagedFiles:
|
|
520
|
+
const ragReq = checkRagRequirement({ config, stagedFiles: liveFiles, newFiles, ledger, standingExceptions: std.standing });
|
|
480
521
|
if (!ragReq.ok) {
|
|
481
522
|
console.log(`✗ ${ragReq.reason}`);
|
|
482
523
|
process.exitCode = 1;
|
package/src/commands/watch.js
CHANGED
|
@@ -8,13 +8,13 @@ const HERE = dirname(fileURLToPath(import.meta.url));
|
|
|
8
8
|
|
|
9
9
|
export async function watchStartCommand({ config, logger, flags = {} }) {
|
|
10
10
|
const projectDir = config?.projectDir || process.cwd();
|
|
11
|
-
const existing = readPidFile();
|
|
11
|
+
const existing = readPidFile(projectDir);
|
|
12
12
|
if (existing && isPidAlive(existing)) { logger.info(`Watcher already running (pid ${existing}).`); return { pid: existing, started: false }; }
|
|
13
|
-
if (existing) clearPidFile();
|
|
13
|
+
if (existing) clearPidFile(projectDir);
|
|
14
14
|
if (flags.foreground) {
|
|
15
|
-
writePidFile();
|
|
15
|
+
writePidFile(projectDir);
|
|
16
16
|
const stop = startWatcher({ projectDir, config, logger, withSources: !!flags.withSources });
|
|
17
|
-
const shutdown = async () => { await stop(); clearPidFile(); process.exit(0); };
|
|
17
|
+
const shutdown = async () => { await stop(); clearPidFile(projectDir); process.exit(0); };
|
|
18
18
|
process.on("SIGINT", shutdown).on("SIGTERM", shutdown);
|
|
19
19
|
logger.info(`Watcher started in foreground (pid ${process.pid}). Ctrl+C to stop.`);
|
|
20
20
|
return { pid: process.pid, started: true };
|
|
@@ -26,18 +26,21 @@ export async function watchStartCommand({ config, logger, flags = {} }) {
|
|
|
26
26
|
return { pid: child.pid, started: true };
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
// KJC-TSK-0883: stop/status son del watcher de ESTE proyecto, resuelto como en start.
|
|
30
|
+
export async function watchStopCommand({ config, logger }) {
|
|
31
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
32
|
+
const pid = readPidFile(projectDir);
|
|
31
33
|
if (!pid) { logger.info("No watcher running."); return { stopped: false }; }
|
|
32
|
-
if (!isPidAlive(pid)) { clearPidFile(); logger.info(`Stale PID file removed (process ${pid} was not alive).`); return { stopped: false, stale: true }; }
|
|
34
|
+
if (!isPidAlive(pid)) { clearPidFile(projectDir); logger.info(`Stale PID file removed (process ${pid} was not alive).`); return { stopped: false, stale: true }; }
|
|
33
35
|
try { process.kill(pid, "SIGTERM"); } catch (err) { logger.warn(`Could not signal pid ${pid}: ${err.message}`); }
|
|
34
|
-
clearPidFile();
|
|
36
|
+
clearPidFile(projectDir);
|
|
35
37
|
logger.info(`Watcher (pid ${pid}) stopped.`);
|
|
36
38
|
return { stopped: true, pid };
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
export async function watchStatusCommand({ logger }) {
|
|
40
|
-
const
|
|
41
|
+
export async function watchStatusCommand({ config, logger }) {
|
|
42
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
43
|
+
const pid = readPidFile(projectDir);
|
|
41
44
|
const alive = pid && isPidAlive(pid);
|
|
42
45
|
if (alive) logger.info(`Watcher running (pid ${pid}).`);
|
|
43
46
|
else if (pid) logger.warn(`Stale PID file (process ${pid} not alive). Run \`kj watch stop\` to clean up.`);
|
package/src/config/defaults.js
CHANGED
|
@@ -179,10 +179,11 @@ const DEFAULTS = {
|
|
|
179
179
|
test_inclusions: "**/*.test.js,**/*.spec.js,**/tests/**,**/__tests__/**"
|
|
180
180
|
}
|
|
181
181
|
},
|
|
182
|
+
// KJC-TSK-0884 (ADR 0011): on by default, like Sonar; a declared false opts out.
|
|
182
183
|
hu_board: {
|
|
183
|
-
enabled:
|
|
184
|
+
enabled: true,
|
|
184
185
|
port: 4000,
|
|
185
|
-
auto_start:
|
|
186
|
+
auto_start: true
|
|
186
187
|
},
|
|
187
188
|
language: "en",
|
|
188
189
|
hu_language: "en",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
import fs from "node:fs/promises";
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
15
|
+
import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
|
|
15
16
|
import { panelLine } from "./panel.js";
|
|
16
17
|
|
|
17
18
|
// AB-A (KJC-TSK-0650): any agent can be the brain. AGENTS.md is the
|
|
@@ -114,15 +115,21 @@ export async function installPlaybook({ projectDir, target = "all", version = "1
|
|
|
114
115
|
throw new Error(`unknown target "${target}" — use one of: ${PLAYBOOK_TARGETS.join(", ")}`);
|
|
115
116
|
}
|
|
116
117
|
const files = [];
|
|
117
|
-
|
|
118
|
+
// KJC-TSK-0879: in a Rulesync repo its rule source is the only file of truth;
|
|
119
|
+
// `rulesync generate` would erase a block written into the agent files.
|
|
120
|
+
const rulesync = detectRulesync(projectDir);
|
|
121
|
+
for (const file of rulesync ? [RULESYNC_RULE] : TARGET_FILES[target]) {
|
|
118
122
|
const fullPath = path.join(projectDir, file);
|
|
119
|
-
let source = "";
|
|
123
|
+
let source = rulesync ? rulesyncRuleSeed() : "";
|
|
120
124
|
try { source = await fs.readFile(fullPath, "utf8"); } catch { /* new file */ }
|
|
121
125
|
const { content, action } = upsertManagedBlock({
|
|
122
126
|
source, blockId: "playbook", version, body: renderPlaybook({ stateBackend, boardName, config }), style: "html",
|
|
123
127
|
note: "do not edit: regenerated by kj env install",
|
|
124
128
|
});
|
|
125
|
-
if (action !== "unchanged")
|
|
129
|
+
if (action !== "unchanged") {
|
|
130
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
131
|
+
await fs.writeFile(fullPath, content);
|
|
132
|
+
}
|
|
126
133
|
files.push(file);
|
|
127
134
|
}
|
|
128
135
|
return { files, target };
|
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* content the user keeps outside the markers. Idempotent.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
-
import { join } from "node:path";
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
10
|
|
|
11
11
|
import { upsertManagedBlock } from "../utils/managed-markers.js";
|
|
12
|
+
import { detectRulesync, RULESYNC_RULE, rulesyncRuleSeed } from "../utils/rulesync.js";
|
|
12
13
|
import { guidelinesBody } from "./guidelines-templates.js";
|
|
13
14
|
|
|
14
15
|
const BLOCK_VERSION = 1;
|
|
@@ -28,7 +29,12 @@ export function stripDevHooksBlock(source) {
|
|
|
28
29
|
return { content: `${before}${joiner}${after}`, migrated: true };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
const
|
|
32
|
+
const nonEmpty = (text) => String(text || "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
33
|
+
const countLines = (text) => nonEmpty(text).length;
|
|
34
|
+
|
|
35
|
+
// KJC-BUG-0232: "do not edit" se leia como "no toques esto", no como "lo que
|
|
36
|
+
// escribas aqui se borra". Solo este bloque: los hooks sellados no cambian.
|
|
37
|
+
const GUIDELINES_NOTE = "regenerated by kj harden: anything written inside is lost; keep your own rules outside this block";
|
|
32
38
|
|
|
33
39
|
export function installGuidelines({ projectDir = process.cwd(), dryRun = false, language = null } = {}) {
|
|
34
40
|
// KJC-BUG-0199 (issue #1773): the rules that enter the agent's context must
|
|
@@ -36,9 +42,13 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
|
|
|
36
42
|
// the configs; the guidelines just never asked.
|
|
37
43
|
const body = guidelinesBody(language);
|
|
38
44
|
const results = [];
|
|
39
|
-
|
|
45
|
+
// KJC-TSK-0879: with Rulesync, its source is the file of truth; writing into
|
|
46
|
+
// CLAUDE.md/AGENTS.md would be erased by the next `rulesync generate`.
|
|
47
|
+
const rulesync = detectRulesync(projectDir);
|
|
48
|
+
for (const file of rulesync ? [RULESYNC_RULE] : TARGETS) {
|
|
40
49
|
const target = join(projectDir, file);
|
|
41
|
-
const
|
|
50
|
+
const seed = rulesync ? rulesyncRuleSeed() : "";
|
|
51
|
+
const raw = existsSync(target) ? readFileSync(target, "utf8") : seed;
|
|
42
52
|
const { content: source, migrated } = stripDevHooksBlock(raw);
|
|
43
53
|
const { content, action } = upsertManagedBlock({
|
|
44
54
|
source,
|
|
@@ -46,13 +56,20 @@ export function installGuidelines({ projectDir = process.cwd(), dryRun = false,
|
|
|
46
56
|
version: BLOCK_VERSION,
|
|
47
57
|
body,
|
|
48
58
|
style: "html",
|
|
59
|
+
note: GUIDELINES_NOTE,
|
|
49
60
|
});
|
|
50
61
|
// KJC-BUG-0206: harden removing rules is a fact worth saying. It silently
|
|
51
62
|
// dropped six lines from this repo's own files because the detected language
|
|
52
63
|
// had no block, and "updated" read like an improvement.
|
|
53
64
|
const shrank = countLines(content) < countLines(raw);
|
|
54
|
-
|
|
55
|
-
|
|
65
|
+
// KJC-BUG-0232: decir CUALES se van, o el aviso obliga a salir a git diff.
|
|
66
|
+
const kept = new Set(nonEmpty(content));
|
|
67
|
+
const removed = shrank ? nonEmpty(raw).filter((l) => !kept.has(l) && !l.includes("kj:managed:")) : [];
|
|
68
|
+
if (!dryRun && (migrated || action !== "unchanged")) {
|
|
69
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
70
|
+
writeFileSync(target, content);
|
|
71
|
+
}
|
|
72
|
+
results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true, removed } : {}) });
|
|
56
73
|
}
|
|
57
74
|
return { dryRun, guidelines: results };
|
|
58
75
|
}
|
|
@@ -156,6 +156,13 @@ export function hookBody(hook, cmds = {}, { globalHooksDir = null, baseBranch =
|
|
|
156
156
|
" echo 'kj harden: commit header must follow Conventional Commits (type: subject)'; exit 1",
|
|
157
157
|
"fi",
|
|
158
158
|
'if [ "${#header}" -gt 100 ]; then echo \'kj harden: commit header exceeds 100 chars\'; exit 1; fi',
|
|
159
|
+
"# KJC-TSK-0909: the rest of what CI's commitlint rejects, caught here.",
|
|
160
|
+
"if ! awk 'NR > 2 && !/^#/ && length($0) > 100 { exit 1 }' \"$msg_file\"; then",
|
|
161
|
+
" echo 'kj harden: a commit body line exceeds 100 chars (CI commitlint rejects it)'; exit 1",
|
|
162
|
+
"fi",
|
|
163
|
+
'if [ -x node_modules/.bin/commitlint ]; then',
|
|
164
|
+
' node_modules/.bin/commitlint --edit "$msg_file" || exit 1',
|
|
165
|
+
"fi",
|
|
159
166
|
`if grep -qiE '${AI_ATTRIBUTION}' "$msg_file"; then`,
|
|
160
167
|
" echo 'kj harden: AI attribution is not allowed in commit messages'; exit 1",
|
|
161
168
|
"fi",
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -17,18 +17,22 @@ const SPKI_ED25519_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
|
17
17
|
// escáner de privacidad del pack deniega claves google con razón general, y
|
|
18
18
|
// servirla desde la landing la hace además rotable sin release.
|
|
19
19
|
const RELAY_CONFIG_URL = "https://karajancode.com/sign/relay.json";
|
|
20
|
-
|
|
20
|
+
// Cacheada por fetch: en produccion es siempre la misma; en tests, cada relé falso es otro.
|
|
21
|
+
const relayCache = new WeakMap();
|
|
21
22
|
async function relayConfig(fetchFn) {
|
|
22
|
-
if (relayCache) return relayCache;
|
|
23
|
+
if (relayCache.has(fetchFn)) return relayCache.get(fetchFn);
|
|
23
24
|
const res = await fetchFn(RELAY_CONFIG_URL);
|
|
24
25
|
if (!res.ok) throw new Error(`phone-sign: no pude cargar la config del relé (${res.status}) — revisa la red`);
|
|
25
26
|
const cfg = await res.json();
|
|
26
27
|
if (!cfg?.apiKey || !cfg?.projectId || !cfg?.collection) throw new Error("phone-sign: config del relé incompleta");
|
|
27
|
-
|
|
28
|
+
const relay = {
|
|
28
29
|
apiKey: cfg.apiKey,
|
|
29
30
|
url: `https://firestore.googleapis.com/v1/projects/${cfg.projectId}/databases/(default)/documents/${cfg.collection}`,
|
|
31
|
+
// KJC-TSK-0901: v2 solo cuando la landing (pagina + reglas) ya lo entiende.
|
|
32
|
+
payloadVersion: cfg.payloadVersion === 2 ? 2 : 1,
|
|
30
33
|
};
|
|
31
|
-
|
|
34
|
+
relayCache.set(fetchFn, relay);
|
|
35
|
+
return relay;
|
|
32
36
|
}
|
|
33
37
|
const SIGN_PAGE = "https://karajancode.com/sign";
|
|
34
38
|
const POLL_INTERVAL_MS = 2000;
|
|
@@ -109,9 +113,15 @@ export function enrollPhone(publicKeyBase64, { home } = {}) {
|
|
|
109
113
|
writeFileSync(phoneKeyPath(home), `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
/**
|
|
113
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios.
|
|
118
|
+
* KJC-TSK-0901/0902: v2 firma también lo demás que el móvil enseña (versión de
|
|
119
|
+
* kj) y la propia vigencia (emisión y caducidad, en ms), para que nada mostrado
|
|
120
|
+
* quede sin firmar y la caducidad sea de lo que el humano aprobó.
|
|
121
|
+
*/
|
|
122
|
+
export function canonicalPayload({ cid, nonce, project, files, v = 1, kjVersion, issuedMs, expiresMs }) {
|
|
114
123
|
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
124
|
+
if (v === 2) return `kj-supervisor-sign:v2:${cid}:${nonce}:${project}:${kjVersion}:${issuedMs}:${expiresMs}:${filesHash}`;
|
|
115
125
|
return `kj-supervisor-sign:v1:${cid}:${nonce}:${project}:${filesHash}`;
|
|
116
126
|
}
|
|
117
127
|
|
|
@@ -135,17 +145,21 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
135
145
|
const drawQr = deps.qr ?? ((url) => qrcode.generate(url, { small: true }));
|
|
136
146
|
const cid = randomBytes(16).toString("hex");
|
|
137
147
|
const nonce = randomBytes(16).toString("hex");
|
|
148
|
+
const relay = await relayConfig(fetchFn);
|
|
149
|
+
const v = relay.payloadVersion;
|
|
150
|
+
const issuedMs = now();
|
|
151
|
+
const expiresMs = issuedMs + TTL_MS;
|
|
138
152
|
const body = {
|
|
139
153
|
fields: {
|
|
140
154
|
nonce: { stringValue: nonce },
|
|
141
155
|
project: { stringValue: project },
|
|
142
156
|
kj_version: { stringValue: kjVersion },
|
|
143
157
|
state: { stringValue: "pending" },
|
|
144
|
-
createdAt: { timestampValue: new Date().toISOString() },
|
|
158
|
+
createdAt: { timestampValue: new Date(issuedMs).toISOString() },
|
|
145
159
|
files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
|
|
160
|
+
...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
|
|
146
161
|
},
|
|
147
162
|
};
|
|
148
|
-
const relay = await relayConfig(fetchFn);
|
|
149
163
|
const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
|
|
150
164
|
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
151
165
|
});
|
|
@@ -157,7 +171,7 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
157
171
|
logger.info?.(`phone-sign: escanea el QR o abre ${signUrl} y firma en el móvil (caduca en ${TTL_MS / 1000}s)`);
|
|
158
172
|
logger.info?.("phone-sign: esperando la firma del móvil…");
|
|
159
173
|
const spinner = deps.spinner ?? makeSpinner();
|
|
160
|
-
const deadline =
|
|
174
|
+
const deadline = expiresMs;
|
|
161
175
|
while (now() < deadline) {
|
|
162
176
|
const res = await fetchFn(`${relay.url}/${cid}?key=${relay.apiKey}`);
|
|
163
177
|
if (!res.ok) { spinner.stop(); throw new Error(`phone-sign: fallo consultando la petición de firma (HTTP ${res.status})`); }
|
|
@@ -171,14 +185,17 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
171
185
|
if (!signerKey || !authorized.has(signerKey)) {
|
|
172
186
|
return { ok: false, reason: "la publicKey que firma no está en el padrón autorizado — el doc es transporte, la verdad es el padrón" };
|
|
173
187
|
}
|
|
174
|
-
const
|
|
188
|
+
const signed2 = v === 2 ? { v, kjVersion, issuedMs, expiresMs } : {};
|
|
189
|
+
const payload = canonicalPayload({ cid, nonce, project, files, ...signed2 });
|
|
175
190
|
const signature = fields.signature?.stringValue ?? "";
|
|
176
191
|
const good = verifyPhoneSignature({ payload, signature, publicKey: signerKey });
|
|
177
192
|
if (!good) return { ok: false, reason: "firma ed25519 inválida para el payload canónico" };
|
|
193
|
+
// KJC-TSK-0901: una firma válida fuera de la vigencia que ella misma firma no vale.
|
|
194
|
+
if (v === 2 && now() > expiresMs) return { ok: false, reason: "la firma llegó fuera de su vigencia firmada" };
|
|
178
195
|
// KJC-TSK-0823: devuelve el desafío + firma para que el sello los GRABE en
|
|
179
196
|
// la provenance y CI pueda re-verificar server-side (clave del padrón).
|
|
180
197
|
const filesHash = createHash("sha256").update(JSON.stringify(files)).digest("hex");
|
|
181
|
-
return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash } };
|
|
198
|
+
return { ok: true, signer: signerKey, signature, challenge: { cid, nonce, project, filesHash, ...signed2 } };
|
|
182
199
|
}
|
|
183
200
|
spinner.tick();
|
|
184
201
|
await sleep(POLL_INTERVAL_MS);
|