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 +3 -1
- package/package.json +1 -1
- package/scripts/esbuild-sea.config.mjs +61 -3
- package/src/commands/init.js +6 -0
- package/src/commands/plan/generate.js +73 -3
- package/src/orchestrator/fs-leak-detector.js +49 -0
- package/src/orchestrator/solomon-rules.js +16 -0
- package/src/orchestrator/stages/coder-stage.js +7 -3
- package/src/plan/plan-fixer.js +137 -0
- package/src/plan/plan-hu-ops.js +9 -3
- package/src/prompts/planner.js +87 -1
- package/src/sonar/token-bootstrap.js +26 -3
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
};
|
package/src/commands/init.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
163
|
-
if (candidateLeaks.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
|
+
}
|
package/src/plan/plan-hu-ops.js
CHANGED
|
@@ -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
|
}
|
package/src/prompts/planner.js
CHANGED
|
@@ -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
|
|
147
|
-
//
|
|
148
|
-
|
|
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
|
+
}
|