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
@@ -0,0 +1,156 @@
1
+ /**
2
+ * KJC-BUG-0032: detect coder writes outside projectDir.
3
+ *
4
+ * The coder subprocess runs with `cwd: projectDir`, but its Bash
5
+ * tool can `cd` anywhere. Real-world repro: a HU titled
6
+ * "Initialize project skeleton: create assistant/ directory…"
7
+ * had Claude run `cd /home/manu/assistant && pnpm init …`. The
8
+ * resulting 36 MB of code lived in $HOME/assistant — outside the
9
+ * projectDir, outside any git repo, lost to tracking, commits and
10
+ * review. The coder was happy, the user only noticed by accident.
11
+ *
12
+ * This module is a deterministic guard: snapshot $HOME's top-level
13
+ * directory entries before the coder runs, scan again after, and
14
+ * any new entry that isn't projectDir itself is a leak.
15
+ *
16
+ * Why top-level only:
17
+ * - Catches the realistic failure mode (coder creates a sibling
18
+ * `~/something` for the project skeleton).
19
+ * - Cheap (single readdirSync) — no `find -newer` traversal of
20
+ * $HOME's potentially massive tree.
21
+ * - Doesn't thrash on writes to existing dirs like ~/.npm or
22
+ * ~/.cache that legitimate node tooling touches.
23
+ *
24
+ * What it WON'T catch (acknowledged trade-offs):
25
+ * - Writes deep inside existing $HOME subdirs (e.g. modifying
26
+ * ~/.bashrc — would need a per-file mtime scan).
27
+ * - Writes to /tmp (high false-positive rate; /tmp is ephemeral
28
+ * by spec; less risky).
29
+ * - Writes to /etc, /var, etc. (would require root anyway).
30
+ *
31
+ * The first-pass guard is the right ROI. Subsequent iterations
32
+ * can extend coverage if real-world cases warrant it.
33
+ */
34
+
35
+ import fs from "node:fs";
36
+ import os from "node:os";
37
+ import path from "node:path";
38
+
39
+ /**
40
+ * Snapshot the immediate children of $HOME. Returns a Set of names
41
+ * (not full paths) so detectNewHomeEntries can do a quick set
42
+ * difference. Empty set on any FS error — treats "can't snapshot"
43
+ * the same as "snapshot OK with nothing".
44
+ *
45
+ * @returns {Set<string>}
46
+ */
47
+ export function snapshotHomeTopLevel() {
48
+ try {
49
+ return new Set(fs.readdirSync(os.homedir()));
50
+ } catch {
51
+ return new Set();
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Detect $HOME top-level entries that didn't exist when `before`
57
+ * was snapshotted. Excludes projectDir itself in case the user
58
+ * keeps their projects directly under $HOME (no false positive
59
+ * if `projectDir = $HOME/some-project` already existed).
60
+ *
61
+ * @param {Set<string>} before — snapshot from snapshotHomeTopLevel()
62
+ * @param {string|null} projectDir — current run's projectDir
63
+ * @returns {string[]} absolute paths of new entries (potential leaks)
64
+ */
65
+ export function detectNewHomeEntries(before, projectDir) {
66
+ const home = os.homedir();
67
+ let after;
68
+ try {
69
+ after = fs.readdirSync(home);
70
+ } catch {
71
+ return [];
72
+ }
73
+ const projectDirAbs = projectDir ? path.resolve(projectDir) : null;
74
+ const leaks = [];
75
+ for (const name of after) {
76
+ if (before.has(name)) continue;
77
+ const full = path.join(home, name);
78
+ // Skip projectDir itself (defensive — shouldn't happen since
79
+ // projectDir is pre-existing, but a paranoid check costs nothing).
80
+ if (projectDirAbs && path.resolve(full) === projectDirAbs) continue;
81
+ leaks.push(full);
82
+ }
83
+ return leaks;
84
+ }
85
+
86
+ /**
87
+ * Issue #546: filter potential leaks by attribution. Snapshot diff
88
+ * cannot tell coder writes from concurrent writes by other processes
89
+ * (host AI scratch files, parallel tooling, manual edits). Real-world
90
+ * 2026-04-29 incident: a host-side write of `~/karajan-status-snapshot.md`
91
+ * during a kj run aborted HU 003 with a false positive.
92
+ *
93
+ * Heuristic: if the coder genuinely created the leaked path, its
94
+ * transcript will mention the path or its basename — the coder
95
+ * narrates what it does (Bash commands, file operations, todos). If
96
+ * neither the absolute path nor the basename appears anywhere in
97
+ * the transcript, the entry was created by some other process during
98
+ * the iteration window and should NOT abort the HU.
99
+ *
100
+ * False-negative trade-off: a coder that creates a file silently
101
+ * (e.g. via a script that doesn't echo paths) escapes this filter.
102
+ * That trade-off is acceptable: the original leak class (coder
103
+ * skeleton-creating in $HOME) always emits the path in commands; the
104
+ * silent-write case has never been observed.
105
+ *
106
+ * Back-compat: when `transcript` is empty/null/undefined, NO filtering
107
+ * happens — every detected leak is passed through unchanged. This
108
+ * preserves the strict behaviour for callers that don't have a
109
+ * transcript handy.
110
+ *
111
+ * @param {string[]} leaks — output of detectNewHomeEntries()
112
+ * @param {string|null|undefined} transcript — coder's full output text
113
+ * @returns {string[]} filtered leaks (the subset attributable to coder)
114
+ */
115
+ export function verifyLeaksAgainstTranscript(leaks, transcript) {
116
+ if (!Array.isArray(leaks) || leaks.length === 0) return [];
117
+ if (!transcript || typeof transcript !== "string" || transcript.length === 0) {
118
+ return leaks; // no transcript ⇒ no filtering, original behaviour
119
+ }
120
+ return leaks.filter((p) => {
121
+ const base = path.basename(p);
122
+ // Skip artefacts whose basename is too short to be discriminating
123
+ // (e.g. ".cache") — a 3-char basename matched against a long
124
+ // transcript is meaningless. For such cases we prefer the strict
125
+ // behaviour (flag) since they're rare and the user can inspect.
126
+ if (base.length < 4) return true;
127
+ return transcript.includes(p) || transcript.includes(base);
128
+ });
129
+ }
130
+
131
+ /**
132
+ * Format a leak list into a multiline plain-Spanish error message.
133
+ * Includes a hint about how to recover (move-or-delete) so the user
134
+ * isn't left guessing.
135
+ *
136
+ * @param {string[]} leaks — output of detectNewHomeEntries()
137
+ * @param {string} projectDir
138
+ * @returns {string}
139
+ */
140
+ export function formatLeakMessage(leaks, projectDir) {
141
+ const list = leaks.map((p) => ` - ${p}`).join("\n");
142
+ return [
143
+ `El coder creó archivos FUERA del proyecto (${projectDir}).`,
144
+ "",
145
+ "Rutas detectadas:",
146
+ list,
147
+ "",
148
+ "Esto suele pasar cuando una HU usa una ruta absoluta o relativa a $HOME y el coder la sigue literalmente. Las rutas SIEMPRE deben ser relativas a la raíz del proyecto.",
149
+ "",
150
+ "Qué hacer:",
151
+ " 1. Inspecciona las rutas listadas. Si tienen código útil, muévelo a tu proyecto manualmente.",
152
+ " 2. Borra las rutas listadas si son basura.",
153
+ " 3. Edita el título / scope de la HU para que no contenga rutas absolutas.",
154
+ " 4. Vuelve a lanzar la HU.",
155
+ ].join("\n");
156
+ }
@@ -9,8 +9,21 @@ import { refineHuWithContext } from "../hu/lazy-planner.js";
9
9
  import { findParallelGroups, createWorktree, mergeWorktree, removeWorktree } from "../hu/parallel-executor.js";
10
10
 
11
11
  /**
12
- * Determine whether the HU reviewer result requires a sub-pipeline
13
- * (more than one certified story).
12
+ * Determine whether the HU reviewer result needs the sub-pipeline path.
13
+ *
14
+ * Pre-fix this returned true only when there were > 1 certified stories.
15
+ * That broke single-HU runs (`kj run --plan <id> --hu <huId>`, the path
16
+ * the board's per-card ▶ button drives): with exactly one HU certified
17
+ * the flow fell through to the standard single-task pipeline, which
18
+ * does NOT call updateStoryStatus / saveHuBatch / emit hu:* events.
19
+ * Result: the HU stayed forever in `coding` even after the run finished
20
+ * because nobody persisted "done"/"failed" anywhere — board kanban
21
+ * showed "1 running…" indefinitely, plan JSON kept the stale status.
22
+ *
23
+ * The sub-pipeline already handles a 1-HU batch as a degenerate case
24
+ * (no dependency graph to resolve, no parallel batching) — so the fix
25
+ * is to take that path for any non-empty batch, not just >1.
26
+ *
14
27
  * @param {object|null} huReviewerResult - stageResults.huReviewer
15
28
  * @returns {boolean}
16
29
  */
@@ -19,7 +32,7 @@ export function needsSubPipeline(huReviewerResult) {
19
32
  const certified = (huReviewerResult.stories || []).filter(
20
33
  s => s.status === "certified"
21
34
  );
22
- return certified.length > 1;
35
+ return certified.length >= 1;
23
36
  }
24
37
 
25
38
  /**
@@ -61,6 +74,59 @@ function buildHuTask(story) {
61
74
  return `Implement HU ${story.id}`;
62
75
  }
63
76
 
77
+ /**
78
+ * Build the per-HU outcome blob from whatever metadata the iteration
79
+ * loop returned. Tolerant by design — most fields are optional and we
80
+ * default to safe values so the board can render something useful
81
+ * even on weird failure modes (HU crashed before producing summary,
82
+ * runIterationFn missing the commits array, etc.).
83
+ *
84
+ * Plain-Spanish summary lives at the bottom of the priority order so
85
+ * we surface what the run actually said before falling back to
86
+ * generic phrases.
87
+ *
88
+ * @param {object} args
89
+ * @param {object} args.story
90
+ * @param {object} args.iterResult — whatever runIterationFn returned
91
+ * @param {string} args.status — HU_STATUS.* terminal value
92
+ * @param {number} args.startedAt — Date.now() when runSingleHu started
93
+ * @returns {object}
94
+ */
95
+ function buildHuOutcome({ story: _story, iterResult, status, startedAt }) {
96
+ const r = iterResult || {};
97
+ const git = r.git || {};
98
+ // Iterations are tracked in the standard pipeline as
99
+ // `result.iterations`, in the tests-first pipeline as the loop
100
+ // counter — fall back to whatever we can find.
101
+ const iterations = r.iterations ?? r.iteration ?? r.attempts ?? null;
102
+ const commits = Array.isArray(git.commits)
103
+ ? git.commits.map((c) => (typeof c === "string" ? c : c.hash || c.sha || "")).filter(Boolean)
104
+ : (Array.isArray(r.commits) ? r.commits : []);
105
+ const blockers = [];
106
+ if (r.error) blockers.push(String(r.error));
107
+ if (r.reason && r.reason !== "approved") blockers.push(String(r.reason));
108
+ if (Array.isArray(r.deferredIssues)) {
109
+ for (const d of r.deferredIssues) blockers.push(typeof d === "string" ? d : d.message || JSON.stringify(d));
110
+ }
111
+ // Plain-Spanish, one-or-two sentences.
112
+ let summary;
113
+ if (r.review?.summary) summary = String(r.review.summary);
114
+ else if (status === "done") summary = `HU completada${commits.length ? ` con ${commits.length} commit(s)` : ""}.`;
115
+ else if (status === "failed") summary = `HU falló${blockers.length ? `: ${blockers[0]}` : ""}.`;
116
+ else summary = `HU ${status}.`;
117
+ return {
118
+ status,
119
+ iterations: typeof iterations === "number" ? iterations : null,
120
+ duration_ms: typeof startedAt === "number" ? Date.now() - startedAt : null,
121
+ branch: r.branch || git.branch || null,
122
+ commits,
123
+ pr_url: r.pr_url || r.prUrl || git.pr_url || null,
124
+ blockers,
125
+ summary,
126
+ finishedAt: new Date().toISOString(),
127
+ };
128
+ }
129
+
64
130
  /**
65
131
  * Execute a single HU through the coder→sonar→reviewer sub-pipeline.
66
132
  * Extracted to support both sequential and parallel execution paths.
@@ -68,7 +134,29 @@ function buildHuTask(story) {
68
134
  * @param {object} params
69
135
  * @returns {Promise<{huId: string, approved: boolean, result?: object, error?: string, blockedDependents?: string[]}>}
70
136
  */
71
- async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath }) {
137
+ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emitter, eventBase, logger, config, results, worktreePath, onStatusChange = null, onOutcome = null }) {
138
+ // PR1 (live HU status): every saveHuBatch should also notify the
139
+ // plan JSON so the board reflects state in real time. Defined as a
140
+ // local helper so we don't repeat the try/null-check boilerplate.
141
+ const notifyStatus = async (huId, status) => {
142
+ if (typeof onStatusChange !== "function") return;
143
+ try { await onStatusChange(huId, status); }
144
+ catch (err) { logger?.warn?.(`onStatusChange(${huId}, ${status}) failed: ${err.message}`); }
145
+ };
146
+ // PR3 (per-HU outcome): single notification at the end of the HU
147
+ // with the rich outcome blob. Plan JSON gets it stamped before
148
+ // we return, so the board's chokidar fires once and the card
149
+ // shows its summary indicator.
150
+ const notifyOutcome = async (huId, outcome) => {
151
+ if (typeof onOutcome !== "function") return;
152
+ try { await onOutcome(huId, outcome); }
153
+ catch (err) { logger?.warn?.(`onOutcome(${huId}) failed: ${err.message}`); }
154
+ };
155
+
156
+ // PR3: capture the start time so duration can be reported in the
157
+ // outcome (wall-clock per HU is what the user needs in the board,
158
+ // not internal stage timings).
159
+ const huStartedAt = Date.now();
72
160
  const story = batch.stories.find(s => s.id === storyId);
73
161
 
74
162
  // --- Lazy refinement ---
@@ -114,6 +202,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
114
202
 
115
203
  updateStoryStatus(batch, story.id, HU_STATUS.CODING);
116
204
  await saveHuBatch(batchSessionId, batch);
205
+ await notifyStatus(story.id, HU_STATUS.CODING);
117
206
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
118
207
  message: `HU ${story.id} status → coding`,
119
208
  detail: { huId: story.id, status: HU_STATUS.CODING, timestamp: new Date().toISOString() }
@@ -126,6 +215,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
126
215
  // --- Transition to reviewing (post-coder, pre-reviewer evaluation) ---
127
216
  updateStoryStatus(batch, story.id, HU_STATUS.REVIEWING);
128
217
  await saveHuBatch(batchSessionId, batch);
218
+ await notifyStatus(story.id, HU_STATUS.REVIEWING);
129
219
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
130
220
  message: `HU ${story.id} status → reviewing`,
131
221
  detail: { huId: story.id, status: HU_STATUS.REVIEWING, timestamp: new Date().toISOString() }
@@ -134,6 +224,10 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
134
224
  if (approved) {
135
225
  updateStoryStatus(batch, story.id, HU_STATUS.DONE);
136
226
  await saveHuBatch(batchSessionId, batch);
227
+ await notifyStatus(story.id, HU_STATUS.DONE);
228
+ // PR3: build + persist the per-HU outcome.
229
+ const okOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.DONE, startedAt: huStartedAt });
230
+ await notifyOutcome(story.id, okOutcome);
137
231
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
138
232
  message: `HU ${story.id} status → done`,
