@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 +2 -2
- package/packages/hu-board/src/plan-mutations.js +21 -0
- package/src/cli/advanced-commands.js +1 -1
- package/src/cli/register-meta.js +47 -7
- package/src/cli/register-pipeline.js +1 -1
- package/src/cli/register-plan.js +1 -1
- package/src/commands/doc.js +26 -0
- package/src/commands/hu.js +114 -13
- package/src/commands/rag.js +60 -10
- package/src/config/defaults.js +3 -0
- package/src/docs/index-first.js +31 -0
- package/src/docs/markdown-index.js +62 -0
- package/src/hu/gh-identity.js +44 -0
- package/src/hu/github-board.js +20 -3
- package/src/hu/team-board-cache.js +84 -0
- package/src/hu/team-board-mirror.js +26 -0
- package/src/lang/chunk-go.js +2 -27
- package/src/lang/chunk-java.js +7 -32
- package/src/lang/chunk-python.js +2 -27
- package/src/lang/chunk-rust.js +2 -27
- package/src/lang/chunk-span.js +49 -0
- package/src/orchestrator/stages/coder-stage.js +2 -0
- package/src/plan/adr-loader.js +1 -1
- package/src/plan/plan-store.js +4 -1
- package/src/prompts/coder.js +7 -4
- package/src/rag/auto-update.js +55 -7
- package/src/rag/bootstrap-index.js +86 -0
- package/src/rag/chunker-ast.js +5 -12
- package/src/rag/chunker.js +2 -14
- package/src/rag/embed-header.js +35 -0
- package/src/rag/embed-schema.js +28 -0
- package/src/rag/eval.js +39 -3
- package/src/rag/indexer.js +36 -14
- package/src/rag/worktree-files.js +26 -0
- package/src/utils/task-file.js +10 -3
|
@@ -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
|
+
}
|
package/src/hu/github-board.js
CHANGED
|
@@ -12,11 +12,16 @@ export const STATUSES = ["pending", "running", "done", "failed", "skipped"];
|
|
|
12
12
|
const statusLabel = (s) => `kj:${s}`;
|
|
13
13
|
|
|
14
14
|
/** gh could not reach GitHub: the caller may queue the change, never invent data. */
|
|
15
|
-
export class BoardOfflineError extends Error {}
|
|
15
|
+
export class BoardOfflineError extends Error { name = "BoardOfflineError"; }
|
|
16
16
|
/** GitHub answered and said no (permissions, missing repo...). */
|
|
17
|
-
export class BoardRejectedError extends Error {}
|
|
17
|
+
export class BoardRejectedError extends Error { name = "BoardRejectedError"; }
|
|
18
18
|
/** The HU is running and someone else holds it: taking it over is explicit (release). */
|
|
19
|
-
export class HuClaimedError extends Error {}
|
|
19
|
+
export class HuClaimedError extends Error { name = "HuClaimedError"; }
|
|
20
|
+
|
|
21
|
+
// KJC-TSK-1165: by name, not only instanceof: a test runner may load this module twice.
|
|
22
|
+
export const isOffline = (err) => err instanceof BoardOfflineError || err?.name === "BoardOfflineError";
|
|
23
|
+
/** GitHub answered and refused (rejected, or the HU is held by someone else): a conflict, not a failure of kj. */
|
|
24
|
+
export const isRefusal = (err) => err instanceof BoardRejectedError || err instanceof HuClaimedError || ["BoardRejectedError", "HuClaimedError"].includes(err?.name);
|
|
20
25
|
|
|
21
26
|
// gh is not localized: these are the network failures it prints.
|
|
22
27
|
const OFFLINE = /dial tcp|no such host|could not resolve host|connection refused|i\/o timeout|network is unreachable|error connecting to/i;
|
|
@@ -48,6 +53,18 @@ export async function createHu({ repo, title, acceptanceCriteria = [], tests = [
|
|
|
48
53
|
const url = (await gh(run, ["issue", "create", "-R", repo, "--title", title, "--body", body, "--label", HU_LABEL, "--label", statusLabel("pending")])).trim();
|
|
49
54
|
const number = Number(/\/issues\/(\d+)$/.exec(url)?.[1]);
|
|
50
55
|
if (!Number.isInteger(number)) throw new BoardRejectedError(`gh issue create returned no issue URL: ${url}`);
|
|
56
|
+
// KJC-BUG-0395 (HU-2408): GitHub answered the create and left the issue without its labels, so
|
|
57
|
+
// the next move refused it as "not a kj HU". An HU the board does not recognise is not announced:
|
|
58
|
+
// the labels are checked, put back once, and a second absence is said loud.
|
|
59
|
+
const wanted = [HU_LABEL, statusLabel("pending")];
|
|
60
|
+
const labelsOf = async () => new Set(JSON.parse(await gh(run, ["issue", "view", String(number), "-R", repo, "--json", "labels"])).labels.map((l) => l.name));
|
|
61
|
+
let have = await labelsOf();
|
|
62
|
+
if (wanted.some((l) => !have.has(l))) {
|
|
63
|
+
await gh(run, ["issue", "edit", String(number), "-R", repo, ...wanted.filter((l) => !have.has(l)).flatMap((l) => ["--add-label", l])]);
|
|
64
|
+
have = await labelsOf();
|
|
65
|
+
const missing = wanted.filter((l) => !have.has(l));
|
|
66
|
+
if (missing.length) throw new BoardRejectedError(`issue #${number} was created without ${missing.join(", ")} and gh could not add them: the board does not recognise it, add the labels by hand`);
|
|
67
|
+
}
|
|
51
68
|
return { short_id: `HU-${number}`, number, status: "pending", title };
|
|
52
69
|
}
|
|
53
70
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1165 (TEAM-GH-D, ADR 0025): the local copy of the shared board, and the
|
|
3
|
+
* queue of moves made without network. The copy is a cache, never the truth: it
|
|
4
|
+
* is rewritten on every answer from GitHub and only read when GitHub does not
|
|
5
|
+
* answer. A queued move is applied when the network is back; one GitHub rejects
|
|
6
|
+
* (someone else holds the HU) is reported and dropped, never forced. Creating a
|
|
7
|
+
* HU offline is refused elsewhere: an id is only ever minted by GitHub.
|
|
8
|
+
*/
|
|
9
|
+
import fs from "node:fs";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { isOffline, isRefusal } from "./github-board.js";
|
|
12
|
+
|
|
13
|
+
const CACHE_FILE = ".karajan/board-cache.json";
|
|
14
|
+
// A fresh object every time: a shared default array would carry one read's queue into the next.
|
|
15
|
+
const empty = () => ({ hus: null, updatedAt: null, queue: [] });
|
|
16
|
+
|
|
17
|
+
export function readCache(projectDir) {
|
|
18
|
+
try {
|
|
19
|
+
return { ...empty(), ...JSON.parse(fs.readFileSync(path.join(projectDir, CACHE_FILE), "utf8")) };
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.code === "ENOENT") return empty();
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function writeCache(projectDir, cache) {
|
|
27
|
+
const file = path.join(projectDir, CACHE_FILE);
|
|
28
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
29
|
+
fs.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, "utf8");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The HUs from GitHub (and the copy refreshed), or the copy when GitHub cannot be reached. */
|
|
33
|
+
export async function listWithCache({ projectDir, repo, list }) {
|
|
34
|
+
try {
|
|
35
|
+
const rows = await list({ repo });
|
|
36
|
+
writeCache(projectDir, { ...readCache(projectDir), hus: rows, updatedAt: new Date().toISOString() });
|
|
37
|
+
return { source: "github", rows };
|
|
38
|
+
} catch (err) {
|
|
39
|
+
const cache = readCache(projectDir);
|
|
40
|
+
if (!isOffline(err) || !cache.hus) throw err;
|
|
41
|
+
return { source: "cache", rows: cache.hus, updatedAt: cache.updatedAt };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Remember a move to apply when the network is back. `expected` is the status the local
|
|
47
|
+
* copy shows now (null when the copy does not know the HU): when it is applied, a HU that
|
|
48
|
+
* moved meanwhile is a conflict, not something to overwrite. The copy takes the move so a
|
|
49
|
+
* second queued move on the same HU expects the first one's result.
|
|
50
|
+
*/
|
|
51
|
+
export function queueMove({ projectDir, number, status, who, release }) {
|
|
52
|
+
const cache = readCache(projectDir);
|
|
53
|
+
const row = cache.hus?.find((h) => h.number === number) ?? null;
|
|
54
|
+
cache.queue.push({ number, status, who, release: Boolean(release), expected: row?.status ?? null, queuedAt: new Date().toISOString() });
|
|
55
|
+
if (row) row.status = status;
|
|
56
|
+
writeCache(projectDir, cache);
|
|
57
|
+
return cache.queue.length;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Apply the queue in order. A move GitHub refuses is a conflict: reported, dropped, the
|
|
62
|
+
* HU stays as the other person left it. Still offline: stop, keep the rest for later.
|
|
63
|
+
* Any other error is kj's own: it surfaces, and the move stays queued.
|
|
64
|
+
* @returns {Promise<{applied: number, conflicts: string[], pending: number}>}
|
|
65
|
+
*/
|
|
66
|
+
export async function flushQueue({ projectDir, apply }) {
|
|
67
|
+
const cache = readCache(projectDir);
|
|
68
|
+
const conflicts = [];
|
|
69
|
+
let applied = 0;
|
|
70
|
+
while (cache.queue.length > 0) {
|
|
71
|
+
const op = cache.queue[0];
|
|
72
|
+
try {
|
|
73
|
+
await apply(op);
|
|
74
|
+
applied += 1;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (isOffline(err)) break;
|
|
77
|
+
if (!isRefusal(err)) throw err;
|
|
78
|
+
conflicts.push(`HU-${op.number} → ${op.status} (queued ${op.queuedAt}) not applied: ${err.message}`);
|
|
79
|
+
}
|
|
80
|
+
cache.queue.shift();
|
|
81
|
+
writeCache(projectDir, cache);
|
|
82
|
+
}
|
|
83
|
+
return { applied, conflicts, pending: cache.queue.length };
|
|
84
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1166 (TEAM-GH-E, ADR 0025): what `kj board` shows of the shared board. The web
|
|
3
|
+
* board reads plans, so the shared board is mirrored into one read-only plan per project,
|
|
4
|
+
* rewritten from the issues every time kj reads them. It is a view: changing it is done
|
|
5
|
+
* with kj hu move, never from the web, and the board refuses to edit it.
|
|
6
|
+
*/
|
|
7
|
+
import { savePlan } from "../plan/plan-store.js";
|
|
8
|
+
|
|
9
|
+
export const MIRROR_PLAN_ID = "plan_github-board";
|
|
10
|
+
|
|
11
|
+
/** The shared board as a plan the web board can show, marked read-only. */
|
|
12
|
+
export async function writeTeamMirror(projectDir, rows, { repo }) {
|
|
13
|
+
const now = new Date().toISOString();
|
|
14
|
+
const plan = {
|
|
15
|
+
version: 2, planId: MIRROR_PLAN_ID, name: "github", alias: "github",
|
|
16
|
+
task: `Shared HU Board: GitHub Issues of ${repo}`,
|
|
17
|
+
status: "ready", readonly: "github", createdAt: now,
|
|
18
|
+
hus: rows.map((r) => ({
|
|
19
|
+
id: r.short_id, short_id: r.short_id, title: r.title, status: r.status,
|
|
20
|
+
claimed_by: r.claimed_by ?? null, created_by: "github", external_ref: `${repo}#${r.number}`,
|
|
21
|
+
acceptance_criteria: [], acceptance_tests: [], createdAt: now, updatedAt: now,
|
|
22
|
+
})),
|
|
23
|
+
};
|
|
24
|
+
await savePlan(projectDir, plan);
|
|
25
|
+
return plan;
|
|
26
|
+
}
|
package/src/lang/chunk-go.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// `type_declaration` contiene type_spec(s); usamos el primero como symbol.
|
|
9
9
|
|
|
10
10
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
11
|
+
import { spanChunks } from "./chunk-span.js";
|
|
11
12
|
|
|
12
13
|
const DEFAULT_LIMIT = 800;
|
|
13
14
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -21,16 +22,6 @@ export async function prepareGo() {
|
|
|
21
22
|
return loadLanguage("go");
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
function windowText(text, limit, overlap) {
|
|
25
|
-
if (text.length <= limit) return [text];
|
|
26
|
-
const step = Math.max(1, limit - overlap);
|
|
27
|
-
const out = [];
|
|
28
|
-
for (let i = 0; i < text.length; i += step) {
|
|
29
|
-
out.push(text.slice(i, i + limit));
|
|
30
|
-
if (i + limit >= text.length) break;
|
|
31
|
-
}
|
|
32
|
-
return out;
|
|
33
|
-
}
|
|
34
25
|
|
|
35
26
|
function symbolForType(node) {
|
|
36
27
|
for (const spec of node.namedChildren) {
|
|
@@ -74,21 +65,5 @@ function marksViaRegex(text) {
|
|
|
74
65
|
}
|
|
75
66
|
|
|
76
67
|
export function chunkGo(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
77
|
-
|
|
78
|
-
if (marks.length === 0) {
|
|
79
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
80
|
-
text: piece,
|
|
81
|
-
metadata: { source: path, kind: "code", symbol: null, language: "go" },
|
|
82
|
-
}));
|
|
83
|
-
}
|
|
84
|
-
const out = [];
|
|
85
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
86
|
-
const start = marks[i].idx;
|
|
87
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
88
|
-
const body = text.slice(start, end);
|
|
89
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
90
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "go" } });
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return out;
|
|
68
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "go", limit, overlap });
|
|
94
69
|
}
|
package/src/lang/chunk-java.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// sin prefijar (coherente con chunk-go), el RAG ya lleva contexto del fichero.
|
|
5
5
|
|
|
6
6
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
7
|
+
import { spanChunks } from "./chunk-span.js";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_LIMIT = 800;
|
|
9
10
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -23,17 +24,6 @@ export async function prepareJava() {
|
|
|
23
24
|
return loadLanguage("java");
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function windowText(text, limit, overlap) {
|
|
27
|
-
if (text.length <= limit) return [text];
|
|
28
|
-
const step = Math.max(1, limit - overlap);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (let i = 0; i < text.length; i += step) {
|
|
31
|
-
out.push(text.slice(i, i + limit));
|
|
32
|
-
if (i + limit >= text.length) break;
|
|
33
|
-
}
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
27
|
function nameOf(node) {
|
|
38
28
|
const n = node.childForFieldName("name");
|
|
39
29
|
return n ? n.text : null;
|
|
@@ -47,14 +37,15 @@ function marksViaAST(text) {
|
|
|
47
37
|
const marks = [];
|
|
48
38
|
for (const child of tree.rootNode.namedChildren) {
|
|
49
39
|
if (!CONTAINER_KINDS.has(child.type)) continue;
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
40
|
+
const className = nameOf(child);
|
|
41
|
+
if (className) marks.push({ idx: child.startIndex, symbol: className });
|
|
52
42
|
const body = child.childForFieldName("body");
|
|
53
43
|
if (!body) continue;
|
|
54
44
|
for (const m of body.namedChildren) {
|
|
55
45
|
if (!MEMBER_KINDS.has(m.type)) continue;
|
|
56
|
-
const
|
|
57
|
-
|
|
46
|
+
const memberName = nameOf(m);
|
|
47
|
+
// KJC-TSK-1171: a member knows its class (by name); the chunk says both.
|
|
48
|
+
if (memberName) marks.push({ idx: m.startIndex, symbol: memberName, parent: className });
|
|
58
49
|
}
|
|
59
50
|
}
|
|
60
51
|
marks.sort((a, b) => a.idx - b.idx);
|
|
@@ -70,21 +61,5 @@ function marksViaRegex(text) {
|
|
|
70
61
|
}
|
|
71
62
|
|
|
72
63
|
export function chunkJava(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
73
|
-
|
|
74
|
-
if (marks.length === 0) {
|
|
75
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
76
|
-
text: piece,
|
|
77
|
-
metadata: { source: path, kind: "code", symbol: null, language: "java" },
|
|
78
|
-
}));
|
|
79
|
-
}
|
|
80
|
-
const out = [];
|
|
81
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
82
|
-
const start = marks[i].idx;
|
|
83
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
84
|
-
const body = text.slice(start, end);
|
|
85
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
86
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "java" } });
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
return out;
|
|
64
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "java", limit, overlap });
|
|
90
65
|
}
|
package/src/lang/chunk-python.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// devuelve `[{ text, metadata: { source, kind, symbol, language } }]`.
|
|
15
15
|
|
|
16
16
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
17
|
+
import { spanChunks } from "./chunk-span.js";
|
|
17
18
|
|
|
18
19
|
const DEFAULT_LIMIT = 800;
|
|
19
20
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -23,16 +24,6 @@ export async function preparePython() {
|
|
|
23
24
|
return loadLanguage("python");
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
function windowText(text, limit, overlap) {
|
|
27
|
-
if (text.length <= limit) return [text];
|
|
28
|
-
const step = Math.max(1, limit - overlap);
|
|
29
|
-
const out = [];
|
|
30
|
-
for (let i = 0; i < text.length; i += step) {
|
|
31
|
-
out.push(text.slice(i, i + limit));
|
|
32
|
-
if (i + limit >= text.length) break;
|
|
33
|
-
}
|
|
34
|
-
return out;
|
|
35
|
-
}
|
|
36
27
|
|
|
37
28
|
function marksViaAST(text) {
|
|
38
29
|
const entry = getLoaded("python");
|
|
@@ -68,21 +59,5 @@ function marksViaRegex(text) {
|
|
|
68
59
|
}
|
|
69
60
|
|
|
70
61
|
export function chunkPython(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
71
|
-
|
|
72
|
-
if (marks.length === 0) {
|
|
73
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
74
|
-
text: piece,
|
|
75
|
-
metadata: { source: path, kind: "code", symbol: null, language: "python" },
|
|
76
|
-
}));
|
|
77
|
-
}
|
|
78
|
-
const out = [];
|
|
79
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
80
|
-
const start = marks[i].idx;
|
|
81
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
82
|
-
const body = text.slice(start, end);
|
|
83
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
84
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "python" } });
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
return out;
|
|
62
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "python", limit, overlap });
|
|
88
63
|
}
|
package/src/lang/chunk-rust.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// que metadata.symbol apunte al tipo implementado (útil para grep en RAG).
|
|
8
8
|
|
|
9
9
|
import { getLoaded, loadLanguage } from "./tree-sitter-loader.js";
|
|
10
|
+
import { spanChunks } from "./chunk-span.js";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_LIMIT = 800;
|
|
12
13
|
const DEFAULT_OVERLAP = 100;
|
|
@@ -25,16 +26,6 @@ export async function prepareRust() {
|
|
|
25
26
|
return loadLanguage("rust");
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
function windowText(text, limit, overlap) {
|
|
29
|
-
if (text.length <= limit) return [text];
|
|
30
|
-
const step = Math.max(1, limit - overlap);
|
|
31
|
-
const out = [];
|
|
32
|
-
for (let i = 0; i < text.length; i += step) {
|
|
33
|
-
out.push(text.slice(i, i + limit));
|
|
34
|
-
if (i + limit >= text.length) break;
|
|
35
|
-
}
|
|
36
|
-
return out;
|
|
37
|
-
}
|
|
38
29
|
|
|
39
30
|
function symbolForImpl(node) {
|
|
40
31
|
const typeNode = node.childForFieldName("type");
|
|
@@ -82,21 +73,5 @@ function marksViaRegex(text) {
|
|
|
82
73
|
}
|
|
83
74
|
|
|
84
75
|
export function chunkRust(text, { path = "<src>", limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
85
|
-
|
|
86
|
-
if (marks.length === 0) {
|
|
87
|
-
return windowText(text, limit, overlap).map((piece) => ({
|
|
88
|
-
text: piece,
|
|
89
|
-
metadata: { source: path, kind: "code", symbol: null, language: "rust" },
|
|
90
|
-
}));
|
|
91
|
-
}
|
|
92
|
-
const out = [];
|
|
93
|
-
for (let i = 0; i < marks.length; i += 1) {
|
|
94
|
-
const start = marks[i].idx;
|
|
95
|
-
const end = i + 1 < marks.length ? marks[i + 1].idx : text.length;
|
|
96
|
-
const body = text.slice(start, end);
|
|
97
|
-
for (const piece of windowText(body, limit, overlap)) {
|
|
98
|
-
out.push({ text: piece, metadata: { source: path, kind: "code", symbol: marks[i].symbol, language: "rust" } });
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
return out;
|
|
76
|
+
return spanChunks(text, marksViaAST(text) ?? marksViaRegex(text), { path, language: "rust", limit, overlap });
|
|
102
77
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1171 (RAG-B, ADR 0026): the shared tail of every code chunker. A chunker
|
|
3
|
+
* finds marks ({ idx, symbol, parent? }); this slices the text from each mark to the
|
|
4
|
+
* next, windows what is too long for the embedder, and stamps each piece with its
|
|
5
|
+
* 1-based line range and the symbol it belongs to, so the embedded header and the
|
|
6
|
+
* citations can name file, symbol, parent and lines.
|
|
7
|
+
*/
|
|
8
|
+
const DEFAULT_LIMIT = 800;
|
|
9
|
+
const DEFAULT_OVERLAP = 100;
|
|
10
|
+
|
|
11
|
+
/** 1-based line of a character index. */
|
|
12
|
+
export const lineAt = (text, idx) => {
|
|
13
|
+
let line = 1;
|
|
14
|
+
for (let i = 0; i < idx && i < text.length; i += 1) if (text.charCodeAt(i) === 10) line += 1;
|
|
15
|
+
return line;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Trailing whitespace (a newline and the next line's indentation) is not a line of the symbol.
|
|
19
|
+
const linesIn = (piece) => {
|
|
20
|
+
const body = piece.trimEnd();
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (let i = 0; i < body.length; i += 1) if (body.charCodeAt(i) === 10) n += 1;
|
|
23
|
+
return n;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** The slice at `startIdx`, windowed, each piece with its line range. */
|
|
27
|
+
export function pieceSpans(text, startIdx, slice, { limit = DEFAULT_LIMIT, overlap = DEFAULT_OVERLAP } = {}) {
|
|
28
|
+
const step = Math.max(1, limit - overlap);
|
|
29
|
+
const out = [];
|
|
30
|
+
for (let i = 0; ; i += step) {
|
|
31
|
+
const piece = slice.slice(i, i + limit);
|
|
32
|
+
const startLine = lineAt(text, startIdx + i);
|
|
33
|
+
out.push({ text: piece, start_line: startLine, end_line: startLine + linesIn(piece) });
|
|
34
|
+
if (i + limit >= slice.length) break;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Chunks from marks; with no marks, the whole text as one anonymous symbol. */
|
|
40
|
+
export function spanChunks(text, marks, { path = "<src>", kind = "code", language, limit, overlap } = {}) {
|
|
41
|
+
const base = { source: path, kind, ...(language === undefined ? {} : { language }) };
|
|
42
|
+
const ranges = marks.length === 0
|
|
43
|
+
? [{ start: 0, end: text.length, symbol: null, parent: null }]
|
|
44
|
+
: marks.map((m, i) => ({ start: m.idx, end: i + 1 < marks.length ? marks[i + 1].idx : text.length, symbol: m.symbol, parent: m.parent ?? null }));
|
|
45
|
+
return ranges.flatMap(({ start, end, symbol, parent }) =>
|
|
46
|
+
pieceSpans(text, start, text.slice(start, end), { limit, overlap }).map(({ text: piece, start_line, end_line }) => ({
|
|
47
|
+
text: piece, metadata: { ...base, symbol, parent, start_line, end_line },
|
|
48
|
+
})));
|
|
49
|
+
}
|
|
@@ -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,
|
package/src/plan/adr-loader.js
CHANGED
|
@@ -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
|
);
|
package/src/plan/plan-store.js
CHANGED
|
@@ -63,8 +63,11 @@ export function projectSlug(projectDir) {
|
|
|
63
63
|
.slice(0, 120);
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// KJC-TSK-1166: the board honours KJ_PLANS_DIR as its plans root; what kj writes must land
|
|
67
|
+
// in the same root, or the board shows none of it.
|
|
66
68
|
export function plansDir(projectDir) {
|
|
67
|
-
|
|
69
|
+
const root = process.env.KJ_PLANS_DIR || path.join(getKjHome(), "plans");
|
|
70
|
+
return path.join(root, projectSlug(projectDir));
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
/**
|
package/src/prompts/coder.js
CHANGED
|
@@ -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.
|
|
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),
|