karajan-code 2.7.3 → 2.8.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.
Files changed (167) hide show
  1. package/README.md +4 -4
  2. package/package.json +24 -4
  3. package/scripts/diet-baseline.mjs +170 -0
  4. package/scripts/regen-arch-stats.sh +71 -0
  5. package/src/agents/availability.js +15 -9
  6. package/src/agents/claude-agent.js +143 -45
  7. package/src/agents/model-registry.js +1 -1
  8. package/src/audit/agent-readiness.js +240 -0
  9. package/src/bootstrap.js +26 -1
  10. package/src/brain/decision-tracker.js +3 -1
  11. package/src/brain/decisor.js +1 -1
  12. package/src/canvas/parse-md.js +238 -0
  13. package/src/canvas/schema.js +101 -0
  14. package/src/canvas/to-flat.js +85 -0
  15. package/src/canvas/validate.js +163 -0
  16. package/src/checks/binaries.js +1 -1
  17. package/src/checks/ports.js +97 -27
  18. package/src/checks/runner.js +26 -10
  19. package/src/checks/sonar.js +1 -1
  20. package/src/checks/system.js +1 -1
  21. package/src/checks/tokens.js +77 -24
  22. package/src/cli/_shared.js +30 -0
  23. package/src/cli/register-meta.js +204 -0
  24. package/src/cli/register-pipeline.js +212 -0
  25. package/src/cli/register-plan.js +152 -0
  26. package/src/cli/register-roles-skills.js +38 -0
  27. package/src/cli/register-sonar.js +28 -0
  28. package/src/cli.js +83 -468
  29. package/src/commands/agents.js +18 -6
  30. package/src/commands/architect.js +29 -2
  31. package/src/commands/audit.js +27 -3
  32. package/src/commands/board.js +160 -30
  33. package/src/commands/clean.js +83 -0
  34. package/src/commands/code.js +7 -2
  35. package/src/commands/discover.js +7 -2
  36. package/src/commands/init.js +2 -2
  37. package/src/commands/plan/_shared.js +40 -0
  38. package/src/commands/plan/add-hu.js +21 -0
  39. package/src/commands/plan/delete.js +9 -0
  40. package/src/commands/plan/generate.js +284 -0
  41. package/src/commands/plan/index.js +14 -0
  42. package/src/commands/plan/list.js +21 -0
  43. package/src/commands/plan/ready.js +46 -0
  44. package/src/commands/plan/remove-hu.js +17 -0
  45. package/src/commands/plan/show.js +81 -0
  46. package/src/commands/plan/validate.js +20 -0
  47. package/src/commands/plan.js +14 -291
  48. package/src/commands/researcher.js +7 -2
  49. package/src/commands/resume.js +24 -1
  50. package/src/commands/review.js +7 -2
  51. package/src/commands/run.js +52 -1
  52. package/src/commands/status.js +1 -1
  53. package/src/commands/sync.js +75 -0
  54. package/src/commands/triage.js +7 -2
  55. package/src/commands/undo.js +1 -1
  56. package/src/config/defaults.js +213 -0
  57. package/src/config/loader.js +137 -0
  58. package/src/config/overrides.js +199 -0
  59. package/src/config/role-resolver.js +159 -0
  60. package/src/config/test-harness.js +79 -0
  61. package/src/config.js +30 -609
  62. package/src/git/automation.js +2 -2
  63. package/src/guards/output-guard.js +0 -2
  64. package/src/hu/acceptance-runner.js +52 -10
  65. package/src/hu/store.js +11 -0
  66. package/src/mcp/handlers/direct-handlers.js +2 -5
  67. package/src/mcp/handlers/management-handlers.js +48 -4
  68. package/src/mcp/handlers/run-handler.js +9 -8
  69. package/src/mcp/preflight.js +32 -13
  70. package/src/mcp/server-handlers.js +4 -3
  71. package/src/mcp/suggest-handler.js +3 -2
  72. package/src/mcp/tools.js +15 -0
  73. package/src/orchestrator/agent-fallback.js +1 -1
  74. package/src/orchestrator/ci-integration.js +10 -10
  75. package/src/orchestrator/config-init.js +25 -7
  76. package/src/orchestrator/direct-actions.js +6 -2
  77. package/src/orchestrator/drivers/error-recovery.js +287 -0
  78. package/src/orchestrator/drivers/init-context.js +222 -0
  79. package/src/orchestrator/drivers/iteration-loop.js +311 -0
  80. package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
  81. package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
  82. package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
  83. package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
  84. package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
  85. package/src/orchestrator/drivers/post-loop.js +370 -0
  86. package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
  87. package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
  88. package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
  89. package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
  90. package/src/orchestrator/drivers/pre-loop.js +434 -0
  91. package/src/orchestrator/drivers/run-hu-batch.js +367 -0
  92. package/src/orchestrator/flow-control.js +4 -3
  93. package/src/orchestrator/flow-runner.js +121 -2019
  94. package/src/orchestrator/fs-leak-detector.js +156 -0
  95. package/src/orchestrator/hu-sub-pipeline.js +197 -9
  96. package/src/orchestrator/integrations.js +88 -0
  97. package/src/orchestrator/post-loop-stages.js +35 -8
  98. package/src/orchestrator/preflight-checks.js +30 -3
  99. package/src/orchestrator/repair/repair-detector.js +117 -0
  100. package/src/orchestrator/repair/repair-runner.js +142 -0
  101. package/src/orchestrator/reviewer-fallback.js +1 -1
  102. package/src/orchestrator/solomon-escalation.js +3 -2
  103. package/src/orchestrator/solomon-rules.js +7 -3
  104. package/src/orchestrator/stages/architect-stage.js +12 -11
  105. package/src/orchestrator/stages/coder-stage.js +56 -7
  106. package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
  107. package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
  108. package/src/orchestrator/stages/planner-stage.js +1 -1
  109. package/src/orchestrator/stages/research-stage.js +1 -1
  110. package/src/orchestrator/stages/reviewer-stage.js +7 -7
  111. package/src/orchestrator/stages/sonar-stage.js +94 -10
  112. package/src/orchestrator/stages/stage-classes.js +86 -0
  113. package/src/orchestrator/stages/stage-executor.js +1 -1
  114. package/src/orchestrator/stages/triage-stage.js +1 -1
  115. package/src/orchestrator/standby.js +3 -2
  116. package/src/orchestrator/verification-gate.js +20 -12
  117. package/src/plan/adr-generator.js +170 -0
  118. package/src/plan/adr-loader.js +60 -0
  119. package/src/plan/plan-executor.js +19 -1
  120. package/src/plan/plan-hu-ops.js +170 -1
  121. package/src/plan/plan-reviewer.js +199 -0
  122. package/src/plan/plan-schema.js +69 -1
  123. package/src/plan/plan-store.js +40 -3
  124. package/src/plan/spec-detector.js +61 -0
  125. package/src/plan/tests-synthesizer.js +116 -0
  126. package/src/planning-game/client.js +3 -14
  127. package/src/planning-game/decomposition.js +1 -1
  128. package/src/planning-game/pipeline-adapter.js +36 -5
  129. package/src/prompts/coder.js +172 -1
  130. package/src/prompts/hu-reviewer.js +0 -1
  131. package/src/prompts/planner.js +81 -4
  132. package/src/review/diff-generator.js +35 -22
  133. package/src/review/snapshot-diff.js +1 -1
  134. package/src/roles/coder-role.js +25 -3
  135. package/src/roles/planner-role.js +42 -1
  136. package/src/roles/repairer-role.js +181 -0
  137. package/src/roles/tester-role.js +70 -4
  138. package/src/session/mutators.js +304 -0
  139. package/src/session/runtime-overrides.js +59 -0
  140. package/src/{session-store.js → session/store.js} +8 -2
  141. package/src/skills/semantic-detector.js +10 -5
  142. package/src/skills/skill-detector.js +1 -1
  143. package/src/sonar/cloud-scanner.js +0 -1
  144. package/src/sonar/credentials.js +14 -6
  145. package/src/sonar/discovery.js +123 -0
  146. package/src/sonar/manager.js +18 -0
  147. package/src/sync/detect-drift.js +113 -0
  148. package/src/tracker-bootstrap.js +44 -0
  149. package/src/utils/agent-detect.js +15 -11
  150. package/src/utils/board-prompt-bridge.js +109 -0
  151. package/src/utils/cli-progress.js +161 -0
  152. package/src/utils/cwd-confirm.js +39 -0
  153. package/src/utils/derive-project-name-from-cwd.js +119 -0
  154. package/src/utils/garbage-collector.js +396 -0
  155. package/src/utils/git.js +16 -4
  156. package/src/utils/locale.js +1 -1
  157. package/src/utils/paths.js +21 -1
  158. package/src/utils/process.js +1 -1
  159. package/src/utils/prompt-project-name.js +35 -0
  160. package/src/utils/run-context.js +75 -0
  161. package/src/utils/run-log.js +12 -0
  162. package/src/utils/task-file.js +1 -1
  163. package/src/utils/telemetry.js +13 -1
  164. package/src/webperf/devtools-detect.js +1 -1
  165. package/templates/coder-rules.md +13 -0
  166. package/templates/roles/coder.md +34 -0
  167. package/templates/roles/repairer.md +94 -0
