karajan-code 4.45.0 → 4.46.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 +1 -1
- package/src/commands/identity.js +4 -0
- package/src/commands/rules-approve.js +4 -4
- package/src/harden/human-act.js +38 -0
- package/src/harden/phone-sign.js +12 -1
- package/src/harden/sign-changes.js +81 -0
- package/src/harden/supervisor-commit.js +11 -4
- package/src/orchestrator/stages/hu-reviewer-stage.js +19 -2
- package/src/prompts/hu-reviewer.js +26 -4
package/package.json
CHANGED
package/src/commands/identity.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { addSigner, enrollPhone } from "../harden/phone-sign.js";
|
|
12
12
|
import { phoneInvite } from "../harden/phone-invite.js";
|
|
13
|
+
import { humanAct } from "../harden/human-act.js";
|
|
13
14
|
import { readIdentity, writeIdentity } from "../identity/store.js";
|
|
14
15
|
import { activeGhUser, effectiveGitEmail } from "../identity/detect.js";
|
|
15
16
|
import { compareIdentity } from "../identity/compare.js";
|
|
@@ -28,6 +29,9 @@ export async function identityCommand({ action = "show", config, flags = {}, dep
|
|
|
28
29
|
// KJC-TSK-0822: enrola la clave PÚBLICA del móvil (la privada nunca toca esta máquina).
|
|
29
30
|
if (action === "enroll-phone") {
|
|
30
31
|
try {
|
|
32
|
+
// KJC-TSK-0966 (HUM-C, ADR 0018): enrolar es un acto del catálogo; hasta hoy
|
|
33
|
+
// solo lo frenaba el Sentinel, y un kj fuera de Claude no tiene Sentinel.
|
|
34
|
+
humanAct("phone-enroll", { env: deps.env ?? process.env, tty: deps.ttyHuman ?? process.stdout.isTTY, ancestry: deps.ancestry ?? {}, confirm: deps.confirm });
|
|
31
35
|
// KJC-TSK-0831 (ADR 0010): añade al PADRÓN versionado del repo (compartible,
|
|
32
36
|
// base para la verificación en CI) y mantiene la clave legacy en ~/.karajan.
|
|
33
37
|
addSigner(flags.publicKeyBase64, { projectDir, label: flags.label });
|
|
@@ -12,12 +12,11 @@ import path from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import yaml from "js-yaml";
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { humanActOf } from "../harden/human-act.js";
|
|
16
16
|
import { checkVerdict } from "../review/verdict-store.js";
|
|
17
17
|
import { approvalView } from "../rules/approval-view.js";
|
|
18
18
|
import { loadRules, LOCAL_RULES_FILE, PROPOSAL_FILE, RULES_FILE, rulesCheck } from "./rules.js";
|
|
19
19
|
|
|
20
|
-
const ACT = "kj rules approve";
|
|
21
20
|
|
|
22
21
|
/**
|
|
23
22
|
* @param {{projectDir: string, file?: string, home?: string, env?: object, tty?: boolean,
|
|
@@ -25,7 +24,8 @@ const ACT = "kj rules approve";
|
|
|
25
24
|
* @returns {Promise<{code: 0|1, lines: string[]}>}
|
|
26
25
|
*/
|
|
27
26
|
export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env, tty, deps = {}, log = console.log }) {
|
|
28
|
-
|
|
27
|
+
const act = humanActOf("rules-approve");
|
|
28
|
+
act.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
|
|
29
29
|
const proposal = path.resolve(projectDir, file);
|
|
30
30
|
let text;
|
|
31
31
|
try { text = fs.readFileSync(proposal, "utf8"); } catch { return { code: 1, lines: [`✗ no ${file}: nothing to approve`] }; }
|
|
@@ -48,7 +48,7 @@ export async function rulesApprove({ projectDir, file = PROPOSAL_FILE, home, env
|
|
|
48
48
|
// KJC-TSK-0962: read in the order of what can hurt, weakened rules first.
|
|
49
49
|
const view = approvalView(rules, loadRules(projectDir).rules, local, { versioned: RULES_FILE, unversioned: LOCAL_RULES_FILE });
|
|
50
50
|
for (const line of view) log(line);
|
|
51
|
-
|
|
51
|
+
act.confirm(deps.confirm);
|
|
52
52
|
// KJC-TSK-0961 (ADR 0017): a rule written only in the user's private MD files
|
|
53
53
|
// is not versioned. Where it goes is the inventory's word, not the proposal's.
|
|
54
54
|
const parts = [[RULES_FILE, rules.filter((rule) => !local.has(rule.id))], [LOCAL_RULES_FILE, rules.filter((rule) => local.has(rule.id))]];
|
package/src/harden/human-act.js
CHANGED
|
@@ -68,3 +68,41 @@ export function confirmHuman(what, confirm = null) {
|
|
|
68
68
|
const answer = confirm ? confirm(nonce) : readTty(what, nonce);
|
|
69
69
|
if (answer !== nonce) throw new Error(`${what}: confirmación humana fallida (esperaba "${nonce}") — ADR 0009`);
|
|
70
70
|
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* KJC-TSK-0966 (HUM-C, ADR 0018): the CLOSED catalog of human acts. Every
|
|
74
|
+
* command that changes who governs, or that destroys work, is listed here and
|
|
75
|
+
* runs through this module; an architecture test fails on a module of the
|
|
76
|
+
* catalog that skips it and on a writer of governance files that is not here.
|
|
77
|
+
* `module` names the file that implements the act; `pending` names the card
|
|
78
|
+
* that will, for acts the ADR declares and kj does not have yet.
|
|
79
|
+
*/
|
|
80
|
+
export const HUMAN_ACTS = Object.freeze({
|
|
81
|
+
"supervisor-seal": { label: "harden --commit", module: "src/harden/supervisor-commit.js" },
|
|
82
|
+
"rules-approve": { label: "kj rules approve", module: "src/commands/rules-approve.js" },
|
|
83
|
+
"phone-enroll": { label: "kj identity enroll-phone", module: "src/commands/identity.js" },
|
|
84
|
+
"handoff-run": { label: "kj handoff run", module: null, pending: "KJC-TSK-0968" },
|
|
85
|
+
"security-level-down": { label: "kj security normal", module: null, pending: "KJC-TSK-0967" },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The one mechanism, by catalog id: `refuse` (layers 1 to 3) and `confirm`
|
|
90
|
+
* (layer 4), apart because an act may check things in between (the seal looks
|
|
91
|
+
* at the stage before asking for the nonce). `humanAct` runs both.
|
|
92
|
+
* @param {keyof typeof HUMAN_ACTS} id
|
|
93
|
+
*/
|
|
94
|
+
export function humanActOf(id) {
|
|
95
|
+
const act = HUMAN_ACTS[id];
|
|
96
|
+
if (!act) throw new Error(`"${id}" is not in the catalog of human acts (src/harden/human-act.js, ADR 0018)`);
|
|
97
|
+
return {
|
|
98
|
+
label: act.label,
|
|
99
|
+
refuse: (opts = {}) => refuseAgentSession(act.label, opts),
|
|
100
|
+
confirm: (confirm = null) => confirmHuman(act.label, confirm),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function humanAct(id, { env, tty, ancestry, confirm } = {}) {
|
|
105
|
+
const act = humanActOf(id);
|
|
106
|
+
act.refuse({ env, tty, ancestry });
|
|
107
|
+
act.confirm(confirm);
|
|
108
|
+
}
|
package/src/harden/phone-sign.js
CHANGED
|
@@ -164,7 +164,14 @@ export function provenanceSignature({ projectDir, provenance }) {
|
|
|
164
164
|
* mala / clave ajena / TTL vencido ⇒ {ok:false, reason}.
|
|
165
165
|
* @returns {Promise<{ok: boolean, reason?: string}>}
|
|
166
166
|
*/
|
|
167
|
-
|
|
167
|
+
// KJC-TSK-0992 (HUM-G): what the page shows beside the sha256, as Firestore values.
|
|
168
|
+
const str = (s) => ({ stringValue: String(s ?? "") });
|
|
169
|
+
const changesValue = (changes) => ({ arrayValue: { values: changes.map((c) => ({ mapValue: { fields: {
|
|
170
|
+
file: str(c.file), status: str(c.status), added: { integerValue: String(c.added ?? 0) }, removed: { integerValue: String(c.removed ?? 0) },
|
|
171
|
+
summary: str(c.summary), diff: str(c.diff), truncated: { booleanValue: Boolean(c.truncated) },
|
|
172
|
+
} } })) } });
|
|
173
|
+
|
|
174
|
+
export async function requestPhoneSignature({ project, files, kjVersion, changes = null, branch = null, origin = null, logger = console, deps = {} }) {
|
|
168
175
|
const fetchFn = deps.fetch ?? fetch;
|
|
169
176
|
const now = deps.now ?? Date.now;
|
|
170
177
|
const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -184,6 +191,10 @@ export async function requestPhoneSignature({ project, files, kjVersion, logger
|
|
|
184
191
|
createdAt: { timestampValue: new Date(issuedMs).toISOString() },
|
|
185
192
|
files: { arrayValue: { values: files.map((f) => ({ mapValue: { fields: { file: { stringValue: f.file }, sha256: { stringValue: f.sha256 ?? "" } } } })) } },
|
|
186
193
|
...(v === 2 ? { v: { integerValue: "2" }, expires_at: { timestampValue: new Date(expiresMs).toISOString() } } : {}),
|
|
194
|
+
// KJC-TSK-0992: informative, outside the signed payload; the page says so.
|
|
195
|
+
...(Array.isArray(changes) ? { changes: changesValue(changes) } : {}),
|
|
196
|
+
...(branch ? { branch: str(branch) } : {}),
|
|
197
|
+
...(origin ? { origin: str(origin) } : {}),
|
|
187
198
|
},
|
|
188
199
|
};
|
|
189
200
|
const created = await fetchFn(`${relay.url}?documentId=${cid}&key=${relay.apiKey}`, {
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KJC-TSK-0992 (HUM-G, ADR 0018): what the phone is asked to sign, said.
|
|
3
|
+
*
|
|
4
|
+
* The signing page used to show a project name and a list of files with their
|
|
5
|
+
* sha256: nothing a person can read as "this is what changes". The signature
|
|
6
|
+
* still covers exactly the sha256 (payload v2); this module describes, file by
|
|
7
|
+
* file, what kj regenerated so the page can show it: new, modified or deleted,
|
|
8
|
+
* lines added and removed, a one-line summary and the unified diff, cut to a
|
|
9
|
+
* size the request can carry and scanned for personal data before it leaves.
|
|
10
|
+
*/
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
import { scanText } from "../privacy/scan.js";
|
|
16
|
+
|
|
17
|
+
export const DIFF_LIMIT_PER_FILE = 12_000;
|
|
18
|
+
export const DIFF_LIMIT_TOTAL = 400_000;
|
|
19
|
+
|
|
20
|
+
const defaultGit = (projectDir) => (args) => {
|
|
21
|
+
try {
|
|
22
|
+
return execFileSync("git", args, { cwd: projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
|
|
23
|
+
} catch (err) {
|
|
24
|
+
// `git diff --no-index` exits 1 when the files differ: the diff is still on stdout.
|
|
25
|
+
if (args.includes("--no-index") && typeof err?.stdout === "string") return err.stdout;
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const inHead = (git, file) => { try { git(["cat-file", "-e", `HEAD:${file}`]); return true; } catch { return false; } };
|
|
31
|
+
|
|
32
|
+
/** The first comment kj added, which in a generated hook says why it changed. */
|
|
33
|
+
const summaryOf = (diff) => {
|
|
34
|
+
const comment = diff.split("\n").find((l) => /^\+\s*(#|\/\/)\s*\S/.test(l) && !l.startsWith("+++"));
|
|
35
|
+
return comment ? comment.replace(/^\+\s*(#|\/\/)\s*/, "").trim().slice(0, 160) : "";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** A file git does not know yet: the whole file is the addition. */
|
|
39
|
+
const newFile = (git, projectDir, file) => ({
|
|
40
|
+
raw: git(["diff", "--no-index", "--", "/dev/null", join(projectDir, file)]),
|
|
41
|
+
added: readFileSync(join(projectDir, file), "utf8").split("\n").filter(Boolean).length,
|
|
42
|
+
removed: 0,
|
|
43
|
+
});
|
|
44
|
+
/** A tracked file, changed or deleted: the diff against HEAD and its numstat. */
|
|
45
|
+
const trackedFile = (git, file) => {
|
|
46
|
+
const stat = git(["diff", "--numstat", "HEAD", "--", file]).trim().split("\t");
|
|
47
|
+
return { raw: git(["diff", "HEAD", "--", file]), added: Number.parseInt(stat[0], 10) || 0, removed: Number.parseInt(stat[1], 10) || 0 };
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const MARKER_ROOM = 60;
|
|
51
|
+
/** Cut `text` so the result, marker included, never exceeds `limit`; no room left is one bounded marker. */
|
|
52
|
+
const cut = (text, limit) => {
|
|
53
|
+
if (text.length <= limit) return { diff: text, truncated: false };
|
|
54
|
+
const keep = Math.max(0, limit - MARKER_ROOM);
|
|
55
|
+
return { diff: `${text.slice(0, keep)}\n… [cut: ${text.length - keep} more characters]`.slice(0, limit), truncated: true };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* @param {{projectDir: string, files: Array<{file: string, deleted?: boolean}>, gitFn?: Function, scan?: Function}} opts
|
|
60
|
+
* @returns {Array<{file: string, status: "new"|"modified"|"deleted", added: number, removed: number, summary: string, diff: string, truncated: boolean}>}
|
|
61
|
+
*/
|
|
62
|
+
export function describeSupervisorChanges({ projectDir, files, gitFn = defaultGit(projectDir), scan = scanText }) {
|
|
63
|
+
let budget = DIFF_LIMIT_TOTAL;
|
|
64
|
+
return files.map(({ file, deleted }) => {
|
|
65
|
+
const tracked = inHead(gitFn, file);
|
|
66
|
+
const present = !deleted && existsSync(join(projectDir, file));
|
|
67
|
+
let status = "modified";
|
|
68
|
+
if (deleted || !present) status = "deleted";
|
|
69
|
+
else if (!tracked) status = "new";
|
|
70
|
+
const measured = status === "new" ? newFile(gitFn, projectDir, file) : trackedFile(gitFn, file);
|
|
71
|
+
const { added, removed } = measured;
|
|
72
|
+
// What leaves the machine is scanned like anything kj publishes: a hit
|
|
73
|
+
// replaces the diff, never travels.
|
|
74
|
+
const hits = scan(measured.raw, { source: file }).filter((h) => h.severity === "block");
|
|
75
|
+
const raw = hits.length > 0 ? `[diff withheld: the privacy scan found ${hits.length} sensitive item(s) in ${file}]` : measured.raw;
|
|
76
|
+
// Per file and in total: what the request carries never exceeds the limits.
|
|
77
|
+
const { diff, truncated } = cut(raw, Math.min(DIFF_LIMIT_PER_FILE, Math.max(0, budget)));
|
|
78
|
+
budget = Math.max(0, budget - diff.length);
|
|
79
|
+
return { file, status, added, removed, summary: summaryOf(raw), diff, truncated };
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -13,8 +13,9 @@ import { basename, join } from "node:path";
|
|
|
13
13
|
import { recordGateDecision } from "../policy/decisions.js";
|
|
14
14
|
import { readIdentity } from "../identity/store.js";
|
|
15
15
|
import { ensureGateTrackable } from "../review/gate-gitignore.js";
|
|
16
|
-
import {
|
|
16
|
+
import { humanActOf } from "./human-act.js";
|
|
17
17
|
import { isPhoneEnrolled, requestPhoneSignature } from "./phone-sign.js";
|
|
18
|
+
import { describeSupervisorChanges } from "./sign-changes.js";
|
|
18
19
|
|
|
19
20
|
export const PROVENANCE_FILE = ".karajan/supervisor-provenance.json";
|
|
20
21
|
const HOOKS_PREFIX = ".karajan/hooks/";
|
|
@@ -28,7 +29,6 @@ const HARNESS_PREFIX = ".karajan/harness/";
|
|
|
28
29
|
|
|
29
30
|
const sha256 = (abs) => createHash("sha256").update(readFileSync(abs)).digest("hex");
|
|
30
31
|
|
|
31
|
-
const ACT = "harden --commit";
|
|
32
32
|
|
|
33
33
|
/** Ficheros de supervisor TRACKEADOS con cambios (staged o no). */
|
|
34
34
|
/**
|
|
@@ -73,7 +73,8 @@ export async function commitSupervisorRegeneration({
|
|
|
73
73
|
}) {
|
|
74
74
|
// El cauce es humano por diseño (ADR 0009): una sesión de agente no lo usa.
|
|
75
75
|
// Capas 1 a 3 (entorno, tty y ascendencia de procesos): src/harden/human-act.js.
|
|
76
|
-
|
|
76
|
+
const seal = humanActOf("supervisor-seal");
|
|
77
|
+
seal.refuse({ env, tty, ancestry: deps.ancestry ?? {} });
|
|
77
78
|
const run = gitFn || ((args) => execFileSync("git", args, { cwd: projectDir, encoding: "utf8" }));
|
|
78
79
|
// KJC-BUG-0244 (grebla #958): el commit sellado solo lleva el supervisor, pero
|
|
79
80
|
// un stage ajeno se quedaba en esta rama y el siguiente commit caía aquí con la
|
|
@@ -86,7 +87,7 @@ export async function commitSupervisorRegeneration({
|
|
|
86
87
|
}
|
|
87
88
|
// Capa 4 (test adversarial 6-sep: un huérfano a init con pty falso y
|
|
88
89
|
// prompts a ciegas llegó hasta aquí): nonce aleatorio tecleado de vuelta.
|
|
89
|
-
|
|
90
|
+
seal.confirm(deps.confirm);
|
|
90
91
|
const drift = supervisorDrift({ projectDir, gitFn: run });
|
|
91
92
|
// La provenance describe SIEMPRE el estado COMPLETO del supervisor (cazado
|
|
92
93
|
// en el primer estreno real: un sello parcial pisaba al anterior y dejaba
|
|
@@ -125,10 +126,16 @@ export async function commitSupervisorRegeneration({
|
|
|
125
126
|
const phone = deps.phone ?? { enrolled: isPhoneEnrolled, request: requestPhoneSignature };
|
|
126
127
|
let signatureBlock = null;
|
|
127
128
|
if (phone.enrolled({})) {
|
|
129
|
+
// KJC-TSK-0992 (HUM-G): the page says what is signed, file by file.
|
|
130
|
+
let branch = null;
|
|
131
|
+
try { branch = run(["rev-parse", "--abbrev-ref", "HEAD"]).trim(); } catch { /* unborn HEAD */ }
|
|
128
132
|
const signed = await phone.request({
|
|
129
133
|
project: basename(projectDir),
|
|
130
134
|
files: hashed.map(({ file, sha256: hash }) => ({ file, sha256: hash ?? "" })),
|
|
131
135
|
kjVersion,
|
|
136
|
+
changes: (deps.describeChanges ?? describeSupervisorChanges)({ projectDir, files: hashed }),
|
|
137
|
+
branch,
|
|
138
|
+
origin: `kj harden ${kjVersion}`,
|
|
132
139
|
logger,
|
|
133
140
|
});
|
|
134
141
|
if (!signed.ok) {
|
|
@@ -8,7 +8,8 @@ import { HuReviewerRole } from "../../roles/hu-reviewer-role.js";
|
|
|
8
8
|
import { createAgent } from "../../agents/index.js";
|
|
9
9
|
import { addCheckpoint } from "../../session/store.js";
|
|
10
10
|
import { emitProgress, makeEvent } from "../../utils/events.js";
|
|
11
|
-
import { buildDecompositionPrompt, parseDecompositionOutput } from "../../prompts/hu-reviewer.js";
|
|
11
|
+
import { buildDecompositionPrompt, parseDecompositionOutput, uncoveredCriteria } from "../../prompts/hu-reviewer.js";
|
|
12
|
+
import { criteriaOfCard } from "../../roles/acceptance-coverage-role.js";
|
|
12
13
|
import { createStallDetector } from "../../utils/stall-detector.js";
|
|
13
14
|
import { createHuBatch, loadHuBatch, saveHuBatch, updateStoryStatus, updateStoryQuality, updateStoryCertified, addContextRequest, answerContextRequest } from "../../hu/store.js";
|
|
14
15
|
import { topologicalSort } from "../../hu/graph.js";
|
|
@@ -20,7 +21,10 @@ import { detectAndApplySplits } from "./hu-reviewer-phases/detect-and-apply-spli
|
|
|
20
21
|
*/
|
|
21
22
|
async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, session, coderRole, trackBudget }) {
|
|
22
23
|
const provider = config?.roles?.hu_reviewer?.provider || coderRole.provider;
|
|
23
|
-
|
|
24
|
+
// KJC-TSK-0989 (#1894): the card's criteria go into the decomposition, numbered,
|
|
25
|
+
// so each one lands in an HU; the ones no HU covers are said and kept, never lost.
|
|
26
|
+
const criteria = criteriaOfCard(session.pg_card);
|
|
27
|
+
const prompt = buildDecompositionPrompt(session.task, { criteria });
|
|
24
28
|
|
|
25
29
|
emitProgress(
|
|
26
30
|
emitter,
|
|
@@ -70,6 +74,19 @@ async function decomposeTaskIntoHUs({ config, logger, emitter, eventBase, sessio
|
|
|
70
74
|
logger.info("HU decomposition returned < 2 stories, falling back to single auto-story");
|
|
71
75
|
return null;
|
|
72
76
|
}
|
|
77
|
+
const uncovered = uncoveredCriteria(stories, criteria);
|
|
78
|
+
if (uncovered.length > 0) {
|
|
79
|
+
const last = stories.at(-1);
|
|
80
|
+
const tags = uncovered.map((c) => `[AC-${c.index}]`);
|
|
81
|
+
last.acceptanceCriteria.push(...uncovered.map((c, i) => `${tags[i]} ${c.text}`));
|
|
82
|
+
last.covers.push(...uncovered.map((c) => c.index));
|
|
83
|
+
logger.warn(`HU decomposition left ${uncovered.length} card criteria with no HU; added to ${last.id}: ${tags.join(" ")}`);
|
|
84
|
+
emitProgress(emitter, makeEvent("hu-reviewer:criteria-uncovered", { ...eventBase, stage: "hu-reviewer" }, {
|
|
85
|
+
status: "warn",
|
|
86
|
+
message: `${uncovered.length} card criteria had no HU and were added to ${last.id}`,
|
|
87
|
+
detail: { uncovered, addedTo: last.id }
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
73
90
|
|
|
74
91
|
emitProgress(
|
|
75
92
|
emitter,
|
|
@@ -136,7 +136,7 @@ export function normalizeAcceptanceCriteria(criteria) {
|
|
|
136
136
|
* @param {string} task - The complex task description to decompose.
|
|
137
137
|
* @returns {string} The assembled decomposition prompt.
|
|
138
138
|
*/
|
|
139
|
-
export function buildDecompositionPrompt(task) {
|
|
139
|
+
export function buildDecompositionPrompt(task, { criteria = [] } = {}) {
|
|
140
140
|
const sections = [SUBAGENT_PREAMBLE];
|
|
141
141
|
|
|
142
142
|
sections.push("## Task Decomposition");
|
|
@@ -147,20 +147,41 @@ export function buildDecompositionPrompt(task) {
|
|
|
147
147
|
|
|
148
148
|
sections.push(`## Task to Decompose\n${task}`);
|
|
149
149
|
|
|
150
|
+
// KJC-TSK-0989 (#1894): the card's criteria are the contract; each one must
|
|
151
|
+
// land in an HU, by number, or the run implements stories of its own invention.
|
|
152
|
+
if (criteria.length > 0) {
|
|
153
|
+
const numbered = criteria.map((c, i) => `${i + 1}. ${c}`).join("\n");
|
|
154
|
+
sections.push(`## Acceptance criteria of the card (the contract)\n${numbered}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
150
157
|
sections.push(
|
|
151
158
|
"Return a single valid JSON object and nothing else.",
|
|
152
|
-
`JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string]}]}`,
|
|
159
|
+
`JSON schema: {"stories":[{"id":string,"title":string,"role":string,"goal":string,"benefit":string,"acceptanceCriteria":[string],"dependsOn":[string],"covers":[number]}]}`,
|
|
153
160
|
"Rules:",
|
|
154
161
|
"- Generate between 2 and 5 HUs",
|
|
155
162
|
"- Each id must be unique, formatted as HU-DECOMP-NNN (e.g. HU-DECOMP-001)",
|
|
156
163
|
"- dependsOn is an array of other HU ids from this decomposition that must be completed first (empty array if no dependencies)",
|
|
157
164
|
"- acceptanceCriteria should be concrete, testable statements",
|
|
158
|
-
"- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)"
|
|
165
|
+
"- Order stories so that dependencies are respected (a story should only depend on stories with lower ids)",
|
|
166
|
+
...(criteria.length > 0 ? [
|
|
167
|
+
"- 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",
|
|
168
|
+
] : ["- covers is an empty array"])
|
|
159
169
|
);
|
|
160
170
|
|
|
161
171
|
return sections.join("\n\n");
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
/**
|
|
175
|
+
* KJC-TSK-0989: the card's criteria no HU covers, by number and text.
|
|
176
|
+
* @param {Array<{covers?: number[]}>} stories
|
|
177
|
+
* @param {string[]} criteria
|
|
178
|
+
* @returns {Array<{index: number, text: string}>}
|
|
179
|
+
*/
|
|
180
|
+
export function uncoveredCriteria(stories, criteria) {
|
|
181
|
+
const covered = new Set(stories.flatMap((s) => s.covers ?? []));
|
|
182
|
+
return criteria.map((text, i) => ({ index: i + 1, text })).filter((c) => !covered.has(c.index));
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
/**
|
|
165
186
|
* Parse the output from a decomposition prompt.
|
|
166
187
|
* @param {string} raw - Raw text output from the agent.
|
|
@@ -179,7 +200,8 @@ export function parseDecompositionOutput(raw) {
|
|
|
179
200
|
goal: s.goal || s.title,
|
|
180
201
|
benefit: s.benefit || "",
|
|
181
202
|
acceptanceCriteria: Array.isArray(s.acceptanceCriteria) ? s.acceptanceCriteria : [],
|
|
182
|
-
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : []
|
|
203
|
+
dependsOn: Array.isArray(s.dependsOn) ? s.dependsOn : [],
|
|
204
|
+
covers: Array.isArray(s.covers) ? s.covers.map(Number).filter(Number.isInteger) : []
|
|
183
205
|
}));
|
|
184
206
|
|
|
185
207
|
return stories.length > 0 ? stories : null;
|