karajan-code 4.61.1 → 4.62.1

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.61.1",
3
+ "version": "4.62.1",
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",
@@ -353,6 +353,15 @@ export function registerMeta(program, { pkgVersion }) {
353
353
  await huCommand({ config, action: "list", flags });
354
354
  });
355
355
  });
356
+ // KJC-TSK-1163 (ADR 0025): the team board, turned on once per repo.
357
+ hu.command("backend <backend>")
358
+ .description("Turn the repo's HU Board on: github (shared, in GitHub Issues) or local; writes .karajan/board.yml for a PR")
359
+ .option("--json", "Machine-readable output")
360
+ .action(async (backend, flags) => {
361
+ await withConfig(pkgVersion, "hu", flags, async ({ config }) => {
362
+ await huCommand({ config, action: "backend", args: [backend], flags });
363
+ });
364
+ });
356
365
 
357
366
  const adr = program.command("adr").description("Architecture decision records in .karajan/adrs/ (git-tracked)");
358
367
  adr.command("add <title>")
@@ -12,6 +12,8 @@ import { detectHostAgent } from "../utils/agent-detect.js";
12
12
  import { readIdentity } from "../identity/store.js";
13
13
  import { effectiveGitEmail } from "../identity/detect.js";
14
14
  import { roleSkills } from "../skills/role-skills.js";
15
+ import { readBoardConfig, setBoardBackend } from "../hu/board-config.js";
16
+ import { createHu, listHus, claim, setStatus, assertNotHeld } from "../hu/github-board.js";
15
17
 
16
18
  // KJC-TSK-0661 (Jorge's friction): every HU records WHO created it — the
17
19
  // board must never show work nobody can trace. Host agent env → that agent;
@@ -46,6 +48,45 @@ export function findTitleMatches(title, allHus) {
46
48
  return { identical, similar };
47
49
  }
48
50
 
