karajan-code 4.48.3 → 4.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/architect.js +18 -30
- package/src/commands/plan/fix.js +5 -3
- package/src/commands/plan/generate.js +3 -3
- package/src/commands/review-gate.js +10 -3
- package/src/config/defaults.js +4 -2
- package/src/environment/playbook.js +4 -3
- package/src/git/hu-automation.js +11 -1
- package/src/harden/advisory.js +20 -1
- package/src/harden/sentinel-hooks.js +5 -2
- package/src/harden/workflow-templates.js +1 -1
- package/src/hu/graph.js +37 -0
- package/src/orchestrator/drivers/init-context.js +12 -1
- package/src/orchestrator/hu-sub-pipeline.js +5 -2
- package/src/plan/plan-fixer.js +74 -13
- package/src/plan/plan-reviewer.js +2 -1
- package/src/plan/plan-schema.js +7 -28
- package/src/planning-game/client.js +25 -11
- package/src/planning-game/pipeline-adapter.js +8 -5
- package/src/policy/exceptions.js +7 -3
- package/src/policy/supervisor-verify.js +8 -2
- package/src/privacy/scan.js +6 -0
- package/src/review/card-first.js +3 -1
- package/src/review/loc-budget.js +2 -0
- package/src/skills/addyosmani-catalog.js +22 -1
- package/src/spec-review/run-spec-review.js +7 -0
- package/templates/roles/coder.md +1 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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"}`, "");
|
package/src/commands/plan/fix.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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;
|
|
@@ -246,7 +246,11 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
246
246
|
const locInv = pol.errors.length === 0
|
|
247
247
|
? (pol.policy.invariants || []).find((i) => i.kind === "diff-threshold" && i.metric === "net_lines_added")
|
|
248
248
|
: null;
|
|
249
|
-
|
|
249
|
+
// KJC-BUG-0326 (#2085): two tiers. ~150 is the ideal (method_gates.pr_size_warn);
|
|
250
|
+
// up to the limit (the policy's net_lines_added max, else method_gates.pr_size_max,
|
|
251
|
+
// 200) is allowed without asking; above the limit, partition.
|
|
252
|
+
const sizeIdeal = config?.method_gates?.pr_size_warn ?? 150;
|
|
253
|
+
const sizeWarn = locInv?.max ?? config?.method_gates?.pr_size_max ?? 200;
|
|
250
254
|
const sizeSource = locInv ? `policy [${locInv.id}]` : "method_gates";
|
|
251
255
|
if (sizeWarn > 0) {
|
|
252
256
|
const numstat = await rawDiff(flags.range, ["--numstat"]);
|
|
@@ -258,6 +262,9 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
258
262
|
// Said out loud: an exemption nobody sees is indistinguishable from a gate
|
|
259
263
|
// that is not looking.
|
|
260
264
|
if (exempt > 0) console.log(`ℓ pr-size: ${exempt} line(s) exempt from the budget (generated output and human docs; AI-rule files always count)`);
|
|
265
|
+
if (added > sizeIdeal && added <= sizeWarn) {
|
|
266
|
+
console.log(`ℓ pr-size: ${added} lines added, over the ~${sizeIdeal} ideal, within the ${sizeWarn} limit: allowed, nothing to ask`);
|
|
267
|
+
}
|
|
261
268
|
if (added > sizeWarn) {
|
|
262
269
|
// KJC-TSK-0795 AC5: say how much of the weight is the module's OWN tests —
|
|
263
270
|
// partitioning is a decision, and it must never split code from its tests.
|
|
@@ -265,12 +272,12 @@ export async function reviewGateCommand({ config, logger = null, flags = {} }) {
|
|
|
265
272
|
const sizePolicy = config?.method_gates?.pr_size || "warn";
|
|
266
273
|
// ADR 0015: no env escape — an exception is the user's label on the PR, judged in CI.
|
|
267
274
|
if (sizePolicy === "block") {
|
|
268
|
-
const reason = `${added} lines added${split}
|
|
275
|
+
const reason = `${added} lines added${split} is over the ${sizeWarn}-line limit (${sizeSource}; method_gates.pr_size: block) — partition the work; an exception is your user's large-pr-justified label on the PR`;
|
|
269
276
|
console.log(`✗ pr-size gate: ${reason}`);
|
|
270
277
|
process.exitCode = 1;
|
|
271
278
|
return { verdict: "rejected", reviewer: "pr-size", issues: [{ severity: "high", description: reason }] };
|
|
272
279
|
} else {
|
|
273
|
-
console.log(`⚠ pr-size: ${added} lines added${split} (
|
|
280
|
+
console.log(`⚠ pr-size: ${added} lines added${split}, over the ${sizeWarn}-line limit (ideal ~${sizeIdeal}, source: ${sizeSource}) — partition the work before committing (method_gates.pr_size: block to harden)`);
|
|
274
281
|
}
|
|
275
282
|
}
|
|
276
283
|
}
|
package/src/config/defaults.js
CHANGED
|
@@ -81,9 +81,11 @@ const DEFAULTS = {
|
|
|
81
81
|
card_first_exempt_branches: ["chore/release-"],
|
|
82
82
|
// MG-B: source changes without a test change — "warn" | "block".
|
|
83
83
|
tests_with_code: "warn",
|
|
84
|
-
// MG-C:
|
|
85
|
-
//
|
|
84
|
+
// MG-C + KJC-BUG-0326: ~pr_size_warn added lines is the ideal; up to
|
|
85
|
+
// pr_size_max is allowed without asking; above it, partition (warn, or
|
|
86
|
+
// block with pr_size: "block"). A policy net_lines_added max wins over pr_size_max.
|
|
86
87
|
pr_size_warn: 150,
|
|
88
|
+
pr_size_max: 200,
|
|
87
89
|
// CLM-B (claims-with-evidence ADR): the Stop gate crosses the hard data of
|
|
88
90
|
// the turn's final message against that turn's tool outputs. "off" (adoption
|
|
89
91
|
// is explicit) | "warn" (report only) | "block" (a datum DENIED by its own
|
|
@@ -76,15 +76,16 @@ ${panel ? `${panel}\n` : ""}- Security findings are never overridable — not ev
|
|
|
76
76
|
\`kj audit --security\` (zero tokens) and remediate BEFORE the review.
|
|
77
77
|
- A design decision the card's AC don't cover belongs to the USER: record
|
|
78
78
|
it as a proposed ADR (\`kj adr add\`) and ask — never bury it in a PR
|
|
79
|
-
bullet.
|
|
80
|
-
|
|
79
|
+
bullet. Diff size: ~150 net lines of code is the ideal, up to 200 is
|
|
80
|
+
allowed without asking, above 200 partition, always.
|
|
81
81
|
- A non-trivial plan names at least two approaches — one as if the
|
|
82
82
|
codebase didn't exist — and says why the winner won. Following the
|
|
83
83
|
legacy line is a choice, never a default.
|
|
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
|
|
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
|
package/src/git/hu-automation.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import { commitAll, pushBranch, createPullRequest, hasChanges } from "../utils/git.js";
|
|
6
6
|
import { runCommand } from "../utils/process.js";
|
|
7
|
+
import { stampStagedVerdict } from "../review/verdict-store.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Build a git-safe branch name for an HU story.
|
|
@@ -184,7 +185,16 @@ export async function finalizeHuCommit({ story, branchName, config, logger, cwd
|
|
|
184
185
|
const title = story.title || story.id;
|
|
185
186
|
const commitMsg = `feat(${story.id}): ${title}`;
|
|
186
187
|
if (config.git?.auto_commit) {
|
|
187
|
-
|
|
188
|
+
// KJC-BUG-0323: this commit runs after the HU's reviewer approved, so it
|
|
189
|
+
// records that verdict for the staged diff, as the other post-review commits do.
|
|
190
|
+
const commitRes = await commitAll(commitMsg, cwd, {
|
|
191
|
+
beforeCommit: ({ paths } = {}) => stampStagedVerdict({
|
|
192
|
+
projectDir: cwd || config.projectDir || process.cwd(),
|
|
193
|
+
paths,
|
|
194
|
+
reviewer: config.reviewer || "pipeline-reviewer",
|
|
195
|
+
summary: `kj run HU ${story.id}: reviewer approved`,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
188
198
|
if (commitRes) {
|
|
189
199
|
result.committed = true;
|
|
190
200
|
logger.info(`HU ${story.id}: committed on '${branchName}'`);
|
package/src/harden/advisory.js
CHANGED
|
@@ -166,9 +166,28 @@ export function classifyArtifact(searchDir, artifact, altDirs = [searchDir]) {
|
|
|
166
166
|
* own root (monorepo-aware, mirroring installConfigsForRoots). `minimal`
|
|
167
167
|
* profile ships no config, so there is nothing to compare.
|
|
168
168
|
*/
|
|
169
|
+
// KJC-BUG-0335 (#2094): the method's own pieces, not only the configs. Without
|
|
170
|
+
// them the report said "0 missing" while the review gate demanded the Sentinel.
|
|
171
|
+
const METHOD_PIECES = [
|
|
172
|
+
{ id: "sentinel", what: "the Sentinel (it records the session, the RAG queries the review gate needs among them)",
|
|
173
|
+
files: [".karajan/harness/pretooluse-sentinel.mjs", ".karajan/harness/posttooluse.mjs", ".karajan/harness/stop.mjs"] },
|
|
174
|
+
{ id: "git-hooks", what: "the git hooks", files: [".karajan/hooks/pre-commit", ".karajan/hooks/commit-msg", ".karajan/hooks/pre-push"] },
|
|
175
|
+
];
|
|
176
|
+
|
|
177
|
+
function methodPiece(projectDir, { id, what, files }) {
|
|
178
|
+
const head = { dir: ".", id, file: files[0], currentVersion: null, managedVersion: null, upToDate: null, improvements: [], mergeable: false };
|
|
179
|
+
const missing = files.filter((f) => !existsSync(join(projectDir, f)));
|
|
180
|
+
return missing.length === 0
|
|
181
|
+
? { ...head, status: "KJ_MANAGED", foundAt: files[0], recommendation: "keep", rationale: `${what}: installed` }
|
|
182
|
+
: { ...head, status: "MISSING", foundAt: null, missing, recommendation: "install", rationale: `kj harden (without --report) installs ${what}; missing: ${missing.join(", ")}` };
|
|
183
|
+
}
|
|
184
|
+
|
|
169
185
|
export function compareHarden({ projectDir = process.cwd(), profile = "standard", only = [], exclude = [] } = {}) {
|
|
170
186
|
if (profile === "minimal") return { artifacts: [] };
|
|
171
|
-
const artifacts =
|
|
187
|
+
const artifacts = [
|
|
188
|
+
...METHOD_PIECES.map((piece) => methodPiece(projectDir, piece)),
|
|
189
|
+
...UNIVERSAL_CONFIGS.map((cfg) => ({ dir: ".", ...classifyArtifact(projectDir, toArtifact(cfg)) })),
|
|
190
|
+
];
|
|
172
191
|
for (const { dir, language } of detectStackRoots(projectDir, { only, exclude })) {
|
|
173
192
|
const searchDir = dir === "." ? projectDir : join(projectDir, dir);
|
|
174
193
|
for (const cfg of CONFIGS_BY_LANGUAGE[language] ?? []) {
|
|
@@ -1423,8 +1423,11 @@ export function installSentinelHooks({ projectDir = process.cwd(), logger = cons
|
|
|
1423
1423
|
};
|
|
1424
1424
|
const [lib, post, stop, pre] = Object.entries(SCRIPT_BODIES).map(([name, body]) => {
|
|
1425
1425
|
if (keepsItsSeal(name)) {
|
|
1426
|
-
|
|
1427
|
-
|
|
1426
|
+
const abs = join(projectDir, ".karajan", "harness", name);
|
|
1427
|
+
// KJC-BUG-0325: only a sealed guard the current template would change is
|
|
1428
|
+
// waiting for the human; one identical to it has nothing to advance.
|
|
1429
|
+
if (readFileSync(abs, "utf8") !== body) deferred.push(name);
|
|
1430
|
+
return abs;
|
|
1428
1431
|
}
|
|
1429
1432
|
return writeHarnessScript(projectDir, name, body);
|
|
1430
1433
|
});
|
|
@@ -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).
|
|
@@ -36,6 +36,17 @@ import { getIntegration } from "../integrations.js";
|
|
|
36
36
|
import { tryAutoStartBoard } from "./post-loop.js";
|
|
37
37
|
import { runPreLoopStages } from "./pre-loop.js";
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* KJC-BUG-0324: the coder is built before the pre-loop, with the run's first
|
|
41
|
+
* config, so what the pre-loop resolves (the Domain Curator's domainContext,
|
|
42
|
+
* the methodology) never reached its prompt. The run and the coder both take
|
|
43
|
+
* the resolved config; the coder is initialized after this.
|
|
44
|
+
*/
|
|
45
|
+
export function adoptPreLoopConfig(ctx, updatedConfig) {
|
|
46
|
+
ctx.config = updatedConfig;
|
|
47
|
+
ctx.coderRoleInstance.config = updatedConfig;
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
export async function initFlowContext({ task, config, logger, emitter, askQuestion, pgTaskId, pgProject, flags }) {
|
|
40
51
|
// Auto-init .karajan/ if missing (copies coder-rules, review-rules, role templates)
|
|
41
52
|
const initProjectDir = config.projectDir || process.cwd();
|
|
@@ -182,7 +193,7 @@ export async function initFlowContext({ task, config, logger, emitter, askQuesti
|
|
|
182
193
|
|
|
183
194
|
const preLoopResult = await runPreLoopStages({ config, logger, emitter, eventBase: ctx.eventBase, session: ctx.session, flags, pipelineFlags: ctx.pipelineFlags, coderRole: ctx.coderRole, trackBudget: ctx.trackBudget, task, askQuestion, pgTaskId, pgProject, stageResults: ctx.stageResults, brainCtx: ctx.brainCtx });
|
|
184
195
|
ctx.plannedTask = preLoopResult.plannedTask;
|
|
185
|
-
ctx
|
|
196
|
+
adoptPreLoopConfig(ctx, preLoopResult.updatedConfig);
|
|
186
197
|
|
|
187
198
|
// --- Session Journal: persist pre-loop outputs + display plan summary ---
|
|
188
199
|
const reportDir = ctx.config.output?.report_dir || ".reviews";
|
|
@@ -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 { /*
|
|
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
|
|
package/src/plan/plan-fixer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
: "";
|
package/src/plan/plan-schema.js
CHANGED
|
@@ -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
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
}
|
|
@@ -14,8 +14,22 @@ import { withRetry } from "../utils/retry.js";
|
|
|
14
14
|
const DEFAULT_API_URL = "http://localhost:3000/api";
|
|
15
15
|
const DEFAULT_TIMEOUT_MS = 10000;
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// KJC-BUG-0322: PG_API_URL > planning_game.api_url (passed by the caller) > localhost.
|
|
18
|
+
function getApiUrl(apiUrl) {
|
|
19
|
+
const fromEnv = process.env.PG_API_URL?.trim();
|
|
20
|
+
if (fromEnv) return fromEnv;
|
|
21
|
+
const fromConfig = typeof apiUrl === "string" ? apiUrl.trim() : "";
|
|
22
|
+
return fromConfig ? fromConfig : DEFAULT_API_URL;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Origin and path only: credentials or a query token in a configured URL never reach a log.
|
|
26
|
+
function urlForMessage(url) {
|
|
27
|
+
try {
|
|
28
|
+
const u = new URL(url);
|
|
29
|
+
return `${u.origin}${u.pathname}`;
|
|
30
|
+
} catch {
|
|
31
|
+
return "(invalid Planning Game URL)";
|
|
32
|
+
}
|
|
19
33
|
}
|
|
20
34
|
|
|
21
35
|
async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
@@ -37,7 +51,7 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
|
|
|
37
51
|
err.httpStatus = 408;
|
|
38
52
|
throw err;
|
|
39
53
|
}
|
|
40
|
-
throw new Error(`Planning Game network error: ${error?.message
|
|
54
|
+
throw new Error(`Planning Game network error at ${urlForMessage(url)}: ${error?.message ?? "unknown error"}`, { cause: error });
|
|
41
55
|
} finally {
|
|
42
56
|
clearTimeout(timeoutId);
|
|
43
57
|
}
|
|
@@ -58,15 +72,15 @@ async function parseJsonResponse(response) {
|
|
|
58
72
|
}
|
|
59
73
|
}
|
|
60
74
|
|
|
61
|
-
export async function fetchCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
62
|
-
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
|
|
75
|
+
export async function fetchCard({ projectId, cardId, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
76
|
+
const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(cardId)}`;
|
|
63
77
|
const response = await fetchWithRetry(url, {}, timeoutMs);
|
|
64
78
|
const data = await parseJsonResponse(response);
|
|
65
79
|
return data?.card || data;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
|
-
export async function updateCard({ projectId, cardId: _cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
69
|
-
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
|
|
82
|
+
export async function updateCard({ projectId, cardId: _cardId, firebaseId, updates, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
83
|
+
const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
|
|
70
84
|
const response = await fetchWithRetry(
|
|
71
85
|
url,
|
|
72
86
|
{
|
|
@@ -79,8 +93,8 @@ export async function updateCard({ projectId, cardId: _cardId, firebaseId, updat
|
|
|
79
93
|
return parseJsonResponse(response);
|
|
80
94
|
}
|
|
81
95
|
|
|
82
|
-
export async function createCard({ projectId, card, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
83
|
-
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
|
|
96
|
+
export async function createCard({ projectId, card, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
97
|
+
const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards`;
|
|
84
98
|
const response = await fetchWithRetry(
|
|
85
99
|
url,
|
|
86
100
|
{
|
|
@@ -93,8 +107,8 @@ export async function createCard({ projectId, card, timeoutMs = DEFAULT_TIMEOUT_
|
|
|
93
107
|
return parseJsonResponse(response);
|
|
94
108
|
}
|
|
95
109
|
|
|
96
|
-
export async function relateCards({ projectId, sourceCardId, targetCardId, relationType, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
97
|
-
const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/relate`;
|
|
110
|
+
export async function relateCards({ projectId, sourceCardId, targetCardId, relationType, apiUrl, timeoutMs = DEFAULT_TIMEOUT_MS }) {
|
|
111
|
+
const url = `${getApiUrl(apiUrl)}/projects/${encodeURIComponent(projectId)}/cards/relate`;
|
|
98
112
|
const response = await fetchWithRetry(
|
|
99
113
|
url,
|
|
100
114
|
{
|
|
@@ -63,9 +63,10 @@ async function handlePgDecomposition({ triageResult, pgTaskId, pgProject, config
|
|
|
63
63
|
const normalizedAnswer = (answer || "").trim().toLowerCase();
|
|
64
64
|
const isAccept = normalizedAnswer === "1" || normalizedAnswer === "yes" || normalizedAnswer === "sí" || normalizedAnswer === "si";
|
|
65
65
|
if (isAccept) {
|
|
66
|
-
const
|
|
66
|
+
const apiUrl = config.planning_game?.api_url;
|
|
67
|
+
const parentCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId, apiUrl }).catch(() => null);
|
|
67
68
|
const createdSubtasks = await createDecompositionSubtasks({
|
|
68
|
-
client: { createCard, relateCards },
|
|
69
|
+
client: { createCard: (args) => createCard({ ...args, apiUrl }), relateCards: (args) => relateCards({ ...args, apiUrl }) },
|
|
69
70
|
projectId: pgProject,
|
|
70
71
|
parentCardId: pgTaskId,
|
|
71
72
|
parentFirebaseId: parentCard?.firebaseId || null,
|
|
@@ -129,7 +130,8 @@ export async function markPgCardToValidate({ pgCard, pgProject, config, session,
|
|
|
129
130
|
projectId: pgProject,
|
|
130
131
|
cardId: session.pg_task_id,
|
|
131
132
|
firebaseId: pgCard.firebaseId,
|
|
132
|
-
updates: pgUpdates
|
|
133
|
+
updates: pgUpdates,
|
|
134
|
+
apiUrl: config.planning_game?.api_url
|
|
133
135
|
});
|
|
134
136
|
logger.info(`Planning Game: ${session.pg_task_id} → To Validate`);
|
|
135
137
|
} catch (err) {
|
|
@@ -201,9 +203,9 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
|
201
203
|
// its own HUs and said approved. With a card named, no card is a loud stop.
|
|
202
204
|
let pgCard;
|
|
203
205
|
try {
|
|
204
|
-
pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId });
|
|
206
|
+
pgCard = await fetchCard({ projectId: pgProject, cardId: pgTaskId, apiUrl: config.planning_game?.api_url });
|
|
205
207
|
} catch (err) {
|
|
206
|
-
throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check PG_API_URL, or run without pgTask.`, { cause: err });
|
|
208
|
+
throw new Error(`Planning Game: card ${pgTaskId} could not be read (${err.message}): the run would implement without the card's acceptance criteria. Check planning_game.api_url in the config (PG_API_URL overrides it), or run without pgTask.`, { cause: err });
|
|
207
209
|
}
|
|
208
210
|
if (!pgCard) throw new Error(`Planning Game: card ${pgTaskId} not found in project ${pgProject}: the run would implement without the card's acceptance criteria. Check the id, or run without pgTask.`);
|
|
209
211
|
try {
|
|
@@ -212,6 +214,7 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
|
|
|
212
214
|
projectId: pgProject,
|
|
213
215
|
cardId: pgTaskId,
|
|
214
216
|
firebaseId: pgCard.firebaseId,
|
|
217
|
+
apiUrl: config.planning_game?.api_url,
|
|
215
218
|
updates: {
|
|
216
219
|
status: "In Progress",
|
|
217
220
|
startDate: new Date().toISOString(),
|
package/src/policy/exceptions.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { appendFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import {
|
|
9
|
+
import { userInfo } from "node:os";
|
|
10
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
10
11
|
import { spawnSync } from "node:child_process";
|
|
11
12
|
import { recordPolicyException as kernelRecord } from "@karajan-family/governance";
|
|
12
13
|
|
|
@@ -36,7 +37,10 @@ export function loadStandingExceptions(projectDir, { home } = {}) {
|
|
|
36
37
|
* Ruta del almacén GLOBAL (~/.karajan): excepciones de la máquina, no de un
|
|
37
38
|
* proyecto. `home` es inyectable SOLO para tests.
|
|
38
39
|
*/
|
|
39
|
-
|
|
40
|
+
// KJC-BUG-0337: with no injected home it is the Karajan home (honours KARAJAN_HOME,
|
|
41
|
+
// and the suite's temp home), not homedir(): the tests read the machine's real grants.
|
|
42
|
+
const globalDir = (home) => (home ? join(home, ".karajan") : getKarajanHome());
|
|
43
|
+
export const globalExceptionsFile = ({ home } = {}) => join(globalDir(home), "policy-exceptions.jsonl");
|
|
40
44
|
|
|
41
45
|
function readRecordsAt(file, origin) {
|
|
42
46
|
let raw;
|
|
@@ -85,7 +89,7 @@ function defaultAppend(projectDir, line) {
|
|
|
85
89
|
}
|
|
86
90
|
|
|
87
91
|
const globalAppend = (home) => (_projectDir, line) => {
|
|
88
|
-
const dir =
|
|
92
|
+
const dir = globalDir(home);
|
|
89
93
|
mkdirSync(dir, { recursive: true });
|
|
90
94
|
appendFileSync(join(dir, "policy-exceptions.jsonl"), line, "utf8");
|
|
91
95
|
};
|
|
@@ -51,6 +51,7 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
51
51
|
if (!signed.ok) return { files: new Set(), reason: signed.reason };
|
|
52
52
|
const ok = new Set();
|
|
53
53
|
const unjudgeable = new Set();
|
|
54
|
+
const signedAbsent = new Set();
|
|
54
55
|
const current = kjVersion();
|
|
55
56
|
const olderSeal = current && typeof prov?.kj_version === "string" && prov.kj_version !== current ? prov.kj_version : null;
|
|
56
57
|
let vouched = false;
|
|
@@ -86,6 +87,9 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
86
87
|
// tiene que ser el de un guardia que kj CONOCE, o una procedencia forjada
|
|
87
88
|
// colaria entradas ausentes con cualquier hash (catch de la review).
|
|
88
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; }
|
|
89
93
|
const body = canonicalHarnessBody(entry.file.slice(prefix.length));
|
|
90
94
|
if (body != null && typeof entry.sha256 === "string" && sha256(Buffer.from(body, "utf8")) === entry.sha256) unjudgeable.add(entry.file);
|
|
91
95
|
continue;
|
|
@@ -111,10 +115,10 @@ export function verifiedSupervisorFiles({ projectDir, trackedOnly = false }) {
|
|
|
111
115
|
// `complete` = ninguna entrada FALLO y alguna verifico. Exigir que todas
|
|
112
116
|
// verifiquen hacia imposible sellar desde cualquier sitio que no fuera la
|
|
113
117
|
// maquina que sello, porque los guardias no viajan con el repo (0212).
|
|
114
|
-
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);
|
|
115
119
|
const complete = ok.size > 0 && entries.every((e) => typeof e?.file === "string" && judged(e));
|
|
116
120
|
return {
|
|
117
|
-
files: ok, complete, unjudgeable, signed: signed.required,
|
|
121
|
+
files: ok, complete, unjudgeable, signedAbsent, signed: signed.required,
|
|
118
122
|
...(vouched ? { vouchedOlder: olderSeal } : {}),
|
|
119
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` } : {}),
|
|
120
124
|
};
|
|
@@ -137,8 +141,10 @@ export function liftSealedSupervisorViolations({ projectDir, violations, tracked
|
|
|
137
141
|
&& (sealed.files.has(v.file) || (v.file === PROVENANCE_FILE && sealed.complete === true));
|
|
138
142
|
const kept = violations.filter((v) => !liftable(v));
|
|
139
143
|
const unjudged = sealed.unjudgeable?.size ?? 0;
|
|
144
|
+
const vouchedAbsent = sealed.signedAbsent?.size ?? 0;
|
|
140
145
|
const notes = [
|
|
141
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`] : []),
|
|
142
148
|
// KJC-TSK-0964: what the signature did or did not prove is said, never assumed.
|
|
143
149
|
...(sealed.signed === false ? ["proyecto sin padrón de firmantes: el sello no lleva firma verificable y su garantía es solo local"] : []),
|
|
144
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`] : []),
|
package/src/privacy/scan.js
CHANGED
|
@@ -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
|
/**
|
package/src/review/card-first.js
CHANGED
|
@@ -11,7 +11,9 @@ import { listPlans, loadPlan } from "../plan/plan-store.js";
|
|
|
11
11
|
import { escapeRegExp } from "../utils/escape-regexp.js";
|
|
12
12
|
import { verifyCardAlive } from "./card-verify.js";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// KJC-BUG-0323: coding/reviewing are the run working the HU, certified is ready
|
|
15
|
+
// to run; the gate blocked the pipeline's own commit on its own HU.
|
|
16
|
+
const LIVE_STATUSES = new Set(["pending", "running", "failed", "certified", "coding", "reviewing"]);
|
|
15
17
|
// Card-shaped reference: LIN-123, bb-002 and multi-segment ids like
|
|
16
18
|
// KJC-TSK-0684 (optional middle segments) — tight enough to skip slugs.
|
|
17
19
|
// Shared with the method report (MG-D): one pattern, one truth.
|
package/src/review/loc-budget.js
CHANGED
|
@@ -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 || {}),
|
|
@@ -51,6 +51,13 @@ export async function runSpecReview({ spec, config, logger, askQuestion, flags =
|
|
|
51
51
|
return { proceed: true, skipped: true };
|
|
52
52
|
}
|
|
53
53
|
if (!spec || !String(spec).trim()) return { proceed: true, skipped: true };
|
|
54
|
+
// KJC-BUG-0321 (#2074): with --plan the spec is the plan's original task, which
|
|
55
|
+
// `kj plan` reviewed when it generated the plan, and the HUs were certified since.
|
|
56
|
+
// Reviewing it again flags the plan, not the HUs being run.
|
|
57
|
+
if (flags.plan) {
|
|
58
|
+
logger?.info?.(`Spec review skipped: plan ${flags.plan} was reviewed when it was generated and its HUs certified.`);
|
|
59
|
+
return { proceed: true, skipped: true };
|
|
60
|
+
}
|
|
54
61
|
|
|
55
62
|
const role = new SpecReviewerRole({ config, logger });
|
|
56
63
|
await role.init({ task: spec });
|
package/templates/roles/coder.md
CHANGED
|
@@ -52,7 +52,7 @@ Karajan projects MAY enforce a CI gate that fails any PR whose net delta exceeds
|
|
|
52
52
|
|
|
53
53
|
- Stay under **~150 LOC per iteration** (safety margin against the 200 hard limit).
|
|
54
54
|
- The gate counts the SUM of every changed file, not per-file. Tests count too. 5 files × 40 LOC = 200 = on the limit.
|
|
55
|
-
- If the task clearly requires >
|
|
55
|
+
- Up to 200 LOC is allowed without asking. If the task clearly requires >200 LOC, STOP and report the partition needed. Don't ship a 500-LOC PR — the gate rejects it and the work is redone, burning tokens twice.
|
|
56
56
|
- Excluded from the count: lockfiles, snapshots, `dist/`, `node_modules/`, generated `tests/_diet/`, `public/docs/`. Source + tests count.
|
|
57
57
|
|
|
58
58
|
## Task completeness
|