@@ -1,7 +1,7 @@
1
1
  // Verification gate: checks whether the coder actually produced changes.
2
2
  // Prevents advancing the pipeline when coder iterations produce 0 file changes.
3
3
 
4
- import { execSync } from "node:child_process";
4
+ import { execFileSync } from "node:child_process";
5
5
 
6
6
  /**
7
7
  * @typedef {Object} VerificationResult
@@ -16,14 +16,20 @@ import { execSync } from "node:child_process";
16
16
  /**
17
17
  * Count files and lines changed since a base reference.
18
18
  * Uses git diff --numstat to get both file and line counts.
19
+ *
20
+ * Audit follow-up: was using execSync with template-string interpolation
21
+ * of `baseRef` and `projectDir`. Both come ultimately from session
22
+ * state / config — not user shell input — but the shape was a known
23
+ * shell-injection vector. Switched to execFileSync with arg arrays so
24
+ * no interpolation reaches /bin/sh.
19
25
  */
20
26
  export function countChangesSince(baseRef, projectDir = null) {
21
27
  try {
22
- const scope = projectDir ? `-- ${projectDir}` : "";
23
- const output = execSync(
24
- `git diff --numstat ${baseRef} ${scope}`,
25
- { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
26
- ).trim();
28
+ const args = ["diff", "--numstat", String(baseRef)];
29
+ if (projectDir) args.push("--", String(projectDir));
30
+ const output = execFileSync("git", args, {
31
+ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
32
+ }).trim();
27
33
 
28
34
  if (!output) return { files: [], filesChanged: 0, linesAdded: 0, linesDeleted: 0 };
29
35
 
@@ -48,14 +54,16 @@ export function countChangesSince(baseRef, projectDir = null) {
48
54
 
49
55
  /**
50
56
  * Also count untracked files (new files not yet added to git).
57
+ *
58
+ * Audit follow-up: same execSync→execFileSync change as countChangesSince.
51
59
  */
52
60
  export function countUntrackedFiles(projectDir = null) {
53
61
  try {
54
- const scope = projectDir || ".";
55
- const output = execSync(
56
- `git ls-files --others --exclude-standard ${scope}`,
57
- { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
58
- ).trim();
62
+ const args = ["ls-files", "--others", "--exclude-standard"];
63
+ if (projectDir) args.push(String(projectDir));
64
+ const output = execFileSync("git", args, {
65
+ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
66
+ }).trim();
59
67
 
60
68
  if (!output) return [];
61
69
  return output.split("\n").filter(Boolean);
@@ -111,7 +119,7 @@ export function verifyCoderOutput({ baseRef, projectDir = null, minFiles = 1, mi
111
119
  /**
112
120
  * Build a retry strategy based on the verification failure.
113
121
  */
114
- function buildRetryStrategy(filesChanged, linesChanged) {
122
+ function buildRetryStrategy(filesChanged, _linesChanged) {
115
123
  if (filesChanged === 0) {
116
124
  return "Rephrase the feedback with explicit file paths. List specific files to create/modify. Include example code snippets.";
117
125
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Architecture Decision Records (ADRs) — distill the architect role's
3
+ * tradeoffs[] output into the canonical "Context · Decision ·
4
+ * Consequences · Status" markdown format.
5
+ *
6
+ * Why ADRs as a first-class artifact (v2.7.5):
7
+ * - The architect already analyses the task and produces a
8
+ * `tradeoffs[]` list with `{ decision, rationale, alternatives }`.
9
+ * That data is exactly what an ADR captures, just unformatted.
10
+ * - An ADR persists the WHY of an architectural choice. Coder and
11
+ * reviewer in the next `kj run` can pull the active ADRs into
12
+ * their prompt for context.
13
+ * - It's also a deliverable in its own right — projects benefit
14
+ * from a `docs/adr/` history regardless of Karajan.
15
+ *
16
+ * Storage: per-plan under `~/.kj/plans/<slug>/adrs/<adr-id>.md` (so
17
+ * an ADR's lifecycle matches its plan's). When the orchestrator runs
18
+ * a plan, the coder gets the ADRs as context. When a plan is removed
19
+ * (`kj plan delete`) the ADRs go with it.
20
+ *
21
+ * Format follows Michael Nygard's classic structure (the most widely
22
+ * adopted in the industry, used by GitHub, Spotify, AWS docs, etc.).
23
+ */
24
+
25
+ import { mkdir, writeFile } from "node:fs/promises";
26
+ import { join } from "node:path";
27
+ import { plansDir } from "./plan-store.js";
28
+
29
+ const VALID_ADR_STATUSES = new Set(["proposed", "accepted", "deprecated", "superseded"]);
30
+
31
+ /**
32
+ * Slugify a decision title into a filesystem-safe ADR id fragment.
33
+ * "Use SQLite for plan storage" → "use-sqlite-for-plan-storage".
34
+ */
35
+ function slugifyTitle(title) {
36
+ return String(title || "")
37
+ .toLowerCase()
38
+ // Decompose accents (NFD) then strip combining marks so "versión"
39
+ // → "version", keeping the ADR id ASCII-only and filesystem-safe.
40
+ .normalize("NFD")
41
+ .replace(/[̀-ͯ]/g, "")
42
+ .replace(/[^a-z0-9\s-]/g, "")
43
+ .replace(/\s+/g, "-")
44
+ .replace(/-+/g, "-")
45
+ .replace(/^-|-$/g, "")
46
+ .slice(0, 80) || "untitled";
47
+ }
48
+
49
+ /**
50
+ * Turn the architect's `architecture.tradeoffs[]` into an array of
51
+ * ADR objects. Each tradeoff carries `{ decision, rationale,
52
+ * alternatives }`; we map them to the four ADR sections plus
53
+ * metadata.
54
+ *
55
+ * Numbering: 1-based, zero-padded to 4 digits ("0001-…"). Stable per
56
+ * call — callers should treat `id` as opaque (filename) and `number`
57
+ * as ordinal.
58
+ *
59
+ * @param {object} architecture - parsed architect output
60
+ * @param {object} [opts]
61
+ * @param {string} [opts.status] - default "accepted" (architect runs imply the decision is taken)
62
+ * @returns {Array<{ number: number, id: string, title: string, status: string,
63
+ * context: string, decision: string, consequences: string,
64
+ * alternatives: string[] }>}
65
+ */
66
+ export function extractAdrsFromArchitecture(architecture, opts = {}) {
67
+ const tradeoffs = Array.isArray(architecture?.tradeoffs) ? architecture.tradeoffs : [];
68
+ const status = VALID_ADR_STATUSES.has(opts.status) ? opts.status : "accepted";
69
+ const adrs = [];
70
+ let n = 0;
71
+ for (const t of tradeoffs) {
72
+ if (!t || (typeof t !== "object" && typeof t !== "string")) continue;
73
+ const title = typeof t === "string" ? t : (t.decision || t.title || "");
74
+ if (!title.trim()) continue;
75
+ n += 1;
76
+ const number = n;
77
+ const slug = slugifyTitle(title);
78
+ const id = `${String(number).padStart(4, "0")}-${slug}`;
79
+ adrs.push({
80
+ number,
81
+ id,
82
+ title: title.trim(),
83
+ status,
84
+ context: typeof t === "object" && t.context ? String(t.context) : "",
85
+ decision: typeof t === "object" && t.decision ? String(t.decision) : title.trim(),
86
+ consequences: typeof t === "object" && t.rationale ? String(t.rationale) : "",
87
+ alternatives: typeof t === "object" && Array.isArray(t.alternatives) ? t.alternatives.map(String) : [],
88
+ });
89
+ }
90
+ return adrs;
91
+ }
92
+
93
+ /**
94
+ * Render an ADR to markdown using Nygard's format. Newline-stable so
95
+ * tests can do exact-match assertions.
96
+ *
97
+ * @param {object} adr
98
+ * @param {object} [opts]
99
+ * @param {Date} [opts.date]
100
+ * @returns {string}
101
+ */
102
+ export function renderAdrMarkdown(adr, opts = {}) {
103
+ const date = (opts.date || new Date()).toISOString().slice(0, 10); // YYYY-MM-DD
104
+ const lines = [
105
+ `# ${String(adr.number).padStart(4, "0")}. ${adr.title}`,
106
+ "",
107
+ `**Status:** ${adr.status} `,
108
+ `**Date:** ${date} `,
109
+ "",
110
+ "## Context",
111
+ "",
112
+ adr.context || "_(architect role did not provide an explicit context — see Decision below.)_",
113
+ "",
114
+ "## Decision",
115
+ "",
116
+ adr.decision,
117
+ "",
118
+ ];
119
+ if (adr.alternatives?.length > 0) {
120
+ lines.push("**Alternatives considered:**");
121
+ lines.push("");
122
+ for (const alt of adr.alternatives) lines.push(`- ${alt}`);
123
+ lines.push("");
124
+ }
125
+ lines.push(
126
+ "## Consequences",
127
+ "",
128
+ adr.consequences || "_(no consequences captured by the architect role.)_",
129
+ ""
130
+ );
131
+ return lines.join("\n");
132
+ }
133
+
134
+ /**
135
+ * Write the ADRs to disk under the plan's adrs/ directory. Returns
136
+ * the list of absolute paths written so callers can surface them in
137
+ * the CLI output.
138
+ *
139
+ * @param {object} args
140
+ * @param {string} args.projectDir
141
+ * @param {string} args.planId - used to scope ADRs to a plan; falls
142
+ * back to "_loose" when this came
143
+ * from `kj architect` standalone
144
+ * @param {Array} args.adrs - output of extractAdrsFromArchitecture
145
+ * @returns {Promise<string[]>} absolute paths written
146
+ */
147
+ export async function writeAdrsToDisk({ projectDir, planId, adrs }) {
148
+ if (!Array.isArray(adrs) || adrs.length === 0) return [];
149
+ if (!projectDir) throw new Error("writeAdrsToDisk: projectDir is required");
150
+ const planSlug = planId || "_loose";
151
+ const dir = join(plansDir(projectDir), planSlug, "adrs");
152
+ await mkdir(dir, { recursive: true });
153
+ const paths = [];
154
+ for (const adr of adrs) {
155
+ const filePath = join(dir, `${adr.id}.md`);
156
+ await writeFile(filePath, renderAdrMarkdown(adr), "utf-8");
157
+ paths.push(filePath);
158
+ }
159
+ return paths;
160
+ }
161
+
162
+ /**
163
+ * Convenience: extract + write in one call. Used by `kj architect`
164
+ * and (later) the orchestrator's architect stage.
165
+ */
166
+ export async function persistAdrsFromArchitecture({ projectDir, planId, architecture }) {
167
+ const adrs = extractAdrsFromArchitecture(architecture);
168
+ const paths = await writeAdrsToDisk({ projectDir, planId, adrs });
169
+ return { adrs, paths };
170
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Counterpart to `adr-generator.js`: reads back the persisted ADRs
3
+ * for a given plan so the coder / reviewer can pull them into their
4
+ * prompt context. PR F of the v2.7.5 multi-step planning roadmap.
5
+ *
6
+ * ADRs live at `~/.kj/plans/<slug>/<planId>/adrs/<id>.md` (per
7
+ * `adr-generator.js::writeAdrsToDisk`). On read we surface the raw
8
+ * markdown to the caller — they decide how much of it to render.
9
+ *
10
+ * Returns an empty array (not an error) when the directory doesn't
11
+ * exist: not every plan has ADRs (architect role is optional).
12
+ */
13
+
14
+ import { readdir, readFile, stat } from "node:fs/promises";
15
+ import { join } from "node:path";
16
+ import { plansDir } from "./plan-store.js";
17
+
18
+ /**
19
+ * @typedef {Object} Adr
20
+ * @property {string} id - "0001-use-vitest"
21
+ * @property {string} markdown - full file contents
22
+ */
23
+
24
+ /**
25
+ * @param {string} projectDir
26
+ * @param {string|null} planId - null falls back to the "_loose" bucket
27
+ * used by `kj architect` standalone
28
+ * @returns {Promise<Adr[]>}
29
+ */
30
+ export async function loadActiveAdrs(projectDir, planId) {
31
+ if (!projectDir) return [];
32
+ const slug = planId || "_loose";
33
+ const dir = join(plansDir(projectDir), slug, "adrs");
34
+ let files;
35
+ try {
36
+ const s = await stat(dir);
37
+ if (!s.isDirectory()) return [];
38
+ files = await readdir(dir);
39
+ } catch {
40
+ return [];
41
+ }
42
+ // Audit follow-up: was a sequential `for ... await readFile(...)`. ADRs
43
+ // are independent files; the loop has no inter-iteration state. Parallel
44
+ // reads cut typical 5-ADR loads from ~5 ms (sequential FS RTT) to a
45
+ // single ~1 ms burst, and the cost grows with ADR count. Filter the
46
+ // markdown files first so we don't fire reads against junk.
47
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
48
+ const settled = await Promise.all(
49
+ mdFiles.map(async (file) => {
50
+ try {
51
+ const md = await readFile(join(dir, file), "utf-8");
52
+ return { id: file.replace(/\.md$/, ""), markdown: md };
53
+ } catch { return null; /* skip unreadable file */ }
54
+ })
55
+ );
56
+ const adrs = settled.filter(Boolean);
57
+ // Numeric prefix in id ("0001-…") sorts naturally.
58
+ adrs.sort((a, b) => a.id.localeCompare(b.id));
59
+ return adrs;
60
+ }
@@ -5,7 +5,7 @@
5
5
  */
6
6
 
7
7
  import { isPlanV2 } from "./plan-schema.js";
8
- import { updateHuStatus } from "./plan-hu-ops.js";
8
+ import { updateHuStatus, computePlanOutcome, setPlanOutcome } from "./plan-hu-ops.js";
9
9
 
10
10
  /**
11
11
  * Convert a v2 plan's HUs into the stageResults.huReviewer format
@@ -27,6 +27,10 @@ export function planToHuBatch(plan) {
27
27
  certified: { text: hu.scope || hu.title },
28
28
  acceptance_criteria: hu.acceptance_criteria || [],
29
29
  acceptance_tests: hu.acceptance_tests || [],
30
+ // PR F (v2.7.5): forward the SPEC section pointer into the story
31
+ // so the per-HU loop in flow-runner can hand it to the coder
32
+ // prompt without having to re-load the plan from disk.
33
+ spec_section: hu.spec_section || null,
30
34
  original: { text: hu.scope || hu.title }
31
35
  }));
32
36
 
@@ -39,6 +43,12 @@ export function planToHuBatch(plan) {
39
43
  certified,
40
44
  batchSessionId: `plan-${plan.planId}`,
41
45
  planId: plan.planId,
46
+ // PR F (v2.7.5): plan-wide reviewer findings ride along the batch
47
+ // so the coder can be told about issues that touch its HU. The
48
+ // shape mirrors plan-reviewer.js output (missing_dependencies,
49
+ // scope_overlaps, order_issues, …) — null when the reviewer
50
+ // never ran for this plan.
51
+ review: plan.review || null,
42
52
  auto_generated: false,
43
53
  source: { plan: true, planId: plan.planId }
44
54
  };
@@ -66,4 +76,12 @@ export function syncResultsToPlan(plan, subPipelineResult) {
66
76
  const anyFailed = plan.hus.some(h => h.status === "failed");
67
77
  plan.status = allDone ? "done" : anyFailed ? "failed" : "running";
68
78
  plan.updatedAt = new Date().toISOString();
79
+
80
+ // PR3: stamp the plan-level rollup so the board can show the
81
+ // "Plan finalizado · X done · Y failed · Z blocked · D min" banner
82
+ // without recomputing it on every render. Built from the per-HU
83
+ // outcomes that hu-sub-pipeline already wrote on the way through.
84
+ const duration_ms = subPipelineResult.duration_ms ?? null;
85
+ const rollup = computePlanOutcome(plan, { duration_ms });
86
+ setPlanOutcome(plan, rollup);
69
87
  }
@@ -37,6 +37,12 @@ export function addHu(plan, huData) {
37
37
  scope: huData.scope || null,
38
38
  acceptance_criteria: huData.acceptance_criteria || [],
39
39
  acceptance_tests: huData.acceptance_tests || [],
40
+ // Spec mapping (v2.7.5 PR C): the SPEC.md section / heading this
41
+ // HU implements. Lets the coder cite "this implements SPEC §5.3"
42
+ // in PRs and the reviewer trace coverage gaps when the SPEC is
43
+ // re-read. Free-form string — typically "5.3", "§5.3 Initial
44
+ // Scope", or whatever the planner extracted from the source spec.
45
+ spec_section: huData.spec_section || null,
40
46
  createdAt: new Date().toISOString(),
41
47
  updatedAt: new Date().toISOString()
42
48
  };
@@ -71,7 +77,7 @@ export function removeHu(plan, huId) {
71
77
  export function updateHu(plan, huId, patch) {
72
78
  const hu = plan.hus.find(h => h.id === huId);
73
79
  if (!hu) return null;
74
- const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by"];
80
+ const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by", "spec_section"];
75
81
  for (const key of allowed) {
76
82
  if (patch[key] !== undefined) hu[key] = patch[key];
77
83
  }
@@ -93,6 +99,169 @@ export function updateHuStatus(plan, huId, status) {
93
99
  return true;
94
100
  }
95
101
 
102
+ /**
103
+ * Stamp the per-HU outcome — what actually happened during execution.
104
+ * Written ONCE per HU, at the end of runSingleHu in hu-sub-pipeline.
105
+ * Consumed by the board to render the per-HU summary indicator and
106
+ * by `kj report` for retrospective inspection.
107
+ *
108
+ * Shape (every field optional except status + finishedAt):
109
+ * {
110
+ * status: "done" | "failed" | "blocked",
111
+ * iterations: number, // how many coder→reviewer rounds
112
+ * duration_ms: number, // wall-clock for this HU
113
+ * branch: string|null, // git branch the coder used
114
+ * commits: string[], // SHA list captured during the run
115
+ * pr_url: string|null,
116
+ * blockers: string[], // why it failed/was blocked, plain
117
+ * summary: string, // 1-2 sentence human summary
118
+ * finishedAt: ISO timestamp
119
+ * }
120
+ *
121
+ * @returns {boolean} true if the HU was found and the outcome stored
122
+ */
123
+ export function setHuOutcome(plan, huId, outcome) {
124
+ const hu = plan.hus.find(h => h.id === huId);
125
+ if (!hu) return false;
126
+ hu.outcome = {
127
+ status: outcome.status || hu.status || null,
128
+ iterations: outcome.iterations ?? null,
129
+ duration_ms: outcome.duration_ms ?? null,
130
+ branch: outcome.branch ?? null,
131
+ commits: Array.isArray(outcome.commits) ? outcome.commits : [],
132
+ pr_url: outcome.pr_url ?? null,
133
+ blockers: Array.isArray(outcome.blockers) ? outcome.blockers : [],
134
+ summary: outcome.summary || "",
135
+ finishedAt: outcome.finishedAt || new Date().toISOString(),
136
+ };
137
+ hu.updatedAt = new Date().toISOString();
138
+ plan.updatedAt = new Date().toISOString();
139
+ return true;
140
+ }
141
+
142
+ /**
143
+ * Stamp the plan-level rollup once every HU has finished. Built from
144
+ * the per-HU outcomes so the board can show "Plan finished · 8 done
145
+ * · 2 failed · 1 blocked · 15 min" in one banner.
146
+ *
147
+ * {
148
+ * status: "done" | "partial" | "failed",
149
+ * total: number,
150
+ * counts: { done, failed, blocked, pending },
151
+ * duration_ms: number,
152
+ * prs: string[], // unique pr_url across HUs
153
+ * blockers: string[], // dedup'd blockers
154
+ * finishedAt: ISO timestamp,
155
+ * }
156
+ */
157
+ export function setPlanOutcome(plan, outcome) {
158
+ plan.outcome = {
159
+ status: outcome.status || "partial",
160
+ total: outcome.total ?? (plan.hus?.length || 0),
161
+ counts: outcome.counts || { done: 0, failed: 0, blocked: 0, pending: 0 },
162
+ duration_ms: outcome.duration_ms ?? null,
163
+ prs: Array.isArray(outcome.prs) ? [...new Set(outcome.prs.filter(Boolean))] : [],
164
+ blockers: Array.isArray(outcome.blockers) ? [...new Set(outcome.blockers.filter(Boolean))] : [],
165
+ finishedAt: outcome.finishedAt || new Date().toISOString(),
166
+ };
167
+ plan.updatedAt = new Date().toISOString();
168
+ }
169
+
170
+ /**
171
+ * PR-E: auto-promote every pending HU with at least one acceptance
172
+ * test to "certified". The intermediate "certified" state was an
173
+ * invisible wall — there's no UI button for it and the user had no
174
+ * way to discover `kj plan ready`. Treating pending-with-tests as
175
+ * runnable removes the wall while keeping the test contract gate.
176
+ *
177
+ * Mutates plan in place. HUs in done / failed / blocked are left
178
+ * alone so re-runs stay explicit (use --hu <id>).
179
+ *
180
+ * @returns {number} how many HUs were promoted (0 if nothing changed).
181
+ */
182
+ export function autoCertifyPendingHus(plan) {
183
+ if (!plan?.hus?.length) return 0;
184
+ let promoted = 0;
185
+ const now = new Date().toISOString();
186
+ for (const hu of plan.hus) {
187
+ if (hu.status === "pending" && Array.isArray(hu.acceptance_tests) && hu.acceptance_tests.length > 0) {
188
+ hu.status = "certified";
189
+ hu.updatedAt = now;
190
+ promoted += 1;
191
+ }
192
+ }
193
+ if (promoted > 0) plan.updatedAt = now;
194
+ return promoted;
195
+ }
196
+
197
+ /**
198
+ * PR-E: assert that the plan has at least one HU that's actually
199
+ * going to run. Throws with a plain-Spanish message otherwise so
200
+ * `kj run --plan` can never silently fall back to single-task mode
201
+ * and burn tokens for nothing.
202
+ *
203
+ * The set of "runnable" statuses matches the sub-pipeline filter
204
+ * (today: certified). The caller should run autoCertifyPendingHus()
205
+ * BEFORE this so pending-with-tests get a chance.
206
+ *
207
+ * @throws {Error} when no HU is runnable.
208
+ */
209
+ export function assertPlanRunnable(plan, planId) {
210
+ if (!plan?.hus?.length) {
211
+ throw new Error(
212
+ `El plan ${planId} no contiene ninguna HU. `
213
+ + `Edita el plan o vuelve a generarlo con \`kj plan\`. `
214
+ + `Aborto para no malgastar tokens en un fallback de "single task".`
215
+ );
216
+ }
217
+ const certified = plan.hus.filter((h) => h.status === "certified").length;
218
+ if (certified > 0) return;
219
+ const counts = plan.hus.reduce((acc, h) => {
220
+ const k = h.status || "pending";
221
+ acc[k] = (acc[k] || 0) + 1;
222
+ return acc;
223
+ }, {});
224
+ const summary = Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ");
225
+ throw new Error(
226
+ `Plan ${planId}: 0 HU(s) listas para ejecutar (estados: ${summary}). `
227
+ + `Si las HU(s) están en done/failed/blocked y quieres relanzarlas, usa --hu <id>. `
228
+ + `Aborto para no malgastar tokens en un fallback de "single task".`
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Compute the rollup from the per-HU outcomes already stamped on the
234
+ * plan. Invoked by syncResultsToPlan in plan-executor when the run
235
+ * ends so the caller doesn't have to assemble the counts by hand.
236
+ */
237
+ export function computePlanOutcome(plan, { duration_ms = null } = {}) {
238
+ const hus = plan.hus || [];
239
+ const counts = { done: 0, failed: 0, blocked: 0, pending: 0 };
240
+ const prs = [];
241
+ const blockers = [];
242
+ for (const hu of hus) {
243
+ const status = hu.outcome?.status || hu.status || "pending";
244
+ if (counts[status] !== undefined) counts[status] += 1;
245
+ else counts.pending += 1;
246
+ if (hu.outcome?.pr_url) prs.push(hu.outcome.pr_url);
247
+ if (Array.isArray(hu.outcome?.blockers)) blockers.push(...hu.outcome.blockers);
248
+ }
249
+ let status = "done";
250
+ if (counts.failed > 0 || counts.blocked > 0) status = counts.done > 0 ? "partial" : "failed";
251
+ if (counts.done === 0 && counts.failed === 0 && counts.blocked === 0) status = "pending";
252
+ return {
253
+ status,
254
+ total: hus.length,
255
+ counts,
256
+ duration_ms,
257
+ // Dedupe at the rollup boundary — same PR / blocker reported by
258
+ // multiple HUs is noise in the banner.
259
+ prs: [...new Set(prs.filter(Boolean))],
260
+ blockers: [...new Set(blockers.filter(Boolean))],
261
+ finishedAt: new Date().toISOString(),
262
+ };
263
+ }
264
+
96
265
  /**
97
266
  * Mark all pending HUs as certified (ready to execute).
98
267
  * Sets plan status to "ready".