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,199 @@
1
+ /**
2
+ * Plan reviewer — third pass after `kj plan` (PR D of the v2.7.5
3
+ * tests-first roadmap). Asks the planner LLM five concrete
4
+ * questions about the plan it just produced. Returns structured
5
+ * findings, NOT free-form prose.
6
+ *
7
+ * The questions are intentionally closed-ended:
8
+ *
9
+ * 1. **Missing HUs** — sections of the SPEC the plan doesn't cover.
10
+ * (Catches "the planner forgot module X".)
11
+ * 2. **Missing dependencies** — pairs of HUs that should have a
12
+ * `blocked_by` edge but don't.
13
+ * (Catches "HU 4 needs the schema HU 2 produces".)
14
+ * 3. **Scope overlap** — pairs of HUs that touch the same scope.
15
+ * (Catches "the planner split one piece of work into two HUs
16
+ * that step on each other".)
17
+ * 4. **Parallelisable** — sets of HUs that have no inter-deps and
18
+ * could run side-by-side.
19
+ * (Surface for the user, not auto-applied — `kj run --plan`
20
+ * runs serially today.)
21
+ * 5. **Order issues** — HUs whose `blocked_by` chain is wrong or
22
+ * circular.
23
+ *
24
+ * The output is purely advisory. The reviewer NEVER edits the plan.
25
+ * It reports findings; the user decides whether to apply them via
26
+ * the board's edit modal or another `kj plan` pass.
27
+ *
28
+ * Free-form "could we add logging?" suggestions are explicitly out
29
+ * of scope to avoid scope-creep noise — see the prompt rules.
30
+ */
31
+
32
+ import { extractFirstJson } from "../utils/json-extract.js";
33
+
34
+ /**
35
+ * Build the focused review prompt. Exported for unit-testing the
36
+ * shape without an LLM in the loop.
37
+ *
38
+ * @param {object} args
39
+ * @param {string} args.task
40
+ * @param {Array<object>} args.hus
41
+ * @returns {string}
42
+ */
43
+ export function buildReviewerPrompt({ task, hus }) {
44
+ const huSummary = hus.map((h, i) => {
45
+ const deps = Array.isArray(h.blocked_by) && h.blocked_by.length > 0
46
+ ? ` ← blocked_by: ${h.blocked_by.join(", ")}`
47
+ : "";
48
+ const spec = h.spec_section ? ` [§${h.spec_section}]` : "";
49
+ return `${i + 1}. ${h.id}${spec}: ${h.title || "(untitled)"}${deps}\n scope: ${h.scope || "(none)"}`;
50
+ }).join("\n\n");
51
+
52
+ return [
53
+ "You previously generated an implementation plan for the task below.",
54
+ "Your ONLY job in this call is to review it against five specific questions.",
55
+ "DO NOT propose new features, logging, monitoring, refactors, or anything",
56
+ "beyond what the SPEC explicitly requires. We're checking the plan for",
57
+ "completeness and correctness — NOT improving its scope.",
58
+ "",
59
+ "Output MUST be a JSON object with this exact shape:",
60
+ "",
61
+ "```json",
62
+ "{",
63
+ ' "missing_hus": [',
64
+ ' { "spec_section": "<section ref or short description>", "rationale": "<why it should exist>" }',
65
+ " ],",
66
+ ' "missing_dependencies": [',
67
+ ' { "from": "<huId that should depend on>", "on": "<huId>", "rationale": "<why>" }',
68
+ " ],",
69
+ ' "scope_overlaps": [',
70
+ ' { "between": ["<huId>", "<huId>"], "rationale": "<why they overlap>" }',
71
+ " ],",
72
+ ' "parallelisable_groups": [',
73
+ ' { "his": ["<huId>", "<huId>"], "rationale": "<why these can run side-by-side>" }',
74
+ " ],",
75
+ ' "order_issues": [',
76
+ ' { "hus": ["<huId>"], "issue": "<wrong order | circular | other>", "rationale": "<why>" }',
77
+ " ],",
78
+ ' "summary": "<one sentence overall verdict>"',
79
+ "}",
80
+ "```",
81
+ "",
82
+ "Rules:",
83
+ "- Each array can be empty if there's nothing to flag for that question.",
84
+ "- Reference HUs by their EXACT id from the list below.",
85
+ "- Suggest at MOST 5 entries per array — only the most important ones.",
86
+ "- DO NOT propose tests / acceptance criteria — those are handled by other roles.",
87
+ "- DO NOT suggest renames / cosmetic changes — only structural issues.",
88
+ "- Return ONLY the JSON — no prose, no markdown fences around it.",
89
+ "",
90
+ "## Original Task",
91
+ task,
92
+ "",
93
+ "## Plan to review",
94
+ "",
95
+ huSummary,
96
+ "",
97
+ ].join("\n");
98
+ }
99
+
100
+ /**
101
+ * Run the focused review pass.
102
+ *
103
+ * Pure-function side: parses the LLM JSON and normalises the shape
104
+ * (missing arrays default to `[]`, summary defaults to `""`). Junk
105
+ * entries (missing required fields) are dropped silently.
106
+ *
107
+ * @param {object} args
108
+ * @param {object} args.agent
109
+ * @param {string} args.task
110
+ * @param {Array<object>} args.hus
111
+ * @param {Function} [args.onOutput]
112
+ * @param {number} [args.silenceTimeoutMs]
113
+ * @param {number} [args.timeoutMs]
114
+ * @returns {Promise<{ ok: boolean, findings?: object, error?: string }>}
115
+ */
116
+ export async function reviewPlan({ agent, task, hus, onOutput, silenceTimeoutMs, timeoutMs }) {
117
+ if (!Array.isArray(hus) || hus.length === 0) {
118
+ return { ok: true, findings: emptyFindings("Plan has no HUs to review.") };
119
+ }
120
+ const prompt = buildReviewerPrompt({ task, hus });
121
+ const runArgs = { prompt, role: "planner" };
122
+ if (onOutput) runArgs.onOutput = onOutput;
123
+ if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
124
+ if (timeoutMs) runArgs.timeoutMs = timeoutMs;
125
+
126
+ const result = await agent.runTask(runArgs);
127
+ if (!result.ok) {
128
+ return { ok: false, error: result.error || "reviewer agent failed" };
129
+ }
130
+ const parsed = extractFirstJson(result.output || "");
131
+ if (!parsed || typeof parsed !== "object") {
132
+ return { ok: false, error: "reviewer returned no JSON object" };
133
+ }
134
+ return { ok: true, findings: normaliseFindings(parsed) };
135
+ }
136
+
137
+ function emptyFindings(summary) {
138
+ return {
139
+ missing_hus: [],
140
+ missing_dependencies: [],
141
+ scope_overlaps: [],
142
+ parallelisable_groups: [],
143
+ order_issues: [],
144
+ summary: summary || "",
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Coerce the raw LLM payload into the canonical shape. Each list is
150
+ * filtered to keep only entries with the required fields populated.
151
+ * @param {object} raw
152
+ */
153
+ function normaliseFindings(raw) {
154
+ const f = emptyFindings(typeof raw.summary === "string" ? raw.summary : "");
155
+ if (Array.isArray(raw.missing_hus)) {
156
+ f.missing_hus = raw.missing_hus
157
+ .filter((m) => m && (m.spec_section || m.section || m.title) && m.rationale)
158
+ .map((m) => ({
159
+ spec_section: String(m.spec_section || m.section || m.title),
160
+ rationale: String(m.rationale),
161
+ }));
162
+ }
163
+ if (Array.isArray(raw.missing_dependencies)) {
164
+ f.missing_dependencies = raw.missing_dependencies
165
+ .filter((d) => d && d.from && d.on && d.rationale)
166
+ .map((d) => ({ from: String(d.from), on: String(d.on), rationale: String(d.rationale) }));
167
+ }
168
+ if (Array.isArray(raw.scope_overlaps)) {
169
+ f.scope_overlaps = raw.scope_overlaps
170
+ .filter((o) => o && Array.isArray(o.between) && o.between.length >= 2 && o.rationale)
171
+ .map((o) => ({ between: o.between.map(String), rationale: String(o.rationale) }));
172
+ }
173
+ if (Array.isArray(raw.parallelisable_groups)) {
174
+ f.parallelisable_groups = raw.parallelisable_groups
175
+ .filter((g) => g && Array.isArray(g.hus) && g.hus.length >= 2)
176
+ .map((g) => ({ hus: g.hus.map(String), rationale: g.rationale ? String(g.rationale) : "" }));
177
+ }
178
+ if (Array.isArray(raw.order_issues)) {
179
+ f.order_issues = raw.order_issues
180
+ .filter((o) => o && Array.isArray(o.hus) && o.hus.length >= 1 && o.issue)
181
+ .map((o) => ({ hus: o.hus.map(String), issue: String(o.issue), rationale: o.rationale ? String(o.rationale) : "" }));
182
+ }
183
+ return f;
184
+ }
185
+
186
+ /**
187
+ * Flatten findings into a count of actionable items (for the CLI
188
+ * banner: "Reviewer found 3 issues.").
189
+ */
190
+ export function findingsCount(findings) {
191
+ if (!findings) return 0;
192
+ return (
193
+ (findings.missing_hus?.length || 0)
194
+ + (findings.missing_dependencies?.length || 0)
195
+ + (findings.scope_overlaps?.length || 0)
196
+ + (findings.order_issues?.length || 0)
197
+ // parallelisable_groups is informational, not an issue.
198
+ );
199
+ }
@@ -4,11 +4,58 @@
4
4
  * v2 = plans + executable HUs with acceptance tests
5
5
  */
6
6
 
7
- import { generatePlanId, generateHuId } from "./plan-id.js";
7
+ import { generatePlanId } from "./plan-id.js";
8
8
 
9
9
  const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
10
10
  const VALID_HU_STATUSES = new Set(["pending", "certified", "coding", "reviewing", "done", "failed", "blocked"]);
11
11
  const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "nocode"]);
