karajan-code 4.52.0 → 4.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/scripts/contract-claude.mjs +24 -0
- package/scripts/esbuild-sea.config.mjs +7 -0
- package/scripts/record-agent-fixtures.mjs +98 -0
- package/src/agents/claude-agent.js +8 -0
- package/src/agents/model-registry.js +5 -0
- package/src/agents/opencode-agent.js +16 -1
- package/src/audit/issue-filter.js +19 -1
- package/src/checks/audit-cycle.js +41 -0
- package/src/checks/claude-contract-check.js +27 -0
- package/src/checks/method.js +3 -2
- package/src/checks/release-check.js +7 -1
- package/src/checks/sonar.js +30 -12
- package/src/checks/supply-chain-check.js +47 -0
- package/src/cli/register-meta.js +6 -1
- package/src/cli/register-pipeline.js +9 -0
- package/src/cli/register-plan.js +10 -0
- package/src/commands/check.js +11 -1
- package/src/commands/config.js +2 -0
- package/src/commands/harden.js +3 -0
- package/src/commands/hu.js +24 -3
- package/src/commands/identity.js +4 -1
- package/src/commands/init.js +34 -1
- package/src/commands/plan/generate.js +5 -0
- package/src/commands/plan/license.js +35 -0
- package/src/commands/plan/ready.js +1 -1
- package/src/commands/plan/validate.js +1 -1
- package/src/commands/report.js +14 -0
- package/src/commands/review-gate.js +22 -0
- package/src/commands/worktree.js +15 -6
- package/src/config/defaults.js +10 -1
- package/src/config/overrides.js +8 -0
- package/src/config/schema.js +7 -0
- package/src/harden/act-sign.js +2 -2
- package/src/harden/branch-protection.js +97 -0
- package/src/harden/phone-invite.js +15 -8
- package/src/harden/phone-texts.js +17 -0
- package/src/harden/security-level.js +18 -2
- package/src/harden/sentinel-hooks.js +2 -1
- package/src/hu/complexity-tier.js +32 -0
- package/src/hu/lane-prune.js +24 -0
- package/src/hu/model-router.js +7 -16
- package/src/hu/routing-metrics.js +16 -0
- package/src/hu/store.js +2 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +2 -0
- package/src/orchestrator/hu-sub-pipeline.js +19 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +10 -2
- package/src/orchestrator/stages/sonar-stage.js +19 -1
- package/src/plan/plan-schema.js +4 -1
- package/src/plan/rework-license.js +38 -0
- package/src/prompts/hu-reviewer.js +5 -2
- package/src/prompts/planner.js +9 -1
- package/src/report/model-tally.js +39 -0
- package/src/review/branch-overlap.js +40 -0
- package/src/review/dep-cooldown.js +87 -0
- package/src/review/one-shot-review.js +2 -2
- package/src/review/parser.js +5 -0
- package/src/review/publish-pr.js +54 -0
- package/src/review/verdict-store.js +19 -7
- package/src/roles/sonar-role.js +4 -2
- package/src/sonar/api.js +7 -0
- package/src/utils/injection-guard.js +59 -43
- package/src/utils/injection-patterns.json +36 -0
- package/src/utils/model-selector.js +47 -3
|
@@ -530,7 +530,8 @@ process.stdin.on("end", () => {
|
|
|
530
530
|
// like the supervisor: a session does not loosen the rules that govern it.
|
|
531
531
|
// .karajan/rules.yml (KJC-TSK-0947, ADR 0016): the compiled rules are the user's to approve.
|
|
532
532
|
// .karajan/rules.local.yml (KJC-TSK-0954, ADR 0017): the same rules, the ones from private sources.
|
|
533
|
-
|
|
533
|
+
// .karajan/security-level.json (KJC-TSK-1054, ADR 0020): only kj security writes it, after the human layers.
|
|
534
|
+
if ([".karajan/policy.yml", ".karajan/kj.config.yml", ".karajan/rules.yml", ".karajan/rules.local.yml", ".karajan/security-level.json", ".ragignore"].includes(relT) || relT.endsWith("/.ragignore")) {
|
|
534
535
|
console.error("karajan sentinel: " + relT + " es configuracion de gobierno (politicas, gates, exclusiones): la cambia tu usuario, no la sesion. Si un gate te parece injusto, proponselo a tu usuario o usa kj report-issue." + doc("governance"));
|
|
535
536
|
process.exit(2);
|
|
536
537
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0698 (MR-A): the tier (trivial, standard, deep) each HU leaves the
|
|
3
|
+
* decomposition with, so the coder model can be chosen per HU on an auditable
|
|
4
|
+
* trail. Two sources: the judgement of the model that already decomposes (same
|
|
5
|
+
* answer, no extra call) and a deterministic signal. The higher one wins, and
|
|
6
|
+
* a sensitive zone is always deep: underestimating there is the costly mistake.
|
|
7
|
+
*/
|
|
8
|
+
export const TIERS = ["trivial", "standard", "deep"];
|
|
9
|
+
|
|
10
|
+
const SENSITIVE = /\b(?<zone>auth|secret|token|password|credential|crypt|security|seguridad|migrat|migraci|supervisor|sentinel|polic(?:y|ies)|permission)\w*/i;
|
|
11
|
+
const STANDARD_MIN_CRITERIA = 3;
|
|
12
|
+
|
|
13
|
+
/** The tier the HU's own text implies, with no model involved. */
|
|
14
|
+
export function deterministicTier({ title = "", goal = "", acceptanceCriteria = [] }) {
|
|
15
|
+
const zone = SENSITIVE.exec([title, goal, ...acceptanceCriteria].join("\n"))?.groups.zone;
|
|
16
|
+
if (zone) return { tier: "deep", reason: `sensitive zone: ${zone.toLowerCase()}` };
|
|
17
|
+
if (acceptanceCriteria.length >= STANDARD_MIN_CRITERIA) return { tier: "standard", reason: `${acceptanceCriteria.length} acceptance criteria` };
|
|
18
|
+
return { tier: "trivial", reason: `${acceptanceCriteria.length} acceptance criteria, no sensitive zone` };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The HU's tier: judged against signal, the higher wins, and the record says why. */
|
|
22
|
+
export function huComplexity(hu) {
|
|
23
|
+
const signal = deterministicTier(hu);
|
|
24
|
+
const judged = TIERS.includes(hu.tier) ? hu.tier : null;
|
|
25
|
+
if (!judged) return { tier: signal.tier, judged: null, signal: signal.tier, reason: `no judged tier from the decomposition; signal ${signal.tier} (${signal.reason})` };
|
|
26
|
+
const judgedText = `judged ${judged} (${hu.tierReason || "no reason given"})`;
|
|
27
|
+
const signalText = `signal ${signal.tier} (${signal.reason})`;
|
|
28
|
+
const rank = (t) => TIERS.indexOf(t);
|
|
29
|
+
if (rank(judged) === rank(signal.tier)) return { tier: judged, judged, signal: signal.tier, reason: `${judgedText}, ${signalText}` };
|
|
30
|
+
const reason = rank(signal.tier) > rank(judged) ? `${signalText} over ${judgedText}` : `${judgedText} over ${signalText}`;
|
|
31
|
+
return { tier: TIERS[Math.max(rank(judged), rank(signal.tier))], judged, signal: signal.tier, reason };
|
|
32
|
+
}
|
package/src/hu/lane-prune.js
CHANGED
|
@@ -72,3 +72,27 @@ export async function classifyLanes({ git, laneRoot, base, processes, now = Date
|
|
|
72
72
|
return { slug: path.basename(lane.path), path: lane.path, branch: lane.branch, reasons };
|
|
73
73
|
}));
|
|
74
74
|
}
|
|
75
|
+
|
|
76
|
+
/** KJC-TSK-1037: the base a lane is measured against: origin/<base> when it exists (a local base can lag far behind). */
|
|
77
|
+
export async function resolveBase({ git, base }) {
|
|
78
|
+
try {
|
|
79
|
+
await git(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${base}`]);
|
|
80
|
+
return `origin/${base}`;
|
|
81
|
+
} catch {
|
|
82
|
+
return base;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** KJC-TSK-1037: loose kj-hu-* branches (no worktree): the empty ones, and the ones with work and how much. */
|
|
87
|
+
export async function looseLaneBranches({ git, base }) {
|
|
88
|
+
const { stdout: listed } = await git(["for-each-ref", "--format=%(refname:short) %(worktreepath)", "refs/heads/kj-hu-*"]);
|
|
89
|
+
const loose = listed.split("\n").map((l) => l.trim()).filter((l) => l && !l.includes(" "));
|
|
90
|
+
const empty = [];
|
|
91
|
+
const withWork = [];
|
|
92
|
+
for (const branch of loose) {
|
|
93
|
+
const { stdout: ahead } = await git(["rev-list", "--count", `${base}..${branch}`]);
|
|
94
|
+
if (Number(ahead) === 0) empty.push(branch);
|
|
95
|
+
else withWork.push({ branch, reason: `${Number(ahead)} commit(s) not in ${base}` });
|
|
96
|
+
}
|
|
97
|
+
return { empty, withWork };
|
|
98
|
+
}
|
package/src/hu/model-router.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getDefaultModelTiers } from "../utils/model-selector.js";
|
|
2
|
+
|
|
1
3
|
// KJC-TSK-0405: dado un HU con complexity score, recomienda coder_model y
|
|
2
4
|
// reviewer_model. El reviewer por defecto usa el provider alternativo al
|
|
3
5
|
// coder (cross-provider review) para que dos cabezas distintas miren el
|
|
@@ -15,22 +17,11 @@ const SCORE_TO_LEVEL = {
|
|
|
15
17
|
5: "complex",
|
|
16
18
|
};
|
|
17
19
|
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
// complex). aider: null porque aider envuelve a otros providers — no
|
|
24
|
-
// tiene "modelos propios". Si el usuario lo elige, debe configurarlo
|
|
25
|
-
// explícito en config.model_routing.by_provider.aider o vía override
|
|
26
|
-
// del modal por HU.
|
|
27
|
-
const DEFAULT_TIERS = {
|
|
28
|
-
claude: { trivial: "haiku", simple: "haiku", medium: "sonnet", complex: "opus" },
|
|
29
|
-
codex: { trivial: "o4-mini", simple: "o4-mini", medium: "o4-mini", complex: "o3" },
|
|
30
|
-
gemini: { trivial: "gemini-2.0-flash", simple: "gemini-2.0-flash", medium: "gemini-2.5-pro", complex: "gemini-2.5-pro" },
|
|
31
|
-
opencode: { trivial: "coder", simple: "coder", medium: "coder", complex: "coder" },
|
|
32
|
-
aider: { trivial: null, simple: null, medium: null, complex: null },
|
|
33
|
-
};
|
|
20
|
+
// KJC-TSK-0699: los defaults por provider viven en UNA tabla, la de
|
|
21
|
+
// utils/model-selector.js. Quien quiera otros modelos (opencode con sus
|
|
22
|
+
// proveedores, aider) los pone en config.model_routing.by_provider o en el
|
|
23
|
+
// override del modal por HU.
|
|
24
|
+
const DEFAULT_TIERS = getDefaultModelTiers();
|
|
34
25
|
|
|
35
26
|
// Cross-provider reviewer default: para cada coder provider, qué provider
|
|
36
27
|
// usa el reviewer por defecto. Dos cabezas distintas miran el código.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0702 (MR-E): the per-HU measurement model routing is judged by. The
|
|
3
|
+
* sub-pipeline puts it on the HU outcome; karajan-core's setHuOutcome keeps a
|
|
4
|
+
* fixed set of fields, so the measured ones are kept here, in the plan, where
|
|
5
|
+
* kj report reads them. Unmeasured fields stay out rather than written as null.
|
|
6
|
+
*/
|
|
7
|
+
export const ROUTING_FIELDS = ["cost_usd", "tokens_in", "tokens_out", "cached_tokens", "coder", "reviewer_rejections"];
|
|
8
|
+
|
|
9
|
+
export function keepRoutingMetrics(plan, huId, outcome) {
|
|
10
|
+
const hu = plan?.hus?.find((h) => h.id === huId);
|
|
11
|
+
if (!hu?.outcome) return false;
|
|
12
|
+
for (const field of ROUTING_FIELDS) {
|
|
13
|
+
if (outcome?.[field] !== null && outcome?.[field] !== undefined) hu.outcome[field] = outcome[field];
|
|
14
|
+
}
|
|
15
|
+
return true;
|
|
16
|
+
}
|
package/src/hu/store.js
CHANGED
|
@@ -55,6 +55,8 @@ export async function createHuBatch(sessionId, stories) {
|
|
|
55
55
|
status: "pending",
|
|
56
56
|
original: { text: s.text },
|
|
57
57
|
blocked_by: s.blocked_by || [],
|
|
58
|
+
// KJC-TSK-0698: the tier travels with the HU; a story without one (single auto-story) carries none.
|
|
59
|
+
...(s.complexity ? { complexity: s.complexity } : {}),
|
|
58
60
|
certified: null,
|
|
59
61
|
quality: null,
|
|
60
62
|
context_requests: [],
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
} from "#session/mutators.js";
|
|
34
34
|
import { emitProgress, makeEvent } from "#utils/events.js";
|
|
35
35
|
import { listActiveRuns } from "../../../utils/run-registry.js";
|
|
36
|
+
import { keepRoutingMetrics } from "../../../hu/routing-metrics.js";
|
|
36
37
|
|
|
37
38
|
/**
|
|
38
39
|
* @param {object} args
|
|
@@ -313,6 +314,7 @@ function wirePlanCallbacks({ session, loadedPlan, projectDir, syncResultsToPlan,
|
|
|
313
314
|
setLiveOutcomeUpdater(session, async (huId, outcome) => {
|
|
314
315
|
try {
|
|
315
316
|
if (!setHuOutcomeFn(loadedPlan, huId, outcome)) return;
|
|
317
|
+
keepRoutingMetrics(loadedPlan, huId, outcome); // KJC-TSK-0702: the measured fields core does not keep
|
|
316
318
|
await savePlanToDisk(projectDir, loadedPlan);
|
|
317
319
|
if (outcome && Number.isFinite(outcome.cost_usd)) {
|
|
318
320
|
try {
|
|
@@ -195,10 +195,29 @@ function buildHuOutcome({ story: _story, iterResult, status, startedAt, huBudget
|
|
|
195
195
|
// can hide the chip on cold runs or providers without cache.
|
|
196
196
|
cached_tokens: sumTokenField(huBudgetEntries, 'cached_tokens'),
|
|
197
197
|
tokens_in: sumTokenField(huBudgetEntries, 'tokens_in'),
|
|
198
|
+
// KJC-TSK-0702 (MR-E): the baseline model routing is measured against.
|
|
199
|
+
tokens_out: sumTokenField(huBudgetEntries, 'tokens_out'),
|
|
200
|
+
...routingBaseline(huBudgetEntries, status),
|
|
198
201
|
finishedAt: new Date().toISOString(),
|
|
199
202
|
};
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
/**
|
|
206
|
+
* KJC-TSK-0702 (MR-E): which model the coder used and how many times the
|
|
207
|
+
* reviewer sent the HU back: its rounds, minus the approval that closed a done HU.
|
|
208
|
+
* Null when nothing was recorded, as "unmeasured".
|
|
209
|
+
*/
|
|
210
|
+
function routingBaseline(entries, status) {
|
|
211
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
212
|
+
const coder = list.findLast((e) => e?.role === "coder");
|
|
213
|
+
const reviewerRounds = list.filter((e) => e?.role === "reviewer").length;
|
|
214
|
+
const approvalRound = status === "done" ? 1 : 0;
|
|
215
|
+
return {
|
|
216
|
+
coder: coder ? { provider: coder.provider ?? null, model: coder.model ?? null } : null,
|
|
217
|
+
reviewer_rejections: reviewerRounds === 0 ? null : reviewerRounds - approvalRound,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
202
221
|
/**
|
|
203
222
|
* Execute a single HU through the coder→sonar→reviewer sub-pipeline.
|
|
204
223
|
* Extracted to support both sequential and parallel execution paths.
|
|
@@ -10,6 +10,7 @@ import { addCheckpoint } from "../../session/store.js";
|
|
|
10
10
|
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
11
11
|
import { buildDecompositionPrompt, parseDecompositionOutput, uncoveredCriteria } from "../../prompts/hu-reviewer.js";
|
|
12
12
|
import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
|
|
13
|
+
import { huComplexity } from "../../hu/complexity-tier.js";
|
|
13
14
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
14
15
|
import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../../hu/store.js";
|
|
15
16
|
import { topologicalSort } from "../../hu/graph.js";
|
|
@@ -88,11 +89,17 @@ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, sessio
|
|
|
88
89
|
}));
|
|
89
90
|
}
|
|
90
91
|
|
|
92
|
+
// KJC-TSK-0698 (MR-A): each HU's tier and why, from this same answer plus the deterministic signal.
|
|
93
|
+
for (const story of stories) {
|
|
94
|
+
story.complexity = huComplexity(story);
|
|
95
|
+
logger.info(`${story.id} tier ${story.complexity.tier}: ${story.complexity.reason}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
91
98
|
emitProgress(
|
|
92
99
|
emitter,
|
|
93
100
|
makeEvent("hu-reviewer:decompose-end", { ...eventBase, stage: "hu-reviewer" }, {
|
|
94
101
|
message: `Task decomposed into ${stories.length} HUs`,
|
|
95
|
-
detail: { count: stories.length, ids: stories.map(s => s.id) }
|
|
102
|
+
detail: { count: stories.length, ids: stories.map(s => s.id), tiers: Object.fromEntries(stories.map((s) => [s.id, s.complexity])) }
|
|
96
103
|
})
|
|
97
104
|
);
|
|
98
105
|
|
|
@@ -230,7 +237,8 @@ export async function runHuReviewerStage({ config, logger, emitter, eventBase, s
|
|
|
230
237
|
id: hu.id,
|
|
231
238
|
text: isFirst ? fullText : skeletonText,
|
|
232
239
|
blocked_by: hu.dependsOn || [],
|
|
233
|
-
needsRefinement: !isFirst
|
|
240
|
+
needsRefinement: !isFirst,
|
|
241
|
+
complexity: hu.complexity
|
|
234
242
|
};
|
|
235
243
|
});
|
|
236
244
|
} else {
|
|
@@ -124,6 +124,21 @@ async function handleSonarRetryLimit({ config, logger, emitter, eventBase, sessi
|
|
|
124
124
|
return null;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// KJC-BUG-0359 (#2158): with only the severity counts the coder guessed and
|
|
128
|
+
// tripped sonar_repeat. Each issue travels in the feedback, its path without
|
|
129
|
+
// the project key; past the cap, the rest is counted, never silently dropped.
|
|
130
|
+
const MAX_ISSUES_FOR_CODER = 25;
|
|
131
|
+
export function formatIssuesForCoder(issues) {
|
|
132
|
+
if (!Array.isArray(issues) || issues.length === 0) return "";
|
|
133
|
+
const lines = issues.slice(0, MAX_ISSUES_FOR_CODER).map((i) => {
|
|
134
|
+
const file = String(i.file ?? "").replace(/^[^:]*:/, "");
|
|
135
|
+
return `- [${i.severity}] ${i.rule} ${file}:${i.line} ${i.message}`;
|
|
136
|
+
});
|
|
137
|
+
const rest = issues.length - lines.length;
|
|
138
|
+
if (rest > 0) lines.push(`…and ${rest} more`);
|
|
139
|
+
return `\nIssues to fix:\n${lines.join("\n")}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
127
142
|
async function handleSonarBlocking({ sonarResult, config, logger, emitter, eventBase, session, iteration, repeatDetector, budgetSummary, askQuestion, task, brainCtx }) {
|
|
128
143
|
// If the ONLY quality gate failure is coverage, treat as non-blocking warning
|
|
129
144
|
if (sonarResult.conditions) {
|
|
@@ -148,7 +163,7 @@ async function handleSonarBlocking({ sonarResult, config, logger, emitter, event
|
|
|
148
163
|
return handleSonarStalled({ repeatDetector, logger, emitter, eventBase, session, budgetSummary });
|
|
149
164
|
}
|
|
150
165
|
|
|
151
|
-
const summary = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first
|
|
166
|
+
const summary = `Sonar gate blocking (${sonarResult.gateStatus}). Resolve critical findings first.${formatIssuesForCoder(sonarResult.issues)}`;
|
|
152
167
|
setReviewerFeedback(session, summary);
|
|
153
168
|
// Brain: push sonar feedback into queue when enabled
|
|
154
169
|
if (brainCtx?.enabled) {
|
|
@@ -363,6 +378,9 @@ export async function runSonarStage({ config, logger, emitter, eventBase, sessio
|
|
|
363
378
|
const issuesFinal = sonarState.issuesFinal ?? sonarResult.openIssuesTotal ?? 0;
|
|
364
379
|
const stageResult = {
|
|
365
380
|
gateStatus: sonarResult.gateStatus,
|
|
381
|
+
// KJC-TSK-0839: the key and the files the scan indexed, for the verdict's per-source proof.
|
|
382
|
+
projectKey: sonarResult.projectKey,
|
|
383
|
+
indexedFiles: sonarResult.indexedFiles ?? [],
|
|
366
384
|
openIssues: sonarResult.openIssuesTotal,
|
|
367
385
|
issuesInitial,
|
|
368
386
|
issuesFinal,
|
package/src/plan/plan-schema.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { generatePlanId } from "./plan-id.js";
|
|
8
8
|
import { findCycle } from "../hu/graph.js";
|
|
9
|
+
import { changeClassErrors } from "./rework-license.js";
|
|
9
10
|
|
|
10
11
|
const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
|
|
11
12
|
// KJC-TSK-0394 step 5: el modelo CANONICAL del lifecycle es
|
|
@@ -255,7 +256,7 @@ export function migratePlanV1toV2(v1plan) {
|
|
|
255
256
|
/**
|
|
256
257
|
* Validate a v2 plan. Returns { valid: true } or { valid: false, errors: string[] }.
|
|
257
258
|
*/
|
|
258
|
-
export function validatePlan(plan) {
|
|
259
|
+
export function validatePlan(plan, { reworkMin } = {}) {
|
|
259
260
|
const errors = [];
|
|
260
261
|
|
|
261
262
|
if (!plan.planId) errors.push("Missing planId");
|
|
@@ -297,6 +298,8 @@ export function validatePlan(plan) {
|
|
|
297
298
|
});
|
|
298
299
|
}
|
|
299
300
|
}
|
|
301
|
+
// KJC-TSK-0717 (SPD-A): EXTEND/REWORK/REBUILD with its criterion, and REWORK only with a licence.
|
|
302
|
+
errors.push(...changeClassErrors(hu, plan.hus || [], { reworkMin }));
|
|
300
303
|
// Check blocked_by references exist
|
|
301
304
|
for (const dep of hu.blocked_by || []) {
|
|
302
305
|
if (!huIds.has(dep) && !(plan.hus || []).some(h => h.id === dep)) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0717 (SPD-A): a plan that changes code already written classifies
|
|
3
|
+
* each HU: EXTEND (edit it), REWORK (regenerate the module with its tests as
|
|
4
|
+
* the contract) or REBUILD (the subsystem), with the criterion written down.
|
|
5
|
+
* REWORK needs a licence: the module's mutation score (kj mutate <path>) at or
|
|
6
|
+
* over the threshold. Under it the plan goes in two phases, an HU that hardens
|
|
7
|
+
* the module first: regenerating against a thin contract is a roulette.
|
|
8
|
+
*/
|
|
9
|
+
export const CHANGE_CLASSES = ["extend", "rework", "rebuild"];
|
|
10
|
+
// The maintainer's choice (2026-10-16): method_gates.rework_mutation_min overrides it.
|
|
11
|
+
export const DEFAULT_REWORK_MIN = 0.8;
|
|
12
|
+
|
|
13
|
+
/** The licence for a module, from its mutation score in percent. */
|
|
14
|
+
export function licenseFor({ module, score, threshold = DEFAULT_REWORK_MIN }) {
|
|
15
|
+
return { module, mutation_score: score, threshold, granted: score / 100 >= threshold };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What is wrong with one HU's classification; empty when nothing is. The
|
|
20
|
+
* licence is judged again from its score against the threshold in force: a
|
|
21
|
+
* stored `granted` (or threshold) is a record, never the authority.
|
|
22
|
+
*/
|
|
23
|
+
export function changeClassErrors(hu, hus, { reworkMin = DEFAULT_REWORK_MIN } = {}) {
|
|
24
|
+
if (hu.change_class === undefined) return [];
|
|
25
|
+
if (!CHANGE_CLASSES.includes(hu.change_class)) return [`HU ${hu.id}: change_class must be ${CHANGE_CLASSES.join("|")}, got ${hu.change_class}`];
|
|
26
|
+
if (typeof hu.change_criterion !== "string" || !hu.change_criterion.trim()) return [`HU ${hu.id}: change_criterion is required with change_class (why this class, written down)`];
|
|
27
|
+
if (hu.change_class !== "rework") return [];
|
|
28
|
+
const licence = hu.rework_license;
|
|
29
|
+
if (!licence) return [`HU ${hu.id}: REWORK needs a licence: kj plan license <plan> ${hu.id} --module <path>`];
|
|
30
|
+
const score = licence.mutation_score;
|
|
31
|
+
if (typeof licence.module !== "string" || !licence.module || !Number.isFinite(score) || score < 0 || score > 100) {
|
|
32
|
+
return [`HU ${hu.id}: rework_license must carry a module and a mutation_score between 0 and 100: kj plan license <plan> ${hu.id} --module <path>`];
|
|
33
|
+
}
|
|
34
|
+
if (score / 100 >= reworkMin) return [];
|
|
35
|
+
const hardened = (hu.blocked_by ?? []).some((id) => hus.find((h) => h.id === id)?.hardens === licence.module);
|
|
36
|
+
if (hardened) return [];
|
|
37
|
+
return [`HU ${hu.id}: REWORK licence denied for ${licence.module}: mutation score ${score}% under ${Math.round(reworkMin * 100)}%. Plan two phases: an HU with hardens: ${licence.module} in this HU's blocked_by, then the REWORK`];
|
|
38
|
+
}
|
|
@@ -156,12 +156,13 @@ export function buildDecompositionPrompt(task, { criteria = [] } = {}) {
|
|
|
156
156
|
|
|
157
157
|
sections.push(
|
|
158
158
|
"Return a single valid JSON object and nothing else.",
|
|
159
|
-
`JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number]}]}`,
|
|
159
|
+
`JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number],"tier":"trivial"|"standard"|"deep","tierReason":string}]}`,
|
|
160
160
|
"Rules:",
|
|
161
161
|
"- Generate between 2 and 5 HUs",
|
|
162
162
|
"- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
|
|
163
163
|
"- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
|
|
164
164
|
"- acceptanceCriteria should be concrete, testable statements",
|
|
165
|
+
"- tier is how hard the HU is to implement right (trivial: a local change; standard: several files or one module; deep: design decisions, many modules, or security/migrations), and tierReason says why in one line",
|
|
165
166
|
"- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)",
|
|
166
167
|
...(criteria.length > 0 ? [
|
|
167
168
|
"- covers lists the numbers of the card's acceptance criteria this HU implements; EVERY card criterion must appear in the covers of at least one HU, and an HU's acceptanceCriteria must include each criterion it covers, verbatim",
|
|
@@ -201,7 +202,9 @@ export function parseDecompositionOutput(raw) {
|
|
|
201
202
|
benefit: s.benefit || "",
|
|
202
203
|
acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
|
|
203
204
|
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
|
|
204
|
-
covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : []
|
|
205
|
+
covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : [],
|
|
206
|
+
tier: s.tier ?? null,
|
|
207
|
+
tierReason: s.tierReason ?? null
|
|
205
208
|
}));
|
|
206
209
|
|
|
207
210
|
return stories.length > 0 ? stories : null;
|
package/src/prompts/planner.js
CHANGED
|
@@ -257,10 +257,18 @@ export function buildPlannerPrompt({ task, context, architectContext, productCon
|
|
|
257
257
|
' "spec_section": "<see rules below>",',
|
|
258
258
|
' "acceptance_tests": [ /* see rules below */ ],',
|
|
259
259
|
' "dependencies": [ "<symbolic ids of OTHER steps this one waits for>" ],',
|
|
260
|
-
' "reuse": [ "<symbolic ids of OTHER steps whose functionality this one piggy-backs on instead of reimplementing>" ]',
|
|
260
|
+
' "reuse": [ "<symbolic ids of OTHER steps whose functionality this one piggy-backs on instead of reimplementing>" ],',
|
|
261
|
+
' "change_class": "<extend | rework | rebuild: only when the step changes code already written>",',
|
|
262
|
+
' "change_criterion": "<one line: why this class>",',
|
|
263
|
+
' "hardens": "<module path, only on a step that strengthens a module\'s tests so it can be reworked>"',
|
|
261
264
|
"}",
|
|
262
265
|
"```",
|
|
263
266
|
"",
|
|
267
|
+
"### `change_class` (KJC-TSK-0717: only for steps that change code already written; omit it for new code)",
|
|
268
|
+
"- `extend`: edit the module in place. `rework`: regenerate the module with its tests as the contract. `rebuild`: replace the subsystem.",
|
|
269
|
+
"- `change_criterion` says why, in one line. A `rework` is the greenfield alternative (what you would build if this code did not exist) winning on price and criterion: say that, do not restate the whole alternatives clause.",
|
|
270
|
+
"- A `rework` needs a licence, the module's mutation score: it is computed after planning with `kj plan license <plan> <hu> --module <path>`. When the module's tests look thin, add a step first with `hardens: <module>` and list it in the rework step's `dependencies`.",
|
|
271
|
+
"",
|
|
264
272
|
"### `description` (REQUIRED — MUST start with `[EPICA] ` prefix)",
|
|
265
273
|
"- The first 80 chars of `description` become the HU's title on the board, so the prefix is the user's only at-a-glance signal of \"which area of the plan does this card belong to\".",
|
|
266
274
|
"- Identify the EPICA from the task text — typically a heading like `### Épica PROFILE`, `## Auth`, `# Layer N — name`, or a categorical noun the user uses repeatedly (e.g. `INFRA`, `SHARED`, `UI`, `API`).",
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1057 (MR-E2): what each coder model cost across the plan's HUs and
|
|
3
|
+
* how often its HUs failed first time (rejected by the reviewer, or failed),
|
|
4
|
+
* from the per-HU measurement of KJC-TSK-0702. HUs run before it are the
|
|
5
|
+
* baseline: counted apart, never as zeros.
|
|
6
|
+
*/
|
|
7
|
+
const sum = (rows, field) => rows.reduce((acc, r) => acc + (Number.isFinite(r.outcome[field]) ? r.outcome[field] : 0), 0);
|
|
8
|
+
const round = (n, digits) => Math.round(n * 10 ** digits) / 10 ** digits;
|
|
9
|
+
|
|
10
|
+
export function modelTally(hus) {
|
|
11
|
+
const measured = (hus ?? []).filter((h) => h?.outcome?.coder?.model);
|
|
12
|
+
const byModel = Object.groupBy(measured, (h) => h.outcome.coder.model);
|
|
13
|
+
const models = Object.entries(byModel).map(([model, rows]) => {
|
|
14
|
+
const withIterations = rows.filter((r) => Number.isFinite(r.outcome.iterations));
|
|
15
|
+
const failed = rows.filter((r) => r.outcome.status !== "done" || r.outcome.reviewer_rejections > 0).length;
|
|
16
|
+
return {
|
|
17
|
+
model,
|
|
18
|
+
hus: rows.length,
|
|
19
|
+
cost_usd: round(sum(rows, "cost_usd"), 6),
|
|
20
|
+
tokens_in: sum(rows, "tokens_in"),
|
|
21
|
+
tokens_out: sum(rows, "tokens_out"),
|
|
22
|
+
mean_iterations: withIterations.length ? round(sum(withIterations, "iterations") / withIterations.length, 2) : null,
|
|
23
|
+
failure_rate: round(failed / rows.length, 4),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
return { models, unmeasured: (hus ?? []).length - measured.length };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The kj report block, or null when no HU was run at all. */
|
|
30
|
+
export function formatModelTally({ models, unmeasured }) {
|
|
31
|
+
if (models.length === 0 && unmeasured === 0) return null;
|
|
32
|
+
const lines = ["Coder models (per HU, from the plans):"];
|
|
33
|
+
for (const m of models) {
|
|
34
|
+
const iterations = m.mean_iterations === null ? "-" : m.mean_iterations.toFixed(1);
|
|
35
|
+
lines.push(` ${m.model.padEnd(24)} ${m.hus} HU · $${m.cost_usd.toFixed(4)} · ${m.tokens_in} in / ${m.tokens_out} out · ${iterations} iter · ${Math.round(m.failure_rate * 100)}% failed first time`);
|
|
36
|
+
}
|
|
37
|
+
if (unmeasured > 0) lines.push(` ${unmeasured} HU(s) before the measurement (baseline, unmeasured)`);
|
|
38
|
+
return lines.join("\n");
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-1052 (TEAM-F): a team on one repo collides in the merge when two
|
|
3
|
+
* branches change the same files. kj warns at review time: the remote branches
|
|
4
|
+
* still in progress (commits outside origin/<base>, active in the last days)
|
|
5
|
+
* whose changes share files with what the commit stages. A warning, never a block.
|
|
6
|
+
*/
|
|
7
|
+
import { runCommand } from "../utils/process.js";
|
|
8
|
+
|
|
9
|
+
const MAX_AGE_DAYS = 14;
|
|
10
|
+
const MAX_BRANCHES = 20;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @returns {Promise<{branch: string, author: string, files: string[]}[]>}
|
|
14
|
+
*/
|
|
15
|
+
export async function branchOverlaps({ projectDir, stagedFiles, base = "main", now = Date.now(), run = runCommand }) {
|
|
16
|
+
const git = async (args) => {
|
|
17
|
+
const res = await run("git", ["-C", projectDir, ...args]);
|
|
18
|
+
return res.exitCode === 0 ? res.stdout.trim() : null;
|
|
19
|
+
};
|
|
20
|
+
const baseRef = `origin/${base}`;
|
|
21
|
+
if (stagedFiles.length === 0 || (await git(["rev-parse", "--verify", "--quiet", baseRef])) === null) return [];
|
|
22
|
+
const current = await git(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
23
|
+
const refs = (await git(["for-each-ref", "--sort=-committerdate", "--format=%(refname:short)|%(committerdate:unix)|%(authorname)", "refs/remotes/origin"])) ?? "";
|
|
24
|
+
const staged = new Set(stagedFiles);
|
|
25
|
+
const candidates = refs.split("\n").filter(Boolean)
|
|
26
|
+
.map((line) => { const [branch, when, author] = line.split("|"); return { branch, when: Number(when) * 1000, author }; })
|
|
27
|
+
.filter((r) => r.branch !== baseRef && r.branch !== "origin/HEAD" && r.branch !== `origin/${current}`)
|
|
28
|
+
.filter((r) => now - r.when < MAX_AGE_DAYS * 86_400_000)
|
|
29
|
+
.slice(0, MAX_BRANCHES);
|
|
30
|
+
const overlaps = [];
|
|
31
|
+
for (const { branch, author } of candidates) {
|
|
32
|
+
if (Number(await git(["rev-list", "--count", `${baseRef}..${branch}`])) === 0) continue;
|
|
33
|
+
const files = ((await git(["diff", "--name-only", `${baseRef}...${branch}`])) ?? "").split("\n").filter((f) => staged.has(f));
|
|
34
|
+
if (files.length > 0) overlaps.push({ branch, author, files });
|
|
35
|
+
}
|
|
36
|
+
return overlaps;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The kj review warning, one line per branch. */
|
|
40
|
+
export const formatOverlaps = (overlaps) => overlaps.map((o) => `⚠ ${o.branch} (${o.author}) also changes ${o.files.join(", ")}: coordinate before the merge collides`);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0722 (step 1): supply-chain campaigns (Shai-Hulud, CanisterWorm) are
|
|
3
|
+
* caught in their first hours, so a dependency published minutes ago is the
|
|
4
|
+
* riskiest one to take. kj reads what a package-lock.json diff installs and
|
|
5
|
+
* asks npm how old each version is. A signal, never a crash: without the
|
|
6
|
+
* registry, the package is listed as unverified with the reason.
|
|
7
|
+
*/
|
|
8
|
+
import { runCommand } from "../utils/process.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_COOLDOWN_HOURS = 72;
|
|
11
|
+
// npm view per package: a lockfile refresh of hundreds is said, not queried one by one.
|
|
12
|
+
const MAX_QUERIES = 40;
|
|
13
|
+
|
|
14
|
+
const KEY = /^[ +]\s*"(?:[^"]*\/)?node_modules\/((?:@[^/"]+\/)?[^/"]+)":\s*\{/;
|
|
15
|
+
const VERSION = /^\+\s*"version":\s*"([^"]+)"/;
|
|
16
|
+
|
|
17
|
+
/** The packages a diff of package-lock.json adds or moves to another version. */
|
|
18
|
+
export function lockfileChanges(diff) {
|
|
19
|
+
const changes = [];
|
|
20
|
+
for (const chunk of String(diff ?? "").split(/^diff --git /m)) {
|
|
21
|
+
if (!/^a\/(?:\S*\/)?package-lock\.json /.test(chunk)) continue;
|
|
22
|
+
let current = null;
|
|
23
|
+
for (const line of chunk.split("\n")) {
|
|
24
|
+
const key = KEY.exec(line);
|
|
25
|
+
if (key) { current = key[1]; continue; }
|
|
26
|
+
const version = VERSION.exec(line);
|
|
27
|
+
if (version && current) { changes.push({ name: current, version: version[1] }); current = null; }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return changes;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function publishedAt({ name, version }, run) {
|
|
34
|
+
const res = await run("npm", ["view", `${name}@${version}`, "time", "--json"]);
|
|
35
|
+
if (res.exitCode !== 0) throw new Error((res.stderr || res.stdout || "npm view failed").trim());
|
|
36
|
+
const when = Date.parse(JSON.parse(res.stdout || "{}")[version]);
|
|
37
|
+
if (Number.isNaN(when)) throw new Error(`npm gives no publish time for ${version}`);
|
|
38
|
+
return when;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const COOLDOWN_RULE_ID = "method.dep_cooldown";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* KJC-TSK-1056: with method_gates.dep_cooldown: block, a young dependency stops
|
|
45
|
+
* the review; only a live human grant on method.dep_cooldown lifts it. What
|
|
46
|
+
* could not be verified (no registry) never blocks: a false positive would
|
|
47
|
+
* stop legitimate work for a network blip.
|
|
48
|
+
*/
|
|
49
|
+
export function cooldownGate({ result, mode, standingExceptions = [], now = new Date() }) {
|
|
50
|
+
if (mode !== "block" || result.young.length === 0) return { block: false };
|
|
51
|
+
const grant = standingExceptions.find((e) => e.rule_id === COOLDOWN_RULE_ID && new Date(e.expiresAt).getTime() > now.getTime());
|
|
52
|
+
if (grant) return { block: false, grant };
|
|
53
|
+
const list = result.young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ");
|
|
54
|
+
return { block: true, reason: `${result.young.length} dependency(ies) under the cooldown: ${list} (method_gates.dep_cooldown: block). Wait it out, pin an older version, or a human lifts it: kj policy grant --rule ${COOLDOWN_RULE_ID} --until <iso> --reason "<why>"` };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What kj review prints, and the note the reviewer gets; empty when there is nothing to say. */
|
|
58
|
+
export function formatCooldown({ young, unverified, skipped = 0 }, thresholdHours = DEFAULT_COOLDOWN_HOURS) {
|
|
59
|
+
const lines = [
|
|
60
|
+
...young.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} was published ${p.hours} h ago (under ${thresholdHours} h): campaigns are caught in their first hours`),
|
|
61
|
+
...unverified.map((p) => `⚠ supply-chain cooldown: ${p.name}@${p.version} unverified (${p.reason})`),
|
|
62
|
+
...(skipped > 0 ? [`⚠ supply-chain cooldown: ${skipped} packages changed (a regenerated lockfile?), too many to check one by one: none was checked`] : []),
|
|
63
|
+
];
|
|
64
|
+
const note = young.length === 0 ? "" : `Dependencies published under ${thresholdHours} h ago (weigh them as a supply-chain risk): ${young.map((p) => `${p.name}@${p.version} (${p.hours} h)`).join(", ")}`;
|
|
65
|
+
return { lines, note };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Over MAX_QUERIES changed packages (a regenerated lockfile) none is queried:
|
|
70
|
+
* one line says so, instead of a line per package and a call per package.
|
|
71
|
+
* @returns {Promise<{young: {name, version, hours}[], unverified: {name, version, reason}[], skipped?: number}>}
|
|
72
|
+
*/
|
|
73
|
+
export async function cooldownAdvisories(diff, { thresholdHours = DEFAULT_COOLDOWN_HOURS, run = runCommand, now = Date.now() } = {}) {
|
|
74
|
+
const changes = lockfileChanges(diff);
|
|
75
|
+
if (changes.length > MAX_QUERIES) return { young: [], unverified: [], skipped: changes.length };
|
|
76
|
+
const young = [];
|
|
77
|
+
const unverified = [];
|
|
78
|
+
for (const pkg of changes) {
|
|
79
|
+
try {
|
|
80
|
+
const hours = Math.floor((now - await publishedAt(pkg, run)) / 3_600_000);
|
|
81
|
+
if (hours < thresholdHours) young.push({ ...pkg, hours });
|
|
82
|
+
} catch (err) {
|
|
83
|
+
unverified.push({ ...pkg, reason: err.message });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return { young, unverified };
|
|
87
|
+
}
|
|
@@ -12,7 +12,7 @@ import { createAgent } from "../agents/index.js";
|
|
|
12
12
|
import { resolveRole } from "../config/role-resolver.js";
|
|
13
13
|
import { buildReviewerPrompt } from "../prompts/reviewer.js";
|
|
14
14
|
import { resolveReviewProfile } from "./profiles.js";
|
|
15
|
-
import {
|
|
15
|
+
import { parseReviewVerdict } from "./parser.js";
|
|
16
16
|
import { detectAvailableAgents, detectHostAgent } from "../utils/agent-detect.js";
|
|
17
17
|
import { saveVerdict, diffHash } from "./verdict-store.js";
|
|
18
18
|
import { summarizeGeneratedFiles } from "./prompt-diff.js";
|
|
@@ -117,7 +117,7 @@ export async function runOneShotReview({
|
|
|
117
117
|
// back, wrapper and all. Yesterday's fix taught normalizeReviewPayload to unwrap {ok, result},
|
|
118
118
|
// but this path never called it, so the bug survived with a green test on the wrong function.
|
|
119
119
|
// A test can only prove the code it actually exercises.
|
|
120
|
-
const parsed =
|
|
120
|
+
const parsed = parseReviewVerdict(result.output);
|
|
121
121
|
if (!parsed || typeof parsed.approved !== "boolean") {
|
|
122
122
|
// KJC-BUG-0146: keep the answer. Throwing it away is what left eight occurrences undiagnosed.
|
|
123
123
|
throw new Error(await reportUnparseableVerdict({ projectDir, reviewer: activeReviewer, output: result.output, hash: diffHash(diff) }));
|
package/src/review/parser.js
CHANGED
|
@@ -26,6 +26,11 @@ export function agentAnswerText(raw) {
|
|
|
26
26
|
return raw;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/** The review verdict in an agent's answer, or null: the one path kj review reads it by. */
|
|
30
|
+
export function parseReviewVerdict(output) {
|
|
31
|
+
return normalizeReviewPayload(parseMaybeJsonString(agentAnswerText(output)));
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
function isReviewPayload(obj) {
|
|
30
35
|
return obj?.approved !== undefined && obj?.blocking_issues !== undefined;
|
|
31
36
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0913: the cross-AI verdict kj holds for each commit of a PR, published
|
|
3
|
+
* on the PR as ONE GitHub review (event COMMENT), so the review that already
|
|
4
|
+
* happened counts in reviews.totalCount instead of looking like a blind merge.
|
|
5
|
+
* A loose conversation comment does not count, so it is never used.
|
|
6
|
+
*/
|
|
7
|
+
import { runCommand } from "../utils/process.js";
|
|
8
|
+
import { diffHash, loadVerdict } from "./verdict-store.js";
|
|
9
|
+
|
|
10
|
+
const MARKER = (head) => `<!-- kj-review head=${head} -->`;
|
|
11
|
+
|
|
12
|
+
// gh runs in the project's own directory, so it reads and writes the same repo the verdicts belong to.
|
|
13
|
+
async function json(run, cmd, args, cwd) {
|
|
14
|
+
const res = await run(cmd, args, { cwd });
|
|
15
|
+
if (res.exitCode !== 0) throw new Error(`${cmd} ${args.slice(0, 3).join(" ")} failed: ${(res.stderr || res.stdout || "").trim()}`);
|
|
16
|
+
return JSON.parse(res.stdout || "null");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The verdict of a commit: the one recorded for the diff it brought, staged byte for byte. */
|
|
20
|
+
async function verdictOf(projectDir, sha, run) {
|
|
21
|
+
const res = await run("git", ["-C", projectDir, "diff", `${sha}^`, sha]);
|
|
22
|
+
return res.exitCode === 0 && res.stdout ? loadVerdict(projectDir, diffHash(res.stdout)) : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @returns {Promise<{published: boolean, commits?: number, reason?: string}>}
|
|
27
|
+
*/
|
|
28
|
+
export async function publishPrReview({ projectDir, pr, run = runCommand }) {
|
|
29
|
+
const info = await json(run, "gh", ["pr", "view", String(pr), "--json", "headRefOid,commits"], projectDir);
|
|
30
|
+
const rows = [];
|
|
31
|
+
const uncovered = [];
|
|
32
|
+
for (const c of info.commits ?? []) {
|
|
33
|
+
const v = await verdictOf(projectDir, c.oid, run);
|
|
34
|
+
if (v?.verdict === "approved") rows.push({ ...c, v });
|
|
35
|
+
else uncovered.push(`${c.oid.slice(0, 7)} (${c.messageHeadline})`);
|
|
36
|
+
}
|
|
37
|
+
if (uncovered.length > 0) throw new Error(`PR #${pr}: ${uncovered.length} commit(s) without an approved verdict of their exact diff, nothing published: ${uncovered.join(", ")}`);
|
|
38
|
+
const reviews = await json(run, "gh", ["api", `repos/{owner}/{repo}/pulls/${pr}/reviews`], projectDir);
|
|
39
|
+
if ((reviews ?? []).some((r) => String(r.body ?? "").includes(MARKER(info.headRefOid)))) {
|
|
40
|
+
return { published: false, reason: `a kj review for head ${info.headRefOid.slice(0, 7)} is already published` };
|
|
41
|
+
}
|
|
42
|
+
const body = [
|
|
43
|
+
`Cross-AI review by Karajan, one verdict per commit, each bound to the exact diff it brought.`,
|
|
44
|
+
"",
|
|
45
|
+
...rows.map((r) => {
|
|
46
|
+
const summary = r.v.summary ? ". " + r.v.summary : "";
|
|
47
|
+
return `- \`${r.oid.slice(0, 7)}\` ${r.messageHeadline}: approved by ${r.v.reviewer} (diff ${r.v.diffHash.slice(0, 12)})${summary}`;
|
|
48
|
+
}),
|
|
49
|
+
"",
|
|
50
|
+
MARKER(info.headRefOid),
|
|
51
|
+
].join("\n");
|
|
52
|
+
await json(run, "gh", ["api", "-X", "POST", `repos/{owner}/{repo}/pulls/${pr}/reviews`, "-f", "event=COMMENT", "-f", `commit_id=${info.headRefOid}`, "-f", `body=${body}`], projectDir);
|
|
53
|
+
return { published: true, commits: rows.length };
|
|
54
|
+
}
|