139
233
  detail: { huId: story.id, status: HU_STATUS.DONE, timestamp: new Date().toISOString() }
@@ -141,12 +235,15 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
141
235
  emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
142
236
  status: "ok",
143
237
  message: `HU ${story.id} completed successfully`,
144
- detail: { huId: story.id, approved: true }
238
+ detail: { huId: story.id, approved: true, outcome: okOutcome }
145
239
  }));
146
240
  return { huId: story.id, approved: true, result: iterResult };
147
241
  } else {
148
242
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
149
243
  await saveHuBatch(batchSessionId, batch);
244
+ await notifyStatus(story.id, HU_STATUS.FAILED);
245
+ const failOutcome = buildHuOutcome({ story, iterResult, status: HU_STATUS.FAILED, startedAt: huStartedAt });
246
+ await notifyOutcome(story.id, failOutcome);
150
247
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
151
248
  message: `HU ${story.id} status → failed`,
152
249
  detail: { huId: story.id, status: HU_STATUS.FAILED, timestamp: new Date().toISOString() }
@@ -154,13 +251,16 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
154
251
  emitProgress(emitter, makeEvent("hu:end", { ...eventBase, stage: "hu-sub-pipeline" }, {
155
252
  status: "fail",
156
253
  message: `HU ${story.id} failed`,
157
- detail: { huId: story.id, approved: false, reason: iterResult?.reason }
254
+ detail: { huId: story.id, approved: false, reason: iterResult?.reason, outcome: failOutcome }
158
255
  }));
159
256
  return { huId: story.id, approved: false, result: iterResult };
160
257
  }