12
+ const VALID_TEST_TYPES = new Set(["shell", "gherkin"]);
13
+
14
+ /**
15
+ * Structured acceptance-test shape introduced in v2.7.5 (tests-first):
16
+ *
17
+ * { type: "shell", content: "<bash command>", file?: "<optional path>" }
18
+ * { type: "gherkin", content: "Given …\nWhen …\nThen …", file?: "<optional path>" }
19
+ *
20
+ * Legacy entries (plain strings) are accepted by validatePlan and get
21
+ * normalised to `{ type: "shell", content: <string> }` at read time by
22
+ * `normaliseAcceptanceTest`. New plans MUST emit the structured form.
23
+ *
24
+ * Why two types: shell tests are executable as-is (a bash command that
25
+ * exits 0 on pass); gherkin tests are human-readable specs that the
26
+ * tester role translates into real code tests during `kj run` — they
27
+ * are the contract the coder is expected to satisfy.
28
+ *
29
+ * @param {unknown} entry
30
+ * @returns {{ type: string, content: string, file?: string } | null}
31
+ */
32
+ export function normaliseAcceptanceTest(entry) {
33
+ if (entry == null) return null;
34
+ if (typeof entry === "string") {
35
+ const trimmed = entry.trim();
36
+ if (!trimmed) return null;
37
+ return { type: "shell", content: trimmed };
38
+ }
39
+ if (typeof entry === "object" && typeof entry.content === "string") {
40
+ const content = entry.content.trim();
41
+ if (!content) return null;
42
+ const type = VALID_TEST_TYPES.has(entry.type) ? entry.type : "shell";
43
+ const out = { type, content };
44
+ if (typeof entry.file === "string" && entry.file.trim()) out.file = entry.file.trim();
45
+ return out;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ /**
51
+ * Normalise an array of raw acceptance_tests, dropping empties.
52
+ * @param {unknown[]} list
53
+ * @returns {{ type: string, content: string, file?: string }[]}
54
+ */
55
+ export function normaliseAcceptanceTests(list) {
56
+ if (!Array.isArray(list)) return [];
57
+ return list.map(normaliseAcceptanceTest).filter(Boolean);
58
+ }
12
59
 
13
60
  /**
14
61
  * Check if a plan uses v2 schema (has version field + hus array).
@@ -98,6 +145,27 @@ export function validatePlan(plan) {
98
145
  if (!hu.title) errors.push(`HU ${hu.id}: missing title`);
99
146
  if (!VALID_HU_STATUSES.has(hu.status)) errors.push(`HU ${hu.id}: invalid status ${hu.status}`);
100
147
  if (hu.task_type && !VALID_TASK_TYPES.has(hu.task_type)) errors.push(`HU ${hu.id}: invalid task_type ${hu.task_type}`);
148
+ // acceptance_tests: may be absent (legacy) or an array of either
149
+ // plain strings (legacy shell) or { type, content, file? } objects.
150
+ if (hu.acceptance_tests !== undefined) {
151
+ if (!Array.isArray(hu.acceptance_tests)) {
152
+ errors.push(`HU ${hu.id}: acceptance_tests must be an array`);
153
+ } else {
154
+ hu.acceptance_tests.forEach((entry, idx) => {
155
+ if (typeof entry === "string") return; // legacy form — OK
156
+ if (!entry || typeof entry !== "object") {
157
+ errors.push(`HU ${hu.id}: acceptance_tests[${idx}] must be a string or { type, content } object`);
158
+ return;
159
+ }
160
+ if (entry.type && !VALID_TEST_TYPES.has(entry.type)) {
161
+ errors.push(`HU ${hu.id}: acceptance_tests[${idx}].type must be one of ${[...VALID_TEST_TYPES].join("|")}`);
162
+ }
163
+ if (typeof entry.content !== "string" || !entry.content.trim()) {
164
+ errors.push(`HU ${hu.id}: acceptance_tests[${idx}].content must be a non-empty string`);
165
+ }
166
+ });
167
+ }
168
+ }
101
169
  // Check blocked_by references exist
102
170
  for (const dep of hu.blocked_by || []) {
103
171
  if (!huIds.has(dep) && !(plan.hus || []).some(h => h.id === dep)) {
@@ -10,11 +10,43 @@ import os from "node:os";
10
10
  import { generatePlanId } from "./plan-id.js";
11
11
  import { isPlanV2, migratePlanV1toV2 } from "./plan-schema.js";
12
12
 
13
- function getKjHome() {
14
- return process.env.KJ_HOME || path.join(os.homedir(), ".kj");
13
+ // Per-process tmp dir for VITEST runs that forget to set KJ_HOME. We
14
+ // memoise across calls so every helper in the same test process sees
15
+ // the same root (mirrors what setting KJ_HOME explicitly would do)
16
+ // without racing. The trailing `.kj` segment keeps semantic parity
17
+ // with non-VITEST defaults so existing path assertions don't break.
18
+ let _vitestKjHome = null;
19
+ function vitestTmpKjHome() {
20
+ if (_vitestKjHome) return _vitestKjHome;
21
+ _vitestKjHome = path.join(
22
+ os.tmpdir(),
23
+ `kj-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
24
+ ".kj"
25
+ );
26
+ return _vitestKjHome;
15
27
  }
16
28
 
17
- function projectSlug(projectDir) {
29
+ export function getKjHome() {
30
+ if (process.env.KJ_HOME) return process.env.KJ_HOME;
31
+ // VITEST guard — without this, a test that calls savePlan() drops
32
+ // fixture files into the developer's real `~/.kj/plans/` and the
33
+ // running HU Board's chokidar watcher ingests them as "real" plans
34
+ // (observed: 49 fake "Add auth" plans contaminating the user's
35
+ // board). Auto-isolate to a per-process tmp dir so accidental
36
+ // writes can't leak. Tests that NEED to assert against a known
37
+ // path should still set KJ_HOME explicitly.
38
+ if (process.env.VITEST) return vitestTmpKjHome();
39
+ return path.join(os.homedir(), ".kj");
40
+ }
41
+
42
+ /**
43
+ * Deterministic slug for a project path — stable across plan-store writes,
44
+ * HU-board syncs and board URL routing. Keep in lockstep with
45
+ * `packages/hu-board/src/sync.js::deriveProjectIdFromDir`.
46
+ * @param {string} projectDir - absolute project path
47
+ * @returns {string}
48
+ */
49
+ export function projectSlug(projectDir) {
18
50
  return projectDir
19
51
  .replace(/^\//, "")
20
52
  .replace(/[/\\]/g, "_")
@@ -40,6 +72,11 @@ export async function savePlan(projectDir, planResult) {
40
72
  if (isPlanV2(planResult)) {
41
73
  const filePath = path.join(dir, `${planResult.planId}.json`);
42
74
  planResult.updatedAt = new Date().toISOString();
75
+ // TSK-0341 / garbage-collector: stamp the absolute projectDir on every
76
+ // plan so the GC can check `fs.exists(projectDir)` reliably. Reverse-
77
+ // engineering it from the slug is lossy (underscores in the original
78
+ // name collide with underscores introduced by slash-substitution).
79
+ if (!planResult.projectDir && projectDir) planResult.projectDir = projectDir;
43
80
  await fs.writeFile(filePath, JSON.stringify(planResult, null, 2), "utf8");
44
81
  return planResult.planId;
45
82
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Detect numbered/sectioned headings in a task description so the
3
+ * planner prompt can demand `spec_section` per HU instead of leaving
4
+ * it as a soft "preferred" hint that LLMs ignore.
5
+ *
6
+ * Recognises three common shapes (kept intentionally narrow — false
7
+ * positives turn the planner prompt into noise):
8
+ *
9
+ * ## 1. Overview → section "1", title "Overview"
10
+ * ### 2.1 add → section "2.1", title "add"
11
+ * ## §5 Initial Scope → section "5", title "Initial Scope"
12
+ *
13
+ * The matcher is anchored to markdown heading lines (`^#+`) so prose
14
+ * mentions of a number ("see version 4.2") never trigger detection.
15
+ *
16
+ * Returns `{ hasSpec, sections, sectionList }`:
17
+ * - `hasSpec` boolean — true when at least one heading matched.
18
+ * Prompt builders use this to switch language from
19
+ * "preferred" to "MUST".
20
+ * - `sections` string[] — bare section refs, dedup, in document
21
+ * order. e.g. ["1", "2", "2.1", "2.2", "3"].
22
+ * - `sectionList` Array<{section, title}> — full list with titles
23
+ * so the prompt can echo the checklist back at the
24
+ * LLM. Keeps order, dedup by section ref.
25
+ *
26
+ * @param {string} text
27
+ * @returns {{ hasSpec: boolean, sections: string[], sectionList: Array<{section: string, title: string}> }}
28
+ */
29
+ export function detectSpecSections(text) {
30
+ const empty = { hasSpec: false, sections: [], sectionList: [] };
31
+ if (!text || typeof text !== "string") return empty;
32
+
33
+ // Anchored to heading lines. Two regex shapes — numbered sections
34
+ // and §-prefixed sections — are both common in the wild.
35
+ //
36
+ // ^#{1,6}\s* ← markdown heading marker (any depth)
37
+ // §?\s* ← optional "§"
38
+ // (\d+(?:\.\d+){0,4}) ← section number, up to 5 levels deep
39
+ // [.)]?\s+ ← optional trailing "." or ")", then space
40
+ // (.+?)\s*$ ← title (lazy, trim trailing whitespace)
41
+ const headingRe = /^#{1,6}\s*§?\s*(\d+(?:\.\d+){0,4})[.)]?\s+(.+?)\s*$/;
42
+
43
+ const seen = new Set();
44
+ const sectionList = [];
45
+ for (const rawLine of text.split(/\r?\n/)) {
46
+ const line = rawLine.trimEnd();
47
+ const m = headingRe.exec(line);
48
+ if (!m) continue;
49
+ const section = m[1];
50
+ if (seen.has(section)) continue;
51
+ seen.add(section);
52
+ sectionList.push({ section, title: m[2].trim() });
53
+ }
54
+
55
+ if (sectionList.length === 0) return empty;
56
+ return {
57
+ hasSpec: true,
58
+ sections: sectionList.map((s) => s.section),
59
+ sectionList,
60
+ };
61
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Tests-first second pass for `kj plan` (v2.7.5).
3
+ *
4
+ * The first planner call asks for steps + acceptance_tests in one
5
+ * shot. Modern LLMs (Claude, Codex) reliably produce the steps but
6
+ * frequently skip the acceptance_tests sub-instruction when the
7
+ * surrounding spec is dense — a known weakness of long single-shot
8
+ * prompts. Pre-v2.7.5 we papered over this with a generic `npx
9
+ * vitest run` placeholder that wasn't actually a per-HU contract.
10
+ *
11
+ * This module runs a focused SECOND pass that asks the LLM ONLY to
12
+ * fill in tests for the HUs that came back without them. Because the
13
+ * prompt is shorter and has a single deliverable, the hit rate is
14
+ * effectively 100% on the HUs that slipped through the first time.
15
+ *
16
+ * Returns a map `{ huId → acceptance_tests[] }` so the caller can
17
+ * patch the plan in place. The patch always normalises through
18
+ * `plan-schema::normaliseAcceptanceTests`, so any malformed entries
19
+ * the LLM produces are silently dropped (better than corrupting the
20
+ * plan with junk).
21
+ */
22
+
23
+ import { extractFirstJson } from "../utils/json-extract.js";
24
+ import { normaliseAcceptanceTests } from "./plan-schema.js";
25
+
26
+ /**
27
+ * Build the focused-pass prompt asking ONLY for tests for the given
28
+ * HUs. Exported for unit-testing the prompt shape without an LLM.
29
+ *
30
+ * @param {object} args
31
+ * @param {string} args.task
32
+ * @param {Array<{ id: string, title?: string, scope?: string }>} args.husNeedingTests
33
+ * @returns {string}
34
+ */
35
+ export function buildSynthesizerPrompt({ task, husNeedingTests }) {
36
+ const lines = [
37
+ "You previously generated an implementation plan for the task below, but some HUs were left without acceptance_tests.",
38
+ "Your ONLY job in this call is to fill that gap.",
39
+ "",
40
+ "Output MUST be a JSON object with this exact shape:",
41
+ "",
42
+ "```json",
43
+ "{",
44
+ ' "tests": {',
45
+ ' "<huId>": [',
46
+ ' { "type": "gherkin", "content": "Given <ctx>\\nWhen <action>\\nThen <outcome>" },',
47
+ ' { "type": "shell", "content": "<bash command that must exit 0>" }',
48
+ " ]",
49
+ " }",
50
+ "}",
51
+ "```",
52
+ "",
53
+ "Rules:",
54
+ "- The keys of `tests` are the HU ids EXACTLY as listed below.",
55
+ "- 2-4 tests per HU. At least one happy-path and one edge case.",
56
+ "- gherkin = behaviour spec; shell = concrete bash command exiting 0 on pass.",
57
+ "- Tests must be specific to the HU. Do NOT use generic fallbacks like `npx vitest run`.",
58
+ "- Tests must be writable BEFORE the HU is implemented (they should fail until done).",
59
+ "- Return ONLY the JSON — no prose, no markdown fences around it.",
60
+ "",
61
+ "## Task",
62
+ task,
63
+ "",
64
+ "## HUs that need acceptance_tests",
65
+ "",
66
+ ];
67
+ for (const hu of husNeedingTests) {
68
+ lines.push(`### ${hu.id}: ${hu.title || ""}`);
69
+ lines.push(hu.scope ? `Scope: ${hu.scope}` : "(no scope)");
70
+ lines.push("");
71
+ }
72
+ return lines.join("\n");
73
+ }
74
+
75
+ /**
76
+ * Run the focused second pass. Pass an `agent` with a `runTask`
77
+ * method (Claude / Codex / Gemini wrappers all expose it).
78
+ *
79
+ * @param {object} args
80
+ * @param {object} args.agent
81
+ * @param {string} args.task
82
+ * @param {Array<{ id: string, title?: string, scope?: string }>} args.husNeedingTests
83
+ * @param {Function} [args.onOutput]
84
+ * @param {number} [args.silenceTimeoutMs]
85
+ * @param {number} [args.timeoutMs]
86
+ * @returns {Promise<{ ok: boolean, tests?: Record<string, object[]>, error?: string }>}
87
+ */
88
+ export async function synthesizeMissingTests({ agent, task, husNeedingTests, onOutput, silenceTimeoutMs, timeoutMs }) {
89
+ if (!Array.isArray(husNeedingTests) || husNeedingTests.length === 0) {
90
+ return { ok: true, tests: {} };
91
+ }
92
+ const prompt = buildSynthesizerPrompt({ task, husNeedingTests });
93
+ const runArgs = { prompt, role: "planner" };
94
+ if (onOutput) runArgs.onOutput = onOutput;
95
+ if (silenceTimeoutMs) runArgs.silenceTimeoutMs = silenceTimeoutMs;
96
+ if (timeoutMs) runArgs.timeoutMs = timeoutMs;
97
+
98
+ const result = await agent.runTask(runArgs);
99
+ if (!result.ok) {
100
+ return { ok: false, error: result.error || "synthesizer agent failed" };
101
+ }
102
+
103
+ const parsed = extractFirstJson(result.output || "");
104
+ const rawTests = parsed?.tests;
105
+ if (!rawTests || typeof rawTests !== "object") {
106
+ return { ok: false, error: "synthesizer returned no `tests` object" };
107
+ }
108
+
109
+ // Normalise per HU so junk entries don't slip into the plan.
110
+ const tests = {};
111
+ for (const [huId, list] of Object.entries(rawTests)) {
112
+ const cleaned = normaliseAcceptanceTests(list);
113
+ if (cleaned.length > 0) tests[huId] = cleaned;
114
+ }
115
+ return { ok: true, tests };
116
+ }
@@ -37,7 +37,7 @@ async function fetchWithTimeout(url, options = {}, timeoutMs = DEFAULT_TIMEOUT_M
37
37
  err.httpStatus = 408;
38
38
  throw err;
39
39
  }
40
- throw new Error(`Planning Game network error: ${error?.message || "unknown error"}`);
40
+ throw new Error(`Planning Game network error: ${error?.message || "unknown error"}`, { cause: error });
41
41
  } finally {
42
42
  clearTimeout(timeoutId);
43
43
  }
@@ -54,7 +54,7 @@ async function parseJsonResponse(response) {
54
54
  try {
55
55
  return await response.json();
56
56
  } catch (error) {
57
- throw new Error(`Planning Game invalid response: ${error?.message || "invalid JSON"}`);
57
+ throw new Error(`Planning Game invalid response: ${error?.message || "invalid JSON"}`, { cause: error });
58
58
  }
59
59
  }
60
60
 
@@ -65,18 +65,7 @@ export async function fetchCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT
65
65
  return data?.card || data;
66
66
  }
67
67
 
68
- async function getCard({ projectId, cardId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
69
- return fetchCard({ projectId, cardId, timeoutMs });
70
- }
71
-
72
- async function listCards({ projectId, timeoutMs = DEFAULT_TIMEOUT_MS }) {
73
- const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards`;
74
- const response = await fetchWithRetry(url, {}, timeoutMs);
75
- const data = await parseJsonResponse(response);
76
- return data?.cards || data;
77
- }
78
-
79
- export async function updateCard({ projectId, cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
68
+ export async function updateCard({ projectId, cardId: _cardId, firebaseId, updates, timeoutMs = DEFAULT_TIMEOUT_MS }) {
80
69
  const url = `${getApiUrl()}/projects/${encodeURIComponent(projectId)}/cards/${encodeURIComponent(firebaseId)}`;
81
70
  const response = await fetchWithRetry(
82
71
  url,
@@ -10,7 +10,7 @@ export async function createDecompositionSubtasks({
10
10
  client,
11
11
  projectId,
12
12
  parentCardId,
13
- parentFirebaseId,
13
+ parentFirebaseId: _parentFirebaseId,
14
14
  subtasks,
15
15
  epic,
16
16
  sprint,
@@ -12,7 +12,8 @@
12
12
  */
13
13
 
14
14
  import { emitProgress, makeEvent } from "../utils/events.js";
15
- import { addCheckpoint, saveSession } from "../session-store.js";
15
+ import { addCheckpoint } from "../session/store.js";
16
+ import { appendPgCommits } from "../session/mutators.js";
16
17
  import { getLang } from "../utils/messages.js";
17
18
 
18
19
  /**
@@ -28,7 +29,7 @@ import { getLang } from "../utils/messages.js";
28
29
  * @param {string|null} opts.pgProject - Planning Game project ID
29
30
  * @returns {{ pgCard: object|null }} The fetched PG card (or null)
30
31
  */
31
- export async function initPgAdapter({ session, config, logger, pgTaskId, pgProject }) {
32
+ export async function initPgAdapter({ session: _session, config, logger, pgTaskId, pgProject }) {
32
33
  if (!pgTaskId || !pgProject || config.planning_game?.enabled === false) {
33
34
  return { pgCard: null };
34
35
  }
@@ -183,13 +184,12 @@ export function buildHuStoriesFromPgCard(pgCard) {
183
184
  */
184
185
  export function accumulateCommit(session, commitInfo) {
185
186
  if (!session || !commitInfo?.hash) return;
186
- if (!session.pg_commits) session.pg_commits = [];
187
- session.pg_commits.push({
187
+ appendPgCommits(session, [{
188
188
  hash: commitInfo.hash,
189
189
  message: commitInfo.message || "",
190
190
  date: commitInfo.date || new Date().toISOString(),
191
191
  author: commitInfo.author || "Karajan"
192
- });
192
+ }]);
193
193
  }
194
194
 
195
195
  // --- Internal helpers ---
@@ -218,3 +218,34 @@ async function markPgCardInProgress({ pgTaskId, pgProject, config, logger }) {
218
218
  return null;
219
219
  }
220
220
  }
221
+
222
+ /**
223
+ * TSK-0339: integration adapter shape — the orchestrator consumes tracker
224
+ * hooks via this neutral interface, not via direct imports. `registerPgAdapter`
225
+ * is called once at bootstrap when `config.planning_game.enabled` is true,
226
+ * wiring these hooks into `src/orchestrator/integrations.js` under the
227
+ * "tracker" slot.
228
+ *
229
+ * Adding a new tracker (GitHub Projects, Jira, Linear, …) means creating
230
+ * a sibling `src/adapters/<name>/adapter.js` with the same hook surface,
231
+ * nothing else in the orchestrator changes.
232
+ */
233
+ export const planningGameAdapter = {
234
+ name: "planning-game",
235
+ async onSessionStart(opts) { return initPgAdapter(opts); },
236
+ onCommit(session, commit) { accumulateCommit(session, commit); },
237
+ async onSessionApproved(opts) { return markPgCardToValidate(opts); },
238
+ buildHuStoriesFromCard(card) { return buildHuStoriesFromPgCard(card); },
239
+ async handleDecomposition(opts) { return handlePgDecomposition(opts); },
240
+ async createAdrsFromTradeoffs({ tradeoffs, pgTaskId, pgProject, taskTitle }) {
241
+ // Lazy-loaded to avoid pulling createArchitectADRs into every import.
242
+ const { createArchitectADRs } = await import("./architect-adrs.js");
243
+ const pgClient = await import("./client.js");
244
+ return createArchitectADRs({ tradeoffs, pgTaskId, pgProject, taskTitle, mcpClient: pgClient });
245
+ },
246
+ };
247
+
248
+ export async function registerPgAdapter() {
249
+ const { registerIntegration } = await import("../orchestrator/integrations.js");
250
+ registerIntegration("tracker", planningGameAdapter);
251
+ }