karajan-code 4.48.1 → 4.49.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.48.1",
3
+ "version": "4.49.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",
@@ -104,6 +104,7 @@
104
104
  "@babel/parser": "^7.29.7",
105
105
  "@karajan-family/governance": "^0.1.0",
106
106
  "@modelcontextprotocol/sdk": "^1.29.0",
107
+ "@xterm/xterm": "^6.0.0",
107
108
  "better-sqlite3": "^12.10.0",
108
109
  "chokidar": "^5.0.0",
109
110
  "commander": "^15.0.0",
@@ -120,8 +121,12 @@
120
121
  "sqlite-vec": "^0.1.9",
121
122
  "valibot": "^1.4.1",
122
123
  "web-tree-sitter": "^0.26.9",
124
+ "ws": "^8.21.3",
123
125
  "yaml": "^2.9.0"
124
126
  },
127
+ "optionalDependencies": {
128
+ "node-pty": "^1.1.0"
129
+ },
125
130
  "devDependencies": {
126
131
  "@eslint/js": "^10.0.1",
127
132
  "@vitest/coverage-v8": "^4.1.8",
@@ -5,44 +5,32 @@ import { buildArchitectPrompt, parseArchitectOutput } from "../prompts/architect
5
5
  import { createCliProgressReporter } from "../utils/cli-progress.js";
6
6
  import { withBrainRecovery } from "../brain/with-brain-recovery.js";
7
7
 
8
- function formatLayers(layers, lines) {
9
- lines.push("### Layers");
10
- for (const l of layers) {
11
- lines.push(typeof l === "string" ? `- ${l}` : `- **${l.name}**: ${l.responsibility || ""}`);
12
- }
13
- lines.push("");
14
- }
15
-
16
- function formatTradeoffs(tradeoffs, lines) {
17
- lines.push("### Tradeoffs");
18
- for (const t of tradeoffs) {
19
- lines.push(`- **${t.decision}**: ${t.rationale || ""}`);
20
- if (t.alternatives?.length) lines.push(` Alternatives: ${t.alternatives.join(", ")}`);
21
- }
8
+ // KJC-BUG-0313 (#2061): the agent answers each list with plain strings or with
9
+ // objects; both render, and a section with neither prints nothing, never "undefined".
10
+ function section(title, items, render, lines) {
11
+ if (!items?.length) return;
12
+ lines.push(`### ${title}`);
13
+ for (const item of items) lines.push(...(typeof item === "string" ? [`- ${item}`] : render(item)));
22
14
  lines.push("");
23
15
  }
24
16
 
25
- function formatApiContracts(contracts, lines) {
26
- lines.push("### API Contracts");
27
- for (const c of contracts) {
28
- lines.push(`- \`${c.method || "GET"} ${c.endpoint}\``);
29
- }
30
- lines.push("");
31
- }
17
+ const named = (o, detail) => [`- **${o.name ?? o.decision ?? JSON.stringify(o)}**${detail ? `: ${detail}` : ""}`];
18
+ const contract = (c) => [`- \`${c.method || "GET"} ${c.endpoint ?? c.path ?? ""}\``];
19
+ const tradeoff = (t) => [...named(t, t.rationale), ...(t.alternatives?.length ? [` Alternatives: ${t.alternatives.join(", ")}`] : [])];
20
+ const entity = (e) => named(e, Array.isArray(e.fields) ? e.fields.join(", ") : e.description);
32
21
 
33
22
  function formatArchitecture(arch, lines) {
34
23
  if (arch.type) lines.push(`**Type:** ${arch.type}`, "");
35
- if (arch.layers?.length) formatLayers(arch.layers, lines);
36
- if (arch.patterns?.length) {
37
- lines.push("### Patterns");
38
- for (const p of arch.patterns) lines.push(`- ${p}`);
39
- lines.push("");
40
- }
41
- if (arch.tradeoffs?.length) formatTradeoffs(arch.tradeoffs, lines);
42
- if (arch.apiContracts?.length) formatApiContracts(arch.apiContracts, lines);
24
+ section("Layers", arch.layers, (l) => named(l, l.responsibility), lines);
25
+ section("Patterns", arch.patterns, (p) => [`- ${p.name ?? JSON.stringify(p)}`], lines);
26
+ section("Data Model", arch.dataModel?.entities, entity, lines);
27
+ // A contract given as a string ("GET /x") is code too: same backticks as an object's.
28
+ section("API Contracts", arch.apiContracts?.map((c) => (typeof c === "string" ? { raw: c } : c)), (c) => (c.raw ? [`- \`${c.raw}\``] : contract(c)), lines);
29
+ section("Dependencies", arch.dependencies, (d) => named(d, d.reason), lines);
30
+ section("Tradeoffs", arch.tradeoffs, tradeoff, lines);
43
31
  }
44
32
 
45
- function formatArchitect(result) {
33
+ export function formatArchitect(result) {
46
34
  const lines = [];
47
35
  lines.push(`## Architecture Design`);
48
36
  lines.push(`**Verdict:** ${result.verdict || "unknown"}`, "");
@@ -8,7 +8,7 @@ import { assertAgentsAvailable } from "../../agents/availability.js";
8
8
  import { resolveRole } from "../../config.js";
9
9
  import { createCliProgressReporter } from "../../utils/cli-progress.js";
10
10
  import { reviewPlan, findingsCount, findingsCountByType } from "../../plan/plan-reviewer.js";
11
- import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js";
11
+ import { applyReviewerFeedback, applyFixerPatch, patchChanged } from "../../plan/plan-fixer.js";
12
12
  import { runStructuralPass } from "../../plan/plan-structural-pass.js";
13
13
  import { loadPlan, savePlan } from "../../plan/plan-store.js";
14
14
 
@@ -50,7 +50,9 @@ export async function planFixCommand({ config, planId, prompt, logger, json }) {
50
50
 
51
51
  // Self-fix loop con smart convergence guard (mismo que generate.js).
52
52
  const maxIter = 4;
53
- for (let i = 1; i <= maxIter && count > 0; i++) {
53
+ // KJC-BUG-0316: with user feedback the fixer runs at least once, even when
54
+ // the reviewer flagged nothing, so the feedback is applied, not just read.
55
+ for (let i = 1; i <= maxIter && (count > 0 || (i === 1 && prompt)); i++) {
54
56
  const husSnapshot = JSON.parse(JSON.stringify(plan.hus));
55
57
  const reviewSnapshot = JSON.parse(JSON.stringify(plan.review));
56
58
  const fixProgress = createCliProgressReporter({ role: `plan-fix:iter ${i}`, quiet: Boolean(json) });
@@ -60,7 +62,7 @@ export async function planFixCommand({ config, planId, prompt, logger, json }) {
60
62
  });
61
63
  if (!fix.ok) { fixProgress.finish("failed"); break; }
62
64
  const ops = applyFixerPatch(plan, fix.patch);
63
- if (ops.added + ops.depsAdded + ops.archived === 0) { fixProgress.finish("done"); break; }
65
+ if (!patchChanged(ops)) { fixProgress.finish("done"); break; }
64
66
  const review2 = await reviewPlan({
65
67
  agent: planner, task: enrichedTask, hus: plan.hus,
66
68
  onOutput: fixProgress.onOutput, silenceTimeoutMs, timeoutMs,
@@ -7,7 +7,7 @@ import { createCliProgressReporter } from "../../utils/cli-progress.js";
7
7
  import { runAutoGC, summarizeGC } from "../../utils/garbage-collector.js";
8
8
  import { promptProjectName } from "../../utils/prompt-project-name.js";
9
9
  import { deriveProjectNameFromCwd } from "../../utils/derive-project-name-from-cwd.js";
10
- import { applyReviewerFeedback, applyFixerPatch } from "../../plan/plan-fixer.js";
10
+ import { applyReviewerFeedback, applyFixerPatch, patchChanged } from "../../plan/plan-fixer.js";
11
11
  import { runStructuralPass } from "../../plan/plan-structural-pass.js";
12
12
  import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-router.js";
13
13
  import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
@@ -370,8 +370,8 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
370
370
  break;
371
371
  }
372
372
  const ops = applyFixerPatch(plan, fix.patch);
373
- runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, ${ops.archived} archived`);
374
- if (ops.added + ops.depsAdded + ops.archived === 0) {
373
+ runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, -${ops.depsRemoved} dep, ${ops.scopesEdited} scope, ${ops.archived} archived, ${ops.cyclesRejected} cycle(s) rejected`);
374
+ if (!patchChanged(ops)) {
375
375
  fixProgress.finish("done");
376
376
  runLog.logText(`[planner] self-fix iter ${i} empty patch — stopping`);
377
377
  break;
@@ -84,7 +84,8 @@ ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not ev
84
84
  - Publishing ANY artifact (a build's dist/, a docs site, a tarball)?
85
85
  \`kj privacy scan <dir>\` first — nothing personal or secret-shaped ships.
86
86
  - Branch first: never commit on the base branch — every change reaches it
87
- through an atomic PR (~150 net lines, Conventional Commits). More than one
87
+ through an atomic PR (~150 net lines of code, Conventional Commits; human
88
+ docs, lockfiles, snapshots and dist/ do not count, AI-rule files do). More than one
88
89
  task, or the base tree must stay untouched? \`kj worktree start <slug>\`
89
90
  (one lane per task; \`kj worktree done <slug>\` after merge). Never CLAIM
90
91
  isolation without proof: in a lane, \`git rev-parse --git-dir\` differs
@@ -212,7 +212,7 @@ export const SHRINK_BUDGET_WORKFLOW = [
212
212
  // enter the agent's context every run and unbounded growth there dilutes the
213
213
  // signal. Documentation is exempt where documentation lives; markdown that
214
214
  // instructs an agent is code.
215
- " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/docs/**/*.md' ':!**/docs/**/*.mdx' ':!**/docs/**/*.txt' ':!**/docs/**/*.rst' ':!README.md' ':!README.*.md' ':!CHANGELOG.md' ':!CONTRIBUTING.md' ':!SECURITY.md' ':!CODE_OF_CONDUCT.md' ':!MIGRATION*.md' ':!TODO*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' ':!**/*.min.js' ':!**/*.map' || true)",
215
+ " d=$(git diff --numstat \"origin/${BASE_REF}...HEAD\" -- . ':!**/docs/**/*.md' ':!**/docs/**/*.mdx' ':!**/docs/**/*.txt' ':!**/docs/**/*.rst' ':!README.md' ':!README.*.md' ':!CHANGELOG.md' ':!CONTRIBUTING.md' ':!SECURITY.md' ':!CODE_OF_CONDUCT.md' ':!MIGRATION*.md' ':!TODO*.md' ':!*.lock' ':!package-lock.json' ':!pnpm-lock.yaml' ':!npm-shrinkwrap.json' ':!dist/**' ':!build/**' ':!coverage/**' ':!**/*.snap' ':!**/__snapshots__/**' ':!**/*.min.js' ':!**/*.map' ':(glob,exclude).karajan-shared/plans/*.json' || true)",
216
216
  ' a=$(printf "%s\\n" "$d" | awk \'$1!="-"{s+=$1}END{print s+0}\')',
217
217
  ' r=$(printf "%s\\n" "$d" | awk \'$2!="-"{s+=$2}END{print s+0}\')',
218
218
  ' net=$((a - r)); echo "net=$net (limit=$LOC_LIMIT)"',
package/src/hu/graph.js CHANGED
@@ -1,3 +1,40 @@
1
+ /**
2
+ * KJC-BUG-0315 (#2065): the first blocked_by cycle among `stories`, as a closed path
3
+ * (["080", "075", "010", "080"]), or null. A dependency on an id outside the set
4
+ * (an HU already done, or in another batch) is not a cycle.
5
+ * @param {Array<{id: string, blocked_by?: string[]}>} stories
6
+ * @returns {string[]|null}
7
+ */
8
+ export function findCycle(stories) {
9
+ const deps = new Map(stories.map((s) => [s.id, s.blocked_by || []]));
10
+ const state = new Map(); // id -> "visiting" | "done"
11
+ const path = [];
12
+ const visit = (id) => {
13
+ if (state.get(id) === "done" || !deps.has(id)) return null;
14
+ if (state.get(id) === "visiting") return [...path.slice(path.indexOf(id)), id];
15
+ state.set(id, "visiting");
16
+ path.push(id);
17
+ for (const dep of deps.get(id)) {
18
+ const cycle = visit(dep);
19
+ if (cycle) return cycle;
20
+ }
21
+ path.pop();
22
+ state.set(id, "done");
23
+ return null;
24
+ };
25
+ for (const s of stories) {
26
+ const cycle = visit(s.id);
27
+ if (cycle) return cycle;
28
+ }
29
+ return null;
30
+ }
31
+
32
+ /** Throws, naming the cycle, when `stories` have one: there is no valid order to run them in. */
33
+ export function assertNoCycle(stories) {
34
+ const cycle = findCycle(stories);
35
+ if (cycle) throw new Error(`Circular dependency between HUs: ${cycle.join(" -> ")}; no valid execution order exists, fix blocked_by (kj plan fix) before running`);
36
+ }
37
+
1
38
  /**
2
39
  * Topological sort of HU stories respecting blocked_by dependencies.
3
40
  * Returns ordered array of story IDs (dependencies first).
@@ -2,7 +2,7 @@
2
2
  * HU Sub-Pipeline — executes each certified HU as an independent sub-pipeline
3
3
  * with per-HU state tracking, topological ordering, and dependency-aware blocking.
4
4
  */
5
- import { topologicalSort } from "../hu/graph.js";
5
+ import { assertNoCycle, topologicalSort } from "../hu/graph.js";
6
6
  import { updateStoryStatus, loadHuBatch, saveHuBatch, HU_STATUS } from "../hu/store.js";
7
7
  import { emitProgress, makeEvent } from "../utils/events.js";
8
8
  import { refineHuWithContext } from "../hu/lazy-planner.js";
@@ -455,10 +455,13 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
455
455
  }
456
456
 
457
457
  const certifiedStories = batch.stories.filter(s => s.status === HU_STATUS.CERTIFIED);
458
+ // KJC-BUG-0315 (#2065): a cycle has no valid order. The catch below fell back to
459
+ // the list order and ran it anyway; a cycle now stops the run and is named.
460
+ assertNoCycle(certifiedStories);
458
461
  let orderedIds;
459
462
  try {
460
463
  orderedIds = topologicalSort(certifiedStories);
461
- } catch { /* cyclic dependency */
464
+ } catch { /* a dependency outside the certified batch (done or pending): keep the list order */
462
465
  orderedIds = certifiedStories.map(s => s.id);
463
466
  }
464
467
 
@@ -15,7 +15,8 @@ import { normaliseAcceptanceTests } from "./plan-schema.js";
15
15
  import { withBrainRecovery } from "../brain/with-brain-recovery.js";
16
16
 
17
17
  export function buildFixerPrompt({ task, hus, findings }) {
18
- const huSummary = hus.map((h) => {
18
+ // KJC-BUG-0314: archived (skipped) HUs are not offered to the fixer to patch.
19
+ const huSummary = hus.filter((h) => h.status !== "skipped").map((h) => {
19
20
  const deps = Array.isArray(h.blocked_by) && h.blocked_by.length > 0
20
21
  ? ` ← blocked_by: ${h.blocked_by.join(", ")}` : "";
21
22
  return `- ${h.id}: ${h.title || "(untitled)"}${deps}`;
@@ -34,19 +35,25 @@ export function buildFixerPrompt({ task, hus, findings }) {
34
35
  "- missing_hus → MUST add one HU each",
35
36
  "- missing_dependencies → MUST declare each edge",
36
37
  "- scope_overlaps → MUST drop one of the pair or merge them",
38
+ "- USER FEEDBACK in the task (if any) → apply it too: deps_to_remove for",
39
+ " an edge it says to drop, scope_edits for a scope it says to narrow",
37
40
  "Do NOT propose new features outside the findings list. Do not add new",
38
41
  "tests/logging/refactors. The fixer is scoped to the report ONLY.",
42
+ "A dependency that would close a cycle is rejected.",
39
43
  "",
40
44
  "Output MUST be a JSON object:",
41
45
  "```json",
42
46
  "{",
43
47
  ' "additions": [',
44
48
  ' { "id": "<symbolic>", "description": "<sentence>",',
49
+ ' "replaces": "<huId of a deleted HU it supersedes, if any>",',
45
50
  ' "spec_section": "<ref>", "dependencies": ["<huId>"],',
46
51
  ' "acceptance_tests": [ { "type":"gherkin","content":"…" } ],',
47
52
  ' "reuse": [] }',
48
53
  " ],",
49
54
  ' "deps_to_add": [ { "huId": "<huId>", "on": "<huId>" } ],',
55
+ ' "deps_to_remove": [ { "huId": "<huId>", "on": "<huId>" } ],',
56
+ ' "scope_edits": [ { "huId": "<huId>", "scope": "<the narrowed scope>" } ],',
50
57
  ' "deletions": [ "<huId>" ]',
51
58
  "}",
52
59
  "```",
@@ -65,14 +72,19 @@ export function buildFixerPrompt({ task, hus, findings }) {
65
72
  ].filter(Boolean).join("\n");
66
73
  }
67
74
 
75
+ const normaliseEdges = (list) => (Array.isArray(list) ? list : [])
76
+ .filter(d => d && typeof d.huId === "string" && typeof d.on === "string")
77
+ .map(d => ({ huId: d.huId.trim(), on: d.on.trim() }));
78
+
68
79
  function normalisePatch(raw) {
69
- const out = { additions: [], deps_to_add: [], deletions: [] };
80
+ const out = { additions: [], deps_to_add: [], deps_to_remove: [], scope_edits: [], deletions: [] };
70
81
  if (!raw || typeof raw !== "object") return out;
71
82
  if (Array.isArray(raw.additions)) {
72
83
  out.additions = raw.additions
73
84
  .filter(a => a && typeof a === "object" && typeof a.description === "string" && a.description.trim())
74
85
  .map(a => ({
75
86
  id: typeof a.id === "string" ? a.id.trim() : null,
87
+ replaces: typeof a.replaces === "string" && a.replaces.trim() ? a.replaces.trim() : null,
76
88
  description: String(a.description).trim(),
77
89
  spec_section: typeof a.spec_section === "string" ? a.spec_section.trim() || null : null,
78
90
  dependencies: Array.isArray(a.dependencies) ? a.dependencies.map(String) : [],
@@ -80,10 +92,12 @@ function normalisePatch(raw) {
80
92
  reuse: Array.isArray(a.reuse) ? a.reuse.map(String) : [],
81
93
  }));
82
94
  }
83
- if (Array.isArray(raw.deps_to_add)) {
84
- out.deps_to_add = raw.deps_to_add
85
- .filter(d => d && typeof d.huId === "string" && typeof d.on === "string")
86
- .map(d => ({ huId: d.huId.trim(), on: d.on.trim() }));
95
+ out.deps_to_add = normaliseEdges(raw.deps_to_add);
96
+ out.deps_to_remove = normaliseEdges(raw.deps_to_remove);
97
+ if (Array.isArray(raw.scope_edits)) {
98
+ out.scope_edits = raw.scope_edits
99
+ .filter(e => e && typeof e.huId === "string" && typeof e.scope === "string" && e.scope.trim())
100
+ .map(e => ({ huId: e.huId.trim(), scope: e.scope.trim() }));
87
101
  }
88
102
  if (Array.isArray(raw.deletions)) {
89
103
  out.deletions = raw.deletions.filter(d => typeof d === "string" && d.trim()).map(d => d.trim());
@@ -110,8 +124,46 @@ export async function applyReviewerFeedback({ agent, task, hus, findings, onOutp
110
124
  return { ok: true, patch: normalisePatch(parsed) };
111
125
  }
112
126
 
127
+ // KJC-BUG-0316: whether `from` depends on `target`, directly or transitively.
128
+ function dependsOn(plan, from, target) {
129
+ const seen = new Set();
130
+ const stack = [from];
131
+ while (stack.length > 0) {
132
+ const id = stack.pop();
133
+ if (id === target) return true;
134
+ if (seen.has(id)) continue;
135
+ seen.add(id);
136
+ stack.push(...(plan.hus.find((h) => h.id === id)?.blocked_by || []));
137
+ }
138
+ return false;
139
+ }
140
+
141
+ /** Whether applying the patch changed the plan (a rejected cycle is not a change). */
142
+ export function patchChanged(counts) {
143
+ return counts.added + counts.depsAdded + counts.depsRemoved + counts.scopesEdited + counts.archived > 0;
144
+ }
145
+
113
146
  export function applyFixerPatch(plan, patch) {
114
- const counts = { added: 0, depsAdded: 0, archived: 0 };
147
+ const counts = { added: 0, depsAdded: 0, depsRemoved: 0, scopesEdited: 0, archived: 0, cyclesRejected: 0 };
148
+ // KJC-BUG-0316 (#2066): an edge the feedback says to drop, and a scope it says to narrow.
149
+ for (const { huId, on } of patch.deps_to_remove || []) {
150
+ const hu = plan.hus.find((h) => h.id === huId);
151
+ if (!hu?.blocked_by?.includes(on)) continue;
152
+ hu.blocked_by = hu.blocked_by.filter((d) => d !== on);
153
+ counts.depsRemoved += 1;
154
+ }
155
+ for (const { huId, scope } of patch.scope_edits || []) {
156
+ const hu = plan.hus.find((h) => h.id === huId);
157
+ if (!hu || hu.scope === scope) continue;
158
+ hu.scope = scope;
159
+ counts.scopesEdited += 1;
160
+ }
161
+ // KJC-BUG-0314 (#2063): what a deleted HU depended on and who depended on it,
162
+ // taken before archiving cleans the edges, so its replacement can take over.
163
+ const replaced = new Map((patch.deletions || []).map((id) => [id, {
164
+ deps: [...(plan.hus.find((h) => h.id === id)?.blocked_by || [])],
165
+ dependents: plan.hus.filter((h) => h.id !== id && h.blocked_by?.includes(id)).map((h) => h.id),
166
+ }]));
115
167
  // KJC-TSK-0669 (absolute rule): HUs are NEVER deleted — not even by the
116
168
  // fix loop. Overlap resolutions ARCHIVE in place (status skipped +
117
169
  // provenance), so history survives and ids stay referenceable. The
@@ -165,15 +217,23 @@ export function applyFixerPatch(plan, patch) {
165
217
  hu.created_by = "plan-reviewer"; // KJC-TSK-0661: provenance stamp
166
218
  if (symbolicId) symbolicToHuId.set(symbolicId, hu.id);
167
219
  const depsSymbolic = Array.isArray(a.dependencies) ? a.dependencies : [];
168
- addedHus.push({ hu, depsSymbolic });
220
+ addedHus.push({ hu, depsSymbolic, replaces: a.replaces });
169
221
  counts.added += 1;
170
222
  }
171
- // Pasada 2: resolver deps simbólicas de las additions a ids largos.
172
- for (const { hu, depsSymbolic } of addedHus) {
223
+ // Pasada 2: resolver deps simbólicas de las additions a ids largos. KJC-BUG-0314:
224
+ // a dependency given by the HU's own long id resolves too.
225
+ const liveIds = new Set(plan.hus.filter((h) => h.status !== "skipped").map((h) => h.id));
226
+ for (const { hu, depsSymbolic, replaces } of addedHus) {
173
227
  const resolved = depsSymbolic
174
- .map((s) => typeof s === "string" ? symbolicToHuId.get(s.trim()) : null)
228
+ .map((s) => (typeof s === "string" ? symbolicToHuId.get(s.trim()) ?? (liveIds.has(s.trim()) ? s.trim() : null) : null))
175
229
  .filter(Boolean);
176
- if (resolved.length > 0) hu.blocked_by = resolved;
230
+ const takeOver = replaced.get(replaces);
231
+ const deps = [...new Set([...resolved, ...(takeOver?.deps || []).filter((d) => liveIds.has(d))])];
232
+ if (deps.length > 0) hu.blocked_by = deps;
233
+ for (const id of takeOver?.dependents || []) {
234
+ const dependent = plan.hus.find((h) => h.id === id);
235
+ if (dependent && !dependent.blocked_by?.includes(hu.id)) dependent.blocked_by = [...(dependent.blocked_by || []), hu.id];
236
+ }
177
237
  }
178
238
  const knownIds = new Set(plan.hus.map(h => h.id));
179
239
  for (const { huId, on } of patch.deps_to_add || []) {
@@ -182,10 +242,11 @@ export function applyFixerPatch(plan, patch) {
182
242
  if (!hu) continue;
183
243
  const blocked = Array.isArray(hu.blocked_by) ? hu.blocked_by : [];
184
244
  if (blocked.includes(on)) continue;
245
+ if (dependsOn(plan, on, huId)) { counts.cyclesRejected += 1; continue; }
185
246
  hu.blocked_by = [...blocked, on];
186
247
  counts.depsAdded += 1;
187
248
  }
188
- if (counts.added + counts.depsAdded + counts.archived > 0) {
249
+ if (patchChanged(counts)) {
189
250
  plan.updatedAt = new Date().toISOString();
190
251
  }
191
252
  return counts;
@@ -42,7 +42,8 @@ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
42
42
  * @returns {string}
43
43
  */
44
44
  export function buildReviewerPrompt({ task, hus }) {
45
- const huSummary = hus.map((h, i) => {
45
+ // KJC-BUG-0314: an archived (skipped) HU is history, not part of the plan to review.
46
+ const huSummary = hus.filter((h) => h.status !== "skipped").map((h, i) => {
46
47
  const deps = Array.isArray(h.blocked_by) && h.blocked_by.length > 0
47
48
  ? ` ← blocked_by: ${h.blocked_by.join(", ")}`
48
49
  : "";
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { generatePlanId } from "./plan-id.js";
8
+ import { findCycle } from "../hu/graph.js";
8
9
 
9
10
  const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
10
11
  // KJC-TSK-0394 step 5: el modelo CANONICAL del lifecycle es
@@ -24,6 +25,9 @@ const VALID_HU_STATUSES = new Set([
24
25
  "pending", "running", "done",
25
26
  // Legacy
26
27
  "certified", "coding", "reviewing", "failed", "blocked", "needs_context",
28
+ // KJC-BUG-0314 (#2063): HUs are never deleted (KJC-TSK-0669); the fix loop
29
+ // archives them as skipped, and validate rejected its own archive status.
30
+ "skipped",
27
31
  ]);
28
32
  export const CANONICAL_HU_STATUSES = new Set(["pending", "running", "done"]);
29
33
  // KJC-TSK-0394: nuevo campo `result` ortogonal a status. Reemplaza la
@@ -301,34 +305,9 @@ export function validatePlan(plan) {
301
305
  }
302
306
  }
303
307
 
304
- // Check for circular dependencies
305
- try {
306
- topoSort(plan.hus || []);
307
- } catch {
308
- errors.push("Circular dependency detected in HU blocked_by graph");
309
- }
308
+ // KJC-BUG-0315 (#2065): the cycle is named, so the person knows which edge to cut.
309
+ const cycle = findCycle(plan.hus || []);
310
+ if (cycle) errors.push(`Circular dependency in HU blocked_by graph: ${cycle.join(" -> ")}`);
310
311
 
311
312
  return errors.length === 0 ? { valid: true } : { valid: false, errors };
312
313
  }
313
-
314
- /** Simple topological sort for cycle detection. */
315
- function topoSort(hus) {
316
- const visited = new Set();
317
- const visiting = new Set();
318
- const order = [];
319
- const map = new Map(hus.map(h => [h.id, h]));
320
-
321
- function visit(id) {
322
- if (visited.has(id)) return;
323
- if (visiting.has(id)) throw new Error("cycle");
324
- visiting.add(id);
325
- const hu = map.get(id);
326
- for (const dep of hu?.blocked_by || []) visit(dep);
327
- visiting.delete(id);
328
- visited.add(id);
329
- order.push(id);
330
- }
331
-
332
- for (const hu of hus) visit(hu.id);
333
- return order;
334
- }
@@ -20,6 +20,16 @@ const HARNESS_PREFIX = ".karajan/harness/";
20
20
  const SAFE_DIR = /^(\$HOME)?(\/[\w.@%+~-]+)+$/;
21
21
 
22
22
  const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
23
+ // KJC-BUG-0311: the kj judging now, to tell a seal made by another version. Read
24
+ // lazily and never fatal (a bundled binary may not carry package.json): unknown
25
+ // means no seal counts as "older", so the strict re-render rule applies.
26
+ let kjVersionCache;
27
+ const kjVersion = () => {
28
+ if (kjVersionCache === undefined) {
29
+ try { kjVersionCache = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version ?? null; } catch { kjVersionCache = null; }
30
+ }
31
+ return kjVersionCache;
32
+ };
23
33
 
24
34
  /**
25
35
  * @returns {{files: Set<string>, reason?: string}} rutas repo-relativas de
@@ -41,6 +51,11 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
41
51
  if (!signed.ok) return { files: new Set(), reason: signed.reason };
42
52
  const ok = new Set();
43
53
  const unjudgeable = new Set();
54
+ const signedAbsent = new Set();
55
+ const current = kjVersion();
56
+ const olderSeal = current && typeof prov?.kj_version === "string" && prov.kj_version !== current ? prov.kj_version : null;
57
+ let vouched = false;
58
+ let stale = false;
44
59
  // KJC-BUG-0259 (user's decision, option A): history is judged with the kj of
45
60
  // when it was committed. The harness guards are machine-local and never in the
46
61
  // repo; the review and each PR's CI verify them when the seal is made. A check
@@ -72,6 +87,9 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
72
87
  // tiene que ser el de un guardia que kj CONOCE, o una procedencia forjada
73
88
  // colaria entradas ausentes con cualquier hash (catch de la review).
74
89
  if (prefix === HARNESS_PREFIX && st === null) {
90
+ // KJC-TSK-0877: with the roster's signature verified, the human signed this
91
+ // guard's hash with the rest; that vouches for it without seeing the file.
92
+ if (signed.required && typeof entry.sha256 === "string") { signedAbsent.add(entry.file); continue; }
75
93
  const body = canonicalHarnessBody(entry.file.slice(prefix.length));
76
94
  if (body != null && typeof entry.sha256 === "string" && sha256(Buffer.from(body, "utf8")) === entry.sha256) unjudgeable.add(entry.file);
77
95
  continue;
@@ -87,13 +105,23 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
87
105
  continue;
88
106
  }
89
107
  if (canonical != null && sha256(Buffer.from(canonical, "utf8")) === entry.sha256) ok.add(entry.file);
108
+ // KJC-BUG-0311 (#2052): sealed with another kj whose template this kj cannot
109
+ // re-render. A phone of the roster signed these very bytes: that vouches for
110
+ // them. Without that signature recomputation is the only proof, so it stays
111
+ // denied (an unsigned provenance could claim any version to skip it).
112
+ else if (olderSeal && signed.required) { ok.add(entry.file); vouched = true; }
113
+ else if (olderSeal) stale = true;
90
114
  }
91
115
  // `complete` = ninguna entrada FALLO y alguna verifico. Exigir que todas
92
116
  // verifiquen hacia imposible sellar desde cualquier sitio que no fuera la
93
117
  // maquina que sello, porque los guardias no viajan con el repo (0212).
94
- const judged = (e) => ok.has(e.file) || unjudgeable.has(e.file);
118
+ const judged = (e) => ok.has(e.file) || unjudgeable.has(e.file) || signedAbsent.has(e.file);
95
119
  const complete = ok.size > 0 && entries.every((e) => typeof e?.file === "string" && judged(e));
96
- return { files: ok, complete, unjudgeable, signed: signed.required };
120
+ return {
121
+ files: ok, complete, unjudgeable, signedAbsent, signed: signed.required,
122
+ ...(vouched ? { vouchedOlder: olderSeal } : {}),
123
+ ...(stale ? { reason: `sellado con kj ${olderSeal} y este kj (${current}) no puede recomputar su plantilla, y sin firma del padrón nada más lo respalda: re-séllalo con kj harden --commit` } : {}),
124
+ };
97
125
  }
98
126
 
99
127
  /**
@@ -113,10 +141,13 @@ export function liftSealedSupervisorViolations({ projectDir, violations, tracked
113
141
  && (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
114
142
  const kept = violations.filter((v) => !liftable(v));
115
143
  const unjudged = sealed.unjudgeable?.size ?? 0;
144
+ const vouchedAbsent = sealed.signedAbsent?.size ?? 0;
116
145
  const notes = [
117
146
  ...(unjudged > 0 ? [`${unjudged} guardia(s) del sello no existen en este checkout y no se han podido comprobar aquí`] : []),
147
+ ...(vouchedAbsent > 0 ? [`${vouchedAbsent} guardia(s) del sello no existen en este checkout y están respaldados por la firma del padrón`] : []),
118
148
  // KJC-TSK-0964: what the signature did or did not prove is said, never assumed.
119
149
  ...(sealed.signed === false ? ["proyecto sin padrón de firmantes: el sello no lleva firma verificable y su garantía es solo local"] : []),
150
+ ...(sealed.vouchedOlder ? [`sellado con kj ${sealed.vouchedOlder}: este kj no puede recomputar su plantilla y lo respalda la firma del padrón sobre esos bytes`] : []),
120
151
  ...(kept.length === violations.length && sealed.reason ? [sealed.reason] : []),
121
152
  ];
122
153
  return {
@@ -98,6 +98,12 @@ const CONTEXT_DISCARDS = [
98
98
  // (about 1 in 100) still warns, because nothing tells it apart from a phone
99
99
  // written after "R-"; the allow list is the way out for that one.
100
100
  { type: "rule-id", re: /\bR-(?=\d*[a-f])[0-9a-f]{10}\b/g },
101
+ // KJC-BUG-0317: a kj plan id is `plan-` + a YYYYMMDDHHMMSS stamp + `-` + 4
102
+ // lowercase alphanumerics, also inside an HU id (hu_<planId>_007, hence the
103
+ // lookarounds instead of \b). Only that WHOLE shape with a real 20xx date and
104
+ // time: no card of 14 digits starts with 20 (Diners is 30/36/38), and a
105
+ // 16-digit card does not fit the slot.
106
+ { type: "plan-id", re: /(?<![A-Za-z0-9])plan-20\d\d(?:0[1-9]|1[0-2])(?:0[1-9]|[12]\d|3[01])(?:[01]\d|2[0-3])[0-5]\d[0-5]\d-[a-z0-9]{4}(?![A-Za-z0-9])/g },
101
107
  ];
102
108
 
103
109
  /**
@@ -33,6 +33,8 @@ const EXEMPT = [
33
33
  /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|npm-shrinkwrap\.json)$/,
34
34
  /\.lock$/,
35
35
  /(^|\/)tests\/_diet\//,
36
+ // KJC-BUG-0318: the plan `kj plan share` writes, at the project root only.
37
+ /^\.karajan-shared\/plans\/[^/]+\.json$/,
36
38
  // Human documentation, wherever the project keeps it. The landing's docs
37
39
  // moved into the monorepo (MONO-3) and the old root-anchored `docs/` rule
38
40
  // stopped matching them, which is how documenting started costing budget.
@@ -97,6 +97,23 @@ export function __resetGitProbe() {
97
97
  _gitAvailable = null;
98
98
  }
99
99
 
100
+ /**
101
+ * KJC-BUG-0320: check out only skills/. The rest of the repo (evals/ with its
102
+ * *.test.js fixtures, scripts/) is never read, and under a project-local
103
+ * KARAJAN home the project's test runner picked those fixtures up.
104
+ * Idempotent, so an older full clone narrows on its next refresh.
105
+ */
106
+ async function narrowToSkills(root, logger) {
107
+ const result = await runCommand("git", ["-C", root, "sparse-checkout", "set", SKILLS_SUBDIR], { timeout: 30_000 });
108
+ if (result?.exitCode === 0) return null;
109
+ // Neither a full checkout (it brings the fixtures back) nor a half one stays:
110
+ // the clone goes, the caller reports it, and the next use clones again.
111
+ await fs.rm(root, { recursive: true, force: true });
112
+ const error = `skills/ could not be checked out alone: ${(result?.stderr || "").trim() || "sparse-checkout failed"}`;
113
+ logger?.warn?.(`addyosmani-catalog: ${error} (catalog removed)`);
114
+ return error;
115
+ }
116
+
100
117
  /**
101
118
  * Clone the addyosmani/agent-skills repo (shallow) if it does not already
102
119
  * exist locally. No-op when it is already present.
@@ -121,7 +138,7 @@ export async function ensureCatalog(opts = {}) {
121
138
 
122
139
  const result = await runCommand(
123
140
  "git",
124
- ["clone", "--depth", "1", repoUrl, root],
141
+ ["clone", "--depth", "1", "--sparse", repoUrl, root],
125
142
  { timeout: 120_000 }
126
143
  );
127
144
 
@@ -130,6 +147,8 @@ export async function ensureCatalog(opts = {}) {
130
147
  logger?.warn?.(`addyosmani-catalog: clone failed — ${stderr}`);
131
148
  return { ok: false, error: stderr || `clone failed (exit ${result.exitCode})` };
132
149
  }
150
+ const narrowError = await narrowToSkills(root, logger);
151
+ if (narrowError) return { ok: false, error: narrowError };
133
152
 
134
153
  await writeMeta({
135
154
  repoUrl,
@@ -204,6 +223,8 @@ export async function refreshIfStale(opts = {}) {
204
223
  action = "reset";
205
224
  logger?.info?.("addyosmani-catalog: ff-only failed, recovered via fetch+reset (upstream force-pushed)");
206
225
  }
226
+ const narrowError = await narrowToSkills(root, logger);
227
+ if (narrowError) return { ok: false, action: "skipped", error: narrowError };
207
228
 
208
229
  await writeMeta({
209
230
  ...(meta || {}),