51
+ /** A repeatable flag as lines: one value or many, each split per line, blanks dropped. */
52
+ function linesOf(v) {
53
+ const values = Array.isArray(v) ? v : [v];
54
+ return values.filter((s) => s != null && s !== "").flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
55
+ }
56
+
57
+ /** KJC-TSK-1163: kj hu add on the shared board; the same no-duplicates rule, checked against the issues. */
58
+ async function addToTeamBoard({ repo, title, flags, deps, emit }) {
59
+ 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);
67
+ }
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
+ return emit({ ...hu, board: "github", repo }, `✓ HU created: ${hu.short_id} (pending) in the shared board, issue #${hu.number} of ${repo}`);
72
+ }
73
+
74
+ /** KJC-TSK-1164: kj hu move on the shared board; starting it claims it (the assignee) first. */
75
+ async function moveOnTeamBoard({ repo, args, flags, deps, emit, who }) {
76
+ const [ref, status] = args;
77
+ if (!ref || !status) throw new Error("usage: kj hu move <id> <status>");
78
+ if (!HU_STATUSES.includes(status)) throw new Error(`invalid status "${status}" — valid: ${HU_STATUSES.join(", ")}`);
79
+ const number = Number(/^(?:HU-|#)(\d+)$/i.exec(ref)?.[1]);
80
+ if (!Number.isInteger(number)) throw new Error(`"${ref}" is not a shared-board HU: use HU-<issue number>`);
81
+ // GitHub assigns by login: kj identity must name the GitHub user of this clone.
82
+ if (!who || who.includes("@")) throw new Error("the shared board reserves by GitHub login: declare it with kj identity set");
83
+ // Starting it, or releasing it, claims it; any other move of a HU someone else runs is refused.
84
+ if (status === "running" || flags.release) await (deps.claimTeamHu ?? claim)({ repo, number, who, release: Boolean(flags.release) });
85
+ else await (deps.assertNotHeld ?? assertNotHeld)({ repo, number, who });
86
+ await (deps.setTeamStatus ?? setStatus)({ repo, number, status });
87
+ return emit({ id: `HU-${number}`, status, claimed_by: status === "running" ? who : null }, `✓ HU-${number} → ${status}`);
88
+ }
89
+
49
90
  // Exported for the Steward's proposed-work sync (KJC-TSK-0792): broken
50
91
  // invariants land in the same backlog the brain already consumes.
51
92
  export async function backlogPlan(projectDir) {
@@ -72,6 +113,30 @@ export async function huCommand({ config = null, action, args = [], flags = {},
72
113
  // tracker coexists with it: each HU points at its replica there (--ref).
73
114
  const externalBoard = config?.state_backend === "external" ? (config?.board?.name || "the external board") : null;
74
115
 
116
+ // KJC-TSK-1163/1164 (ADR 0025): the repo's board; with the shared one on, add/list/move go to GitHub.
117
+ // The repo turns its board on once; the file travels in a PR. Before reading it: it may be what is fixed.
118
+ if (action === "backend") {
119
+ if (!args[0]) throw new Error("usage: kj hu backend <github | local>");
120
+ const r = await (deps.setBackend ?? setBoardBackend)({ projectDir, backend: args[0] });
121
+ const where = r.backend === "github" ? `GitHub Issues of ${r.repo}` : "the local board";
122
+ const files = r.gitignoreChanged ? `${r.file} and .gitignore` : r.file;
123
+ return emit(r, `✓ HU Board: ${where} — commit ${files} in a PR so the whole team inherits it`);
124
+ }
125
+ const board = await (deps.boardConfig ?? readBoardConfig)(projectDir);
126
+ 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" }));
128
+ if (flags.json) { console.log(JSON.stringify(rows)); return rows; }
129
+ for (const r of rows) {
130
+ const held = r.claimed_by ? " · held by " + r.claimed_by : "";
131
+ console.log(`${r.short_id.padEnd(28)} ${r.status.padEnd(8)} ${r.title} [${board.repo}${held}]`);
132
+ }
133
+ if (rows.length === 0) console.log("no HUs yet — create one with: kj hu add \"<story>\"");
134
+ return rows;
135
+ }
136
+ // KJC-BUG-0392: a zero-padded id (HU-0104) is a local HU from before the shared board: it stays local.
137
+ 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 });
139
+
75
140
  if (action === "list") {
76
141
  const plans = await listPlans(projectDir);
77
142
  const rows = [];
@@ -104,6 +169,8 @@ export async function huCommand({ config = null, action, args = [], flags = {},
104
169
  if (action === "add") {
105
170
  const title = args[0];
106
171
  if (!title || !title.trim()) throw new Error("kj hu add requires a title: kj hu add \"<story>\"");
172
+ // KJC-TSK-1163 (ADR 0025): with the shared board on, the HU is an issue and GitHub gives its id.
173
+ if (board.backend === "github") return addToTeamBoard({ repo: board.repo, title, flags, deps, emit });
107
174
  // Cross-plan title dedup (KJC-TSK-0675) — check BEFORE creating anything.
108
175
  const allHus = [];
109
176
  for (const meta of await listPlans(projectDir)) {
@@ -152,8 +219,7 @@ export async function huCommand({ config = null, action, args = [], flags = {},
152
219
  if (flags.taskType && !TASK_TYPES.includes(flags.taskType)) {
153
220
  throw new Error(`invalid --task-type "${flags.taskType}" — valid: ${TASK_TYPES.join(", ")}`);
154
221
  }
155
- const splitLines = (v) => (Array.isArray(v) ? v : v ? [v] : [])
156
- .flatMap((s) => String(s).split("\n")).map((s) => s.trim()).filter(Boolean);
222
+ const splitLines = linesOf;
157
223
  const hu = addHu(plan, {
158
224
  title,
159
225
  // KJC-BUG-0189: the card-first gate demands a branch that references a
@@ -249,5 +315,5 @@ export async function huCommand({ config = null, action, args = [], flags = {},
249
315
  return emit({ id: hu.id, status, claimed_by: hu.claimed_by ?? null }, `✓ ${label} → ${status}`);
250
316
  }
251
317
 
252
- throw new Error(`unknown action "${action}" — use: add | move | list`);
318
+ throw new Error(`unknown action "${action}" — use: add | move | list | backend`);
253
319
  }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * KJC-TSK-1163 (TEAM-GH-B, ADR 0025): which HU Board a repo uses. The shared
3
+ * board on GitHub Issues is turned on once per repo (kj hu backend github),
4
+ * in a versioned file the whole team inherits with a pull; kj never turns it
5
+ * on because the remote happens to be GitHub. No file means the local board.
6
+ */
7
+ import fs from "node:fs/promises";
8
+ import path from "node:path";
9
+ import { parse as parseYaml, stringify as toYaml } from "yaml";
10
+ import { runCommand } from "../utils/process.js";
11
+ import { ensureGateTrackable } from "../review/gate-gitignore.js";
12
+
13
+ export const BOARD_FILE = ".karajan/board.yml";
14
+ const BACKENDS = ["github", "local"];
15
+ const REPO_RE = /^[\w.-]+\/[\w.-]+$/;
16
+
17
+ /** owner/repo of a GitHub remote (https, ssh, or an ssh host alias like github.com-work), or null. */
18
+ export function repoFromRemote(url) {
19
+ const u = String(url).trim();
20
+ // The host is github.com exactly, or an ssh alias of it written github.com-<name>; never a lookalike.
21
+ const shapes = [
22
+ /^https:\/\/(?:[^@/]+@)?github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
23
+ /^ssh:\/\/(?:[\w.-]+@)?github\.com(?:-\w+)?(?::\d+)?\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
24
+ /^(?:[\w.-]+@)?github\.com(?:-\w+)?:([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/,
25
+ ];
26
+ const m = shapes.map((re) => re.exec(u)).find(Boolean);
27
+ return m ? `${m[1]}/${m[2]}` : null;
28
+ }
29
+
30
+ /** The repo's board: {backend: "local"} with no file, or the validated content of the file. */
31
+ export async function readBoardConfig(projectDir) {
32
+ let text;
33
+ try {
34
+ text = await fs.readFile(path.join(projectDir, BOARD_FILE), "utf8");
35
+ } catch (err) {
36
+ if (err.code === "ENOENT") return { backend: "local" };
37
+ throw err;
38
+ }
39
+ const raw = parseYaml(text) || {};
40
+ if (!BACKENDS.includes(raw.backend)) throw new Error(`${BOARD_FILE}: backend must be one of ${BACKENDS.join(", ")}`);
41
+ if (raw.backend === "local") return { backend: "local" };
42
+ if (!REPO_RE.test(String(raw.repo ?? ""))) throw new Error(`${BOARD_FILE}: repo must be owner/repo, got "${raw.repo}"`);
43
+ return { backend: "github", repo: raw.repo };
44
+ }
45
+
46
+ /** Turn a board on for the repo, writing the versioned file and making sure git tracks it. */
47
+ export async function setBoardBackend({ projectDir, backend, run = runCommand, trackable = ensureGateTrackable }) {
48
+ if (!BACKENDS.includes(backend)) throw new Error(`unknown board backend "${backend}": use ${BACKENDS.join(", ")}`);
49
+ const git = (...args) => run("git", ["-C", projectDir, ...args]);
50
+ const doc = { backend };
51
+ if (backend === "github") {
52
+ const remote = await git("remote", "get-url", "origin");
53
+ const repo = remote.exitCode === 0 ? repoFromRemote(remote.stdout) : null;
54
+ if (!repo) throw new Error(`origin is not a GitHub remote (${(remote.stdout || remote.stderr || "").trim() || "none"}): the shared board lives in GitHub Issues`);
55
+ doc.repo = repo;
56
+ }
57
+ // Trackable BEFORE it is written (check-ignore needs no file): a board the team cannot
58
+ // inherit is never left behind. Exit 0 means git ignores it.
59
+ const isIgnored = async () => (await git("check-ignore", "-q", BOARD_FILE)).exitCode === 0;
60
+ const ignored = await isIgnored();
61
+ if (ignored) {
62
+ // A .karajan root exclude becomes the canonical block, which re-includes the board file.
63
+ await trackable(projectDir);
64
+ if (await isIgnored()) await appendReInclude(projectDir);
65
+ if (await isIgnored()) throw new Error(`git still ignores ${BOARD_FILE}: re-include it in .gitignore so the team inherits the board`);
66
+ }
67
+ await fs.mkdir(path.join(projectDir, ".karajan"), { recursive: true });
68
+ await fs.writeFile(path.join(projectDir, BOARD_FILE), toYaml(doc), "utf8");
69
+ return { ...doc, file: BOARD_FILE, gitignoreChanged: ignored };
70
+ }
71
+
72
+ async function appendReInclude(projectDir) {
73
+ const file = path.join(projectDir, ".gitignore");
74
+ // Ignored by a global exclude with no .gitignore: the repo's own file outranks it.
75
+ const text = await fs.readFile(file, "utf8").catch((err) => {
76
+ if (err.code === "ENOENT") { return ""; }
77
+ throw err;
78
+ });
79
+ await fs.writeFile(file, `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}!${BOARD_FILE}\n`, "utf8");
80
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * KJC-TSK-1162 (TEAM-GH-A, ADR 0025): the shared HU Board on GitHub Issues.
3
+ * Each HU is an issue of the repo: its number is the id (HU-<n>, unique by
4
+ * construction, so two people never mint the same one), its state a
5
+ * kj:<status> label, and who holds it the assignee. gh authenticates each
6
+ * person; every call names the repo explicitly.
7
+ */
8
+ import { runCommand } from "../utils/process.js";
9
+
10
+ export const HU_LABEL = "kj:hu";
11
+ export const STATUSES = ["pending", "running", "done", "failed", "skipped"];
12
+ const statusLabel = (s) => `kj:${s}`;
13
+
14
+ /** gh could not reach GitHub: the caller may queue the change, never invent data. */
15
+ export class BoardOfflineError extends Error {}
16
+ /** GitHub answered and said no (permissions, missing repo...). */
17
+ export class BoardRejectedError extends Error {}
18
+ /** The HU is running and someone else holds it: taking it over is explicit (release). */
19
+ export class HuClaimedError extends Error {}
20
+
21
+ // gh is not localized: these are the network failures it prints.
22
+ const OFFLINE = /dial tcp|no such host|could not resolve host|connection refused|i\/o timeout|network is unreachable|error connecting to/i;
23
+
24
+ async function gh(run, args) {
25
+ const res = await run("gh", args);
26
+ if (res.exitCode === 0) return res.stdout ?? "";
27
+ const why = (res.stderr || res.stdout || "").trim();
28
+ const Kind = OFFLINE.test(why) ? BoardOfflineError : BoardRejectedError;
29
+ throw new Kind(`gh ${args.slice(0, 2).join(" ")} failed: ${why}`);
30
+ }
31
+
32
+ // KJC-BUG-0393: a --json listing that matches nothing prints nothing at all, not "[]".
33
+ const listOf = (stdout) => (stdout.trim() === "" ? [] : JSON.parse(stdout));
34
+
35
+ async function ensureLabels(repo, run) {
36
+ const existing = new Set(listOf(await gh(run, ["label", "list", "-R", repo, "--search", "kj:", "--json", "name", "--limit", "100"])).map((l) => l.name));
37
+ for (const name of [HU_LABEL, ...STATUSES.map(statusLabel)].filter((n) => !existing.has(n))) {
38
+ await gh(run, ["label", "create", name, "-R", repo, "--color", "1f6feb", "--description", "Karajan HU Board"]);
39
+ }
40
+ }
41
+
42
+ const section = (title, items) => (items.length ? [`## ${title}`, ...items.map((i) => `- ${i}`), ""] : []);
43
+
44
+ /** Create the HU as an issue; returns its id, never one made up when GitHub did not answer. */
45
+ export async function createHu({ repo, title, acceptanceCriteria = [], tests = [], ref = null, run = runCommand }) {
46
+ await ensureLabels(repo, run);
47
+ const body = [...section("Acceptance criteria", acceptanceCriteria), ...section("Tests", tests), ...(ref ? [`Ref: ${ref}`, ""] : []), "<!-- kj:hu -->"].join("\n");
48
+ const url = (await gh(run, ["issue", "create", "-R", repo, "--title", title, "--body", body, "--label", HU_LABEL, "--label", statusLabel("pending")])).trim();
49
+ const number = Number(/\/issues\/(\d+)$/.exec(url)?.[1]);
50
+ if (!Number.isInteger(number)) throw new BoardRejectedError(`gh issue create returned no issue URL: ${url}`);
51
+ return { short_id: `HU-${number}`, number, status: "pending", title };
52
+ }
53
+
54
+ /** Every HU of the repo, with its status and who holds it. */
55
+ export async function listHus({ repo, run = runCommand }) {
56
+ const issues = listOf(await gh(run, ["issue", "list", "-R", repo, "--label", HU_LABEL, "--state", "all", "--limit", "1000", "--json", "number,title,state,labels,assignees"]));
57
+ return issues.map((i) => ({ short_id: `HU-${i.number}`, number: i.number, title: i.title, status: statusOf(i), claimed_by: i.assignees[0]?.login ?? null }));
58
+ }
59
+
60
+ /** The one kj:<status> of an issue; none or several is a board someone broke by hand. */
61
+ function statusOf(issue) {
62
+ const found = issue.labels.map((l) => l.name).filter((n) => STATUSES.some((s) => statusLabel(s) === n));
63
+ if (found.length !== 1) throw new BoardRejectedError(`HU-${issue.number} has ${found.length} status labels (${found.join(", ") || "none"}): fix it to exactly one kj:<status>`);
64
+ return found[0].slice(3);
65
+ }
66
+
67
+ async function view(repo, number, run) {
68
+ const issue = JSON.parse(await gh(run, ["issue", "view", String(number), "-R", repo, "--json", "state,labels,assignees"]));
69
+ // KJC-BUG-0392: only issues kj created as HUs are its to move; any other issue is left alone.
70
+ if (!issue.labels.some((l) => l.name === HU_LABEL)) throw new BoardRejectedError(`issue #${number} is not a kj HU (no ${HU_LABEL} label): kj does not touch it`);
71
+ return { ...issue, number, status: statusOf({ ...issue, number }), holders: issue.assignees.map((a) => a.login) };
72
+ }
73
+
74
+ /** Move the HU to `status`; done and skipped close the issue, anything else keeps it open. */
75
+ export async function setStatus({ repo, number, status, run = runCommand }) {
76
+ if (!STATUSES.includes(status)) throw new Error(`invalid status "${status}": use ${STATUSES.join(", ")}`);
77
+ const issue = await view(repo, number, run);
78
+ // Every other status label goes, not only the one read: a concurrent move must not leave two.
79
+ const others = STATUSES.filter((s) => s !== status).flatMap((s) => ["--remove-label", statusLabel(s)]);
80
+ await gh(run, ["issue", "edit", String(number), "-R", repo, "--add-label", statusLabel(status), ...others]);
81
+ const after = await view(repo, number, run);
82
+ if (after.status !== status) throw new BoardRejectedError(`HU-${number} was moved to ${after.status} at the same time: check it before moving it again`);
83
+ const closes = status === "done" || status === "skipped";
84
+ if (closes && issue.state === "OPEN") await gh(run, ["issue", "close", String(number), "-R", repo]);
85
+ if (!closes && issue.state === "CLOSED") await gh(run, ["issue", "reopen", String(number), "-R", repo]);
86
+ }
87
+
88
+ /** A running HU held by someone else is theirs: any move of it by `who` needs release. */
89
+ export async function assertNotHeld({ repo, number, who, run = runCommand }) {
90
+ const issue = await view(repo, number, run);
91
+ const others = issue.holders.filter((h) => h !== who);
92
+ if (issue.status === "running" && others.length > 0) {
93
+ throw new HuClaimedError(`HU-${number} is held by ${others.join(", ")}: taking it over is explicit (release)`);
94
+ }
95
+ }
96
+
97
+ /** Reserve the HU for `who` (the assignee is the reservation); a running HU held by another needs release. */
98
+ export async function claim({ repo, number, who, release = false, run = runCommand }) {
99
+ const issue = await view(repo, number, run);
100
+ const others = issue.holders.filter((h) => h !== who);
101
+ if (others.length > 0 && issue.status === "running" && !release) {
102
+ throw new HuClaimedError(`HU-${number} is held by ${others.join(", ")}: taking it over is explicit (release)`);
103
+ }
104
+ const drop = others.flatMap((h) => ["--remove-assignee", h]);
105
+ await gh(run, ["issue", "edit", String(number), "-R", repo, "--add-assignee", who, ...drop]);
106
+ // Two people may claim at once and both see it free: read it back, and the same rule
107
+ // on both sides (the first login in order keeps it; a release always keeps it) leaves one.
108
+ const after = (await view(repo, number, run)).holders;
109
+ const rivals = after.filter((h) => h !== who);
110
+ if (rivals.length > 0) {
111
+ if (!release && [who, ...rivals].sort()[0] !== who) {
112
+ await gh(run, ["issue", "edit", String(number), "-R", repo, "--remove-assignee", who]);
113
+ throw new HuClaimedError(`HU-${number} was claimed at the same time by ${rivals.join(", ")}, who keeps it`);
114
+ }
115
+ // The one who keeps it does not rely on the others backing off: it removes them.
116
+ await gh(run, ["issue", "edit", String(number), "-R", repo, ...rivals.flatMap((h) => ["--remove-assignee", h])]);
117
+ }
118
+ // Every claim, release or not, ends with who as the only holder, or says it did not.
119
+ const final = rivals.length > 0 ? (await view(repo, number, run)).holders : after;
120
+ if (final.length !== 1 || final[0] !== who) {
121
+ throw new HuClaimedError(`HU-${number} is held by ${final.join(", ") || "nobody"} after a concurrent claim: check it before working it`);
122
+ }
123
+ return { claimed_by: who };
124
+ }
@@ -44,6 +44,8 @@ export const CONTRACT_BLOCK = [
44
44
  "!.karajan/rules.yml",
45
45
  // KJC-TSK-0995 (HUM-D, ADR 0018): the security level, signed when lowered, is the team's.
46
46
  "!.karajan/security-level.json",
47
+ // KJC-TSK-1163 (ADR 0025): which HU Board the repo uses; the team shares it.
48
+ "!.karajan/board.yml",
47
49
  ];
48
50
 
49
51
  /**