karajan-code 4.63.0 → 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-code",
3
- "version": "4.63.0",
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",
@@ -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
  });
@@ -542,6 +543,29 @@ export function registerMeta(program, { pkgVersion }) {
542
543
  });
543
544
  });
544
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
+
545
569
  // KJC-TSK-0712 — memory is the reminder, the check is the guarantee.
546
570
  const release = program.command("release").description("Release ceremony helpers");
547
571
  release.command("check")
@@ -798,7 +822,8 @@ export function registerMeta(program, { pkgVersion }) {
798
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")
799
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)")
800
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).")
801
- .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)")
802
827
  .option("--rerank-model <name>", "Cross-encoder model id. Default: Xenova/ms-marco-MiniLM-L-6-v2")
803
828
  .option("--json", "Output the hits as JSON")
804
829
  .action(async (text, flags) => {
@@ -821,6 +846,8 @@ export function registerMeta(program, { pkgVersion }) {
821
846
  .option("--save-baseline <path>", "Write recall@k, MRR and latency to this file as the frozen baseline")
822
847
  .option("--baseline <path>", "Fail (exit 1) if recall@k, MRR or p95 latency got worse than that baseline")
823
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)")
824
851
  .option("--json", "Emit the full report as JSON")
825
852
  .action(async (flags) => {
826
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
+ }
@@ -14,6 +14,7 @@ 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
16
  import { createHu, listHus, claim, setStatus, assertNotHeld, isOffline } from "../hu/github-board.js";
17
+ import { assertGhIdentity } from "../hu/gh-identity.js";
17
18
  import { listWithCache, queueMove, flushQueue } from "../hu/team-board-cache.js";
18
19
  import { writeTeamMirror } from "../hu/team-board-mirror.js";
19
20
 
@@ -196,6 +197,9 @@ export async function huCommand({ config = null, action, args = [], flags = {},
196
197
  return emit(r, `✓ HU Board: ${where} — commit ${files} in a PR so the whole team inherits it`);
197
198
  }
198
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 });
199
203
  const who = deps.who === undefined ? workerOf(projectDir) : deps.who;
200
204
  // KJC-TSK-1166 (TEAM-GH-E): the HUs created before the shared board, moved to it once.
201
205
  if (action === "migrate") {
@@ -174,7 +174,7 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
174
174
  // composed clause cannot leak past the kind filter; it fails loudly.
175
175
  let where = flags.where || null;
176
176
  if (library) where = flags.where ? `kind=library AND ${flags.where}` : "kind=library";
177
- const rerankOpts = flags.rerank ? { model: flags.rerankModel } : null;
177
+ const rerankOpts = rerankOptionsFor({ config, flags, logger });
178
178
  const ranked = await query(db, makeGovernedEmbedder(config), text, { topK, scope, project, mode, rrfK, where, whereRoot, rerankOpts });
179
179
  const hits = library ? ranked : withPathNamedHits(pathNamedHits(db, text, { project, projectDir: config?.projectDir || process.cwd() }), ranked, topK);
180
180
  if (flags.json) {
@@ -193,6 +193,28 @@ export async function ragQueryCommand({ text, config, logger, flags = {} }) {
193
193
  }
194
194
  }
195
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
+
196
218
  // KJC-TSK-0483 — `kj rag eval`. Runs the retriever against a golden query
197
219
  // set and reports recall@5, recall@10 and MRR per query and aggregated.
198
220
  // Exit code != 0 when `--min-recall` is set and the aggregated recall@5
@@ -207,7 +229,9 @@ export async function ragEvalCommand({ config, logger, flags = {} }) {
207
229
  const detected = projectSlug(config?.projectDir || process.cwd());
208
230
  const project = flags.project === "all" ? null : (flags.project || detected || null);
209
231
  const embedder = makeGovernedEmbedder(config);
210
- 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 });
211
235
  const report = await runEval(queries, runQuery, { topK, ks: [5, 10] });
212
236
  const { aggregate: agg } = report;
213
237
  if (flags.json) {
@@ -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
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * KJC-TSK-1183 — Long markdown documents read by index: the headings first, then only the
3
+ * sections a task needs, each one whole and exactly as it is in the file. The headings are the
4
+ * index, so there is nothing extra to keep in sync. Independent of any spec kit's format.
5
+ */
6
+
7
+ // ATX headings only: up to three spaces, `#` to `######`, a space, the title, an optional closing run of `#`.
8
+ const HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
9
+ // A fence opens with three or more of one character; it closes with the same character, a run at
10
+ // least as long, and nothing else on the line (CommonMark): ```` is not closed by ```.
11
+ const FENCE_OPEN = /^[ \t]{0,3}(`{3,}|~{3,})/;
12
+ const FENCE_CLOSE = /^[ \t]{0,3}(`{3,}|~{3,})[ \t]*$/;
13
+
14
+ export class UnknownHeadingError extends Error {
15
+ constructor(message) {
16
+ super(message);
17
+ this.name = "UnknownHeadingError";
18
+ }
19
+ }
20
+
21
+ /** The headings of the document, with level and 1-based line; a heading inside a fenced code block is text. */
22
+ export function indexMarkdown(text) {
23
+ const headings = [];
24
+ let fence = null; // the opening run, e.g. "````", while inside a code block
25
+ text.split("\n").forEach((raw, i) => {
26
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
27
+ if (fence !== null) {
28
+ const c = FENCE_CLOSE.exec(line);
29
+ if (c && c[1][0] === fence[0] && c[1].length >= fence.length) fence = null;
30
+ return;
31
+ }
32
+ const o = FENCE_OPEN.exec(line);
33
+ if (o) {
34
+ fence = o[1];
35
+ return;
36
+ }
37
+ const m = HEADING.exec(line);
38
+ if (m) headings.push({ level: m[1].length, title: m[2], line: i + 1 });
39
+ });
40
+ return headings;
41
+ }
42
+
43
+ /**
44
+ * The section under `title`: its heading line and everything up to the next heading of the same
45
+ * or a higher level, as the file has it. Exact title first, case-insensitive as a fallback; with
46
+ * duplicates the first wins and the others are named by line. An unknown title is an error that
47
+ * lists the headings, never an empty section.
48
+ */
49
+ export function sectionOf(text, title) {
50
+ const headings = indexMarkdown(text);
51
+ let hits = headings.filter((h) => h.title === title);
52
+ if (hits.length === 0) hits = headings.filter((h) => h.title.toLowerCase() === title.toLowerCase());
53
+ if (hits.length === 0) {
54
+ const available = headings.map((h) => h.title).join(", ") || "none";
55
+ throw new UnknownHeadingError(`"${title}" is not a heading of this document; the headings are: ${available}`);
56
+ }
57
+ const [first, ...others] = hits;
58
+ const lines = text.split("\n");
59
+ const next = headings.find((h) => h.line > first.line && h.level <= first.level);
60
+ const body = next ? `${lines.slice(first.line - 1, next.line - 1).join("\n")}\n` : lines.slice(first.line - 1).join("\n");
61
+ return { level: first.level, title: first.title, line: first.line, text: body, ...(others.length ? { duplicates: others.map((h) => h.line) } : {}) };
62
+ }
@@ -0,0 +1,44 @@
1
+ // KJC-TSK-1186: the shared board is written as whoever gh is logged in as. With several GitHub
2
+ // accounts on one machine, another session switching the active one turned `kj hu move` into an
3
+ // opaque GraphQL permission error, three times in a day. The clone declares its account in
4
+ // .karajan/identity.local.yml (ADR 0005): with a declaration, the active account must be it.
5
+ import { readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import yaml from "js-yaml";
9
+ import { readIdentity } from "../identity/store.js";
10
+ import { BoardRejectedError } from "./github-board.js";
11
+
12
+ /** Where gh keeps its accounts, honouring the same overrides gh does. */
13
+ export function ghHostsPath(env = process.env) {
14
+ const dir = env.GH_CONFIG_DIR || join(env.XDG_CONFIG_HOME || join(homedir(), ".config"), "gh");
15
+ return join(dir, "hosts.yml");
16
+ }
17
+
18
+ /** The account gh would use now for github.com, or null when gh has none. */
19
+ export function activeGhUser(env = process.env) {
20
+ let raw;
21
+ try {
22
+ raw = readFileSync(ghHostsPath(env), "utf8");
23
+ } catch (err) {
24
+ if (err.code === "ENOENT") return null;
25
+ throw err;
26
+ }
27
+ const user = yaml.load(raw)?.["github.com"]?.user;
28
+ return typeof user === "string" && user.length > 0 ? user : null;
29
+ }
30
+
31
+ /**
32
+ * With an identity declared, the active gh account must be it; the error carries the exact remedy.
33
+ * Without a declaration nothing is asserted: gh answers for itself.
34
+ * @returns {{ declared: string | null, active: string | null }}
35
+ */
36
+ export function assertGhIdentity({ projectDir, env = process.env }) {
37
+ const declared = readIdentity(projectDir)?.gh_user ?? null;
38
+ if (!declared) return { declared: null, active: null };
39
+ const active = activeGhUser(env);
40
+ if (active && active !== declared) {
41
+ throw new BoardRejectedError(`the active gh account is ${active} and this clone declares ${declared}: run gh auth switch --user ${declared} and retry`);
42
+ }
43
+ return { declared, active };
44
+ }
@@ -73,6 +73,8 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
73
73
  // the plan, the rest are scoped to this HU. All four pass through
74
74
  // untouched when the caller omits them (legacy single-task runs).
75
75
  adrs, specSection, reviewerFindings, huId,
76
+ // KJC-TSK-1183: a long ADR reaches the coder as its index, sections on demand.
77
+ indexFirstBytes: config?.docs?.indexFirstBytes ?? null,
76
78
  // PAR-E2 (KJC-TSK-0629): the stage's config wins over the role's own —
77
79
  // worktree lanes pass a laneConfig whose projectDir is the worktree.
78
80
  projectDir: config?.projectDir || null,
@@ -49,7 +49,7 @@ export async function loadActiveAdrs(projectDir, planId) {
49
49
  mdFiles.map(async (file) => {
50
50
  try {
51
51
  const md = await readFile(join(dir, file), "utf-8");
52
- return { id: file.replace(/\.md$/, ""), markdown: md };
52
+ return { id: file.replace(/\.md$/, ""), markdown: md, path: join(dir, file) };
53
53
  } catch { return null; /* skip unreadable file */ }
54
54
  })
55
55
  );
@@ -2,6 +2,7 @@ import { buildRtkInstructions } from "./rtk-snippet.js";
2
2
  import { roleSkills, buildRoleSkillSection } from "../skills/role-skills.js";
3
3
  import { getLanguageInstruction } from "../utils/locale.js";
4
4
  import { section, buildPromptLayout, joinLayout, STABLE, VOLATILE } from "./prompt-layout.js";
5
+ import { indexFirstView } from "../docs/index-first.js";
5
6
 
6
7
  const SUBAGENT_PREAMBLE = [
7
8
  "IMPORTANT: You are running as a Karajan sub-agent.",
@@ -95,9 +96,11 @@ function renderAcceptanceTestsSection(tests) {
95
96
  * 8 ADRs (the planner shouldn't realistically emit more for one
96
97
  * plan, but bound it just in case).
97
98
  */
98
- function renderAdrSection(adrs) {
99
+ function renderAdrSection(adrs, indexFirstBytes = null) {
99
100
  if (!Array.isArray(adrs) || adrs.length === 0) return "";
100
101
  const capped = adrs.slice(0, 8);
102
+ // KJC-TSK-1183: a long ADR travels as its index; the coder fetches a section when it needs it.
103
+ const bodyOf = (adr) => (indexFirstBytes && adr.path ? indexFirstView({ path: adr.path, text: adr.markdown, limit: indexFirstBytes }) : null) ?? adr.markdown;
101
104
  const lines = [
102
105
  "## Architecture Decision Records (active)",
103
106
  "",
@@ -105,7 +108,7 @@ function renderAdrSection(adrs) {
105
108
  "",
106
109
  ];
107
110
  for (const adr of capped) {
108
- lines.push(adr.markdown.trim(), "", "---", "");
111
+ lines.push(bodyOf(adr).trim(), "", "---", "");
109
112
  }
110
113
  if (adrs.length > capped.length) {
111
114
  lines.push(`_(…${adrs.length - capped.length} more ADRs not shown — see ~/.kj/plans/<slug>/<planId>/adrs/.)_`, "");
@@ -189,7 +192,7 @@ function buildStackSection({ stack, testFramework }) {
189
192
  * volatile: per-HU/iteration content, rendered LAST preserving the relative
190
193
  * order the legacy prompt used (ADRs → SPEC → findings → tests → … → feedback).
191
194
  */
192
- export async function buildCoderPromptLayout({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en", provider = null, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null, stack = null, testFramework = null }) {
195
+ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en", provider = null, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null, stack = null, testFramework = null, indexFirstBytes = null }) {
193
196
  const langInstruction = getLanguageInstruction(language);
194
197
  // KJC-BUG-0032 (PR-I): hard rule about file paths. Real bug: a HU
195
198
  // titled "Initialize project skeleton: create assistant/ directory…"
@@ -278,7 +281,7 @@ export async function buildCoderPromptLayout({ task, reviewerFeedback = null, so
278
281
  section(skillSection, STABLE),
279
282
  section(`Task:\n${task}`, VOLATILE),
280
283
  section(plan ? `## Implementation Plan (from planner)\nFollow these steps:\n${plan}` : null, VOLATILE),
281
- section(renderAdrSection(adrs) || null, VOLATILE),
284
+ section(renderAdrSection(adrs, indexFirstBytes) || null, VOLATILE),
282
285
  section(renderSpecSectionLine(specSection) || null, VOLATILE),
283
286
  section(renderReviewerFindingsSection(reviewerFindings, huId) || null, VOLATILE),
284
287
  section(renderAcceptanceTestsSection(acceptanceTests) || null, VOLATILE),
@@ -25,6 +25,7 @@
25
25
 
26
26
  import fs from "node:fs/promises";
27
27
  import path from "node:path";
28
+ import { indexFirstView } from "../docs/index-first.js";
28
29
 
29
30
  export const DEFAULT_TASK_FILE_MAX_BYTES = 256 * 1024;
30
31
 
@@ -41,7 +42,7 @@ export async function readTaskFile(filePath, opts = {}) {
41
42
  if (!filePath || typeof filePath !== "string") {
42
43
  throw new Error("readTaskFile: path must be a non-empty string");
43
44
  }
44
- const { projectDir, maxBytes = DEFAULT_TASK_FILE_MAX_BYTES } = opts;
45
+ const { projectDir, maxBytes = DEFAULT_TASK_FILE_MAX_BYTES, indexFirstBytes = null, logger = null } = opts;
45
46
  const base = projectDir || process.cwd();
46
47
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(base, filePath);
47
48
 
@@ -68,6 +69,12 @@ export async function readTaskFile(filePath, opts = {}) {
68
69
  if (!trimmed) {
69
70
  throw new Error(`Task file is empty (or whitespace-only): ${resolved}`);
70
71
  }
72
+ // KJC-TSK-1183: over the limit the roles get the index and fetch sections on demand.
73
+ const view = indexFirstBytes ? indexFirstView({ path: resolved, text: trimmed, limit: indexFirstBytes }) : null;
74
+ if (view) {
75
+ logger?.info?.(`[docs] ${resolved} read by index (${trimmed.length} chars > ${indexFirstBytes}): sections on demand with kj doc section`);
76
+ return view;
77
+ }
71
78
  return trimmed;
72
79
  }
73
80
 
@@ -89,7 +96,7 @@ export async function readTaskFile(filePath, opts = {}) {
89
96
  * @param {Object} [args.logger] - optional; uses `logger.warn(msg)` only
90
97
  * @returns {Promise<string>}
91
98
  */
92
- export async function resolveTaskInput({ task, taskFile, projectDir, logger } = {}) {
99
+ export async function resolveTaskInput({ task, taskFile, projectDir, logger, indexFirstBytes = null } = {}) {
93
100
  const hasTask = typeof task === "string" && task.trim().length > 0;
94
101
  const hasFile = typeof taskFile === "string" && taskFile.trim().length > 0;
95
102
 
@@ -103,7 +110,7 @@ export async function resolveTaskInput({ task, taskFile, projectDir, logger } =
103
110
  return task.trim();
104
111
  }
105
112
  if (hasFile) {
106
- return readTaskFile(taskFile, { projectDir });
113
+ return readTaskFile(taskFile, { projectDir, indexFirstBytes, logger });
107
114
  }
108
115
  return task.trim();
109
116
  }