karajan-code 3.0.0 → 3.1.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 -3
- package/docs/README.es.md +1 -1
- package/package.json +3 -2
- package/packages/hu-board/public/app.js +81 -4025
- package/packages/hu-board/public/index.html +20 -0
- package/packages/hu-board/public/utils/api.js +139 -0
- package/packages/hu-board/public/utils/board-view.js +453 -0
- package/packages/hu-board/public/utils/command-launcher.js +213 -0
- package/packages/hu-board/public/utils/config-editor.js +181 -0
- package/packages/hu-board/public/utils/dashboard-view.js +103 -0
- package/packages/hu-board/public/utils/formatters.js +232 -0
- package/packages/hu-board/public/utils/graph-view.js +216 -0
- package/packages/hu-board/public/utils/hu-actions.js +224 -0
- package/packages/hu-board/public/utils/init-listeners.js +180 -0
- package/packages/hu-board/public/utils/log-panel.js +222 -0
- package/packages/hu-board/public/utils/modals.js +290 -0
- package/packages/hu-board/public/utils/plan-rollup-view.js +53 -0
- package/packages/hu-board/public/utils/preflight-view.js +89 -0
- package/packages/hu-board/public/utils/project-actions.js +143 -0
- package/packages/hu-board/public/utils/project-picker-view.js +139 -0
- package/packages/hu-board/public/utils/run-launcher.js +169 -0
- package/packages/hu-board/public/utils/server-push.js +252 -0
- package/packages/hu-board/public/utils/sessions-view.js +199 -0
- package/packages/hu-board/public/utils/story-detail-view.js +324 -0
- package/packages/hu-board/public/utils/story-edit-form.js +271 -0
- package/packages/hu-board/src/config-yaml.js +14 -0
- package/packages/hu-board/src/db.js +15 -2
- package/packages/hu-board/src/routes/api.js +27 -9
- package/scripts/audit-test-diet.mjs +151 -0
- package/src/audit/harness-scorecard.js +52 -3
- package/src/audit/harness-section.js +6 -2
- package/src/checks/runner.js +10 -0
- package/src/cli/register-meta.js +15 -2
- package/src/cli/register-plan.js +10 -1
- package/src/commands/clean.js +62 -0
- package/src/commands/plan/generate.js +9 -3
- package/src/commands/sync.js +30 -2
- package/src/config/defaults.js +5 -0
- package/src/config/schema.js +2 -0
- package/src/orchestrator/config-init.js +1 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +23 -0
- package/src/orchestrator/stages/coder-stage.js +8 -2
- package/src/orchestrator/stages/tdd-discipline-stage.js +56 -0
- package/src/orchestrator/stages/tool-judge-stage.js +53 -0
- package/src/prompts/tool-correctness-judge.js +31 -0
- package/src/review/git-stash-helper.js +104 -0
- package/src/review/tdd-discipline.js +90 -0
- package/src/roles/tool-correctness-judge-role.js +78 -0
- package/src/session/mutators.js +4 -0
- package/src/sync/apply-canvas-patch.js +55 -0
- package/src/utils/paths.js +12 -0
- package/src/utils/repo-cleaner.js +79 -0
- package/src/utils/tool-call-extractor.js +56 -0
- package/src/utils/vector-store-orphans.js +89 -0
package/src/checks/runner.js
CHANGED
|
@@ -249,8 +249,15 @@ function summarize(reports) {
|
|
|
249
249
|
*
|
|
250
250
|
* @example setDotPath({}, "git.auto_pr", false) // → { git: { auto_pr: false } }
|
|
251
251
|
*/
|
|
252
|
+
// KJC-BUG-0076: prototype-pollution guard. dot-paths come from check
|
|
253
|
+
// remediation outputs (internal but LLM-generated), so a malformed
|
|
254
|
+
// `__proto__.x` would mutate Object.prototype. Skip the whole write
|
|
255
|
+
// when any segment matches a forbidden key.
|
|
256
|
+
const UNSAFE_DOT_KEY_RE = /^(?:__proto__|constructor|prototype)$/;
|
|
257
|
+
|
|
252
258
|
function setDotPath(target, dotPath, value) {
|
|
253
259
|
const parts = String(dotPath).split(".");
|
|
260
|
+
if (parts.some((p) => UNSAFE_DOT_KEY_RE.test(p))) return;
|
|
254
261
|
let cursor = target;
|
|
255
262
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
256
263
|
const k = parts[i];
|
|
@@ -314,3 +321,6 @@ export function toLegacyShape(runReport) {
|
|
|
314
321
|
fix: c.fix ?? null,
|
|
315
322
|
}));
|
|
316
323
|
}
|
|
324
|
+
|
|
325
|
+
// Exposed for prototype-pollution regression tests (KJC-BUG-0076).
|
|
326
|
+
export const __pollutionInternals = { setDotPath };
|
package/src/cli/register-meta.js
CHANGED
|
@@ -313,12 +313,13 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
313
313
|
|
|
314
314
|
program
|
|
315
315
|
.command("sync")
|
|
316
|
-
.description("Detect drift between code and the latest plan. Read-only
|
|
316
|
+
.description("Detect drift between code and the latest plan. Read-only by default; --apply writes a patch back to the Canvas.")
|
|
317
317
|
.option("--plan <planId>", "Sync against a specific plan instead of the latest")
|
|
318
318
|
.option("--json", "Emit machine-readable JSON instead of human output")
|
|
319
|
+
.option("--apply", "Write the drift report into the source Canvas (creates a .bak backup)")
|
|
319
320
|
.action(async (flags) => {
|
|
320
321
|
await withConfig(pkgVersion, "sync", flags, async ({ config, logger }) => {
|
|
321
|
-
await syncCommand({ config, logger, planId: flags.plan, json: flags.json });
|
|
322
|
+
await syncCommand({ config, logger, planId: flags.plan, json: flags.json, apply: Boolean(flags.apply) });
|
|
322
323
|
});
|
|
323
324
|
});
|
|
324
325
|
|
|
@@ -352,10 +353,22 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
352
353
|
.option("--draft-days <n>", "Keep draft plans for N days (default: 60)")
|
|
353
354
|
.option("--session-days <n>", "Keep finalised sessions for N days (default: 7)")
|
|
354
355
|
.option("--hu-days <n>", "Keep HU story batches for N days (default: 14)")
|
|
356
|
+
.option("--repo", "Also report repo-level candidates (merged branches, dist/, coverage/, *.tmp, *.bak)")
|
|
357
|
+
.option("--repo-days <n>", "Age threshold for repo artifacts in days (default: 7)")
|
|
358
|
+
.option("--repo-base <ref>", "Base ref to detect merged branches (default: origin/main)")
|
|
359
|
+
.option("--vector-stores", "Also report orphan RAG vector-store entries in ~/.karajan/rag.db")
|
|
360
|
+
.option("--project-roots <list>", "Colon-separated directories to scan for live projects (default: ~/ws_*, ~/projects, ~/code)")
|
|
361
|
+
.option("--all", "Shortcut for --repo --vector-stores (full read-only cleanup audit)")
|
|
355
362
|
.action(async (flags) => {
|
|
363
|
+
const allOn = Boolean(flags.all);
|
|
356
364
|
await cleanCommand({
|
|
357
365
|
yes: Boolean(flags.yes),
|
|
358
366
|
nuke: Boolean(flags.nuke),
|
|
367
|
+
repo: Boolean(flags.repo) || allOn,
|
|
368
|
+
repoDays: flags.repoDays ? Number(flags.repoDays) : undefined,
|
|
369
|
+
repoBase: flags.repoBase || undefined,
|
|
370
|
+
vectorStores: Boolean(flags.vectorStores) || allOn,
|
|
371
|
+
projectRoots: flags.projectRoots ? flags.projectRoots.split(":").filter(Boolean) : undefined,
|
|
359
372
|
planDays: flags.planDays ? Number(flags.planDays) : undefined,
|
|
360
373
|
draftDays: flags.draftDays ? Number(flags.draftDays) : undefined,
|
|
361
374
|
sessionDays: flags.sessionDays ? Number(flags.sessionDays) : undefined,
|
package/src/cli/register-plan.js
CHANGED
|
@@ -93,7 +93,16 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
93
93
|
// Norms + Safeguards ride along under explicit headings the
|
|
94
94
|
// coder prompt can grep for.
|
|
95
95
|
const taskFromCanvas = canvasToTask(validation.canvas);
|
|
96
|
-
|
|
96
|
+
// KJC-TSK-0348: remember WHERE the Canvas lived on disk so
|
|
97
|
+
// `kj sync --apply` later knows which file to patch.
|
|
98
|
+
let sourceCanvasPath = null;
|
|
99
|
+
if (typeof flags?.taskFile === "string" && flags.taskFile.length > 0) {
|
|
100
|
+
const path = await import("node:path");
|
|
101
|
+
sourceCanvasPath = path.isAbsolute(flags.taskFile)
|
|
102
|
+
? flags.taskFile
|
|
103
|
+
: path.resolve(config.projectDir || process.cwd(), flags.taskFile);
|
|
104
|
+
}
|
|
105
|
+
await planGenerateCommand({ task: taskFromCanvas, config, logger, flags, sourceCanvasPath });
|
|
97
106
|
return;
|
|
98
107
|
}
|
|
99
108
|
await planGenerateCommand({
|
package/src/commands/clean.js
CHANGED
|
@@ -19,11 +19,18 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { runManualGC, summarizeGC, nukeBoardDb } from "../utils/garbage-collector.js";
|
|
22
|
+
import { collectRepoCandidates } from "../utils/repo-cleaner.js";
|
|
23
|
+
import { collectVectorStoreOrphans } from "../utils/vector-store-orphans.js";
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
26
|
* @param {Object} opts
|
|
25
27
|
* @param {boolean} [opts.yes=false] - when false, dry-run only
|
|
26
28
|
* @param {boolean} [opts.nuke=false] - retention=0 for every bucket + wipe board DB
|
|
29
|
+
* @param {boolean} [opts.repo=false] - also report repo-level candidates (branches, dist, *.tmp, *.bak)
|
|
30
|
+
* @param {number} [opts.repoDays=7] - age threshold for repo artifacts (default 7d)
|
|
31
|
+
* @param {string} [opts.repoBase="origin/main"]
|
|
32
|
+
* @param {boolean} [opts.vectorStores=false] - also report orphan RAG vector-store entries
|
|
33
|
+
* @param {string[]} [opts.projectRoots] - directories scanned to resolve slugs (default: ~/ws_*, ~/projects, ~/code)
|
|
27
34
|
* @param {number} [opts.planDays=30]
|
|
28
35
|
* @param {number} [opts.draftDays=60]
|
|
29
36
|
* @param {number} [opts.sessionDays=7]
|
|
@@ -79,5 +86,60 @@ export async function cleanCommand(opts = {}) {
|
|
|
79
86
|
console.log(summarizeGC(result));
|
|
80
87
|
}
|
|
81
88
|
|
|
89
|
+
if (opts.repo) await printRepoReport(opts);
|
|
90
|
+
if (opts.vectorStores) await printVectorStoreReport(opts);
|
|
91
|
+
|
|
82
92
|
return { ok: true, removed: result.removed.length, bytesFreed: result.bytesFreed };
|
|
83
93
|
}
|
|
94
|
+
|
|
95
|
+
async function printRepoReport(opts) {
|
|
96
|
+
const { candidates, errors } = await collectRepoCandidates({
|
|
97
|
+
maxAgeDays: opts.repoDays ?? 7,
|
|
98
|
+
mergedTo: opts.repoBase ?? "origin/main",
|
|
99
|
+
});
|
|
100
|
+
console.log("");
|
|
101
|
+
if (!candidates.length) {
|
|
102
|
+
console.log("[clean:repo] no repo-level candidates — branches/dist/tmp are tidy.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
console.log(`[clean:repo] ${candidates.length} candidate(s) — review and run the command to remove:`);
|
|
106
|
+
for (const c of candidates) {
|
|
107
|
+
console.log(` - ${c.kind.padEnd(11)} ${c.target}`);
|
|
108
|
+
console.log(` ${c.reason}`);
|
|
109
|
+
console.log(` $ ${c.command}`);
|
|
110
|
+
}
|
|
111
|
+
if (errors.length) {
|
|
112
|
+
console.log(`[clean:repo] ${errors.length} non-blocking error(s):`);
|
|
113
|
+
for (const e of errors) console.log(` - ${e.stage}: ${e.error}`);
|
|
114
|
+
}
|
|
115
|
+
console.log("[clean:repo] read-only — commands are printed, never executed.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function printVectorStoreReport(opts) {
|
|
119
|
+
const { rows, orphans, errors } = await collectVectorStoreOrphans({
|
|
120
|
+
projectRoots: opts.projectRoots,
|
|
121
|
+
});
|
|
122
|
+
console.log("");
|
|
123
|
+
if (errors.length && !rows.length) {
|
|
124
|
+
console.log("[clean:rag] could not read rag.db — RAG may not be initialised yet.");
|
|
125
|
+
for (const e of errors) console.log(` - ${e.stage}: ${e.error}`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
console.log(`[clean:rag] ${rows.length} project_slug(s) indexed in rag.db:`);
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
const tag = r.orphan ? " ORPHAN" : "";
|
|
131
|
+
const when = r.lastAt || "n/a";
|
|
132
|
+
console.log(` - ${r.slug.padEnd(28)} chunks=${String(r.chunkCount).padStart(5)} last=${when}${tag}`);
|
|
133
|
+
}
|
|
134
|
+
if (!orphans.length) {
|
|
135
|
+
console.log("[clean:rag] no orphans — every indexed slug resolves to a live project directory.");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(`[clean:rag] ${orphans.length} orphan(s) — review and run to purge:`);
|
|
140
|
+
for (const o of orphans) {
|
|
141
|
+
console.log(` - ${o.slug}`);
|
|
142
|
+
console.log(` $ ${o.command}`);
|
|
143
|
+
}
|
|
144
|
+
console.log("[clean:rag] read-only — commands are printed, never executed.");
|
|
145
|
+
}
|
|
@@ -21,14 +21,14 @@ import { formatPlan, formatHuTable } from "./_shared.js";
|
|
|
21
21
|
/**
|
|
22
22
|
* kj plan "task" — generate plan + HUs
|
|
23
23
|
*/
|
|
24
|
-
export async function planGenerateCommand({ task, config, logger, json, context, flags = {} }) {
|
|
24
|
+
export async function planGenerateCommand({ task, config, logger, json, context, flags = {}, sourceCanvasPath = null }) {
|
|
25
25
|
const { withCliRunLog } = await import("../../utils/cli-run-log.js");
|
|
26
26
|
return withCliRunLog("plan", { projectDir: config?.projectDir, logger }, async ({ runLog }) => {
|
|
27
|
-
return planGenerateImpl({ task, config, logger, json, context, runLog, flags });
|
|
27
|
+
return planGenerateImpl({ task, config, logger, json, context, runLog, flags, sourceCanvasPath });
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
async function planGenerateImpl({ task, config, logger, json, context, runLog, flags = {} }) {
|
|
31
|
+
async function planGenerateImpl({ task, config, logger, json, context, runLog, flags = {}, sourceCanvasPath = null }) {
|
|
32
32
|
// Auto-GC: prune orphan plans (project dir gone) + old finalised plans/
|
|
33
33
|
// sessions/HU batches before we start. Silent unless something was
|
|
34
34
|
// removed; one-liner summary on stderr in that case so --json stays
|
|
@@ -164,6 +164,12 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
164
164
|
plan.approach = parsed?.approach || (typeof parsed === "string" ? parsed : (typeof result.output === "string" ? result.output : null));
|
|
165
165
|
plan.risks = parsed?.risks || [];
|
|
166
166
|
plan.outOfScope = parsed?.outOfScope || [];
|
|
167
|
+
// KJC-TSK-0348: persist the SPEC.canvas.md path so `kj sync --apply`
|
|
168
|
+
// can later locate it and append drift notes. Only set when the plan
|
|
169
|
+
// actually came from a Canvas SPEC; flat-task plans stay unstamped.
|
|
170
|
+
if (typeof sourceCanvasPath === "string" && sourceCanvasPath.length > 0) {
|
|
171
|
+
plan.sourceCanvasPath = sourceCanvasPath;
|
|
172
|
+
}
|
|
167
173
|
|
|
168
174
|
// Convert planner steps → HUs, carrying the structured acceptance_tests
|
|
169
175
|
// the LLM was asked to emit. If the LLM fell back to legacy behaviour
|
package/src/commands/sync.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `kj sync` — closed-loop drift detection between code and Canvas/plan.
|
|
3
|
-
* Issue #540 MVP: detection + report
|
|
3
|
+
* Issue #540 MVP: detection + report.
|
|
4
|
+
* KJC-TSK-0348: `--apply` writes drift notes back into the source Canvas
|
|
5
|
+
* markdown (with `.bak` backup) so the user can review + commit.
|
|
4
6
|
*/
|
|
5
7
|
|
|
6
8
|
import fs from "node:fs";
|
|
7
9
|
import path from "node:path";
|
|
8
10
|
import { listPlans, loadPlan } from "../plan/plan-store.js";
|
|
9
11
|
import { classifyDrift, formatDriftReport } from "../sync/detect-drift.js";
|
|
12
|
+
import { applyCanvasPatch } from "../sync/apply-canvas-patch.js";
|
|
10
13
|
|
|
11
14
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage", ".karajan", ".kj", "tmp", ".cache", ".astro", ".next"]);
|
|
12
15
|
|
|
@@ -36,8 +39,11 @@ function listSourceFiles(rootDir, out = []) {
|
|
|
36
39
|
* @param {object} args.logger
|
|
37
40
|
* @param {string} [args.planId] — when set, sync against this plan; else use the latest
|
|
38
41
|
* @param {boolean} [args.json]
|
|
42
|
+
* @param {boolean} [args.apply] — when true, write a drift patch back into
|
|
43
|
+
* the Canvas markdown the plan came from
|
|
44
|
+
* (with `.bak` backup).
|
|
39
45
|
*/
|
|
40
|
-
export async function syncCommand({ config, logger, planId, json }) {
|
|
46
|
+
export async function syncCommand({ config, logger, planId, json, apply }) {
|
|
41
47
|
const projectDir = config?.projectDir || process.cwd();
|
|
42
48
|
|
|
43
49
|
// Resolve which plan to sync against.
|
|
@@ -60,14 +66,36 @@ export async function syncCommand({ config, logger, planId, json }) {
|
|
|
60
66
|
const files = listSourceFiles(projectDir);
|
|
61
67
|
const report = classifyDrift(plan, files);
|
|
62
68
|
|
|
69
|
+
let applyResult = null;
|
|
70
|
+
if (apply) {
|
|
71
|
+
if (!plan.sourceCanvasPath) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
"kj sync --apply: this plan has no sourceCanvasPath — it was not generated from a SPEC.canvas.md. "
|
|
74
|
+
+ "Re-run `kj plan generate --task-file SPEC.canvas.md` so the plan remembers the Canvas it came from."
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
applyResult = await applyCanvasPatch({ canvasPath: plan.sourceCanvasPath, report });
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
if (json) {
|
|
64
81
|
console.log(JSON.stringify({
|
|
65
82
|
planId: plan.planId,
|
|
66
83
|
projectDir,
|
|
67
84
|
...report,
|
|
85
|
+
apply: applyResult,
|
|
68
86
|
}, null, 2));
|
|
69
87
|
} else {
|
|
70
88
|
console.log(formatDriftReport(report, plan));
|
|
89
|
+
if (applyResult) {
|
|
90
|
+
if (applyResult.skipped) {
|
|
91
|
+
console.log("\nkj sync --apply: no actionable drift, Canvas left untouched.");
|
|
92
|
+
} else {
|
|
93
|
+
console.log(
|
|
94
|
+
`\nkj sync --apply: wrote ${applyResult.mutations} drift note(s) to ${applyResult.patchedPath} `
|
|
95
|
+
+ `(backup at ${applyResult.backupPath}).`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
// Exit 0 always — drift is informational, not an error. CI users
|
package/src/config/defaults.js
CHANGED
|
@@ -70,6 +70,11 @@ const DEFAULTS = {
|
|
|
70
70
|
development: {
|
|
71
71
|
methodology: "tdd",
|
|
72
72
|
require_test_changes: true,
|
|
73
|
+
// KJC-TSK-0398: opt-in red-then-green gate. When true, the pipeline
|
|
74
|
+
// stashes source files and re-runs the test suite to confirm the new
|
|
75
|
+
// tests actually fail without the implementation. Off by default to
|
|
76
|
+
// preserve current iteration latency.
|
|
77
|
+
require_red_then_green: false,
|
|
73
78
|
test_file_patterns: ["/tests/", "/__tests__/", ".test.", ".spec."],
|
|
74
79
|
source_file_extensions: [".js", ".jsx", ".ts", ".tsx", ".py", ".go", ".java", ".rb", ".php", ".cs"]
|
|
75
80
|
},
|
package/src/config/schema.js
CHANGED
|
@@ -115,6 +115,8 @@ const PipelineMap = v.optional(v.looseObject({
|
|
|
115
115
|
const Development = v.optional(v.looseObject({
|
|
116
116
|
methodology: v.optional(Methodology),
|
|
117
117
|
require_test_changes: v.optional(v.boolean()),
|
|
118
|
+
// KJC-TSK-0398: opt-in flag; module lands in PR1, integration in PR3.
|
|
119
|
+
require_red_then_green: v.optional(v.boolean()),
|
|
118
120
|
test_file_patterns: v.optional(v.array(v.string())),
|
|
119
121
|
source_file_extensions: v.optional(v.array(v.string())),
|
|
120
122
|
}));
|
|
@@ -229,6 +229,7 @@ export function resolvePipelineFlags(config) {
|
|
|
229
229
|
securityEnabled: Boolean(config.pipeline?.security?.enabled),
|
|
230
230
|
impeccableEnabled: Boolean(config.pipeline?.impeccable?.enabled),
|
|
231
231
|
perfEnabled: Boolean(config.pipeline?.perf?.enabled),
|
|
232
|
+
toolJudgeEnabled: Boolean(config.pipeline?.tool_judge?.enabled),
|
|
232
233
|
reviewerEnabled: config.pipeline?.reviewer?.enabled !== false,
|
|
233
234
|
discoverEnabled: Boolean(config.pipeline?.discover?.enabled),
|
|
234
235
|
architectEnabled: Boolean(config.pipeline?.architect?.enabled),
|
|
@@ -19,6 +19,8 @@ import {
|
|
|
19
19
|
} from "../../iteration-stages.js";
|
|
20
20
|
import { runImpeccableStage } from "../../post-loop-stages.js";
|
|
21
21
|
import { runPerfStage } from "../../stages/perf-stage.js";
|
|
22
|
+
import { runTddDisciplineStage } from "../../stages/tdd-discipline-stage.js";
|
|
23
|
+
import { runToolJudgeStage } from "../../stages/tool-judge-stage.js";
|
|
22
24
|
import { tryCiComment } from "../../ci-integration.js";
|
|
23
25
|
|
|
24
26
|
export async function runQualityGateStages({ config, logger, emitter, eventBase, session, trackBudget, i, askQuestion, repeatDetector, budgetSummary, sonarState, task, stageResults, coderRole, pipelineFlags, brainCtx }) {
|
|
@@ -26,6 +28,16 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
|
|
|
26
28
|
if (tddResult.action === "pause") return { action: "return", result: tddResult.result };
|
|
27
29
|
if (tddResult.action === "continue") return { action: "continue" };
|
|
28
30
|
|
|
31
|
+
// KJC-TSK-0398 PR3: opt-in red-then-green check.
|
|
32
|
+
if (config.development?.require_red_then_green) {
|
|
33
|
+
const disc = await runTddDisciplineStage({
|
|
34
|
+
config, logger, emitter, eventBase,
|
|
35
|
+
sourceFiles: tddResult.sourceFiles || [],
|
|
36
|
+
testFiles: tddResult.testFiles || [],
|
|
37
|
+
});
|
|
38
|
+
if (disc.action === "continue") return { action: "continue" };
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
// Sonar runs for code tasks per policy. Since v2.7.4 it is NOT
|
|
30
42
|
// toggleable via config — that's intrinsic to Karajan. The taskType
|
|
31
43
|
// policy (resolved_policies.sonar) is the single source of truth.
|
|
@@ -84,5 +96,16 @@ export async function runQualityGateStages({ config, logger, emitter, eventBase,
|
|
|
84
96
|
if (perfResult.action === "continue") return { action: "continue" };
|
|
85
97
|
}
|
|
86
98
|
|
|
99
|
+
// KJC-TSK-0375 PR3: opt-in tool-call quality judge. Non-blocking.
|
|
100
|
+
if (pipelineFlags?.toolJudgeEnabled) {
|
|
101
|
+
const judgeResult = await runToolJudgeStage({
|
|
102
|
+
config, logger, emitter, eventBase, session, coderRole, trackBudget,
|
|
103
|
+
iteration: i, task,
|
|
104
|
+
});
|
|
105
|
+
if (judgeResult.stageResult) {
|
|
106
|
+
stageResults.toolJudge = judgeResult.stageResult;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
return { action: "ok" };
|
|
88
111
|
}
|
|
@@ -8,7 +8,7 @@ import { CoderRole } from "../../roles/coder-role.js";
|
|
|
8
8
|
import { RefactorerRole } from "../../roles/refactorer-role.js";
|
|
9
9
|
import { addCheckpoint, markSessionStatus, saveSession } from "../../session/store.js";
|
|
10
10
|
import {
|
|
11
|
-
setReviewerFeedback, incrementRetryCount, resetRetryCount,
|
|
11
|
+
setReviewerFeedback, setCoderTranscript, incrementRetryCount, resetRetryCount,
|
|
12
12
|
} from "../../session/mutators.js";
|
|
13
13
|
import { generateDiff, getUntrackedFiles } from "../../review/diff-generator.js";
|
|
14
14
|
import { evaluateTddPolicy } from "../../review/tdd-policy.js";
|
|
@@ -223,6 +223,12 @@ export async function runCoderStage({ coderRoleInstance, coderRole, config, logg
|
|
|
223
223
|
logger?.warn?.(`[verification-gate] Skipped: ${err?.message || err}`);
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// KJC-TSK-0375 PR3: stash the coder's raw transcript on the session so
|
|
227
|
+
// the optional tool-judge stage can extract structured tool calls later
|
|
228
|
+
// without re-invoking the coder. Cleared between iterations by the coder
|
|
229
|
+
// overwriting it; ignored when the tool-judge flag is off.
|
|
230
|
+
setCoderTranscript(session, coderTranscript);
|
|
231
|
+
|
|
226
232
|
await addCheckpoint(session, { stage: "coder", iteration, note: "Coder applied changes", provider: coderRole.provider, model: coderRole.model || null, filesChanged });
|
|
227
233
|
emitProgress(
|
|
228
234
|
emitter,
|
|
@@ -459,5 +465,5 @@ export async function runTddCheckStage({ config, logger, emitter, eventBase, ses
|
|
|
459
465
|
return handleTddFailure({ tddEval, config, logger, emitter, eventBase, session, iteration, askQuestion, task, brainCtx });
|
|
460
466
|
}
|
|
461
467
|
|
|
462
|
-
return { action: "ok" };
|
|
468
|
+
return { action: "ok", sourceFiles: tddEval.sourceFiles, testFiles: tddEval.testFiles };
|
|
463
469
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// TDD discipline stage (KJC-TSK-0398 PR3).
|
|
2
|
+
// Runs the red-then-green check when `development.require_red_then_green`
|
|
3
|
+
// is true. Wraps `verifyTddDiscipline` with the surgical stash helper and
|
|
4
|
+
// the project's detected test framework. Gate is opt-in; an unsupported
|
|
5
|
+
// framework reports a warning and does not block the iteration.
|
|
6
|
+
|
|
7
|
+
import { detectTestFramework } from "../../utils/project-detect.js";
|
|
8
|
+
import { verifyTddDiscipline } from "../../review/tdd-discipline.js";
|
|
9
|
+
import { stashFilesAside, restoreFiles } from "../../review/git-stash-helper.js";
|
|
10
|
+
import { runCommand } from "../../utils/process.js";
|
|
11
|
+
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
12
|
+
|
|
13
|
+
const TDD_DISCIPLINE_COMMANDS = {
|
|
14
|
+
vitest: ["npx", ["vitest", "run", "--reporter=verbose"]],
|
|
15
|
+
jest: ["npx", ["jest", "--verbose"]],
|
|
16
|
+
pytest: ["pytest", ["--tb=short"]],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export async function runTddDisciplineStage({
|
|
20
|
+
config, logger, emitter, eventBase, sourceFiles = [], testFiles = [],
|
|
21
|
+
detectFramework = detectTestFramework,
|
|
22
|
+
runTestsImpl,
|
|
23
|
+
stashImpl = stashFilesAside,
|
|
24
|
+
restoreImpl = restoreFiles,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const projectDir = config?.projectDir || process.cwd();
|
|
27
|
+
const detection = await detectFramework(projectDir);
|
|
28
|
+
const framework = detection?.framework || null;
|
|
29
|
+
|
|
30
|
+
const runTests = runTestsImpl || (async () => {
|
|
31
|
+
const cmd = TDD_DISCIPLINE_COMMANDS[framework];
|
|
32
|
+
if (!cmd) return { exitCode: 0, output: "" };
|
|
33
|
+
const res = await runCommand(cmd[0], cmd[1], { cwd: projectDir, reject: false });
|
|
34
|
+
return { exitCode: res.exitCode ?? 0, output: `${res.stdout || ""}\n${res.stderr || ""}` };
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const result = await verifyTddDiscipline({
|
|
38
|
+
projectDir, sourceFiles, testFiles, framework,
|
|
39
|
+
runTests,
|
|
40
|
+
stashFiles: (files) => stashImpl(files, { projectDir }),
|
|
41
|
+
restoreFiles: (token) => restoreImpl(token, { projectDir }),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
emitProgress(emitter, makeEvent("tdd:discipline", { ...eventBase, stage: "tdd" }, {
|
|
45
|
+
status: result.ok ? "ok" : "fail",
|
|
46
|
+
message: result.message || `TDD discipline: ${result.reason}`,
|
|
47
|
+
detail: { reason: result.reason, framework },
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
if (result.warning) logger?.warn?.(result.warning);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
logger?.warn?.(`TDD discipline violation: ${result.reason}`);
|
|
53
|
+
return { action: "continue", result };
|
|
54
|
+
}
|
|
55
|
+
return { action: "ok", result };
|
|
56
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// KJC-TSK-0375 PR3 — ToolJudgeStage.
|
|
2
|
+
// Opt-in (`pipeline.tool_judge.enabled`). Scores the coder's tool calls
|
|
3
|
+
// extracted from `session.last_coder_transcript`. Non-blocking: a low
|
|
4
|
+
// score emits a warn event and surfaces in summary but never gates the
|
|
5
|
+
// iteration loop. Wiring lives in `quality-gates.js`.
|
|
6
|
+
|
|
7
|
+
import { ToolCorrectnessJudgeRole } from "../../roles/tool-correctness-judge-role.js";
|
|
8
|
+
import { extractToolCalls } from "../../utils/tool-call-extractor.js";
|
|
9
|
+
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
10
|
+
|
|
11
|
+
export async function runToolJudgeStage({
|
|
12
|
+
config, logger, emitter, eventBase, session, coderRole, trackBudget,
|
|
13
|
+
iteration, task,
|
|
14
|
+
extractFn = extractToolCalls,
|
|
15
|
+
RoleClass = ToolCorrectnessJudgeRole,
|
|
16
|
+
} = {}) {
|
|
17
|
+
logger?.setContext?.({ iteration, stage: "tool-judge" });
|
|
18
|
+
emitProgress(emitter, makeEvent("toolJudge:start", { ...eventBase, stage: "tool-judge" }, {
|
|
19
|
+
message: "Tool-judge scoring coder tool calls",
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const transcript = session?.last_coder_transcript || "";
|
|
23
|
+
const provider = coderRole?.provider || "claude";
|
|
24
|
+
const toolCalls = extractFn({ transcript, provider });
|
|
25
|
+
|
|
26
|
+
const role = new RoleClass({ config, logger, emitter });
|
|
27
|
+
const roleResult = await role.execute({ task, toolCalls });
|
|
28
|
+
|
|
29
|
+
try { trackBudget?.("tool-judge", roleResult.usage || { tokens_in: 0, tokens_out: 0, cost_usd: 0 }); } catch { /* best-effort */ }
|
|
30
|
+
|
|
31
|
+
const r = roleResult.result;
|
|
32
|
+
const status = r?.skipped ? "skip" : r?.lowQuality ? "warn" : roleResult.ok ? "ok" : "fail";
|
|
33
|
+
|
|
34
|
+
emitProgress(emitter, makeEvent("toolJudge:end", { ...eventBase, stage: "tool-judge" }, {
|
|
35
|
+
status,
|
|
36
|
+
message: roleResult.summary || "tool-judge done",
|
|
37
|
+
detail: { score: r?.score ?? null, threshold: r?.threshold ?? null, skipped: Boolean(r?.skipped), callCount: toolCalls.length },
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
action: "ok",
|
|
42
|
+
stageResult: {
|
|
43
|
+
ok: roleResult.ok !== false,
|
|
44
|
+
score: r?.score ?? null,
|
|
45
|
+
lowQuality: Boolean(r?.lowQuality),
|
|
46
|
+
skipped: Boolean(r?.skipped),
|
|
47
|
+
threshold: r?.threshold ?? null,
|
|
48
|
+
summary: roleResult.summary || "",
|
|
49
|
+
breakdown: r?.breakdown || [],
|
|
50
|
+
callCount: toolCalls.length,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// KJC-TSK-0375 — Prompt builder for the ToolCorrectnessJudge role.
|
|
2
|
+
// Inspired by deepeval's tool-correctness metric.
|
|
3
|
+
|
|
4
|
+
const PREAMBLE = "IMPORTANT: You are running as a Karajan sub-agent. "
|
|
5
|
+
+ "Do NOT use any MCP tools, do NOT mention Karajan. "
|
|
6
|
+
+ "Your job is to judge another agent's tool choices.";
|
|
7
|
+
|
|
8
|
+
export function buildToolCorrectnessJudgePrompt({ task, toolCalls, instructions }) {
|
|
9
|
+
const calls = toolCalls
|
|
10
|
+
.map((c, i) => `${i + 1}. **${c.tool}** — args: ${JSON.stringify(c.args)}`)
|
|
11
|
+
.join("\n");
|
|
12
|
+
const body = [
|
|
13
|
+
"You are the ToolCorrectnessJudge for Karajan Code.",
|
|
14
|
+
"Input: (a) a task description and (b) the ordered tool calls an LLM coder",
|
|
15
|
+
"made (Read/Edit/Write/Bash/Glob/Grep/MultiEdit). Judge each call on:",
|
|
16
|
+
" - tool choice: right tool for the intent?",
|
|
17
|
+
" - argument quality: paths plausible, commands valid, no garbage placeholders?",
|
|
18
|
+
"Per-call verdict: `correct=true|false` + one-sentence `reason`.",
|
|
19
|
+
"Aggregate into a single 0-100 score:",
|
|
20
|
+
" - 100 = every call right with clean args",
|
|
21
|
+
" - 70-99 = mostly right, minor missteps",
|
|
22
|
+
" - 40-69 = several wrong choices, budget wasted",
|
|
23
|
+
" - 0-39 = predominantly bad, retry advised",
|
|
24
|
+
"Return a single valid JSON object and nothing else.",
|
|
25
|
+
'Schema: {"score":0-100,"summary":string,"breakdown":[{"tool":string,"args":object,"correct":boolean,"reason":string}]}',
|
|
26
|
+
].join("\n");
|
|
27
|
+
const sections = [PREAMBLE];
|
|
28
|
+
if (instructions) sections.push(instructions);
|
|
29
|
+
sections.push(body, `## Task\n${task}`, `## Tool calls (${toolCalls.length})\n${calls || "(no tool calls captured)"}`);
|
|
30
|
+
return sections.join("\n\n");
|
|
31
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Surgical stash helper for the TDD discipline gate (KJC-TSK-0398 PR2).
|
|
2
|
+
// Stashes only the source files we want to hide for the "red" run and
|
|
3
|
+
// restores them afterwards. Tracked files use `git stash push -- <paths>`;
|
|
4
|
+
// untracked files (not yet known to git) are moved aside to a private
|
|
5
|
+
// `.karajan/tmp/stash-<id>/` tree because `git stash` would ignore them.
|
|
6
|
+
|
|
7
|
+
import { mkdir, rename, rm } from "node:fs/promises";
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { runCommand } from "../utils/process.js";
|
|
11
|
+
|
|
12
|
+
function defaultRunner(cmd, args, opts) {
|
|
13
|
+
return runCommand(cmd, args, opts);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function isTracked(runner, projectDir, file) {
|
|
17
|
+
const res = await runner("git", ["ls-files", "--error-unmatch", "--", file], { cwd: projectDir });
|
|
18
|
+
return res.exitCode === 0;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function stashRefForToken(runner, projectDir, marker) {
|
|
22
|
+
const res = await runner("git", ["stash", "list"], { cwd: projectDir });
|
|
23
|
+
if (res.exitCode !== 0) return null;
|
|
24
|
+
for (const line of String(res.stdout || "").split("\n")) {
|
|
25
|
+
if (line.includes(marker)) {
|
|
26
|
+
const m = line.match(/^(stash@\{\d+\})/);
|
|
27
|
+
if (m) return m[1];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Move `files` aside so a test run sees them as missing. Returns a token to
|
|
34
|
+
// pass to `restoreFiles()`. Throws on failure with the partial token attached.
|
|
35
|
+
export async function stashFilesAside(files, { projectDir = process.cwd(), runner = defaultRunner, tmpRoot } = {}) {
|
|
36
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
37
|
+
return { trackedStashRef: null, untrackedMoves: [], marker: null };
|
|
38
|
+
}
|
|
39
|
+
const tracked = [];
|
|
40
|
+
const untracked = [];
|
|
41
|
+
for (const f of files) {
|
|
42
|
+
if (await isTracked(runner, projectDir, f)) tracked.push(f);
|
|
43
|
+
else if (existsSync(resolve(projectDir, f))) untracked.push(f);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const marker = `kj-tdd-discipline-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
const stashRoot = tmpRoot || join(projectDir, ".karajan", "tmp", marker);
|
|
48
|
+
const untrackedMoves = [];
|
|
49
|
+
let trackedStashRef = null;
|
|
50
|
+
|
|
51
|
+
if (tracked.length > 0) {
|
|
52
|
+
const args = ["stash", "push", "--keep-index", "-m", marker, "--", ...tracked];
|
|
53
|
+
const res = await runner("git", args, { cwd: projectDir });
|
|
54
|
+
if (res.exitCode !== 0) {
|
|
55
|
+
throw new Error(`git stash push failed: ${res.stderr || res.stdout}`);
|
|
56
|
+
}
|
|
57
|
+
trackedStashRef = await stashRefForToken(runner, projectDir, marker);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (const f of untracked) {
|
|
61
|
+
const src = resolve(projectDir, f);
|
|
62
|
+
const dst = join(stashRoot, f);
|
|
63
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
64
|
+
await rename(src, dst);
|
|
65
|
+
untrackedMoves.push({ src, dst });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { trackedStashRef, untrackedMoves, marker, stashRoot };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Reverse a `stashFilesAside` token. Idempotent: a restored token becomes a no-op.
|
|
72
|
+
export async function restoreFiles(token, { projectDir = process.cwd(), runner = defaultRunner } = {}) {
|
|
73
|
+
if (!token || (token.trackedStashRef == null && token.untrackedMoves.length === 0)) return;
|
|
74
|
+
|
|
75
|
+
for (const { src, dst } of token.untrackedMoves) {
|
|
76
|
+
if (existsSync(dst)) {
|
|
77
|
+
await mkdir(dirname(src), { recursive: true });
|
|
78
|
+
await rename(dst, src);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (token.stashRoot && existsSync(token.stashRoot)) {
|
|
82
|
+
await rm(token.stashRoot, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (token.trackedStashRef) {
|
|
86
|
+
const res = await runner("git", ["stash", "pop", token.trackedStashRef], { cwd: projectDir });
|
|
87
|
+
if (res.exitCode !== 0) {
|
|
88
|
+
throw new Error(`git stash pop failed: ${res.stderr || res.stdout}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
token.trackedStashRef = null;
|
|
92
|
+
token.untrackedMoves = [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Run `body` with `files` stashed aside, always restoring them — even when
|
|
96
|
+
// `body` throws. The TDD discipline stage wraps its "red" run with this.
|
|
97
|
+
export async function withStashedFiles(files, body, opts = {}) {
|
|
98
|
+
const token = await stashFilesAside(files, opts);
|
|
99
|
+
try {
|
|
100
|
+
return await body(token);
|
|
101
|
+
} finally {
|
|
102
|
+
await restoreFiles(token, opts).catch(() => { /* swallow: surface body error */ });
|
|
103
|
+
}
|
|
104
|
+
}
|