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,240 @@
1
+ /**
2
+ * kj audit --agent-readiness — score a repo for AI-agent readability.
3
+ *
4
+ * Static, deterministic, NO LLM call. Inspired by addyosmani/agentic-seo.
5
+ * Catches the failure modes that make repos invisible to Cursor /
6
+ * Cline / Aider / Claude Code:
7
+ * - no llms.txt → agents have to crawl the README
8
+ * - oversized doc pages → agents truncate or drop them
9
+ * - JS-only docs → agents can't render
10
+ * - broken heading hierarchy → agents misinfer structure
11
+ * - no robots allowlist → some agents skip silently
12
+ *
13
+ * Returns a 0-100 score + per-check detail + top remediation hints.
14
+ * Pure data transformation: filesystem reads in, analysis out.
15
+ */
16
+
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ const PAGE_BYTE_LIMIT = 32 * 1024; // ≈ 8K tokens, the typical agent budget per fetch
21
+
22
+ /**
23
+ * Catalogue of checks. Each check has:
24
+ * - id — stable identifier
25
+ * - weight — points contributed when ok
26
+ * - run — (rootDir) => { ok, hint, details }
27
+ */
28
+ const CHECKS = [
29
+ {
30
+ id: "llms-txt-present",
31
+ weight: 25,
32
+ description: "llms.txt at repo root",
33
+ run(rootDir) {
34
+ const p = path.join(rootDir, "llms.txt");
35
+ const ok = fs.existsSync(p) && fs.statSync(p).isFile();
36
+ return ok
37
+ ? { ok: true, hint: null, details: { path: p } }
38
+ : { ok: false, hint: "Add llms.txt at repo root with a single-fetch index of docs + commands.", details: {} };
39
+ },
40
+ },
41
+ {
42
+ id: "llms-txt-valid",
43
+ weight: 10,
44
+ description: "llms.txt has at least one section heading and one link",
45
+ run(rootDir) {
46
+ const p = path.join(rootDir, "llms.txt");
47
+ if (!fs.existsSync(p)) return { ok: false, hint: "llms.txt missing — see llms-txt-present.", details: {} };
48
+ const text = safeRead(p);
49
+ const hasSection = /^##\s+/m.test(text);
50
+ const hasLink = /\[[^\]]+\]\([^)]+\)/.test(text);
51
+ const ok = hasSection && hasLink;
52
+ return ok
53
+ ? { ok: true, hint: null, details: { hasSection, hasLink } }
54
+ : { ok: false, hint: "Add `## Section` headings and `[label](path)` links to llms.txt.", details: { hasSection, hasLink } };
55
+ },
56
+ },
57
+ {
58
+ id: "robots-allowlist",
59
+ weight: 10,
60
+ description: "robots.txt explicitly Allows AI crawlers",
61
+ run(rootDir) {
62
+ // Check root or public/.
63
+ const candidates = ["robots.txt", "public/robots.txt", "static/robots.txt"];
64
+ const found = candidates.find((c) => fs.existsSync(path.join(rootDir, c)));
65
+ if (!found) return { ok: false, hint: "Add robots.txt with `User-agent: GPTBot|ClaudeBot|PerplexityBot\\nAllow: /` blocks.", details: {} };
66
+ const text = safeRead(path.join(rootDir, found));
67
+ const aiBots = ["GPTBot", "ClaudeBot", "PerplexityBot", "anthropic-ai", "Google-Extended"];
68
+ const allowed = aiBots.filter((bot) => new RegExp(`User-agent:\\s*${bot}[\\s\\S]*?Allow:\\s*/`, "i").test(text));
69
+ const ok = allowed.length > 0;
70
+ return ok
71
+ ? { ok: true, hint: null, details: { found, allowed } }
72
+ : { ok: false, hint: `robots.txt found at ${found} but no AI crawler explicitly Allowed. Add explicit User-agent: ClaudeBot blocks.`, details: { found, allowed } };
73
+ },
74
+ },
75
+ {
76
+ id: "page-token-budget",
77
+ weight: 15,
78
+ description: "every .md doc fits within ≈ 8K tokens (32 KB raw)",
79
+ run(rootDir) {
80
+ const docsDir = path.join(rootDir, "docs");
81
+ if (!fs.existsSync(docsDir)) {
82
+ // No docs/ at all is its own kind of agent-unfriendly, but
83
+ // not strictly a budget violation. Pass with a hint.
84
+ return { ok: true, hint: "No docs/ directory found. Consider adding one.", details: { docsDirPresent: false } };
85
+ }
86
+ const oversized = listMarkdownFiles(docsDir).filter((f) => fs.statSync(f).size > PAGE_BYTE_LIMIT);
87
+ if (oversized.length === 0) {
88
+ return { ok: true, hint: null, details: { oversizedCount: 0 } };
89
+ }
90
+ const sample = oversized.slice(0, 3).map((f) => `${path.relative(rootDir, f)} (${(fs.statSync(f).size / 1024).toFixed(1)} KB)`);
91
+ return {
92
+ ok: false,
93
+ hint: `Split or trim ${oversized.length} oversized doc(s): ${sample.join(", ")}.`,
94
+ details: { oversizedCount: oversized.length, sample },
95
+ };
96
+ },
97
+ },
98
+ {
99
+ id: "heading-hierarchy",
100
+ weight: 10,
101
+ description: "each .md has exactly one H1 and no skipped levels",
102
+ run(rootDir) {
103
+ const docsDir = path.join(rootDir, "docs");
104
+ if (!fs.existsSync(docsDir)) return { ok: true, hint: null, details: { docsDirPresent: false } };
105
+ const violations = [];
106
+ for (const f of listMarkdownFiles(docsDir)) {
107
+ const text = safeRead(f);
108
+ const h1Count = (text.match(/^#\s+/gm) || []).length;
109
+ if (h1Count !== 1) {
110
+ violations.push({ file: path.relative(rootDir, f), h1Count });
111
+ continue;
112
+ }
113
+ // Check for skipped levels (#### without preceding ###, etc.)
114
+ const headings = [...text.matchAll(/^(#{1,6})\s/gm)].map((m) => m[1].length);
115
+ let prev = 0;
116
+ for (const lvl of headings) {
117
+ if (prev > 0 && lvl > prev + 1) {
118
+ violations.push({ file: path.relative(rootDir, f), reason: `H${prev}→H${lvl} skip` });
119
+ break;
120
+ }
121
+ prev = lvl;
122
+ }
123
+ }
124
+ if (violations.length === 0) {
125
+ return { ok: true, hint: null, details: { violationsCount: 0 } };
126
+ }
127
+ const sample = violations.slice(0, 3);
128
+ return {
129
+ ok: false,
130
+ hint: `Fix heading hierarchy in ${violations.length} doc(s): ${sample.map((v) => v.file).join(", ")}.`,
131
+ details: { violationsCount: violations.length, sample },
132
+ };
133
+ },
134
+ },
135
+ {
136
+ id: "agents-readme",
137
+ weight: 10,
138
+ description: "docs/agents/README.md as agent entry point",
139
+ run(rootDir) {
140
+ const p = path.join(rootDir, "docs/agents/README.md");
141
+ const ok = fs.existsSync(p);
142
+ return ok
143
+ ? { ok: true, hint: null, details: { path: p } }
144
+ : { ok: false, hint: "Create docs/agents/README.md as the agent-facing index (links from llms.txt).", details: {} };
145
+ },
146
+ },
147
+ {
148
+ id: "skill-md-coverage",
149
+ weight: 20,
150
+ description: "at least one SKILL.md per declared CLI capability",
151
+ run(rootDir) {
152
+ const skillDir = path.join(rootDir, "docs/agents");
153
+ if (!fs.existsSync(skillDir)) return { ok: false, hint: "Create docs/agents/ with one SKILL.<command>.md per CLI capability.", details: {} };
154
+ const skillFiles = fs.readdirSync(skillDir).filter((f) => /^SKILL\..+\.md$/.test(f));
155
+ if (skillFiles.length === 0) {
156
+ return { ok: false, hint: "Add SKILL.<command>.md files documenting each CLI capability.", details: { skillFilesCount: 0 } };
157
+ }
158
+ // We don't enumerate the user's own CLI here (out of scope for a
159
+ // generic auditor). At least 1 SKILL.md is the minimum signal.
160
+ return { ok: true, hint: null, details: { skillFilesCount: skillFiles.length } };
161
+ },
162
+ },
163
+ ];
164
+
165
+ function safeRead(p) {
166
+ try { return fs.readFileSync(p, "utf8"); } catch { return ""; }
167
+ }
168
+
169
+ function listMarkdownFiles(dir, out = []) {
170
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
171
+ if (e.name.startsWith(".") || e.name === "node_modules") continue;
172
+ const p = path.join(dir, e.name);
173
+ if (e.isDirectory()) listMarkdownFiles(p, out);
174
+ else if (e.isFile() && e.name.endsWith(".md")) out.push(p);
175
+ }
176
+ return out;
177
+ }
178
+
179
+ /**
180
+ * Run every agent-readiness check against `rootDir`. Returns a
181
+ * report with score, per-check results, and ranked remediation hints.
182
+ *
183
+ * @param {string} rootDir — absolute path to the repo root to audit
184
+ * @returns {{ score: number, checks: object[], topFixes: object[] }}
185
+ */
186
+ export function runAgentReadiness(rootDir) {
187
+ const checks = [];
188
+ let earned = 0;
189
+ let possible = 0;
190
+ for (const c of CHECKS) {
191
+ possible += c.weight;
192
+ let result;
193
+ try {
194
+ result = c.run(rootDir);
195
+ } catch (err) {
196
+ result = { ok: false, hint: `Check threw: ${err.message}`, details: { error: err.message } };
197
+ }
198
+ if (result.ok) earned += c.weight;
199
+ checks.push({
200
+ id: c.id,
201
+ description: c.description,
202
+ weight: c.weight,
203
+ score: result.ok ? c.weight : 0,
204
+ ok: result.ok,
205
+ hint: result.hint,
206
+ details: result.details || {},
207
+ });
208
+ }
209
+ const score = possible === 0 ? 0 : Math.round((earned / possible) * 100);
210
+ const topFixes = checks
211
+ .filter((c) => !c.ok && c.hint)
212
+ .sort((a, b) => b.weight - a.weight)
213
+ .slice(0, 5)
214
+ .map((c, i) => ({
215
+ priority: i + 1,
216
+ checkId: c.id,
217
+ weightLost: c.weight,
218
+ action: c.hint,
219
+ }));
220
+ return { score, possible, earned, checks, topFixes };
221
+ }
222
+
223
+ /**
224
+ * Render the report in plain text for the human terminal.
225
+ */
226
+ export function formatAgentReadinessReport({ score, checks, topFixes }, rootDir) {
227
+ const lines = [`Agent Readiness — ${rootDir}: ${score}/100`, ""];
228
+ for (const c of checks) {
229
+ const tick = c.ok ? "✓" : "✗";
230
+ const pts = `(+${c.score})`.padStart(6);
231
+ lines.push(` ${tick} ${c.description.padEnd(50)} ${pts}${c.hint ? " " + c.hint : ""}`);
232
+ }
233
+ if (topFixes.length > 0) {
234
+ lines.push("", "Top fixes:");
235
+ for (const f of topFixes) {
236
+ lines.push(` ${f.priority}. [${f.checkId}] ${f.action}`);
237
+ }
238
+ }
239
+ return lines.join("\n");
240
+ }
package/src/bootstrap.js CHANGED
@@ -36,7 +36,7 @@ function getPackageVersion() {
36
36
  // ── Individual checks ────────────────────────────────────────────────
37
37
 
38
38
  async function checkGitRepo() {
39
- let ok = false;
39
+ let ok;
40
40
  try {
41
41
  ok = await ensureGitRepo();
42
42
  } catch { /* git may not be installed */
@@ -219,6 +219,31 @@ function formatBootstrapFailure(failures) {
219
219
  * Throws Error with actionable message if any prerequisite fails.
220
220
  */
221
221
  export async function ensureBootstrap(projectDir, config) {
222
+ // Plugin discovery runs unconditionally on every bootstrap, regardless
223
+ // of whether the bootstrap cache says environment is valid. A user
224
+ // dropping a new file into `.karajan/plugins/` expects it to be picked
225
+ // up without running `kj init` again.
226
+ //
227
+ // Pre-v2.7.5 this was never called — `loadPlugins()` was exported but
228
+ // never imported from production code, so any plugin the user added
229
+ // was silently ignored. Fixed in TSK-0329.
230
+ try {
231
+ const { loadPlugins } = await import("./plugins/loader.js");
232
+ await loadPlugins({ projectDir });
233
+ } catch { /* plugin system failed — non-blocking, continue bootstrap */ }
234
+
235
+ // TSK-0339: register the Planning Game adapter in the integrations registry
236
+ // when enabled. The orchestrator no longer imports `src/planning-game/*`
237
+ // directly — it calls `getIntegration("tracker")?.hookName(...)` instead.
238
+ // Other trackers (GitHub Projects, Jira, Linear, …) drop in by writing
239
+ // a sibling adapter that calls `registerIntegration("tracker", …)`.
240
+ if (config?.planning_game?.enabled) {
241
+ try {
242
+ const { registerPgAdapter } = await import("./planning-game/pipeline-adapter.js");
243
+ await registerPgAdapter();
244
+ } catch { /* PG registration failed — non-blocking, continue bootstrap */ }
245
+ }
246
+
222
247
  const cached = await readBootstrapFile(projectDir);
223
248
  if (isBootstrapValid(cached, projectDir)) {
224
249
  return; // Environment already validated
@@ -11,6 +11,8 @@
11
11
  * the session for human input.
12
12
  */
13
13
 
14
+ import { setBrainDecisions } from "../session/mutators.js";
15
+
14
16
  export const DEFAULT_MAX_DECISIONS = 20;
15
17
  export const INDECISION_REPEAT_THRESHOLD = 3; // 3 identical classifications in a row
16
18
 
@@ -42,7 +44,7 @@ export const INDECISION_REPEAT_THRESHOLD = 3; // 3 identical classifications in
42
44
  */
43
45
  export function createTracker(session, { max = DEFAULT_MAX_DECISIONS } = {}) {
44
46
  if (!Array.isArray(session.brainDecisions)) {
45
- session.brainDecisions = [];
47
+ setBrainDecisions(session, []);
46
48
  }
47
49
  return { session, max };
48
50
  }
@@ -125,7 +125,7 @@ function applyOverrides(roles, overrides) {
125
125
  * @param {{ forceRoles?: string[], skipRoles?: string[] }} [args.overrides]
126
126
  * @returns {Decision}
127
127
  */
128
- export function buildDecision({ triage, task, config = {}, overrides = {} } = {}) {
128
+ export function buildDecision({ triage, task: _task, config = {}, overrides = {} } = {}) {
129
129
  const level = triage?.level || "simple";
130
130
  const taskType = triage?.taskType || "sw";
131
131
 
@@ -0,0 +1,238 @@
1
+ /**
2
+ * REASONS Canvas markdown parser.
3
+ *
4
+ * Reads a `SPEC.canvas.md` file and returns a Canvas object that
5
+ * validates against canvas/schema.js.
6
+ *
7
+ * Format:
8
+ *
9
+ * # <title>
10
+ *
11
+ * ## REASONS:Requirements
12
+ * <problem statement, free text>
13
+ *
14
+ * Definition of done:
15
+ * - bullet 1
16
+ * - bullet 2
17
+ *
18
+ * ## REASONS:Entities
19
+ * - `User` — fields: id, email. has-many Order.
20
+ * - `Order` — ...
21
+ *
22
+ * ## REASONS:Approach
23
+ * <free text>
24
+ *
25
+ * ## REASONS:Structure
26
+ * - touches: src/auth/*, src/orders/*
27
+ * - depends_on: TokenService
28
+ * - contracts: POST /orders, GET /orders/:id
29
+ *
30
+ * ## REASONS:Operations
31
+ * 1. <title>
32
+ * Acceptance:
33
+ * - shell: `cmd here`
34
+ * - gherkin: ```Given … When … Then …```
35
+ * 2. ...
36
+ *
37
+ * ## REASONS:Norms
38
+ * - bullet
39
+ *
40
+ * ## REASONS:Safeguards
41
+ * - bullet
42
+ *
43
+ * Permissive on whitespace and bullet style ("- ", "* ", "1. ").
44
+ * Strict on structure: a missing required section ⇒ exception with a
45
+ * pointer to which section is missing.
46
+ */
47
+
48
+ const SECTION_HEADER_RE = /^##\s+REASONS:(\w+)\s*$/i;
49
+ const TITLE_RE = /^#\s+(.+?)\s*$/;
50
+ const BULLET_RE = /^\s*(?:[-*]|\d+\.)\s+(.+?)\s*$/;
51
+ const ACCEPTANCE_HEADER_RE = /^\s*Acceptance(?:\s+tests?)?\s*:\s*$/i;
52
+ const TEST_LINE_RE = /^\s*(?:[-*])\s+(shell|gherkin)\s*:\s*(.+?)\s*$/i;
53
+
54
+ /**
55
+ * Parse a SPEC.canvas.md string into a Canvas object.
56
+ * Throws { name: "CanvasParseError", message } on missing required sections.
57
+ *
58
+ * @param {string} source — markdown text
59
+ * @returns {object} Canvas object (un-validated against schema; caller validates)
60
+ */
61
+ export function parseCanvasMarkdown(source) {
62
+ if (typeof source !== "string" || source.length === 0) {
63
+ throw makeError("Canvas markdown is empty or not a string.");
64
+ }
65
+
66
+ const lines = source.split(/\r?\n/);
67
+ const sections = {};
68
+ let title = null;
69
+ let currentName = null;
70
+ let currentBuf = [];
71
+
72
+ // First pass: split into sections.
73
+ for (const line of lines) {
74
+ const titleMatch = line.match(TITLE_RE);
75
+ if (titleMatch && currentName === null && title === null) {
76
+ title = titleMatch[1].trim();
77
+ continue;
78
+ }
79
+ const sectionMatch = line.match(SECTION_HEADER_RE);
80
+ if (sectionMatch) {
81
+ // flush previous
82
+ if (currentName) {
83
+ sections[currentName] = currentBuf.join("\n");
84
+ }
85
+ currentName = sectionMatch[1].toLowerCase();
86
+ currentBuf = [];
87
+ continue;
88
+ }
89
+ if (currentName) currentBuf.push(line);
90
+ }
91
+ if (currentName) sections[currentName] = currentBuf.join("\n");
92
+
93
+ if (!title) throw makeError("Missing title — first line must be `# <title>`.");
94
+
95
+ // Required sections — must be PRESENT (header exists), but content
96
+ // may be empty for sections like Structure where every key is
97
+ // optional. Use `in` so an empty-body section is accepted.
98
+ for (const required of ["requirements", "approach", "structure", "operations"]) {
99
+ if (!(required in sections)) {
100
+ throw makeError(`Missing required section: ## REASONS:${capitalize(required)}`);
101
+ }
102
+ }
103
+
104
+ return {
105
+ title,
106
+ requirements: parseRequirements(sections.requirements),
107
+ entities: sections.entities ? parseEntities(sections.entities) : [],
108
+ approach: sections.approach.trim(),
109
+ structure: parseStructure(sections.structure),
110
+ operations: parseOperations(sections.operations),
111
+ norms: sections.norms ? parseBullets(sections.norms) : [],
112
+ safeguards: sections.safeguards ? parseBullets(sections.safeguards) : [],
113
+ };
114
+ }
115
+
116
+ function capitalize(s) {
117
+ return s.length > 0 ? s[0].toUpperCase() + s.slice(1) : s;
118
+ }
119
+
120
+ function makeError(msg) {
121
+ const e = new Error(msg);
122
+ e.name = "CanvasParseError";
123
+ return e;
124
+ }
125
+
126
+ /**
127
+ * Requirements: a paragraph (problem) + optional "Definition of done:" bullets.
128
+ */
129
+ function parseRequirements(text) {
130
+ const lines = text.split("\n");
131
+ const problemLines = [];
132
+ const done = [];
133
+ let inDone = false;
134
+ for (const line of lines) {
135
+ if (/^\s*Definition of done:?\s*$/i.test(line)) { inDone = true; continue; }
136
+ if (inDone) {
137
+ const m = line.match(BULLET_RE);
138
+ if (m) done.push(m[1].trim());
139
+ continue;
140
+ }
141
+ if (line.trim()) problemLines.push(line.trim());
142
+ }
143
+ const problem = problemLines.join(" ").trim();
144
+ if (!problem) throw makeError("REASONS:Requirements is empty or has no problem statement.");
145
+ return { problem, done };
146
+ }
147
+
148
+ /**
149
+ * Entities: each bullet is "Name — fields: f1, f2. notes." (loose).
150
+ * We parse the first word as the entity name and capture the rest as notes.
151
+ */
152
+ function parseEntities(text) {
153
+ const out = [];
154
+ for (const raw of text.split("\n")) {
155
+ const m = raw.match(BULLET_RE);
156
+ if (!m) continue;
157
+ const body = m[1].trim();
158
+ // First token (possibly `quoted`) is the name; rest is notes.
159
+ const nameMatch = body.match(/^[`"']?([A-Za-z][\w-]*)[`"']?\s*[—-]?\s*(.*)$/);
160
+ if (!nameMatch) continue;
161
+ const [, name, rest] = nameMatch;
162
+ const fields = [];
163
+ const fm = rest.match(/fields?:\s*([^.]+)/i);
164
+ if (fm) fields.push(...fm[1].split(",").map((s) => s.trim()).filter(Boolean));
165
+ out.push({ name, fields, notes: rest });
166
+ }
167
+ return out;
168
+ }
169
+
170
+ function parseStructure(text) {
171
+ const out = { touches: [], depends_on: [], contracts: [] };
172
+ for (const raw of text.split("\n")) {
173
+ const m = raw.match(BULLET_RE);
174
+ if (!m) continue;
175
+ const body = m[1].trim();
176
+ const kvMatch = body.match(/^(touches|depends_on|contracts)\s*:\s*(.+)$/i);
177
+ if (!kvMatch) continue;
178
+ const key = kvMatch[1].toLowerCase();
179
+ const values = kvMatch[2].split(",").map((s) => s.trim()).filter(Boolean);
180
+ out[key] = values;
181
+ }
182
+ return out;
183
+ }
184
+
185
+ function parseBullets(text) {
186
+ const out = [];
187
+ for (const raw of text.split("\n")) {
188
+ const m = raw.match(BULLET_RE);
189
+ if (m) out.push(m[1].trim());
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Operations: each top-level numbered/bulleted entry is one Operation.
196
+ * Inside, an "Acceptance:" sub-line introduces nested bullets where each
197
+ * bullet is "shell: <cmd>" or "gherkin: <scenario>".
198
+ */
199
+ function parseOperations(text) {
200
+ const ops = [];
201
+ const lines = text.split("\n");
202
+ let current = null;
203
+ let inAcceptance = false;
204
+
205
+ for (const line of lines) {
206
+ if (!line.trim()) continue;
207
+ // Top-level operation: numbered list or single dash at left margin.
208
+ const topMatch = line.match(/^(\d+\.\s+|[-*]\s+)(.+)$/);
209
+ if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
210
+ if (current) ops.push(current);
211
+ current = { title: topMatch[2].trim(), acceptance: [] };
212
+ inAcceptance = false;
213
+ continue;
214
+ }
215
+ if (current) {
216
+ if (ACCEPTANCE_HEADER_RE.test(line)) { inAcceptance = true; continue; }
217
+ const testMatch = line.match(TEST_LINE_RE);
218
+ if (testMatch && inAcceptance) {
219
+ current.acceptance.push({
220
+ type: testMatch[1].toLowerCase(),
221
+ content: testMatch[2].replace(/^`+|`+$/g, "").trim(),
222
+ });
223
+ }
224
+ }
225
+ }
226
+ if (current) ops.push(current);
227
+
228
+ if (ops.length === 0) {
229
+ throw makeError("REASONS:Operations has no operations. Each operation must be a numbered or bulleted item with at least one acceptance test.");
230
+ }
231
+ for (const op of ops) {
232
+ if (op.acceptance.length === 0) {
233
+ throw makeError(`Operation "${op.title}" has no acceptance tests. Add at least one ` +
234
+ '`- shell: <cmd>` or `- gherkin: <scenario>` under an "Acceptance:" line.');
235
+ }
236
+ }
237
+ return ops;
238
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * REASONS Canvas — Valibot schema for the structured SPEC format.
3
+ *
4
+ * Issue #539. Per Martin Fowler's Structured Prompt-Driven Development
5
+ * (https://martinfowler.com/articles/structured-prompt-driven/), a
6
+ * Canvas captures intent in 7 named sections so the planner doesn't
7
+ * have to improvise. The shape:
8
+ *
9
+ * Requirements — problem + done-when bullets
10
+ * Entities — domain nouns + relationships
11
+ * Approach — strategy + trade-offs
12
+ * Structure — system fit, dependencies, contracts
13
+ * Operations — concrete numbered steps. Each = one HU.
14
+ * Norms — task-level engineering standards
15
+ * Safeguards — non-negotiables: invariants, perf, security
16
+ *
17
+ * Operations carry their acceptance tests inline so the planner-time
18
+ * validator (validate.js) can check them parse BEFORE any LLM call.
19
+ * That's the bug-class fix this whole feature exists for: the
20
+ * 2026-04-29 incident where the planner emitted a syntactically-broken
21
+ * jq query and the coder iterated 3× against an irreparable test.
22
+ */
23
+
24
+ import * as v from "valibot";
25
+
26
+ const NonEmptyString = v.pipe(v.string(), v.minLength(1));
27
+
28
+ const Entity = v.looseObject({
29
+ name: NonEmptyString,
30
+ fields: v.optional(v.array(v.string())),
31
+ relationships: v.optional(v.array(v.unknown())),
32
+ notes: v.optional(v.string()),
33
+ });
34
+
35
+ const AcceptanceTest = v.looseObject({
36
+ type: v.picklist(["shell", "gherkin"]),
37
+ content: NonEmptyString,
38
+ // optional: file hint for tester translation of gherkin
39
+ file: v.optional(v.string()),
40
+ });
41
+
42
+ const Operation = v.looseObject({
43
+ // Either a free-form title or numbered step text.
44
+ title: NonEmptyString,
45
+ // Optional: the file or module the operation produces / touches.
46
+ file_hint: v.optional(v.string()),
47
+ // Optional: dependencies on other operation titles or HU ids.
48
+ blocked_by: v.optional(v.array(v.string())),
49
+ // Required: at least ONE acceptance test per operation. Empty array
50
+ // is rejected at plan-time so we never ship an HU without a contract.
51
+ acceptance: v.pipe(v.array(AcceptanceTest), v.minLength(1)),
52
+ });
53
+
54
+ export const CanvasSchema = v.looseObject({
55
+ // Title is the project / feature name. The planner uses it as the
56
+ // plan name unless the user overrides on prompt.
57
+ title: NonEmptyString,
58
+ requirements: v.looseObject({
59
+ problem: NonEmptyString,
60
+ // Definition-of-done bullets. Empty array allowed — sometimes the
61
+ // problem statement IS the contract — but a non-empty array
62
+ // discourages woolly specs.
63
+ done: v.optional(v.array(NonEmptyString)),
64
+ }),
65
+ entities: v.optional(v.array(Entity)),
66
+ approach: NonEmptyString,
67
+ structure: v.looseObject({
68
+ touches: v.optional(v.array(v.string())),
69
+ depends_on: v.optional(v.array(v.string())),
70
+ contracts: v.optional(v.array(v.string())),
71
+ }),
72
+ operations: v.pipe(v.array(Operation), v.minLength(1)),
73
+ norms: v.optional(v.array(NonEmptyString)),
74
+ safeguards: v.optional(v.array(NonEmptyString)),
75
+ });
76
+
77
+ /**
78
+ * Validate-only entry point. Throws an Error with the offending path
79
+ * + message when the canvas doesn't match the schema. The validator
80
+ * (validate.js) uses this AND adds parse-checks for acceptance tests.
81
+ *
82
+ * @param {unknown} canvas
83
+ * @returns {import("valibot").InferOutput<typeof CanvasSchema>}
84
+ */
85
+ export function parseCanvas(canvas) {
86
+ return v.parse(CanvasSchema, canvas);
87
+ }
88
+
89
+ /**
90
+ * Soft check: returns the validation result without throwing. Useful
91
+ * for surfacing every schema error at once instead of bailing on the
92
+ * first.
93
+ *
94
+ * @param {unknown} canvas
95
+ * @returns {{ success: true, output: object } | { success: false, issues: object[] }}
96
+ */
97
+ export function safeParseCanvas(canvas) {
98
+ const r = v.safeParse(CanvasSchema, canvas);
99
+ if (r.success) return { success: true, output: r.output };
100
+ return { success: false, issues: r.issues };
101
+ }