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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/packages/hu-board/public/utils/board-view.js +2 -1
  3. package/packages/hu-board/public/utils/config-editor.js +5 -2
  4. package/packages/hu-board/public/utils/terminal-panel.js +2 -1
  5. package/packages/hu-board/src/config-yaml.js +11 -9
  6. package/packages/hu-board/src/project-dir.js +42 -0
  7. package/packages/hu-board/src/routes/api.js +11 -3
  8. package/packages/hu-board/src/terminal-wire.js +5 -1
  9. package/packages/hu-board/src/terminal.js +8 -3
  10. package/src/checks/rag-coverage.js +5 -4
  11. package/src/cli/advanced-commands.js +1 -1
  12. package/src/cli/register-meta.js +26 -4
  13. package/src/commands/board.js +20 -8
  14. package/src/commands/bootstrap.js +2 -0
  15. package/src/commands/env.js +8 -4
  16. package/src/commands/harden.js +26 -4
  17. package/src/commands/init.js +2 -1
  18. package/src/commands/plan/generate.js +4 -6
  19. package/src/commands/pr-size.js +45 -0
  20. package/src/commands/rag.js +69 -10
  21. package/src/commands/review-gate.js +47 -6
  22. package/src/commands/watch.js +13 -10
  23. package/src/config/defaults.js +3 -2
  24. package/src/environment/playbook.js +10 -3
  25. package/src/harden/guidelines-engine.js +24 -7
  26. package/src/harden/hook-templates.js +7 -0
  27. package/src/harden/phone-sign.js +28 -11
  28. package/src/harden/sentinel-hooks.js +82 -7
  29. package/src/lang/registry.js +2 -1
  30. package/src/mcp/handlers/rag-handler.js +14 -4
  31. package/src/orchestrator/drivers/post-loop.js +13 -11
  32. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +3 -2
  33. package/src/orchestrator/stages/rag-context-stage.js +5 -2
  34. package/src/rag/auto-update.js +5 -3
  35. package/src/rag/coverage.js +13 -12
  36. package/src/rag/indexable.js +128 -0
  37. package/src/rag/indexer.js +31 -13
  38. package/src/rag/library.js +19 -13
  39. package/src/rag/migrate.js +73 -0
  40. package/src/rag/onnx-fallback.js +5 -3
  41. package/src/rag/project-store.js +48 -0
  42. package/src/rag/watcher.js +25 -14
  43. package/src/review/comment-only.js +93 -0
  44. package/src/review/kj-provenance.js +26 -0
  45. package/src/review/tests-with-code.js +5 -2
  46. package/src/review/verdict-store.js +6 -1
  47. package/src/start/sweep.js +4 -3
  48. package/src/utils/rulesync.js +61 -0
@@ -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 { openVecStore, countChunks, projectSlug, getLastIndexedCommit, setLastIndexedCommit } from "../rag/vec-store.js";
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 openVecStore({ dim: config?.rag?.embedder?.dim || 768 });
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
- await indexLibrary({ db, embedder, logger, projectDir });
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
- const db = openDb(config);
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
- if (countChunks(db) === 0) {
111
- logger.warn("[rag] No chunks indexed yet. Run 'kj rag index' first.");
112
- if (flags.json) process.stdout.write(`${JSON.stringify({ hits: [], empty: true, topK, scope })}\n`);
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: changedFiles, sonar: res.verdict.sonar, standingExceptions: std.standing });
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: changedFiles, rag: res.verdict.rag, harness, verified, mismatched, drift, standingExceptions: std.standing });
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: changedFiles, touchedLines, logger });
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: changedFiles, sonar: sonarRecord, standingExceptions: std.standing });
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: changedFiles, newFiles, ledger, standingExceptions: std.standing });
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;
@@ -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
- export async function watchStopCommand({ logger }) {
30
- const pid = readPidFile();
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 pid = readPidFile();
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.`);
@@ -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: false,
184
+ enabled: true,
184
185
  port: 4000,
185
- auto_start: false
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
- for (const file of TARGET_FILES[target]) {
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") await fs.writeFile(fullPath, content);
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 countLines = (text) => String(text || "").split("\n").filter((l) => l.trim()).length;
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
- for (const file of TARGETS) {
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 raw = existsSync(target) ? readFileSync(target, "utf8") : "";
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
- if (!dryRun && (migrated || action !== "unchanged")) writeFileSync(target, content);
55
- results.push({ file, action: migrated ? "migrated" : action, ...(shrank ? { shrank: true } : {}) });
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",
@@ -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
- let relayCache = null;
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
- relayCache = {
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
- return relayCache;
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
- /** Payload canónico firmado: files EXACTAMENTE como se enviaron, JSON sin espacios. */
113
- export function canonicalPayload({ cid, nonce, project, files }) {
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 = now() + TTL_MS;
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 payload = canonicalPayload({ cid, nonce, project, files });
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);