@karajan-family/code 4.62.1 → 4.64.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karajan-family/code",
3
- "version": "4.62.1",
3
+ "version": "4.64.0",
4
4
  "description": "Local multi-agent coding orchestrator with TDD, SonarQube, and code review pipeline",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0",
@@ -115,7 +115,7 @@
115
115
  "express-rate-limit": "^8.5.0",
116
116
  "helmet": "^8.1.0",
117
117
  "js-yaml": "^4.2.0",
118
- "karajan-core": "^1.5.0",
118
+ "karajan-core": "^1.6.0",
119
119
  "karajan-rag": "^1.2.0",
120
120
  "knip": "^6.15.0",
121
121
  "madge": "^8.0.0",
@@ -86,6 +86,13 @@ function readPlan(filePath) {
86
86
  return JSON.parse(raw);
87
87
  }
88
88
 
89
+ // KJC-TSK-1166 (ADR 0025): a plan that mirrors the shared board is a view. The truth is
90
+ // in the issues; the board shows it and refuses to edit it, so the two never diverge.
91
+ function mirrorGuard(plan) {
92
+ if (!plan?.readonly) return null;
93
+ return { ok: false, error: `this plan mirrors the shared board (${plan.readonly}) and is read-only here: change it with kj hu move` };
94
+ }
95
+
89
96
  function writePlan(filePath, plan) {
90
97
  writeJsonAtomicSync(filePath, plan);
91
98
  }
