karajan-code 2.13.0 β†’ 2.14.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/README.md CHANGED
@@ -23,7 +23,9 @@
23
23
 
24
24
  ---
25
25
 
26
- > **v2.13.0 released** β€” HU Board hardening. Five PRs make the board resilient and self-healing after a dogfooding session uncovered four pathologies: a "Karajan needs an answer" prompt modal from days earlier blocking the entire UI, 18 zombi projects reappearing after every `kj board start`, the browser serving stale HTML/JS after a server restart, and the prompt modal showing a transparent background. Now: **tombstones** persist deletes across `fullScan` (`KJC-TSK-0380`), new endpoints `DELETE /api/prompts/:id`, `DELETE /api/plans/:planId`, `GET /api/tombstones`, `POST /api/tombstones/:type/:id/restore`; new command **`kj board cleanup`** sweeps ephemeral projects, orphan prompts and dangling session dirs in one pass; **`Cache-Control: no-store`** + `/api/version` polling auto-reloads the client when the server boot time changes; new **🧹 button** as manual escape hatch; CSS `--bg-secondary` finally declared so modals are opaque. 5 PRs (#654, #655, #656, #657, #658). 4522/4522 tests passing. Safe upgrade from 2.12.0.
26
+ > **v2.14.0 released** β€” Quality pass. 16 PRs absorbing bug blockers (Solomon no longer rubber-stamps security issues misclassified as "style", coder filesystem-leak detection gains a second layer that catches `cd <abs> && pnpm init` even when the dir pre-existed, Sonar admin password rotation now surfaces silent failures), the four planner pathologies surfaced by the GRETA Plan 2 dogfooding (scope respect, transversal one-to-many deps, explicit `reuse` marker, and a brand-new **self-fix loop** where the plan-reviewer re-invokes the planner with structured feedback until zero issues remain), HU Board polish (zombie-TTL for crashed-runner prompts, less aggressive rate-limit with SSE exempt), and the first wave of `tests/` reorg (issue #368): ~93 files moved from root to mirror-subfolders. **4577/4577 tests passing**, **0 regressions** across all 16 PRs. Safe upgrade from 2.13.0.
27
+ >
28
+ > **v2.13.0** β€” HU Board hardening. Five PRs make the board resilient and self-healing after a dogfooding session uncovered four pathologies: a "Karajan needs an answer" prompt modal from days earlier blocking the entire UI, 18 zombi projects reappearing after every `kj board start`, the browser serving stale HTML/JS after a server restart, and the prompt modal showing a transparent background. Now: **tombstones** persist deletes across `fullScan` (`KJC-TSK-0380`), new endpoints `DELETE /api/prompts/:id`, `DELETE /api/plans/:planId`, `GET /api/tombstones`, `POST /api/tombstones/:type/:id/restore`; new command **`kj board cleanup`** sweeps ephemeral projects, orphan prompts and dangling session dirs in one pass; **`Cache-Control: no-store`** + `/api/version` polling auto-reloads the client when the server boot time changes; new **🧹 button** as manual escape hatch; CSS `--bg-secondary` finally declared so modals are opaque. 5 PRs (#654, #655, #656, #657, #658). 4522/4522 tests passing.
27
29
  >
28
30
  > **v2.12.0** β€” Quality-measurement release. Two new features land together: every `kj run` against a known plan now scores how faithfully the coder followed it (deterministic 0–100 **plan adherence** metric, four weighted components, rendered in `summary.md`), and a small **golden-tasks** regression suite (`todo-rest-api`, `npm-package-cli`, `react-counter-component`) catches output-quality drops between Karajan versions before npm publish. Plus the shrink-budget CI gate now exempts human-facing docs from its 200-LOC ceiling while keeping AI-rule files (CLAUDE.md, AGENTS.md, role prompts) capped. 3 PRs for plan adherence (#645–#647), 4 for golden tasks (#648, #650–#652), 1 for the CI policy (#649). 4522/4522 tests passing. Safe upgrade from 2.11.0.
29
31
  >
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "karajan-code",
3
- "version": "2.13.0",
3
+ "version": "2.14.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",
@@ -19,7 +19,10 @@ const PKG_VERSION = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "ut
19
19
  const seaTransformPlugin = {
20
20
  name: "sea-transform",
21
21
  setup(build) {
22
- build.onLoad({ filter: /\.m?js$/ }, async (args) => {
22
+ // Only process real files. Imports redirected by hu-board-stub (or any
23
+ // other namespaced plugin) MUST not pass through this transform β€”
24
+ // their `args.path` is a virtual id, not a path readFile can open.
25
+ build.onLoad({ filter: /\.m?js$/, namespace: "file" }, async (args) => {
23
26
  let contents = await fs.readFile(args.path, "utf8");
24
27
  let modified = false;
25
28
 
@@ -86,6 +89,56 @@ const seaTransformPlugin = {
86
89
  }
87
90
  };
88
91
 
92
+ /**
93
+ * Stub plugin for the HU Board package (KJC-BUG-0040).
94
+ *
95
+ * The HU Board lives in `packages/hu-board/` and requires `better-sqlite3`
96
+ * β€” a native node-gyp module that esbuild cannot resolve at bundle time
97
+ * (it has no JS entry point, only `.node` binaries compiled per platform).
98
+ *
99
+ * The SEA binary is the lean CLI distribution. Users who want the HU
100
+ * Board install via `npm install -g karajan-code`, which installs
101
+ * better-sqlite3 normally. So the right answer is: do NOT include
102
+ * `packages/hu-board/` in the SEA bundle at all.
103
+ *
104
+ * This plugin intercepts every import / dynamic import that points into
105
+ * `packages/hu-board/src/*` and rewrites it to a stub module that throws
106
+ * a clear, actionable error when invoked at runtime β€” instead of failing
107
+ * silently during bundling.
108
+ */
109
+ const huBoardStubPlugin = {
110
+ name: "hu-board-stub",
111
+ setup(build) {
112
+ build.onResolve({ filter: /packages[\\/]hu-board[\\/]src[\\/].*/ }, (args) => ({
113
+ path: args.path,
114
+ namespace: "hu-board-stub",
115
+ }));
116
+ build.onLoad({ filter: /.*/, namespace: "hu-board-stub" }, () => ({
117
+ contents: `
118
+ const NOT_AVAILABLE = "The HU Board is not available in this standalone binary.\\n" +
119
+ "Install karajan-code from npm to get the dashboard:\\n" +
120
+ " npm install -g karajan-code\\n";
121
+ function notAvailable() { throw new Error(NOT_AVAILABLE); }
122
+ // Explicit named exports for every symbol the CLI imports from
123
+ // hu-board. Adding them by name (rather than relying on a Proxy)
124
+ // avoids esbuild's CJS↔ESM interop confusion: a Proxy that
125
+ // returns truthy for "__esModule" makes esbuild wrap the import
126
+ // and the destructured names resolve to undefined.
127
+ module.exports = {
128
+ __esModule: false,
129
+ initDb: notAvailable,
130
+ closeDb: notAvailable,
131
+ cleanupZombies: notAvailable,
132
+ // Catch-all default so \`import foo from\` and \`import * as foo\`
133
+ // also throw the same message instead of returning undefined.
134
+ default: notAvailable,
135
+ };
136
+ `,
137
+ loader: "js",
138
+ }));
139
+ },
140
+ };
141
+
89
142
  /** @type {import('esbuild').BuildOptions} */
90
143
  export const seaBuildOptions = {
91
144
  entryPoints: ["src/cli.js"],
@@ -95,7 +148,12 @@ export const seaBuildOptions = {
95
148
  target: "node20",
96
149
  bundle: true,
97
150
  minify: false,
98
- external: [],
99
- plugins: [seaTransformPlugin],
151
+ // better-sqlite3 is a native module imported by packages/hu-board/src/db.js.
152
+ // The hu-board-stub plugin below replaces the whole hu-board package so
153
+ // this `external` entry is belt-and-braces β€” if any other dependency
154
+ // pulls better-sqlite3 in we want a clear "not bundled" error, not a
155
+ // silent half-bundle.
156
+ external: ["better-sqlite3"],
157
+ plugins: [seaTransformPlugin, huBoardStubPlugin],
100
158
  logLevel: "info",
101
159
  };
@@ -397,6 +397,12 @@ async function setupSonarQube(config, logger, { interactive } = {}) {
397
397
  logger.info(` -> Sonar token saved to ${result.savedTo}`);
398
398
  if (result.passwordRotated) {
399
399
  logger.info(` -> Sonar admin password rotated. New value at ${result.passwordSavedTo}`);
400
+ } else if (result.passwordRotationError) {
401
+ // KJC-BUG-0035: rotation failed but token generation succeeded.
402
+ // admin/admin is still live β€” surface it loudly so the user can
403
+ // fix it manually before someone else exploits it.
404
+ logger.warn(` -> SonarQube admin password NOT rotated: ${result.passwordRotationError}`);
405
+ logger.warn(" admin/admin remains active. Rotate it manually at http://localhost:9000/account/security");
400
406
  }
401
407
  return;
402
408
  }
@@ -7,6 +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
11
  import { formatPlan, formatHuTable } from "./_shared.js";
11
12
 
12
13
  /**
@@ -105,8 +106,19 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
105
106
  // the tests manually before executing.
106
107
  const { normaliseAcceptanceTests } = await import("../../plan/plan-schema.js");
107
108
  const steps = parsed?.steps || [];
108
- let prevId = null;
109
109
  let stepsMissingTests = 0;
110
+
111
+ // Two-pass HU creation (KJC-TSK-0382 follow-up):
112
+ // Pass 1 β€” create every HU with `blocked_by: []` and remember the
113
+ // mapping `<symbolic id from planner> -> <stable hu id>`.
114
+ // Pass 2 β€” translate the planner's `dependencies: [symbolicId, …]`
115
+ // into the corresponding `blocked_by: [huId, …]`.
116
+ // The split is needed because `addHu` assigns the stable id at insertion
117
+ // time, so a forward reference (step B depends on step A whose hu id
118
+ // doesn't exist yet) would otherwise be impossible to resolve.
119
+ const symbolicToHuId = new Map();
120
+ const stepDeps = []; // parallel array: stepDeps[i] = array of symbolic dep ids
121
+ const stepReuse = []; // parallel array: stepReuse[i] = array of symbolic reuse ids (KJC-BUG-0044 / P3)
110
122
  for (const step of steps) {
111
123
  const desc = typeof step === "string" ? step : step.description || step.title || JSON.stringify(step);
112
124
  const rawTests = typeof step === "object" ? step.acceptance_tests : null;
@@ -116,13 +128,33 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
116
128
  title: desc.slice(0, 80),
117
129
  task_type: classifyTaskType(desc),
118
130
  scope: desc,
119
- blocked_by: prevId ? [prevId] : [],
131
+ blocked_by: [],
120
132
  acceptance_tests: tests,
121
133
  // Spec mapping (PR C): preserve the planner's section citation
122
134
  // so the coder/reviewer / board can show "implements Β§5.3".
123
135
  spec_section: typeof step === "object" && typeof step.spec_section === "string" ? step.spec_section.trim() || null : null,
124
136
  });
125
- prevId = hu.id;
137
+ if (typeof step === "object" && typeof step.id === "string" && step.id.trim()) {
138
+ symbolicToHuId.set(step.id.trim(), hu.id);
139
+ }
140
+ const deps = typeof step === "object" && Array.isArray(step.dependencies) ? step.dependencies : [];
141
+ stepDeps.push(deps);
142
+ const reuse = typeof step === "object" && Array.isArray(step.reuse) ? step.reuse : [];
143
+ stepReuse.push(reuse);
144
+ }
145
+ // Pass 2: resolve dependencies + reuse. Unknown symbolic ids are dropped
146
+ // silently (the plan-reviewer pass will flag them as gaps if they matter).
147
+ for (let i = 0; i < plan.hus.length; i++) {
148
+ const symbolicDeps = stepDeps[i] || [];
149
+ const resolvedDeps = symbolicDeps
150
+ .map((s) => typeof s === "string" ? symbolicToHuId.get(s.trim()) : null)
151
+ .filter(Boolean);
152
+ if (resolvedDeps.length > 0) plan.hus[i].blocked_by = resolvedDeps;
153
+ const symbolicReuse = stepReuse[i] || [];
154
+ const resolvedReuse = symbolicReuse
155
+ .map((s) => typeof s === "string" ? symbolicToHuId.get(s.trim()) : null)
156
+ .filter(Boolean);
157
+ if (resolvedReuse.length > 0) plan.hus[i].reuse = resolvedReuse;
126
158
  }
127
159
  // Tests-synthesizer pass (v2.7.5): the first planner call asks for
128
160
  // acceptance_tests inline, but Claude / Codex frequently skip that
@@ -202,6 +234,44 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
202
234
  ? `[planner] reviewer surfaced ${count} item(s): ${review.findings.summary || "(see plan.review for details)"}`
203
235
  : `[planner] reviewer found nothing actionable: ${review.findings.summary || "OK"}`
204
236
  );
237
+ // KJC-BUG-0045 / P4: self-fix loop. The reviewer is flag-only by
238
+ // contract; when it surfaces actionable issues, ask the planner to
239
+ // PATCH the plan, apply in-process, and re-review. Loop until 0
240
+ // findings or maxIterations. Skippable with --no-plan-fixer/--quick.
241
+ const skipFixer = Boolean(flags?.noPlanFixer || flags?.quick);
242
+ const maxFixerIterations = 2;
243
+ if (count > 0 && !skipFixer) {
244
+ for (let i = 1; i <= maxFixerIterations; i++) {
245
+ const currentCount = findingsCount(plan.review);
246
+ if (currentCount === 0) break;
247
+ runLog.logText(`[planner] self-fix iter ${i}/${maxFixerIterations} β€” patching ${currentCount} issue(s)`);
248
+ progress.onOutput?.({ line: `Self-fix iter ${i}: patching ${currentCount} issue(s)`, kind: "text" });
249
+ const fix = await applyReviewerFeedback({
250
+ agent: planner, task, hus: plan.hus, findings: plan.review,
251
+ onOutput: progress.onOutput, silenceTimeoutMs, timeoutMs,
252
+ });
253
+ if (!fix.ok) {
254
+ runLog.logText(`[planner] self-fix iter ${i} failed (non-blocking): ${fix.error}`);
255
+ break;
256
+ }
257
+ const ops = applyFixerPatch(plan, fix.patch);
258
+ runLog.logText(`[planner] self-fix iter ${i} applied: +${ops.added} HU, +${ops.depsAdded} dep, -${ops.deleted} HU`);
259
+ if (ops.added + ops.depsAdded + ops.deleted === 0) {
260
+ runLog.logText(`[planner] self-fix iter ${i} empty patch β€” stopping`);
261
+ break;
262
+ }
263
+ const review2 = await reviewPlan({
264
+ agent: planner, task, hus: plan.hus,
265
+ onOutput: progress.onOutput, silenceTimeoutMs, timeoutMs,
266
+ });
267
+ if (!review2.ok) {
268
+ runLog.logText(`[planner] re-review after iter ${i} failed: ${review2.error}`);
269
+ break;
270
+ }
271
+ plan.review = review2.findings;
272
+ runLog.logText(`[planner] after self-fix iter ${i}: ${findingsCount(review2.findings)} issue(s) remaining`);
273
+ }
274
+ }
205
275
  } else {
206
276
  runLog.logText(`[planner] reviewer pass failed (non-blocking): ${review.error}`);
207
277
  }
@@ -128,6 +128,55 @@ export function verifyLeaksAgainstTranscript(leaks, transcript) {
128
128
  });
129
129
  }
130
130
 
131
+ /**
132
+ * Layer 2 detection (KJC-BUG-0032): scan the coder's transcript for
133
+ * `cd <abs-path> && <write-cmd>` patterns where <abs-path> is OUTSIDE
134
+ * projectDir. Catches the bug class even when the target directory
135
+ * already existed (so snapshot-diff misses it) β€” e.g.
136
+ * `cd /home/manu/assistant && pnpm init -y`
137
+ * when `~/assistant` was pre-existing.
138
+ *
139
+ * Filters applied:
140
+ * - Only absolute paths (`/`, `~/`, `$HOME/`) β€” relative `cd subdir`
141
+ * is assumed inside projectDir.
142
+ * - Skips `/tmp/...` (ephemeral, legitimate scratch).
143
+ * - Skips targets inside projectDir.
144
+ * - Only flags if followed (same line, within 200 chars) by a
145
+ * write/creation command: mkdir / touch / cp / mv / git init /
146
+ * {pnpm,npm,yarn} init / {pnpm,npm,yarn,npx} create / cat > /
147
+ * echo > / shell redirect (>, >>).
148
+ * - Pure read commands (ls, which, cat <file>, grep) do NOT flag.
149
+ *
150
+ * @param {string|null|undefined} transcript
151
+ * @param {string|null} projectDir
152
+ * @returns {string[]} deduplicated absolute leak paths
153
+ */
154
+ export function detectTranscriptCdLeaks(transcript, projectDir) {
155
+ if (!transcript || typeof transcript !== "string") return [];
156
+ const projectDirAbs = projectDir ? path.resolve(projectDir) : null;
157
+ const homeDir = os.homedir();
158
+ // Capture cd target + the next ~200 chars of the same line for write-cmd lookup.
159
+ const cdRegex = /\bcd\s+(~\/[^\s&|;`'"\n]+|\$HOME\/[^\s&|;`'"\n]+|\/[^\s&|;`'"\n]+)([^\n]{0,200})/g;
160
+ const writeCmdRegex = /\b(mkdir|touch|cp|mv|git\s+init|(?:pnpm|npm|yarn)\s+(?:init|create)|npx\s+create|cat\s*>|echo\s+[^|]*>|>>?\s*\S)/i;
161
+ const leaks = new Set();
162
+ let match;
163
+ while ((match = cdRegex.exec(transcript)) !== null) {
164
+ const original = match[1];
165
+ let target = original;
166
+ const rest = match[2] || "";
167
+ if (target.startsWith("~/")) target = path.join(homeDir, target.slice(2));
168
+ else if (target.startsWith("$HOME/")) target = path.join(homeDir, target.slice(6));
169
+ const abs = path.resolve(target);
170
+ // /tmp guard only applies to literal /tmp/... paths (ephemeral scratch),
171
+ // not to ~/ expansions that happen to land in /tmp during tests.
172
+ if (original === "/tmp" || original.startsWith("/tmp/")) continue;
173
+ if (projectDirAbs && (abs === projectDirAbs || abs.startsWith(projectDirAbs + path.sep))) continue;
174
+ if (!writeCmdRegex.test(rest)) continue;
175
+ leaks.add(abs);
176
+ }
177
+ return Array.from(leaks);
178
+ }
179
+
131
180
  /**
132
181
  * Format a leak list into a multiline plain-Spanish error message.
133
182
  * Includes a hint about how to recover (move-or-delete) so the user
@@ -68,8 +68,24 @@ export function evaluateRules(context, rulesConfig = {}) {
68
68
  // Rule 6: Reviewer style-only block β€” all blocking issues are style/naming/formatting, not security/correctness
69
69
  if (rules.reviewer_style_block && context.blockingIssues?.length > 0) {
70
70
  const styleKeywords = /\b(naming|name|rename|style|format|formatting|indent|spacing|camelCase|snake_case|convention|cosmetic|readability|comment|jsdoc|documentation|whitespace|semicolon|quotes|trailing)\b/i;
71
+ // Anti-style: if any of these match, the issue is NOT style β€” it's a real correctness/security concern.
72
+ // Fixes KJC-BUG-0026 (Solomon approving legitimate security blockers misclassified as "style").
73
+ const securityKeywords = /\b(sql\s*injection|xss|csrf|ssrf|rce|injection|vulnerab|exploit|sanitiz|escape\b|encod\b|cors|auth|authn|authz|password|secret|credential|token|api[-_\s]*key|private[-_\s]*key|hash|cipher|crypto|encrypt|decrypt|tls|ssl|certificate|leak|sensitive|pii|privacy|insecure|unsafe|race[-_\s]*condition|deadlock|memory[-_\s]*leak|null[-_\s]*deref|uaf|overflow|traversal|prototype[-_\s]*pollution|deserializ|eval\b|exec\b|spawn|shell|command[-_\s]*injection)\b/i;
74
+ const securityCategories = new Set(["security", "correctness", "bug", "vulnerability"]);
71
75
  const styleSeverities = new Set(["low", "minor"]);
76
+ const blockingSeverities = new Set(["critical", "high", "blocker", "major"]);
77
+ const isSecurityIssue = issue => {
78
+ const desc = issue.description || "";
79
+ const sev = (issue.severity || "").toLowerCase();
80
+ const cat = (issue.category || issue.type || "").toLowerCase();
81
+ const tags = Array.isArray(issue.tags) ? issue.tags.map(t => String(t).toLowerCase()) : [];
82
+ if (blockingSeverities.has(sev)) return true;
83
+ if (securityCategories.has(cat)) return true;
84
+ if (tags.some(t => securityCategories.has(t))) return true;
85
+ return securityKeywords.test(desc);
86
+ };
72
87
  const allStyle = context.blockingIssues.every(issue => {
88
+ if (isSecurityIssue(issue)) return false;
73
89
  const desc = issue.description || "";
74
90
  const sev = (issue.severity || "").toLowerCase();
75
91
  return styleSeverities.has(sev) || styleKeywords.test(desc);
@@ -18,7 +18,7 @@ import { runCoderWithFallback } from "../agent-fallback.js";
18
18
  import { invokeSolomon } from "../solomon-escalation.js";
19
19
  import { detectRateLimit } from "../../utils/rate-limit-detector.js";
20
20
  import { createStallDetector } from "../../utils/stall-detector.js";
21
- import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript } from "../fs-leak-detector.js";
21
+ import { snapshotHomeTopLevel, detectNewHomeEntries, formatLeakMessage, verifyLeaksAgainstTranscript, detectTranscriptCdLeaks } from "../fs-leak-detector.js";
22
22
 
23
23
  export async function runCoderStage({ coderRoleInstance, coderRole, config, logger, emitter, eventBase, session, plannedTask, trackBudget, iteration, brainCtx, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null }) {
24
24
  logger.setContext({ iteration, stage: "coder" });
@@ -159,10 +159,14 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
159
159
  // detected entries against the coder's transcript: only flag those
160
160
  // the coder demonstrably referenced.
161
161
  const coderTranscript = coderExecResult.result?.output || "";
162
- const fsLeaks = verifyLeaksAgainstTranscript(candidateLeaks, coderTranscript);
163
- if (candidateLeaks.length > 0 && fsLeaks.length === 0) {
162
+ const layer1Leaks = verifyLeaksAgainstTranscript(candidateLeaks, coderTranscript);
163
+ if (candidateLeaks.length > 0 && layer1Leaks.length === 0) {
164
164
  logger.warn(`fs-leak-detector: ${candidateLeaks.length} new $HOME entr(y/ies) detected but none referenced in the coder transcript β€” likely a concurrent host-side write, not flagging (#546).`);
165
165
  }
166
+ // Layer 2 (BUG-0032): scan the transcript for `cd <abs-out-of-project> && <write-cmd>`
167
+ // patterns the snapshot-diff misses (e.g. when target dir pre-existed).
168
+ const layer2Leaks = detectTranscriptCdLeaks(coderTranscript, projectDirAbs);
169
+ const fsLeaks = Array.from(new Set([...layer1Leaks, ...layer2Leaks]));
166
170
  if (fsLeaks.length > 0) {
167
171
  const msg = formatLeakMessage(fsLeaks, projectDirAbs);
168
172
  logger.error(msg);
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Plan fixer (KJC-BUG-0045 / P4) β€” self-fix loop after plan-reviewer.
3
+ *
4
+ * The reviewer is flag-only by contract. This module asks the planner
5
+ * to PATCH the plan with the missing pieces and applies the patch
6
+ * in-process. Patch shape:
7
+ * - additions: new HUs (planner-step shape, id is auto-assigned)
8
+ * - deps_to_add: blocked_by edges to declare on existing HUs
9
+ * - deletions: HU ids to drop (used for overlap resolution)
10
+ */
11
+
12
+ import { extractFirstJson } from "../utils/json-extract.js";
13
+ import { addHu, removeHu } from "./plan-hu-ops.js";
14
+ import { normaliseAcceptanceTests } from "./plan-schema.js";
15
+
16
+ export function buildFixerPrompt({ task, hus, findings }) {
17
+ const huSummary = hus.map((h) => {
18
+ const deps = Array.isArray(h.blocked_by) && h.blocked_by.length > 0
19
+ ? ` ← blocked_by: ${h.blocked_by.join(", ")}` : "";
20
+ return `- ${h.id}: ${h.title || "(untitled)"}${deps}`;
21
+ }).join("\n");
22
+
23
+ const missingHus = (findings?.missing_hus || [])
24
+ .map(m => ` - Β§${m.spec_section}: ${m.rationale}`).join("\n");
25
+ const missingDeps = (findings?.missing_dependencies || [])
26
+ .map(d => ` - ${d.from} should be blocked_by ${d.on} (${d.rationale})`).join("\n");
27
+ const overlaps = (findings?.scope_overlaps || [])
28
+ .map(o => ` - ${o.between.join(" ↔ ")}: ${o.rationale}`).join("\n");
29
+
30
+ return [
31
+ "A reviewer flagged the issues listed under \"Findings\". Your ONLY",
32
+ "job is to PATCH the plan to fix EXACTLY those issues:",
33
+ "- missing_hus β†’ MUST add one HU each",
34
+ "- missing_dependencies β†’ MUST declare each edge",
35
+ "- scope_overlaps β†’ MUST drop one of the pair or merge them",
36
+ "Do NOT propose new features outside the findings list. Do not add new",
37
+ "tests/logging/refactors. The fixer is scoped to the report ONLY.",
38
+ "",
39
+ "Output MUST be a JSON object:",
40
+ "```json",
41
+ "{",
42
+ ' "additions": [',
43
+ ' { "id": "<symbolic>", "description": "<sentence>",',
44
+ ' "spec_section": "<ref>", "dependencies": ["<huId>"],',
45
+ ' "acceptance_tests": [ { "type":"gherkin","content":"…" } ],',
46
+ ' "reuse": [] }',
47
+ " ],",
48
+ ' "deps_to_add": [ { "huId": "<huId>", "on": "<huId>" } ],',
49
+ ' "deletions": [ "<huId>" ]',
50
+ "}",
51
+ "```",
52
+ "Reference existing HUs by exact id. Empty arrays OK. JSON only.",
53
+ "",
54
+ "## Original Task",
55
+ task,
56
+ "",
57
+ "## Current plan",
58
+ huSummary,
59
+ "",
60
+ "## Findings to address",
61
+ missingHus ? `### missing_hus (MUST add)\n${missingHus}` : "",
62
+ missingDeps ? `### missing_dependencies (MUST declare)\n${missingDeps}` : "",
63
+ overlaps ? `### scope_overlaps (MUST drop or merge)\n${overlaps}` : "",
64
+ ].filter(Boolean).join("\n");
65
+ }
66
+
67
+ function normalisePatch(raw) {
68
+ const out = { additions: [], deps_to_add: [], deletions: [] };
69
+ if (!raw || typeof raw !== "object") return out;
70
+ if (Array.isArray(raw.additions)) {
71
+ out.additions = raw.additions
72
+ .filter(a => a && typeof a === "object" && typeof a.description === "string" && a.description.trim())
73
+ .map(a => ({
74
+ id: typeof a.id === "string" ? a.id.trim() : null,
75
+ description: String(a.description).trim(),
76
+ spec_section: typeof a.spec_section === "string" ? a.spec_section.trim() || null : null,
77
+ dependencies: Array.isArray(a.dependencies) ? a.dependencies.map(String) : [],
78
+ acceptance_tests: normaliseAcceptanceTests(a.acceptance_tests),
79
+ reuse: Array.isArray(a.reuse) ? a.reuse.map(String) : [],
80
+ }));
81
+ }
82
+ if (Array.isArray(raw.deps_to_add)) {
83
+ out.deps_to_add = raw.deps_to_add
84
+ .filter(d => d && typeof d.huId === "string" && typeof d.on === "string")
85
+ .map(d => ({ huId: d.huId.trim(), on: d.on.trim() }));
86
+ }
87
+ if (Array.isArray(raw.deletions)) {
88
+ out.deletions = raw.deletions.filter(d => typeof d === "string" && d.trim()).map(d => d.trim());
89
+ }
90
+ return out;
91
+ }
92
+
93
+ export async function applyReviewerFeedback({ agent, task, hus, findings, onOutput, silenceTimeoutMs, timeoutMs }) {
94
+ const prompt = buildFixerPrompt({ task, hus, findings });
95
+ const runArgs = { prompt, role: "planner" };
96
+ if (onOutput) runArgs.onOutput = onOutput;
97
+ if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
98
+ if (timeoutMs) runArgs.timeoutMs = timeoutMs;
99
+ const result = await agent.runTask(runArgs);
100
+ if (!result.ok) return { ok: false, error: result.error || "fixer agent failed" };
101
+ const parsed = extractFirstJson(result.output || "");
102
+ if (!parsed || typeof parsed !== "object") {
103
+ return { ok: false, error: "fixer returned no JSON object" };
104
+ }
105
+ return { ok: true, patch: normalisePatch(parsed) };
106
+ }
107
+
108
+ export function applyFixerPatch(plan, patch) {
109
+ const counts = { added: 0, depsAdded: 0, deleted: 0 };
110
+ for (const huId of patch.deletions || []) {
111
+ if (removeHu(plan, huId)) counts.deleted += 1;
112
+ }
113
+ for (const a of patch.additions || []) {
114
+ addHu(plan, {
115
+ title: (a.description || "").slice(0, 80),
116
+ scope: a.description,
117
+ spec_section: a.spec_section,
118
+ acceptance_tests: a.acceptance_tests,
119
+ reuse: a.reuse || [],
120
+ });
121
+ counts.added += 1;
122
+ }
123
+ const knownIds = new Set(plan.hus.map(h => h.id));
124
+ for (const { huId, on } of patch.deps_to_add || []) {
125
+ if (!knownIds.has(huId) || !knownIds.has(on)) continue;
126
+ const hu = plan.hus.find(h => h.id === huId);
127
+ if (!hu) continue;
128
+ const blocked = Array.isArray(hu.blocked_by) ? hu.blocked_by : [];
129
+ if (blocked.includes(on)) continue;
130
+ hu.blocked_by = [...blocked, on];
131
+ counts.depsAdded += 1;
132
+ }
133
+ if (counts.added + counts.depsAdded + counts.deleted > 0) {
134
+ plan.updatedAt = new Date().toISOString();
135
+ }
136
+ return counts;
137
+ }
@@ -22,7 +22,7 @@ function nextSeq(plan) {
22
22
  /**
23
23
  * Add an HU to the plan. Auto-generates a globally unique ID.
24
24
  * @param {object} plan - v2 plan
25
- * @param {object} huData - { title, task_type?, scope?, acceptance_criteria?, acceptance_tests?, blocked_by? }
25
+ * @param {object} huData - { title, task_type?, scope?, acceptance_criteria?, acceptance_tests?, blocked_by?, reuse? }
26
26
  * @returns {object} the created HU (with id assigned)
27
27
  */
28
28
  export function addHu(plan, huData) {
@@ -34,6 +34,11 @@ export function addHu(plan, huData) {
34
34
  task_type: huData.task_type || "sw",
35
35
  status: "pending",
36
36
  blocked_by: huData.blocked_by || [],
37
+ // KJC-BUG-0044 / P3: ids of OTHER HUs whose implementation this HU
38
+ // piggy-backs on instead of reimplementing the same logic. Set by
39
+ // the planner; consumed by the coder prompt + plan-reviewer to
40
+ // avoid duplicate code generation.
41
+ reuse: huData.reuse || [],
37
42
  scope: huData.scope || null,
38
43
  acceptance_criteria: huData.acceptance_criteria || [],
39
44
  acceptance_tests: huData.acceptance_tests || [],
@@ -59,9 +64,10 @@ export function removeHu(plan, huId) {
59
64
  const idx = plan.hus.findIndex(h => h.id === huId);
60
65
  if (idx === -1) return false;
61
66
  plan.hus.splice(idx, 1);
62
- // Clean up blocked_by refs
67
+ // Clean up blocked_by + reuse refs
63
68
  for (const hu of plan.hus) {
64
69
  hu.blocked_by = (hu.blocked_by || []).filter(dep => dep !== huId);
70
+ hu.reuse = (hu.reuse || []).filter(dep => dep !== huId);
65
71
  }
66
72
  plan.updatedAt = new Date().toISOString();
67
73
  return true;
@@ -77,7 +83,7 @@ export function removeHu(plan, huId) {
77
83
  export function updateHu(plan, huId, patch) {
78
84
  const hu = plan.hus.find(h => h.id === huId);
79
85
  if (!hu) return null;
80
- const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "spec_section"];
86
+ const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "reuse", "spec_section"];
81
87
  for (const key of allowed) {
82
88
  if (patch[key] !== undefined) hu[key] = patch[key];
83
89
  }
@@ -50,6 +50,61 @@ export function parsePlannerOutput(output) {
50
50
  return { title: state.title, approach: state.approach, steps };
51
51
  }
52
52
 
53
+ /**
54
+ * KJC-BUG-0042 / P1: extract explicit scope exclusions from the task.
55
+ *
56
+ * The planner consistently produced HUs for items the user explicitly
57
+ * excluded ("NO incluye en este plan: vistas compartidas, …"). Once
58
+ * these phrases land in the task text, the LLM still generated steps
59
+ * for them β€” the rule was implicit, never made FORBIDDEN. This function
60
+ * harvests them so the prompt can echo them back literally.
61
+ *
62
+ * Detected patterns (inline comma-separated lists, case-insensitive):
63
+ * - "NO incluye[...]: a, b, c" (Spanish)
64
+ * - "Fuera de(l) scope[...]: a, b, c"
65
+ * - "Out of scope[...]: a, b, c" (English)
66
+ * - "Not in (this) plan[...]: a, b, c"
67
+ * - "Plan N handles: a, b, c" (cross-plan reference)
68
+ * - "Reserved for plan[...]: a, b, c"
69
+ *
70
+ * @param {string} task
71
+ * @returns {string[]} deduplicated trimmed exclusion items
72
+ */
73
+ export function extractScopeExclusions(task) {
74
+ if (!task || typeof task !== "string") return [];
75
+ const patterns = [
76
+ /\bNO\s+incluye[^:\n]{0,60}:\s*([^.\n]+)/gi,
77
+ /\bFuera\s+de(?:l)?\s+scope[^:\n]{0,60}:\s*([^.\n]+)/gi,
78
+ /\bOut\s+of\s+scope[^:\n]{0,60}:\s*([^.\n]+)/gi,
79
+ /\bNot\s+in\s+(?:this\s+)?plan[^:\n]{0,60}:\s*([^.\n]+)/gi,
80
+ /\bPlan\s+\d+\s+handles[^:\n]{0,60}:?\s*([^.\n]+)/gi,
81
+ /\bReserved\s+for\s+plan[^:\n]{0,60}:\s*([^.\n]+)/gi,
82
+ ];
83
+ const items = new Set();
84
+ for (const re of patterns) {
85
+ let m;
86
+ while ((m = re.exec(task)) !== null) {
87
+ const list = m[1] || "";
88
+ for (const raw of list.split(/,|;| y |\sand\s/)) {
89
+ const item = raw.trim().replace(/^[-*]\s*/, "").replace(/\.$/, "");
90
+ if (item && item.length < 120) items.add(item);
91
+ }
92
+ }
93
+ }
94
+ return Array.from(items);
95
+ }
96
+
97
+ function renderScopeExclusions(exclusions) {
98
+ if (!exclusions || exclusions.length === 0) return null;
99
+ const list = exclusions.map((e) => ` - ${e}`).join("\n");
100
+ return [
101
+ "## FORBIDDEN scope (out-of-scope items declared in the task)",
102
+ "The task explicitly excludes the following from this plan. Do NOT generate steps that implement them. If a step would touch any of these, drop it and put the item in `outOfScope` instead:",
103
+ "",
104
+ list,
105
+ ].join("\n");
106
+ }
107
+
53
108
  function formatArchitectContext(architectContext) {
54
109
  if (!architectContext) return null;
55
110
  const arch = architectContext.architecture || {};
@@ -148,6 +203,12 @@ export function buildPlannerPrompt({ task, context, architectContext, productCon
148
203
  parts.push("## Context", context, "");
149
204
  }
150
205
 
206
+ const exclusions = extractScopeExclusions(task);
207
+ const exclusionsSection = renderScopeExclusions(exclusions);
208
+ if (exclusionsSection) {
209
+ parts.push(exclusionsSection, "");
210
+ }
211
+
151
212
  const archSection = formatArchitectContext(architectContext);
152
213
  if (archSection) {
153
214
  parts.push(archSection, "");
@@ -164,13 +225,38 @@ export function buildPlannerPrompt({ task, context, architectContext, productCon
164
225
  "Each step in `steps` has this shape:",
165
226
  "```json",
166
227
  "{",
228
+ ' "id": "<short symbolic identifier you choose for this step, e.g. \'INFRA-001\' or \'AUTH-SIGNUP\'>",',
167
229
  ' "description": "<what this step achieves, one sentence>",',
168
230
  ' "commit": "<conventional commit message>",',
169
231
  ' "spec_section": "<see rules below>",',
170
- ' "acceptance_tests": [ /* see rules below */ ]',
232
+ ' "acceptance_tests": [ /* see rules below */ ],',
233
+ ' "dependencies": [ "<symbolic ids of OTHER steps this one waits for>" ],',
234
+ ' "reuse": [ "<symbolic ids of OTHER steps whose functionality this one piggy-backs on instead of reimplementing>" ]',
171
235
  "}",
172
236
  "```",
173
237
  "",
238
+ "### `id` (REQUIRED for cross-references)",
239
+ "- A short symbolic identifier you assign to this step (e.g. `INFRA-001`, `AUTH-SIGNUP`, `team-list-page`).",
240
+ "- It only has to be unique within this plan; the system maps it to a stable internal HU id afterwards.",
241
+ "- If the user spec already prefixes its bullets with `[INFRA-001]`, `[AUTH-005]`, etc., REUSE those exact strings as `id`.",
242
+ "",
243
+ "### `dependencies` (REQUIRED β€” empty array if none)",
244
+ "- The list of `id`s of OTHER steps in this same plan that MUST be completed before this one starts.",
245
+ "- Read the user's spec carefully: phrases like `Depende: X, Y`, `requires X`, `after X`, `blocked by X`, `needs X first` MUST be translated into entries here.",
246
+ "- If the spec says `Sin deps` or makes no mention of dependencies for a step, return `[]`. Do NOT invent dependencies. Do NOT chain steps by their order of appearance.",
247
+ "- Only reference `id`s that exist among the steps you are emitting in THIS response. External or vague references (e.g. `\"the design system\"`) belong in `risks`, not `dependencies`.",
248
+ "- **Transversal dependencies (KJC-BUG-0043 / P2)**: when a step's description or `acceptance_tests` requires ALL members of a category (e.g. `listado transversal de warnings filtrables por guardrail`, `list of all assessments`, `summary across all guardrails`, `dashboard que agrega todos los X`), declare `dependencies` to ALL step ids in that category β€” NOT just the first one, NOT just the index/master step.",
249
+ " - Example: a step `LIST-WARNINGS` whose AC reads *\"todos los warnings filtrables por guardrail\"* MUST list every `GUARD-001`, `GUARD-002`, … `GUARD-N` step in its plan as dependencies. Listing only `GUARD-001` is a bug β€” at execution time the listado will run before the rest of the guardrails exist and the AC will fail.",
250
+ " - The detection signal is the AC/description, not the step title. If the title is generic (\"List warnings\") but the AC requires aggregation across N concrete items, you MUST resolve the N concrete items and depend on each.",
251
+ "",
252
+ "### `reuse` (REQUIRED β€” empty array if none) β€” KJC-BUG-0044 / P3",
253
+ "- `reuse` is NOT the same as `dependencies`. `dependencies` means \"X must complete before me (ordering)\"; `reuse` means \"I piggy-back on X's implementation instead of reimplementing the same logic in my own code\".",
254
+ "- If the functionality your step needs is already provided by another step in this plan (versioning utility, hash helper, encryption envelope, cache layer, schema validator, etc.), declare `reuse: [\"<id>\"]` and do NOT regenerate the same code in your step's commit.",
255
+ " - Example: a second step that needs prompt versioning MUST set `reuse: [\"ASSESS-003\"]` instead of reimplementing the versioning logic β€” `ASSESS-003` already produced it.",
256
+ " - Example: every step that needs envelope encryption sets `reuse: [\"INFRA-CRYPTO\"]` instead of including a new key-derivation routine.",
257
+ "- If the spec describes a brand-new utility no other step produces, leave `reuse` as `[]`.",
258
+ "- A step's `reuse` ids MUST also appear in its `dependencies` (you can't piggy-back on something that runs after you).",
259
+ "",
174
260
  renderSpecSectionRules(specInfo),
175
261
  "",
176
262
  renderAcceptanceTestsRules(),
@@ -102,6 +102,10 @@ export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {
102
102
  let pass = DEFAULT_PASS;
103
103
  let passwordRotated = false;
104
104
  let passwordSavedTo = null;
105
+ // KJC-BUG-0035: capture rotation failures so the caller can log them
106
+ // instead of swallowing the error silently. Pre-fix the rotation
107
+ // returned status 403/500/network were ignored and the user never knew.
108
+ let passwordRotationError = null;
105
109
 
106
110
  // 1) Probe: does admin/admin still work?
107
111
  const probe = await call(host, "/api/authentication/validate", { user, pass }).catch((err) => ({ status: 0, body: err.message }));
@@ -142,10 +146,19 @@ export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {
142
146
  pass = longerPass;
143
147
  passwordRotated = true;
144
148
  passwordSavedTo = persistSecret("sonar.admin-password", pass);
149
+ } else {
150
+ passwordRotationError = `rotate-retry HTTP ${retry.status}: ${detailFromBody(retry.body)}`;
145
151
  }
146
- } else if (change.status !== 0 && change.status !== 401) {
147
- // Non-fatal: probably the admin already changed the password. We
148
- // continue using whatever credentials worked at the probe step.
152
+ } else if (change.status === 401) {
153
+ // Expected: admin already changed password β€” not a failure.
154
+ } else if (change.status !== 0) {
155
+ // KJC-BUG-0035: ANY other non-success status (403, 500, 400-without-default, …)
156
+ // means the rotation didn't happen and admin/admin is still live on disk.
157
+ // Surface it via the result so the caller can warn the user.
158
+ passwordRotationError = `rotate HTTP ${change.status}: ${detailFromBody(change.body)}`;
159
+ } else {
160
+ // status === 0: network error before getting any HTTP response.
161
+ passwordRotationError = `rotate network error: ${detailFromBody(change.body)}`;
149
162
  }
150
163
 
151
164
  // 3) Token already exists? Revoke it so we can re-create cleanly.
@@ -178,5 +191,15 @@ export async function bootstrapSonarToken({ host = "http://localhost:9000" } = {
178
191
  savedTo,
179
192
  passwordRotated,
180
193
  passwordSavedTo: passwordRotated ? passwordSavedTo : null,
194
+ passwordRotationError,
181
195
  };
182
196
  }
197
+
198
+ function detailFromBody(body) {
199
+ if (!body) return "no body";
200
+ if (typeof body === "string") return body.slice(0, 200);
201
+ if (Array.isArray(body.errors)) {
202
+ return body.errors.map(e => e?.msg || JSON.stringify(e)).join("; ").slice(0, 200);
203
+ }
204
+ try { return JSON.stringify(body).slice(0, 200); } catch { return "(unserialisable)"; }
205
+ }