161
258
  } catch (err) {
162
259
  updateStoryStatus(batch, story.id, HU_STATUS.FAILED);
163
260
  await saveHuBatch(batchSessionId, batch);
261
+ await notifyStatus(story.id, HU_STATUS.FAILED);
262
+ const errOutcome = buildHuOutcome({ story, iterResult: { error: err.message }, status: HU_STATUS.FAILED, startedAt: huStartedAt });
263
+ await notifyOutcome(story.id, errOutcome);
164
264
  emitProgress(emitter, makeEvent("hu:status-change", { ...eventBase, stage: "hu-sub-pipeline" }, {
165
265
  message: `HU ${story.id} status → failed`,
166
266
  detail: { huId: story.id, status: HU_STATUS.FAILED, timestamp: new Date().toISOString() }
@@ -187,7 +287,7 @@ async function runSingleHu({ storyId, batch, batchSessionId, runIterationFn, emi
187
287
  * @param {object} params.logger - Logger instance
188
288
  * @returns {Promise<{approved: boolean, results: object[], blockedIds: string[]}>}
189
289
  */
190
- export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null }) {
290
+ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitter, eventBase, logger, config = null, onStatusChange = null, onOutcome = null }) {
191
291
  const batchSessionId = huReviewerResult.batchSessionId;
192
292
  let batch;
193
293
  try {
@@ -201,6 +301,75 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
201
301
  };
202
302
  }
203
303
 
304
+ // Plan-driven runs: the plan JSON is the source of truth for the
305
+ // ENTIRE HU, NOT just status, NOT the on-disk hu-stories batch.
306
+ //
307
+ // PR #537 reconciled `status` and fixed the "All HUs completed
308
+ // successfully for zero work" bug. But status is only ONE field of
309
+ // the HU. The 2026-04-29 incident hit a different field — a user
310
+ // edited the plan JSON to fix a broken jq query inside the HU's
311
+ // `acceptance_tests`, but the persisted batch kept the broken
312
+ // version, so the acceptance runner used the broken test, the coder
313
+ // burned every iteration trying to make an irreparable test pass,
314
+ // and the HU failed.
315
+ //
316
+ // Treat the plan as authoritative for ALL content fields the user
317
+ // might edit between runs (acceptance_tests, title, scope,
318
+ // blocked_by, spec_section, original.text). In-flight runtime
319
+ // context that doesn't live in the plan (worktree path, refinement
320
+ // state, certification quality scores, context_requests) is
321
+ // preserved.
322
+ if (huReviewerResult.source?.plan === true && Array.isArray(huReviewerResult.stories)) {
323
+ // Fields the plan owns. Anything NOT in this set is preserved
324
+ // from the persisted batch (in-flight runtime state).
325
+ const PLAN_OWNED_FIELDS = [
326
+ "status",
327
+ "title",
328
+ "scope",
329
+ "blocked_by",
330
+ "acceptance_tests",
331
+ "acceptance_criteria",
332
+ "spec_section",
333
+ "task_type",
334
+ "certified", // canonical HU body
335
+ "original", // original text reference
336
+ ];
337
+ const planById = new Map(huReviewerResult.stories.map((s) => [s.id, s]));
338
+ let statusChanges = 0;
339
+ let contentChanges = 0;
340
+ for (const persisted of batch.stories) {
341
+ const fromPlan = planById.get(persisted.id);
342
+ if (!fromPlan) continue;
343
+ for (const field of PLAN_OWNED_FIELDS) {
344
+ if (!(field in fromPlan)) continue;
345
+ const before = JSON.stringify(persisted[field]);
346
+ const after = JSON.stringify(fromPlan[field]);
347
+ if (before === after) continue;
348
+ persisted[field] = fromPlan[field];
349
+ if (field === "status") statusChanges += 1;
350
+ else contentChanges += 1;
351
+ }
352
+ }
353
+ // Stories present in the plan but missing from disk → append.
354
+ const persistedIds = new Set(batch.stories.map((s) => s.id));
355
+ let appended = 0;
356
+ for (const fromPlan of huReviewerResult.stories) {
357
+ if (!persistedIds.has(fromPlan.id)) {
358
+ batch.stories.push(fromPlan);
359
+ appended += 1;
360
+ }
361
+ }
362
+ const totalChanges = statusChanges + contentChanges + appended;
363
+ if (totalChanges > 0) {
364
+ const parts = [];
365
+ if (statusChanges > 0) parts.push(`${statusChanges} status`);
366
+ if (contentChanges > 0) parts.push(`${contentChanges} content field(s)`);
367
+ if (appended > 0) parts.push(`${appended} appended`);
368
+ logger.info(`HU sub-pipeline: reconciled from plan — ${parts.join(", ")} (disk batch was stale)`);
369
+ await saveHuBatch(batchSessionId, batch);
370
+ }
371
+ }
372
+
204
373
  const certifiedStories = batch.stories.filter(s => s.status === HU_STATUS.CERTIFIED);
205
374
  let orderedIds;
206
375
  try {
@@ -235,13 +404,22 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
235
404
  // Single HU: run as before, no worktree needed
236
405
  const singleResult = await runSingleHu({
237
406
  storyId: runnableIds[0], batch, batchSessionId, runIterationFn,
238
- emitter, eventBase, logger, config, results
407
+ emitter, eventBase, logger, config, results, onStatusChange, onOutcome
239
408
  });
240
409
  results.push(singleResult);
241
410
  if (!singleResult.approved) {
242
411
  allApproved = false;
243
412
  const newlyBlocked = blockDependents(batch, singleResult.huId);
244
413
  blockedIds.push(...newlyBlocked);
414
+ // PR1 (live HU status): notify the plan JSON of every newly
415
+ // BLOCKED HU so the board moves them into the blocked column
416
+ // without waiting for the run to finish.
417
+ if (typeof onStatusChange === "function") {
418
+ for (const blockedHuId of newlyBlocked) {
419
+ try { await onStatusChange(blockedHuId, HU_STATUS.BLOCKED); }
420
+ catch (err) { logger?.warn?.(`onStatusChange(${blockedHuId}, blocked) failed: ${err.message}`); }
421
+ }
422
+ }
245
423
  }
246
424
  await saveHuBatch(batchSessionId, batch);
247
425
  } else {
@@ -264,7 +442,8 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
264
442
  return runSingleHu({
265
443
  storyId, batch, batchSessionId, runIterationFn,
266
444
  emitter, eventBase, logger, config, results,
267
- worktreePath: worktrees.get(storyId)
445
+ worktreePath: worktrees.get(storyId),
446
+ onStatusChange, onOutcome
268
447
  });
269
448
  });
270
449
 
@@ -283,6 +462,15 @@ export async function runHuSubPipeline({ huReviewerResult, runIterationFn, emitt
283
462
  allApproved = false;
284
463
  const newlyBlocked = blockDependents(batch, res.huId);
285
464
  blockedIds.push(...newlyBlocked);
465
+ // PR1 (live HU status): same as the single-HU path — push
466
+ // BLOCKED transitions to the plan JSON immediately so the
467
+ // board shows them moving to the blocked column live.
468
+ if (typeof onStatusChange === "function") {
469
+ for (const blockedHuId of newlyBlocked) {
470
+ try { await onStatusChange(blockedHuId, HU_STATUS.BLOCKED); }
471
+ catch (err) { logger?.warn?.(`onStatusChange(${blockedHuId}, blocked) failed: ${err.message}`); }
472
+ }
473
+ }
286
474
  // Clean up failed worktree
287
475
  if (worktrees.has(res.huId)) {
288
476
  try { await removeWorktree(projectDir, res.huId); } catch { /* ignore */ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Integrations registry — single site the orchestrator consults to talk to
3
+ * external task trackers (Planning Game, future: GitHub Projects, Jira, …).
4
+ *
5
+ * TSK-0339 (KJC-PCS-0040) removes the direct coupling between
6
+ * `src/orchestrator/` and `src/planning-game/`: pre-v2.7.5 there were 8
7
+ * dynamic `await import("../../planning-game/…")` callsites scattered
8
+ * across flow-runner, ci-integration, drivers, and architect-stage. Now
9
+ * the orchestrator calls the registry instead of importing trackers:
10
+ *
11
+ * const tracker = getIntegration("tracker");
12
+ * await tracker?.onCommit?.(session, commit);
13
+ *
14
+ * `src/planning-game/pipeline-adapter.js` registers itself once at
15
+ * bootstrap (gated by `config.planning_game.enabled`). Adding a new
16
+ * tracker means writing a single file under `src/adapters/…` with the
17
+ * same hook surface; zero orchestrator edits.
18
+ */
19
+
20
+ /**
21
+ * Hook contract (all optional — callers use `adapter?.hook?.(args)`):
22
+ *
23
+ * onSessionStart({ session, config, logger, pgTaskId, pgProject })
24
+ * → { pgCard } | void
25
+ * onCommit(session, commit) — fire-and-forget
26
+ * onSessionApproved({ session, config, gitResult, logger, pgCard, pgProject })
27
+ * → void
28
+ * buildHuStoriesFromCard(card) → HuStory[] | null
29
+ * handleDecomposition({ triageResult, ... }) → void
30
+ * createAdr({ title, context, decision, consequences }) → string | null
31
+ *
32
+ * No adapter is required to implement every hook — orchestrator callsites
33
+ * use optional chaining so missing hooks are a silent skip.
34
+ *
35
+ * The registry itself is process-scoped (one map, mutated at bootstrap).
36
+ * That's fine: adapter identity doesn't vary per-run — the same PG/Jira
37
+ * backend serves every run in the process. Per-run tracker CONFIG (e.g.
38
+ * different PG projects) is passed through the hook call, not by
39
+ * swapping adapters.
40
+ */
41
+
42
+ /** @type {Map<string, object>} */
43
+ const registry = new Map();
44
+
45
+ /**
46
+ * Register an adapter under `name`. Idempotent: re-registering overwrites
47
+ * the previous entry (tests use this to swap in fakes).
48
+ *
49
+ * @param {string} name
50
+ * @param {object} adapter
51
+ */
52
+ export function registerIntegration(name, adapter) {
53
+ if (!name || typeof name !== "string") {
54
+ throw new Error("registerIntegration: name must be a non-empty string");
55
+ }
56
+ registry.set(name, adapter);
57
+ }
58
+
59
+ /**
60
+ * @param {string} name
61
+ * @returns {object|undefined}
62
+ */
63
+ export function getIntegration(name) {
64
+ return registry.get(name);
65
+ }
66
+
67
+ /**
68
+ * Remove an adapter. Tests use this to clear state between cases.
69
+ * @param {string} name
70
+ */
71
+ export function unregisterIntegration(name) {
72
+ registry.delete(name);
73
+ }
74
+
75
+ /**
76
+ * Clear the registry. Test-only; production never calls this.
77
+ */
78
+ export function __clearIntegrations() {
79
+ registry.clear();
80
+ }
81
+
82
+ /**
83
+ * List registered integration names. Useful for bootstrap logging.
84
+ * @returns {string[]}
85
+ */
86
+ export function listIntegrations() {
87
+ return [...registry.keys()];
88
+ }
@@ -2,7 +2,8 @@ import { TesterRole } from "../roles/tester-role.js";
2
2
  import { SecurityRole } from "../roles/security-role.js";
3
3
  import { ImpeccableRole } from "../roles/impeccable-role.js";
4
4
  import { AuditRole } from "../roles/audit-role.js";
5
- import { addCheckpoint, saveSession } from "../session-store.js";
5
+ import { addCheckpoint } from "../session/store.js";
6
+ import { resetRetryCount } from "../session/mutators.js";
6
7
  import { emitProgress, makeEvent } from "../utils/events.js";
7
8
  import { invokeSolomon } from "./solomon-escalation.js";
8
9
 
@@ -37,7 +38,7 @@ function isAgentFailure(output) {
37
38
  *
38
39
  * @returns {{ output, provider, attempts }}
39
40
  */
40
- async function runRoleWithFallback(RoleClass, { roleName, config, logger, emitter, eventBase, task, iteration, diff }) {
41
+ async function runRoleWithFallback(RoleClass, { roleName, config, logger, emitter, eventBase, task, iteration, diff, pendingGherkinTests = null, shellTestResults = null }) {
41
42
  const chain = buildFallbackChain(config, roleName);
42
43
  const attempts = [];
43
44
 
@@ -53,7 +54,10 @@ async function runRoleWithFallback(RoleClass, { roleName, config, logger, emitte
53
54
  const start = Date.now();
54
55
  let output;
55
56
  try {
56
- output = await role.run({ task, diff });
57
+ // Tests-first Phase 3: the tester role accepts optional extra
58
+ // inputs. Other roles ignore them harmlessly (they destructure
59
+ // only what they need from the input object).
60
+ output = await role.run({ task, diff, pendingGherkinTests, shellTestResults });
57
61
  } catch (err) {
58
62
  output = {
59
63
  ok: false,
@@ -93,7 +97,7 @@ async function runRoleWithFallback(RoleClass, { roleName, config, logger, emitte
93
97
  };
94
98
  }
95
99
 
96
- export async function runTesterStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, iteration, task, diff, askQuestion }) {
100
+ export async function runTesterStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, iteration, task, diff, askQuestion: _askQuestion, pendingGherkinTests = null, shellTestResults = null }) {
97
101
  logger.setContext({ iteration, stage: "tester" });
98
102
  emitProgress(
99
103
  emitter,
@@ -106,7 +110,15 @@ export async function runTesterStage({ config, logger, emitter, eventBase, sessi
106
110
  const testerStart = Date.now();
107
111
  const { output: testerOutput, provider, attempts } = await runRoleWithFallback(
108
112
  TesterRole,
109
- { roleName: "tester", config, logger, emitter, eventBase, task, iteration, diff }
113
+ {
114
+ roleName: "tester", config, logger, emitter, eventBase, task, iteration, diff,
115
+ // Tests-first Phase 3 (v2.7.5): hand the tester the HU's pending
116
+ // Gherkin scenarios and the upstream shell-test results so it
117
+ // translates and reports accurately instead of re-running the
118
+ // gate or ignoring the behavioural spec.
119
+ pendingGherkinTests,
120
+ shellTestResults,
121
+ }
110
122
  );
111
123
  const totalDuration = Date.now() - testerStart;
112
124
 
@@ -154,8 +166,23 @@ export async function runTesterStage({ config, logger, emitter, eventBase, sessi
154
166
  };
155
167
  }
156
168
 
157
- session.tester_retry_count = 0;
158
- return { action: "ok", stageResult: { ok: true, summary: testerOutput.summary || "All tests passed" } };
169
+ resetRetryCount(session, "tester");
170
+ // Propagate the full tester result (verdict, failing_scenarios,
171
+ // translated_scenarios, coverage…) — not just {ok, summary}. Callers
172
+ // in the HU sub-pipeline read `stageResult.verdict === "pass"` to
173
+ // decide approve-vs-feedback when Gherkin translation is involved;
174
+ // dropping the verdict here forced every Gherkin-bearing HU into the
175
+ // "fail" branch even when the tester explicitly returned pass, which
176
+ // burned all max_iterations and broke the FASE-2 test-diet run.
177
+ return {
178
+ action: "ok",
179
+ stageResult: {
180
+ ...testerOutput.result,
181
+ ok: true,
182
+ summary: testerOutput.summary || "All tests passed",
183
+ provider: testerProvider,
184
+ },
185
+ };
159
186
  }
160
187
 
161
188
  export async function runSecurityStage({ config, logger, emitter, eventBase, session, coderRole, trackBudget, iteration, task, diff, askQuestion, brainCtx }) {
@@ -263,7 +290,7 @@ export async function runSecurityStage({ config, logger, emitter, eventBase, ses
263
290
  };
264
291
  }
265
292
 
266
- session.security_retry_count = 0;
293
+ resetRetryCount(session, "security");
267
294
  return { action: "ok", stageResult: { ok: true, summary: securityOutput.summary || "No vulnerabilities found" } };
268
295
  }
269
296