@@ -110,6 +117,8 @@ export function setHuStatus({ planId, huId, status, projectId }) {
110
117
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
111
118
 
112
119
  const plan = readPlan(filePath);
120
+ const mirrored = mirrorGuard(plan);
121
+ if (mirrored) return mirrored;
113
122
  if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
114
123
 
115
124
  const existed = updateHuStatus(plan, huId, status);
@@ -153,6 +162,8 @@ export function setHuFields({ planId, huId, patch, projectId }) {
153
162
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
154
163
 
155
164
  const plan = readPlan(filePath);
165
+ const mirrored = mirrorGuard(plan);
166
+ if (mirrored) return mirrored;
156
167
  if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
157
168
 
158
169
  // KJC-TSK-0401: si el patch toca blocked_by, validar antes de
@@ -188,6 +199,8 @@ export function setHuFailResult({ planId, huId, blocker, projectId }) {
188
199
  const filePath = findPlanFilePath(planId, projectId);
189
200
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
190
201
  const plan = readPlan(filePath);
202
+ const mirrored = mirrorGuard(plan);
203
+ if (mirrored) return mirrored;
191
204
  if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
192
205
  const hu = plan.hus.find(h => h.id === huId);
193
206
  if (!hu) return { ok: false, error: `hu not found: ${huId}` };
@@ -220,6 +233,8 @@ export async function revertHuFromSnapshot({ planId, huId, projectId }) {
220
233
  const filePath = findPlanFilePath(planId, projectId);
221
234
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
222
235
  const plan = readPlan(filePath);
236
+ const mirrored = mirrorGuard(plan);
237
+ if (mirrored) return mirrored;
223
238
  const hu = plan.hus?.find(h => h.id === huId);
224
239
  if (!hu) return { ok: false, error: `hu not found: ${huId}` };
225
240
  const snapshotSha = hu.outcome?.snapshot_sha;
@@ -287,6 +302,8 @@ export function renameProject({ projectId, name }) {
287
302
  const filePath = join(projDir, f);
288
303
  try {
289
304
  const plan = readPlan(filePath);
305
+ // A mirror of the shared board keeps its own name; the rename goes on with the rest.
306
+ if (plan.readonly) continue;
290
307
  plan.name = trimmed;
291
308
  plan.updatedAt = new Date().toISOString();
292
309
  writePlan(filePath, plan);
@@ -316,6 +333,8 @@ export function markPlanReady({ planId, projectId }) {
316
333
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
317
334
 
318
335
  const plan = readPlan(filePath);
336
+ const mirrored = mirrorGuard(plan);
337
+ if (mirrored) return mirrored;
319
338
  if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
320
339
  return { ok: false, error: 'plan has no hus[]' };
321
340
  }
@@ -354,6 +373,8 @@ export function runPlan({ planId, projectId, taskOverride, huIds = null } = {})
354
373
  if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
355
374
 
356
375
  const plan = readPlan(filePath);
376
+ const mirrored = mirrorGuard(plan);
377
+ if (mirrored) return mirrored;
357
378
  if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
358
379
  return { ok: false, error: 'plan has no hus[]' };
359
380
  }
@@ -33,7 +33,7 @@ export const META_COMMANDS = ["advanced", "help"];
33
33
  export const ADVANCED_GROUPS = [
34
34
  { title: "Pipeline (piezas sueltas)", commands: ["autorun", "code", "review", "solomon", "agent", "scan", "tournament"] },
35
35
  { title: "Análisis pre-run", commands: ["discover", "triage", "researcher", "architect", "onboard", "brief"] },
36
- { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch"] },
36
+ { title: "Búsqueda / RAG", commands: ["rag", "qmd", "watch", "doc"] },
37
37
  { title: "Calidad / auditoría", commands: ["audit", "check", "mutate", "webperf", "sonar", "privacy", "release", "policy", "rules", "rulesync", "claims", "steward", "pr-size"] },
38
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"] },
@@ -29,6 +29,7 @@ import { mutateCommand } from "../commands/mutate.js";
29
29
  import { hardenCommand } from "../commands/harden.js";
30
30
  import { telemetryPreviewCommand, telemetryStatusCommand } from "../commands/telemetry.js";
31
31
  import { envInstallCommand, briefCommand } from "../commands/env.js";
32
+ import { docCommand } from "../commands/doc.js";
32
33
  import { runReleaseCheck } from "../checks/release-check.js";
33
34
  import { agentRunCommand } from "../commands/agent-run.js";
34
35
  import { reportIssueCommand } from "../commands/report-issue.js";
@@ -75,7 +76,7 @@ export function registerMeta(program, { pkgVersion }) {
75
76
  .action(async (task, flags) => {
76
77
  await withConfig(pkgVersion, "discover", flags, async ({ config, logger }) => {
77
78
  const { resolveTaskInput } = await import("../utils/task-file.js");
78
- const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
79
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
79
80
  await discoverCommand({ task: resolvedTask, config, logger, mode: flags.mode, json: flags.json });
80
81
  });
81
82
  });
@@ -91,7 +92,7 @@ export function registerMeta(program, { pkgVersion }) {
91
92
  .action(async (task, flags) => {
92
93
  await withConfig(pkgVersion, "triage", flags, async ({ config, logger }) => {
93
94
  const { resolveTaskInput } = await import("../utils/task-file.js");
94
- const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
95
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
95
96
  await triageCommand({ task: resolvedTask, config, logger, json: flags.json });
96
97
  });
97
98
  });
@@ -106,7 +107,7 @@ export function registerMeta(program, { pkgVersion }) {
106
107
  .action(async (task, flags) => {
107
108
  await withConfig(pkgVersion, "researcher", flags, async ({ config, logger }) => {
108
109
  const { resolveTaskInput } = await import("../utils/task-file.js");
109
- const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
110
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
110
111
  await researcherCommand({ task: resolvedTask, config, logger });
111
112
  });
112
113
  });
@@ -123,7 +124,7 @@ export function registerMeta(program, { pkgVersion }) {
123
124
  .action(async (task, flags) => {
124
125
  await withConfig(pkgVersion, "architect", flags, async ({ config, logger }) => {
125
126
  const { resolveTaskInput } = await import("../utils/task-file.js");
126
- const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
127
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
127
128
  await architectCommand({ task: resolvedTask, config, logger, context: flags.context, json: flags.json });
128
129
  });
129
130
  });
@@ -353,6 +354,17 @@ export function registerMeta(program, { pkgVersion }) {
353
354
  await huCommand({ config, action: "list", flags });
354
355
  });
355
356
  });
357
+ // KJC-TSK-1166 (ADR 0025): the HUs created before the shared board, moved to it once.
358
+ hu.command("migrate")
359
+ .description("Move every live local HU (pending or running) to the shared board, once: an issue each, status and old id kept")
360
+ .requiredOption("--to <backend>", "github")
361
+ .option("--dry-run", "Say what would move, touching nothing")
362
+ .option("--json", "Machine-readable output")
363
+ .action(async (flags) => {
364
+ await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
365
+ await huCommand({ config, action: "migrate", flags });
366
+ });
367
+ });
356
368
  // KJC-TSK-1163 (ADR 0025): the team board, turned on once per repo.
357
369
  hu.command("backend <backend>")
358
370
  .description("Turn the repo's HU Board on: github (shared, in GitHub Issues) or local; writes .karajan/board.yml for a PR")
@@ -531,6 +543,29 @@ export function registerMeta(program, { pkgVersion }) {
531
543
  });
532
544
  });
533
545
 
546
+ // KJC-TSK-1183 — a long markdown document is read by index: the headings first, then only the
547
+ // sections a task needs, each one whole and exactly as the file has it.
548
+ const doc = program.command("doc").description("Read a markdown document by index: headings first, whole sections on demand");
549
+ doc.command("index")
550
+ .description("List the headings of a markdown file with their level and line (a heading inside a code block is text)")
551
+ .argument("<file>", "Markdown file")
552
+ .option("--json", "Machine-readable output")
553
+ .action(async (file, flags) => {
554
+ await withConfig(pkgVersion, "doc-index", flags, async () => {
555
+ docCommand({ action: "index", file, flags });
556
+ });
557
+ });
558
+ doc.command("section")
559
+ .description("Print one section whole, as the file has it, up to the next heading of the same or a higher level; an unknown heading lists the available ones")
560
+ .argument("<file>", "Markdown file")
561
+ .argument("<heading>", "Heading title, exact or case-insensitive")
562
+ .option("--json", "Machine-readable output")
563
+ .action(async (file, heading, flags) => {
564
+ await withConfig(pkgVersion, "doc-section", flags, async () => {
565
+ docCommand({ action: "section", file, heading, flags });
566
+ });
567
+ });
568
+
534
569
  // KJC-TSK-0712 — memory is the reminder, the check is the guarantee.
535
570
  const release = program.command("release").description("Release ceremony helpers");
536
571
  release.command("check")
@@ -784,10 +819,11 @@ export function registerMeta(program, { pkgVersion }) {
784
819
  // first call). Internally we rebind to `flags.mode` so the handler
785
820
  // in src/commands/rag.js stays untouched.
786
821
  .option("--rag-mode <mode>", "hybrid (default) | semantic (cosine only) | keyword (BM25 only)", "hybrid")
787
- .option("--alpha <n>", "Hybrid weight for semantic component (0..1). Default 0.6", "0.6")
788
- .option("--where <clause>", "Metadata filter, e.g. 'symbol=loadConfig' or 'hu_id=HU-003 AND kind=plan'")
822
+ .option("--alpha <n>", "Deprecated: hybrid fusion is reciprocal rank since 4.63; tune rag.search.rrf_k (default 60) in kj.config.yml", "0.6")
823
+ .option("--where <clause>", "Metadata filter: 'symbol=loadConfig', 'hu_id=HU-003 AND kind=plan', or a prefix with ~= such as 'source~=src/billing' (relative to the project)")
789
824
  .option("--library", "Query the distilled engineering canon (pattern cards shipped with kj + ~/.karajan/library, in ~/.karajan/library.db) instead of the project corpus; a project's own .karajan/library cards live in its index and answer normal queries. Cards carry problem signatures, when-NOT-to-apply and canonical citations (KJC-TSK-0697).")
790
- .option("--rerank", "Apply cross-encoder rerank to top-K (needs @huggingface/transformers, slower but more precise)")
825
+ .option("--rerank", "Re-score the top-N candidates with a cross-encoder (needs @huggingface/transformers; or rag.search.rerank.enabled in kj.config.yml)")
826
+ .option("--rerank-top-n <n>", "Candidates the cross-encoder re-scores before the top-K is cut (default 40, or rag.search.rerank.top_n)")
791
827
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
792
828
  .option("--json", "Output the hits as JSON")
793
829
  .action(async (text, flags) => {
@@ -807,7 +843,11 @@ export function registerMeta(program, { pkgVersion }) {
807
843
  .option("--golden <path>", "Path to golden queries JSON (default: tests/rag/golden-queries.json)")
808
844
  .option("--top-k <n>", "Number of hits to retrieve per query (default: 10)", "10")
809
845
  .option("--min-recall <n>", "Fail (exit 1) if aggregated recall@5 falls below this threshold")
846
+ .option("--save-baseline <path>", "Write recall@k, MRR and latency to this file as the frozen baseline")
847
+ .option("--baseline <path>", "Fail (exit 1) if recall@k, MRR or p95 latency got worse than that baseline")
810
848
  .option("--project <slug>", "Filter by project slug. Pass 'all' to query across every indexed project.")
849
+ .option("--rerank", "Measure with the cross-encoder rerank on (needs @huggingface/transformers)")
850
+ .option("--rerank-top-n <n>", "Candidates the cross-encoder re-scores before the top-K is cut (default 40)")
811
851
  .option("--json", "Emit the full report as JSON")
812
852
  .action(async (flags) => {
813
853
  await withConfig(pkgVersion, "rag-eval", flags, async ({ config, logger }) => {
@@ -153,7 +153,7 @@ export function registerPipeline(program, { pkgVersion }) {
153
153
  effectiveTask = plan.task;
154
154
  }
155
155
  }
156
- const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
156
+ const resolvedTask = await resolveTaskInput({ task: effectiveTask, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
157
157
  const res = await runCommandHandler({ task: resolvedTask, config, logger, flags });
158
158
  // KJC-TSK-0674 (issue #1289): an aborted run (spec-review cancel,
159
159
  // unanswerable non-interactive gate) must be observable by CI.
@@ -52,7 +52,7 @@ export function registerPlan(program, { pkgVersion }) {
52
52
  .action(async (task, flags) => {
53
53
  await withConfig(pkgVersion, "plan", flags, async ({ config, logger }) => {
54
54
  const { resolveTaskInput } = await import("../utils/task-file.js");
55
- const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger });
55
+ const resolvedTask = await resolveTaskInput({ task, taskFile: flags.taskFile, projectDir: config.projectDir, logger, indexFirstBytes: config.docs?.indexFirstBytes });
56
56
 
57
57
  // REASONS Canvas auto-detection: a SPEC that contains
58
58
  // `## REASONS:Section` headings is treated as a structured
@@ -0,0 +1,26 @@
1
+ // KJC-TSK-1183 — `kj doc index <file>` and `kj doc section <file> <heading>`: a long markdown
2
+ // document is read by index, the headings first and only the sections a task needs, each one
3
+ // whole and exactly as the file has it. The headings are the index; nothing extra to keep in sync.
4
+ import { readFileSync } from "node:fs";
5
+ import { resolve } from "node:path";
6
+ import { indexMarkdown, sectionOf } from "../docs/markdown-index.js";
7
+
8
+ export function docCommand({ action, file, heading = null, flags = {}, cwd = process.cwd(), out = process.stdout }) {
9
+ if (!file) throw new Error("usage: kj doc index <file> | kj doc section <file> <heading>");
10
+ const path = resolve(cwd, file);
11
+ const text = readFileSync(path, "utf8"); // a missing file fails loud, with the path
12
+ if (action === "index") {
13
+ const headings = indexMarkdown(text);
14
+ if (flags.json) out.write(`${JSON.stringify({ file: path, headings })}\n`);
15
+ else for (const h of headings) out.write(`${" ".repeat(h.level - 1)}${"#".repeat(h.level)} ${h.title} (line ${h.line})\n`);
16
+ return headings;
17
+ }
18
+ if (action === "section") {
19
+ if (!heading) throw new Error("usage: kj doc section <file> <heading>");
20
+ const section = sectionOf(text, heading);
21
+ if (flags.json) out.write(`${JSON.stringify({ file: path, ...section })}\n`);
22
+ else out.write(section.text); // exactly as the file has it, nothing added
23
+ return section;
24
+ }
25
+ throw new Error(`unknown action "${action}": use index | section`);
26
+ }
@@ -13,7 +13,10 @@ import { readIdentity } from "../identity/store.js";
13
13
  import { effectiveGitEmail } from "../identity/detect.js";
14
14
  import { roleSkills } from "../skills/role-skills.js";
15
15
  import { readBoardConfig, setBoardBackend } from "../hu/board-config.js";
16
- import { createHu, listHus, claim, setStatus, assertNotHeld } from "../hu/github-board.js";
16
+ import { createHu, listHus, claim, setStatus, assertNotHeld, isOffline } from "../hu/github-board.js";
17
+ import { assertGhIdentity } from "../hu/gh-identity.js";
18
+ import { listWithCache, queueMove, flushQueue } from "../hu/team-board-cache.js";
19
+ import { writeTeamMirror } from "../hu/team-board-mirror.js";
17
20
 
18
21
  // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
19
22
  // board must never show work nobody can trace. Host agent env → that agent;
@@ -57,20 +60,91 @@ function linesOf(v) {
57
60
  /** KJC-TSK-1163: kj hu add on the shared board; the same no-duplicates rule, checked against the issues. */
58
61
  async function addToTeamBoard({ repo, title, flags, deps, emit }) {
59
62
  if (flags.id) throw new Error(`--id is not used with the shared board: GitHub gives each HU its id (the issue number)`);
60
- const { identical, similar } = findTitleMatches(title, await (deps.listTeamHus ?? listHus)({ repo }));
61
- if (identical.length > 0) {
62
- throw new Error(`An HU with this title already exists: ${identical[0].short_id} (status: ${identical[0].status}). Work that card or move it with: kj hu move ${identical[0].short_id} <status>`);
63
- }
64
- if (similar.length > 0) {
65
- const candidates = similar.map((h) => ` · ${h.short_id} (${h.status}) "${h.title}"`).join("\n");
66
- console.warn("⚠ possible duplicate — check before working it:\n" + candidates);
63
+ let hu;
64
+ try {
65
+ const { identical, similar } = findTitleMatches(title, await (deps.listTeamHus ?? listHus)({ repo }));
66
+ if (identical.length > 0) {
67
+ throw new Error(`An HU with this title already exists: ${identical[0].short_id} (status: ${identical[0].status}). Work that card or move it with: kj hu move ${identical[0].short_id} <status>`);
68
+ }
69
+ if (similar.length > 0) {
70
+ const candidates = similar.map((h) => ` · ${h.short_id} (${h.status}) "${h.title}"`).join("\n");
71
+ console.warn("⚠ possible duplicate — check before working it:\n" + candidates);
72
+ }
73
+ hu = await (deps.createTeamHu ?? createHu)({
74
+ repo, title, acceptanceCriteria: [...linesOf(flags.ac), ...linesOf(flags.criteria)], tests: linesOf(flags.tests), ref: flags.ref ? String(flags.ref).trim() : null,
75
+ });
76
+ } catch (err) {
77
+ // KJC-TSK-1165: an id is only ever minted by GitHub; nothing is created offline.
78
+ if (isOffline(err)) throw new Error(`creating a HU needs the network: GitHub gives it its id and no local one is made up (${err.message})`, { cause: err });
79
+ throw err;
67
80
  }
68
- const hu = await (deps.createTeamHu ?? createHu)({
69
- repo, title, acceptanceCriteria: [...linesOf(flags.ac), ...linesOf(flags.criteria)], tests: linesOf(flags.tests), ref: flags.ref ? String(flags.ref).trim() : null,
70
- });
71
81
  return emit({ ...hu, board: "github", repo }, `✓ HU created: ${hu.short_id} (pending) in the shared board, issue #${hu.number} of ${repo}`);
72
82
  }
73
83
 
84
+ /**
85
+ * KJC-TSK-1166: every live local HU (pending or running) becomes an issue with its criteria,
86
+ * tests and old id; a running one is claimed and moved by whoever migrates. The local card is
87
+ * marked skipped and points at its issue, so a second run has nothing left to move.
88
+ */
89
+ async function migrateToTeamBoard({ projectDir, repo, who, dryRun, deps, emit }) {
90
+ const migrated = [];
91
+ const skipped = [];
92
+ for (const meta of await listPlans(projectDir)) {
93
+ const plan = await loadPlan(projectDir, meta.planId);
94
+ for (const h of plan.hus || []) {
95
+ const label = h.short_id || h.id;
96
+ const unfinished = h.migrated_to && h.migration_pending; // the issue exists, its running state does not yet
97
+ if (!unfinished && (h.migrated_to || !["pending", "running"].includes(h.status))) { skipped.push(label); continue; }
98
+ if (dryRun) { migrated.push({ from: label, to: h.migrated_to ?? null, status: h.migration_pending ?? h.status }); continue; }
99
+ const status = h.migration_pending ?? h.status;
100
+ if (!h.migrated_to) {
101
+ const was = `was ${label}`;
102
+ const issue = await (deps.createTeamHu ?? createHu)({ repo, title: h.title, acceptanceCriteria: h.acceptance_criteria ?? [], tests: h.acceptance_tests ?? [], ref: h.external_ref ? `${h.external_ref} · ${was}` : was });
103
+ // Persisted the moment the issue exists: a retry after a failure further on never
104
+ // creates it twice; what is still owed to it (its running state) is remembered.
105
+ h.migrated_to = issue.short_id;
106
+ h.external_ref = [h.external_ref, issue.short_id].filter(Boolean).join(" · ");
107
+ if (status === "running") h.migration_pending = "running";
108
+ await savePlan(projectDir, plan);
109
+ }
110
+ if (h.migration_pending === "running") {
111
+ const number = Number(h.migrated_to.slice(3));
112
+ await (deps.claimTeamHu ?? claim)({ repo, number, who, release: false });
113
+ await (deps.setTeamStatus ?? setStatus)({ repo, number, status: "running" });
114
+ delete h.migration_pending;
115
+ }
116
+ updateHuStatus(plan, h.id, "skipped");
117
+ await savePlan(projectDir, plan);
118
+ migrated.push({ from: label, to: h.migrated_to, status });
119
+ }
120
+ }
121
+ const verb = dryRun ? "would move" : "moved";
122
+ const lines = migrated.map((m) => ` ${m.from} → ${m.to ?? "(new issue)"} [${m.status}]`).join("\n");
123
+ const detail = lines ? "\n" + lines : "";
124
+ return emit({ migrated, skipped, dryRun }, `✓ ${verb} ${migrated.length} HU(s) to the shared board of ${repo}${detail}\n skipped ${skipped.length} (finished, discarded or already moved)`);
125
+ }
126
+
127
+ /** KJC-TSK-1165: apply the moves queued offline; conflicts are said, never forced; still offline is silent. */
128
+ async function applyQueuedMoves({ projectDir, repo, deps }) {
129
+ let now = null; // status of every HU as GitHub has it, read once, kept up to date as moves apply
130
+ const apply = async (op) => {
131
+ now ??= new Map((await (deps.listTeamHus ?? listHus)({ repo })).map((h) => [h.number, h.status]));
132
+ // The HU moved while this clone was offline: the newer state wins, the queued move is a conflict.
133
+ if (op.expected && now.get(op.number) !== op.expected) {
134
+ const err = new Error(`HU-${op.number} is ${now.get(op.number) ?? "gone"} now and was ${op.expected} when the move was queued`);
135
+ err.name = "BoardRejectedError";
136
+ throw err;
137
+ }
138
+ if (op.status === "running" || op.release) await (deps.claimTeamHu ?? claim)({ repo, number: op.number, who: op.who, release: op.release });
139
+ else await (deps.assertNotHeld ?? assertNotHeld)({ repo, number: op.number, who: op.who });
140
+ await (deps.setTeamStatus ?? setStatus)({ repo, number: op.number, status: op.status });
141
+ now.set(op.number, op.status);
142
+ };
143
+ const r = await flushQueue({ projectDir, apply });
144
+ if (r.applied) console.warn(`✓ ${r.applied} queued move(s) applied to the shared board`);
145
+ for (const c of r.conflicts) console.warn(`⚠ ${c}`);
146
+ }
147
+
74
148
  /** KJC-TSK-1164: kj hu move on the shared board; starting it claims it (the assignee) first. */
75
149
  async function moveOnTeamBoard({ repo, args, flags, deps, emit, who }) {
76
150
  const [ref, status] = args;
@@ -123,8 +197,24 @@ export async function huCommand({ config = null, action, args = [], flags = {},
123
197
  return emit(r, `✓ HU Board: ${where} — commit ${files} in a PR so the whole team inherits it`);
124
198
  }
125
199
  const board = await (deps.boardConfig ?? readBoardConfig)(projectDir);
200
+ // KJC-TSK-1186: the shared board is written as the active gh account; with an identity declared
201
+ // it must be that one, said with the remedy before any gh call instead of a GraphQL permission error.
202
+ if (board.backend === "github") (deps.assertGhIdentity ?? assertGhIdentity)({ projectDir });
203
+ const who = deps.who === undefined ? workerOf(projectDir) : deps.who;
204
+ // KJC-TSK-1166 (TEAM-GH-E): the HUs created before the shared board, moved to it once.
205
+ if (action === "migrate") {
206
+ if (flags.to !== "github") throw new Error("usage: kj hu migrate --to github [--dry-run]");
207
+ if (board.backend !== "github") throw new Error("the shared board is off in this repo: turn it on first with kj hu backend github");
208
+ return migrateToTeamBoard({ projectDir, repo: board.repo, who, dryRun: Boolean(flags.dryRun), deps, emit });
209
+ }
210
+ // KJC-TSK-1165 (TEAM-GH-D): moves made without network go first, once GitHub answers.
211
+ if (board.backend === "github") await applyQueuedMoves({ projectDir, repo: board.repo, deps });
126
212
  if (board.backend === "github" && action === "list") {
127
- const rows = (await (deps.listTeamHus ?? listHus)({ repo: board.repo })).map((h) => ({ ...h, id: h.short_id, plan: "github" }));
213
+ const { source, rows: hus, updatedAt } = await listWithCache({ projectDir, repo: board.repo, list: deps.listTeamHus ?? listHus });
214
+ if (source === "cache") console.warn(`⚠ GitHub did not answer: this is the local copy of the board from ${updatedAt}`);
215
+ // KJC-TSK-1166: what GitHub answered is what kj board shows, as a read-only plan.
216
+ else await writeTeamMirror(projectDir, hus, { repo: board.repo });
217
+ const rows = hus.map((h) => ({ ...h, id: h.short_id, plan: "github" }));
128
218
  if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
129
219
  for (const r of rows) {
130
220
  const held = r.claimed_by ? " · held by " + r.claimed_by : "";
@@ -135,7 +225,18 @@ export async function huCommand({ config = null, action, args = [], flags = {},
135
225
  }
136
226
  // KJC-BUG-0392: a zero-padded id (HU-0104) is a local HU from before the shared board: it stays local.
137
227
  const legacyLocal = /^HU-0\d+$/i.test(String(args[0] ?? ""));
138
- if (board.backend === "github" && action === "move" && !legacyLocal) return moveOnTeamBoard({ repo: board.repo, args, flags, deps, emit, who: deps.who === undefined ? workerOf(projectDir) : deps.who });
228
+ if (board.backend === "github" && action === "move" && !legacyLocal) {
229
+ try {
230
+ return await moveOnTeamBoard({ repo: board.repo, args, flags, deps, emit, who });
231
+ } catch (err) {
232
+ // Offline: the move waits in the queue; GitHub still decides when it is applied.
233
+ if (!isOffline(err)) throw err;
234
+ const number = Number(/^(?:HU-|#)(\d+)$/i.exec(args[0])[1]);
235
+ queueMove({ projectDir, number, status: args[1], who, release: Boolean(flags.release) });
236
+ console.warn(`⚠ GitHub did not answer: HU-${number} → ${args[1]} queued, applied when GitHub answers again`);
237
+ return emit({ id: `HU-${number}`, status: args[1], queued: true }, `⏳ HU-${number} → ${args[1]} (queued)`);
238
+ }
239
+ }
139
240
 
140
241
  if (action === "list") {
141
242
  const plans = await listPlans(projectDir);
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  // KJC-PCS-0049 Step 6 — `kj rag` command group. Two subcommands:
4
4
  // kj rag index [--project <slug>] [--with-sources]
@@ -13,7 +13,7 @@ import { query } from "../rag/retriever.js";
13
13
  import { pathNamedHits, withPathNamedHits } from "../rag/path-hits.js";
14
14
  import { installPostMergeHook, maybeAutoUpdate } from "../rag/auto-update.js";
15
15
  import { indexLibrary, LIBRARY_PROJECT } from "../rag/library.js";
16
- import { loadGoldenQueries, runEval } from "../rag/eval.js";
16
+ import { compareToBaseline, loadGoldenQueries, runEval } from "../rag/eval.js";
17
17
  import { getKarajanHome } from "../utils/paths.js";
18
18
  import { countProjectChunks, countProjectSources, emptyIndexRemedy, migrateProjectIndex } from "../rag/migrate.js";
19
19
  import { openLibraryStore, openProjectStore, projectDbPath } from "../rag/project-store.js";
@@ -38,7 +38,7 @@ async function indexFiles({ files, projectDir, db, embedder, slug, logger, json,
38
38
  logger.warn?.(`[rag] ${out}: not indexed`);
39
39
  continue;
40
40
  }
41
- const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind() });
41
+ const r = await indexFile(abs, { db, embedder, logger, project: slug, kind: sourceKind(), root: projectDir });
42
42
  totals.indexed += r.indexed; totals.failed += r.failed; totals.files += 1;
43
43
  }
44
44
  const deleted = totals.deleted ? `, ${totals.deleted} chunks deleted` : "";
@@ -160,14 +160,22 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
160
160
  logger.warn("[rag] this project's index holds none of its own files (only plans or briefs): run kj rag index --with-sources");
161
161
  }
162
162
  const mode = flags.mode || "hybrid";
163
- const alpha = Math.max(0, Math.min(1, Number(flags.alpha) || 0.6));
163
+ // KJC-TSK-1180 (RAG-C, core 1.6.0): hybrid fusion is reciprocal rank; `alpha` weighs nothing
164
+ // any more, so a value that is still around is said once, never silently ignored.
165
+ if ((flags.alpha !== undefined && flags.alpha !== "0.6") || config?.rag?.search?.alpha !== undefined) {
166
+ logger.warn("[rag] alpha no longer applies: hybrid fusion is reciprocal rank (RRF); tune rag.search.rrf_k instead and drop alpha from the config");
167
+ }
168
+ const rrfK = config?.rag?.search?.rrf_k;
169
+ if (rrfK !== undefined && !(Number.isInteger(rrfK) && rrfK > 0)) throw new Error(`rag.search.rrf_k must be a positive integer, got ${JSON.stringify(rrfK)}`);
170
+ // KJC-TSK-1180 (RAG-B3): a relative `source~=` prefix is placed under this project's root.
171
+ const whereRoot = config?.projectDir || process.cwd();
164
172
  // Safe by grammar: parseWhere (core where-parser.js) accepts ONLY
165
173
  // AND-joined key=value pairs — an OR or parens never parses, so the
166
174
  // composed clause cannot leak past the kind filter; it fails loudly.
167
175
  let where = flags.where || null;
168
176
  if (library) where = flags.where ? `kind=library AND ${flags.where}` : "kind=library";
169
- const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
170
- const ranked = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, alpha, where, rerankOpts });
177
+ const rerankOpts = rerankOptionsFor({ config, flags, logger });
178
+ const ranked = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, rrfK, where, whereRoot, rerankOpts });
171
179
  const hits = library ? ranked : withPathNamedHits(pathNamedHits(db, text, { project, projectDir: config?.projectDir || process.cwd() }), ranked, topK);
172
180
  if (flags.json) {
173
181
  process.stdout.write(`${JSON.stringify(hits)}\n`);
@@ -185,6 +193,28 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
185
193
  }
186
194
  }
187
195
 
196
+ // KJC-TSK-1181 (RAG-D2): the cross-encoder, from the flag or from rag.search.rerank in the config
197
+ // (enabled, top_n, model). Without the library or the model the retrieval answers alone and the
198
+ // install is said once per process, never on every query, never in silence.
199
+ const rerankWarned = new Set();
200
+ export function rerankOptionsFor({ config, flags = {}, logger }) {
201
+ const cfg = config?.rag?.search?.rerank ?? {};
202
+ const enabled = flags.rerank === true || (flags.rerank !== false && cfg.enabled === true);
203
+ if (!enabled) return null;
204
+ const topN = flags.rerankTopN ?? cfg.top_n;
205
+ if (topN !== undefined && !(Number.isInteger(Number(topN)) && Number(topN) > 0)) throw new Error(`rag.search.rerank.top_n must be a positive integer, got ${JSON.stringify(topN)}`);
206
+ const key = config?.projectDir || "rerank";
207
+ return {
208
+ model: flags.rerankModel || cfg.model || undefined,
209
+ ...(topN === undefined ? {} : { topN: Number(topN) }),
210
+ onUnavailable: (err) => {
211
+ if (rerankWarned.has(key)) return;
212
+ rerankWarned.add(key);
213
+ logger.warn(`[rag] rerank skipped: ${err.message}. Install it with npm install @huggingface/transformers; the retrieval answered without it`);
214
+ },
215
+ };
216
+ }
217
+
188
218
  // KJC-TSK-0483 — `kj rag eval`. Runs the retriever against a golden query
189
219
  // set and reports recall@5, recall@10 and MRR per query and aggregated.
190
220
  // Exit code != 0 when `--min-recall` is set and the aggregated recall@5
@@ -199,22 +229,42 @@ export async function ragEvalCommand({ config, logger, flags = {} }) {
199
229
  const detected = projectSlug(config?.projectDir || process.cwd());
200
230
  const project = flags.project === "all" ? null : (flags.project || detected || null);
201
231
  const embedder = makeGovernedEmbedder(config);
202
- const runQuery = async (text, k) => query(db, embedder, text, { topK: k, scope: "all", project });
232
+ // KJC-TSK-1181: measured with the cross-encoder too, so its default is a number, not a hunch.
233
+ const rerankOpts = rerankOptionsFor({ config, flags, logger });
234
+ const runQuery = async (text, k) => query(db, embedder, text, { topK: k, scope: "all", project, rerankOpts });
203
235
  const report = await runEval(queries, runQuery, { topK, ks: [5, 10] });
236
+ const { aggregate: agg } = report;
204
237
  if (flags.json) {
205
238
  process.stdout.write(`${JSON.stringify(report)}\n`);
206
239
  } else {
207
- logger.info(`[rag-eval] ${report.aggregate.n} queries · recall@5=${report.aggregate.recall["@5"].toFixed(3)} · recall@10=${report.aggregate.recall["@10"].toFixed(3)} · MRR=${report.aggregate.mrr.toFixed(3)}`);
240
+ const lat = agg.latency ? ` · latency mean=${agg.latency.meanMs}ms p95=${agg.latency.p95Ms}ms` : "";
241
+ logger.info(`[rag-eval] ${agg.n} queries · recall@5=${agg.recall["@5"].toFixed(3)} · recall@10=${agg.recall["@10"].toFixed(3)} · MRR=${agg.mrr.toFixed(3)}${lat}`);
208
242
  for (const r of report.results) {
209
243
  const rank = r.firstRank == null ? "—" : `#${r.firstRank}`;
210
244
  logger.info(` [${rank}] ${r.query}`);
211
245
  }
212
246
  }
213
247
  const minRecall = Number(flags.minRecall);
214
- if (Number.isFinite(minRecall) && report.aggregate.recall["@5"] < minRecall) {
215
- logger.error?.(`[rag-eval] recall@5=${report.aggregate.recall["@5"].toFixed(3)} below --min-recall=${minRecall}`);
248
+ if (Number.isFinite(minRecall) && agg.recall["@5"] < minRecall) {
249
+ logger.error?.(`[rag-eval] recall@5=${agg.recall["@5"].toFixed(3)} below --min-recall=${minRecall}`);
216
250
  process.exitCode = 1;
217
251
  }
252
+ // KJC-TSK-1170 (RAG-E): freeze the numbers, or fail when they got worse than the frozen ones.
253
+ if (flags.saveBaseline) {
254
+ const doc = { measuredAt: new Date().toISOString(), golden: goldenPath, topK, embedder: config?.rag?.embedder ?? null, search: config?.rag?.search ?? null, aggregate: agg };
255
+ writeFileSync(flags.saveBaseline, `${JSON.stringify(doc, null, 2)}\n`, "utf8");
256
+ logger.info(`[rag-eval] baseline saved to ${flags.saveBaseline}`);
257
+ }
258
+ if (flags.baseline) {
259
+ const base = JSON.parse(readFileSync(flags.baseline, "utf8"));
260
+ const check = compareToBaseline(agg, base.aggregate ?? base);
261
+ if (!check.ok) {
262
+ logger.error?.(`[rag-eval] worse than the baseline ${flags.baseline}:\n - ${check.problems.join("\n - ")}`);
263
+ process.exitCode = 1;
264
+ } else {
265
+ logger.info(`[rag-eval] not worse than the baseline ${flags.baseline}`);
266
+ }
267
+ }
218
268
  return report;
219
269
  } finally {
220
270
  db.close();
@@ -274,6 +274,9 @@ const DEFAULTS = {
274
274
  // safe default blocks them (local ollama/onnx always allowed).
275
275
  sensitivity: "internal"
276
276
  },
277
+ // KJC-TSK-1183 — a document over this size (task file, ADR) reaches the roles as its headings
278
+ // index; they fetch a section whole with `kj doc section` when the task needs it.
279
+ docs: { indexFirstBytes: 12000 },
277
280
  // Opt-in. Set by the `kj init` wizard (explicit consent prompt in the OS
278
281
  // locale). When the key is absent or false, no events are sent. See
279
282
  // src/utils/telemetry.js and the Privacy & Telemetry section of README.md.
@@ -0,0 +1,31 @@
1
+ // KJC-TSK-1183 — Roles read long documents by index. Over the limit, a document reaches the prompt
2
+ // as its preamble plus its headings and the command that fetches one section whole; under it, or
3
+ // without headings to index, it travels as it is.
4
+ import { indexMarkdown } from "./markdown-index.js";
5
+
6
+ export const DEFAULT_INDEX_FIRST_BYTES = 12000;
7
+ const PREAMBLE_MAX = 1500;
8
+ const shellQuote = (s) => (/^[A-Za-z0-9_./:@%+=-]+$/.test(s) ? s : ["'", s.replaceAll("'", String.raw`'\''`), "'"].join(""));
9
+
10
+ /**
11
+ * What the agent reads instead of the whole document, or null when the whole fits or there is
12
+ * nothing to index (fewer than two headings: a single one covers it all).
13
+ * @param {{ path: string, text: string, limit?: number }} doc
14
+ */
15
+ export function indexFirstView({ path, text, limit = DEFAULT_INDEX_FIRST_BYTES }) {
16
+ if (!Number.isInteger(limit) || limit <= 0 || text.length <= limit) return null;
17
+ const headings = indexMarkdown(text);
18
+ if (headings.length < 2) return null;
19
+ const preamble = text.split("\n").slice(0, headings[1].line - 1).join("\n").trim().slice(0, PREAMBLE_MAX);
20
+ const index = headings.map((h) => `${" ".repeat(h.level - 1)}- ${h.title}`).join("\n");
21
+ const file = shellQuote(path);
22
+ return [
23
+ preamble,
24
+ "",
25
+ `_This document is ${text.length} characters, over the ${limit} read-whole limit, so it is read by index. Its headings:_`,
26
+ "",
27
+ index,
28
+ "",
29
+ `Read a section whole only when the task needs it: \`kj doc section ${file} "<heading>"\` (the index again: \`kj doc index ${file}\`).`,
30
+ ].join("\n");
31
+ }