karajan-code 2.4.1 → 2.6.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 (115) hide show
  1. package/README.md +20 -4
  2. package/package.json +5 -2
  3. package/src/agents/aider-agent.js +1 -2
  4. package/src/agents/base-agent.js +63 -1
  5. package/src/agents/claude-agent.js +3 -4
  6. package/src/agents/codex-agent.js +1 -2
  7. package/src/agents/gemini-agent.js +1 -2
  8. package/src/agents/index.js +11 -2
  9. package/src/agents/opencode-agent.js +1 -2
  10. package/src/brain/decision-tracker.js +123 -0
  11. package/src/brain/decisor.js +243 -0
  12. package/src/brain/solomon-consult.js +163 -0
  13. package/src/budget/comparison.js +96 -0
  14. package/src/checks/binaries.js +123 -0
  15. package/src/checks/ci.js +105 -0
  16. package/src/checks/config-files.js +217 -0
  17. package/src/checks/dir-setup.js +63 -0
  18. package/src/checks/mcp-health.js +157 -0
  19. package/src/checks/node.js +61 -0
  20. package/src/checks/ports.js +110 -0
  21. package/src/checks/rtk.js +36 -0
  22. package/src/checks/runner.js +267 -0
  23. package/src/checks/skills.js +49 -0
  24. package/src/checks/sonar.js +67 -0
  25. package/src/checks/system.js +53 -0
  26. package/src/checks/tokens.js +96 -0
  27. package/src/checks/types.js +105 -0
  28. package/src/cli.js +90 -7
  29. package/src/commands/board.js +23 -0
  30. package/src/commands/doctor.js +130 -362
  31. package/src/commands/plan.js +221 -37
  32. package/src/commands/resume.js +1 -1
  33. package/src/commands/run.js +2 -1
  34. package/src/commands/skills.js +49 -0
  35. package/src/config/schema.js +222 -0
  36. package/src/config.js +32 -3
  37. package/src/guards/policy-resolver.js +8 -0
  38. package/src/hu/store.js +9 -0
  39. package/src/infrastructure/command-runner.js +44 -0
  40. package/src/infrastructure/environment.js +38 -0
  41. package/src/infrastructure/file-system-service.js +63 -0
  42. package/src/infrastructure/mocks.js +182 -0
  43. package/src/mcp/handlers/management-handlers.js +7 -2
  44. package/src/mcp/tools.js +10 -3
  45. package/src/orchestrator/config-init.js +16 -1
  46. package/src/orchestrator/flow-runner.js +2085 -0
  47. package/src/orchestrator/pipeline-context.js +17 -0
  48. package/src/orchestrator/preflight-checks.js +78 -4
  49. package/src/orchestrator/solomon-escalation.js +21 -0
  50. package/src/orchestrator/stages/stage-executor.js +155 -0
  51. package/src/orchestrator.js +19 -1884
  52. package/src/plan/plan-executor.js +69 -0
  53. package/src/plan/plan-hu-ops.js +134 -0
  54. package/src/plan/plan-id.js +25 -0
  55. package/src/plan/plan-schema.js +139 -0
  56. package/src/plan/plan-store.js +58 -25
  57. package/src/prompts/architect.js +2 -2
  58. package/src/prompts/coder.js +2 -2
  59. package/src/prompts/prompt-resolver.js +127 -0
  60. package/src/prompts/reviewer.js +2 -2
  61. package/src/roles/architect-role.js +3 -1
  62. package/src/roles/base-role.js +23 -2
  63. package/src/roles/coder-role.js +3 -1
  64. package/src/roles/security-role.js +12 -0
  65. package/src/roles/tester-role.js +11 -0
  66. package/src/session/journal/decisions-writer.js +200 -0
  67. package/src/session/journal/iteration-logger.js +194 -0
  68. package/src/session/journal/summary-writer.js +176 -0
  69. package/src/session/journal/tree-writer.js +164 -0
  70. package/src/skills/semantic-detector.js +147 -0
  71. package/src/skills/skill-detector.js +186 -8
  72. package/src/skills/skill-loader.js +131 -1
  73. package/src/skills/skills-cache.js +128 -0
  74. package/src/types/agent.js +55 -0
  75. package/src/types/config.js +114 -0
  76. package/src/types/hu.js +47 -0
  77. package/src/types/index.js +34 -0
  78. package/src/types/policy.js +43 -0
  79. package/src/types/session.js +74 -0
  80. package/src/types/stage.js +68 -0
  81. package/src/utils/display/event-handlers.js +389 -0
  82. package/src/utils/display/formatters.js +132 -0
  83. package/src/utils/display/header.js +48 -0
  84. package/src/utils/display/session-summary.js +108 -0
  85. package/src/utils/display/solomon.js +42 -0
  86. package/src/utils/port-check.js +45 -0
  87. package/src/utils/port-occupant.js +95 -0
  88. package/src/utils/provider-env.js +116 -0
  89. package/src/utils/with-timeout.js +42 -0
  90. package/templates/roles/architect/anthropic.md +65 -0
  91. package/templates/roles/architect/default.md +63 -0
  92. package/templates/roles/architect/google.md +52 -0
  93. package/templates/roles/architect/openai.md +51 -0
  94. package/templates/roles/architect/opencode.md +50 -0
  95. package/templates/roles/coder/anthropic.md +72 -0
  96. package/templates/roles/coder/default.md +69 -0
  97. package/templates/roles/coder/google.md +78 -0
  98. package/templates/roles/coder/openai.md +59 -0
  99. package/templates/roles/coder/opencode.md +49 -0
  100. package/templates/roles/planner/anthropic.md +50 -0
  101. package/templates/roles/planner/default.md +48 -0
  102. package/templates/roles/planner/google.md +66 -0
  103. package/templates/roles/planner/openai.md +46 -0
  104. package/templates/roles/planner/opencode.md +53 -0
  105. package/templates/roles/reviewer/anthropic.md +61 -0
  106. package/templates/roles/reviewer/default.md +62 -0
  107. package/templates/roles/reviewer/google.md +63 -0
  108. package/templates/roles/reviewer/openai.md +57 -0
  109. package/templates/roles/reviewer/opencode.md +42 -0
  110. package/templates/roles/solomon/anthropic.md +71 -0
  111. package/templates/roles/solomon/default.md +83 -0
  112. package/templates/roles/solomon/google.md +61 -0
  113. package/templates/roles/solomon/openai.md +53 -0
  114. package/templates/roles/solomon/opencode.md +55 -0
  115. package/src/utils/display.js +0 -669
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Plan Executor — bridge between v2 plans and hu-sub-pipeline.
3
+ * Converts plan.hus into the format expected by runHuSubPipeline,
4
+ * and syncs execution results back to the plan file.
5
+ */
6
+
7
+ import { isPlanV2 } from "./plan-schema.js";
8
+ import { updateHuStatus } from "./plan-hu-ops.js";
9
+
10
+ /**
11
+ * Convert a v2 plan's HUs into the stageResults.huReviewer format
12
+ * expected by needsSubPipeline + runHuSubPipeline.
13
+ * @param {object} plan - v2 plan with plan.hus
14
+ * @returns {object} { ok, stories, total, certified, batchSessionId, planId }
15
+ */
16
+ export function planToHuBatch(plan) {
17
+ if (!isPlanV2(plan) || !plan.hus?.length) {
18
+ return { ok: false, stories: [], total: 0, certified: 0 };
19
+ }
20
+
21
+ const stories = plan.hus.map(hu => ({
22
+ id: hu.id,
23
+ title: hu.title,
24
+ task_type: hu.task_type || "sw",
25
+ status: hu.status === "certified" ? "certified" : hu.status,
26
+ blocked_by: hu.blocked_by || [],
27
+ certified: { text: hu.scope || hu.title },
28
+ acceptance_criteria: hu.acceptance_criteria || [],
29
+ acceptance_tests: hu.acceptance_tests || [],
30
+ original: { text: hu.scope || hu.title }
31
+ }));
32
+
33
+ const certified = stories.filter(s => s.status === "certified").length;
34
+
35
+ return {
36
+ ok: certified > 0,
37
+ stories,
38
+ total: stories.length,
39
+ certified,
40
+ batchSessionId: `plan-${plan.planId}`,
41
+ planId: plan.planId,
42
+ auto_generated: false,
43
+ source: { plan: true, planId: plan.planId }
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Sync execution results from hu-sub-pipeline back to the plan file.
49
+ * Updates each HU's status based on the sub-pipeline results.
50
+ * @param {object} plan - v2 plan (mutated in place)
51
+ * @param {object} subPipelineResult - { results: [{huId, approved}], blockedIds }
52
+ */
53
+ export function syncResultsToPlan(plan, subPipelineResult) {
54
+ if (!isPlanV2(plan)) return;
55
+
56
+ for (const r of subPipelineResult.results || []) {
57
+ const status = r.approved ? "done" : "failed";
58
+ updateHuStatus(plan, r.huId, status);
59
+ }
60
+
61
+ for (const blockedId of subPipelineResult.blockedIds || []) {
62
+ updateHuStatus(plan, blockedId, "blocked");
63
+ }
64
+
65
+ const allDone = plan.hus.every(h => h.status === "done");
66
+ const anyFailed = plan.hus.some(h => h.status === "failed");
67
+ plan.status = allDone ? "done" : anyFailed ? "failed" : "running";
68
+ plan.updatedAt = new Date().toISOString();
69
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * HU CRUD operations on a v2 plan.
3
+ * All functions mutate the plan in-place and return it.
4
+ */
5
+
6
+ import { generateHuId } from "./plan-id.js";
7
+
8
+ /**
9
+ * Get the next sequential HU number for this plan.
10
+ */
11
+ function nextSeq(plan) {
12
+ if (!plan.hus || plan.hus.length === 0) return 1;
13
+ const nums = plan.hus
14
+ .map(h => {
15
+ const m = h.id.match(/_(\d+)$/);
16
+ return m ? Number(m[1]) : 0;
17
+ })
18
+ .filter(n => n > 0);
19
+ return nums.length > 0 ? Math.max(...nums) + 1 : plan.hus.length + 1;
20
+ }
21
+
22
+ /**
23
+ * Add an HU to the plan. Auto-generates a globally unique ID.
24
+ * @param {object} plan - v2 plan
25
+ * @param {object} huData - { title, task_type?, scope?, acceptance_criteria?, acceptance_tests?, blocked_by? }
26
+ * @returns {object} the created HU (with id assigned)
27
+ */
28
+ export function addHu(plan, huData) {
29
+ const seq = nextSeq(plan);
30
+ const id = generateHuId(plan.planId, seq);
31
+ const hu = {
32
+ id,
33
+ title: huData.title,
34
+ task_type: huData.task_type || "sw",
35
+ status: "pending",
36
+ blocked_by: huData.blocked_by || [],
37
+ scope: huData.scope || null,
38
+ acceptance_criteria: huData.acceptance_criteria || [],
39
+ acceptance_tests: huData.acceptance_tests || [],
40
+ createdAt: new Date().toISOString(),
41
+ updatedAt: new Date().toISOString()
42
+ };
43
+ plan.hus.push(hu);
44
+ plan.updatedAt = new Date().toISOString();
45
+ return hu;
46
+ }
47
+
48
+ /**
49
+ * Remove an HU from the plan. Also cleans blocked_by references.
50
+ * @returns {boolean} true if removed
51
+ */
52
+ export function removeHu(plan, huId) {
53
+ const idx = plan.hus.findIndex(h => h.id === huId);
54
+ if (idx === -1) return false;
55
+ plan.hus.splice(idx, 1);
56
+ // Clean up blocked_by refs
57
+ for (const hu of plan.hus) {
58
+ hu.blocked_by = (hu.blocked_by || []).filter(dep => dep !== huId);
59
+ }
60
+ plan.updatedAt = new Date().toISOString();
61
+ return true;
62
+ }
63
+
64
+ /**
65
+ * Partial update of an HU.
66
+ * @param {object} plan
67
+ * @param {string} huId
68
+ * @param {object} patch - fields to update (title, scope, task_type, acceptance_criteria, acceptance_tests, blocked_by)
69
+ * @returns {object|null} updated HU or null if not found
70
+ */
71
+ export function updateHu(plan, huId, patch) {
72
+ const hu = plan.hus.find(h => h.id === huId);
73
+ if (!hu) return null;
74
+ const allowed = ["title", "task_type", "scope", "acceptance_criteria", "acceptance_tests", "blocked_by"];
75
+ for (const key of allowed) {
76
+ if (patch[key] !== undefined) hu[key] = patch[key];
77
+ }
78
+ hu.updatedAt = new Date().toISOString();
79
+ plan.updatedAt = new Date().toISOString();
80
+ return hu;
81
+ }
82
+
83
+ /**
84
+ * Update HU status.
85
+ * @returns {boolean} true if updated
86
+ */
87
+ export function updateHuStatus(plan, huId, status) {
88
+ const hu = plan.hus.find(h => h.id === huId);
89
+ if (!hu) return false;
90
+ hu.status = status;
91
+ hu.updatedAt = new Date().toISOString();
92
+ plan.updatedAt = new Date().toISOString();
93
+ return true;
94
+ }
95
+
96
+ /**
97
+ * Mark all pending HUs as certified (ready to execute).
98
+ * Sets plan status to "ready".
99
+ * @returns {number} count of certified HUs
100
+ */
101
+ export function certifyAllHus(plan) {
102
+ let count = 0;
103
+ for (const hu of plan.hus) {
104
+ if (hu.status === "pending") {
105
+ hu.status = "certified";
106
+ hu.updatedAt = new Date().toISOString();
107
+ count++;
108
+ }
109
+ }
110
+ plan.status = "ready";
111
+ plan.updatedAt = new Date().toISOString();
112
+ return count;
113
+ }
114
+
115
+ /**
116
+ * Reorder HUs by providing an ordered list of IDs.
117
+ * Updates blocked_by to reflect the linear order.
118
+ * @param {object} plan
119
+ * @param {string[]} orderedIds
120
+ */
121
+ export function reorderHus(plan, orderedIds) {
122
+ const map = new Map(plan.hus.map(h => [h.id, h]));
123
+ const reordered = [];
124
+ for (const id of orderedIds) {
125
+ const hu = map.get(id);
126
+ if (hu) reordered.push(hu);
127
+ }
128
+ // Add any HUs not in the ordered list at the end
129
+ for (const hu of plan.hus) {
130
+ if (!orderedIds.includes(hu.id)) reordered.push(hu);
131
+ }
132
+ plan.hus = reordered;
133
+ plan.updatedAt = new Date().toISOString();
134
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Plan + HU ID generation.
3
+ * Globally unique IDs that never collide across plans.
4
+ */
5
+
6
+ /**
7
+ * Generate a unique plan ID: plan-<YYYYMMDDHHMMSS>-<4-char random>
8
+ */
9
+ export function generatePlanId() {
10
+ const now = new Date();
11
+ const ts = now.toISOString().replace(/[-:T]/g, "").replace(/\.\d+Z$/, "");
12
+ const rand = Math.random().toString(36).slice(2, 6);
13
+ return `plan-${ts}-${rand}`;
14
+ }
15
+
16
+ /**
17
+ * Generate a globally unique HU ID scoped to a plan.
18
+ * Format: hu_<planId>_<3-digit sequence>
19
+ * @param {string} planId
20
+ * @param {number} seq - 1-based sequence number
21
+ * @returns {string}
22
+ */
23
+ export function generateHuId(planId, seq) {
24
+ return `hu_${planId}_${String(seq).padStart(3, "0")}`;
25
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Plan v2 schema: plans with embedded HUs.
3
+ * v1 = text-only plans (approach + steps + risks)
4
+ * v2 = plans + executable HUs with acceptance tests
5
+ */
6
+
7
+ import { generatePlanId, generateHuId } from "./plan-id.js";
8
+
9
+ const VALID_PLAN_STATUSES = new Set(["draft", "ready", "running", "done", "failed"]);
10
+ const VALID_HU_STATUSES = new Set(["pending", "certified", "coding", "reviewing", "done", "failed", "blocked"]);
11
+ const VALID_TASK_TYPES = new Set(["sw", "infra", "doc", "add-tests", "refactor", "nocode"]);
12
+
13
+ /**
14
+ * Check if a plan uses v2 schema (has version field + hus array).
15
+ */
16
+ export function isPlanV2(plan) {
17
+ return plan?.version === 2 && Array.isArray(plan.hus);
18
+ }
19
+
20
+ /**
21
+ * Create an empty v2 plan.
22
+ * @param {string} task - original task description
23
+ * @param {object} [context] - research/architect/triage context
24
+ * @returns {object} v2 plan with empty hus array
25
+ */
26
+ export function createPlanV2(task, context = {}) {
27
+ const planId = generatePlanId();
28
+ return {
29
+ planId,
30
+ version: 2,
31
+ task,
32
+ name: null, // set by caller via deriveProjectName or manually
33
+ status: "draft",
34
+ createdAt: new Date().toISOString(),
35
+ updatedAt: new Date().toISOString(),
36
+ createdBy: "planner",
37
+ context: {
38
+ researchContext: context.researchContext || null,
39
+ architectContext: context.architectContext || null,
40
+ triageLevel: context.triageLevel || null,
41
+ domain: context.domain || null
42
+ },
43
+ approach: null,
44
+ risks: [],
45
+ outOfScope: [],
46
+ hus: []
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Migrate a v1 plan to v2 schema. Preserves all existing data.
52
+ * v1 plans have no HUs — they become v2 with empty hus array.
53
+ * @param {object} v1plan
54
+ * @returns {object} v2 plan
55
+ */
56
+ export function migratePlanV1toV2(v1plan) {
57
+ if (isPlanV2(v1plan)) return v1plan; // already v2
58
+ return {
59
+ planId: v1plan.planId,
60
+ version: 2,
61
+ task: v1plan.task || null,
62
+ name: null,
63
+ status: "draft",
64
+ createdAt: v1plan.createdAt || new Date().toISOString(),
65
+ updatedAt: new Date().toISOString(),
66
+ createdBy: "migrated",
67
+ context: {
68
+ researchContext: v1plan.researchContext || null,
69
+ architectContext: v1plan.architectContext || null,
70
+ triageLevel: null,
71
+ domain: null
72
+ },
73
+ approach: v1plan.plan?.approach || (typeof v1plan.plan === "string" ? v1plan.plan : null),
74
+ risks: v1plan.plan?.risks || [],
75
+ outOfScope: v1plan.plan?.outOfScope || [],
76
+ hus: [],
77
+ _v1: { raw: v1plan.raw, plan: v1plan.plan } // preserve original for reference
78
+ };
79
+ }
80
+
81
+ /**
82
+ * Validate a v2 plan. Returns { valid: true } or { valid: false, errors: string[] }.
83
+ */
84
+ export function validatePlan(plan) {
85
+ const errors = [];
86
+
87
+ if (!plan.planId) errors.push("Missing planId");
88
+ if (plan.version !== 2) errors.push(`Expected version 2, got ${plan.version}`);
89
+ if (!plan.task) errors.push("Missing task");
90
+ if (!VALID_PLAN_STATUSES.has(plan.status)) errors.push(`Invalid status: ${plan.status}`);
91
+ if (!Array.isArray(plan.hus)) errors.push("hus must be an array");
92
+
93
+ const huIds = new Set();
94
+ for (const hu of plan.hus || []) {
95
+ if (!hu.id) { errors.push("HU missing id"); continue; }
96
+ if (huIds.has(hu.id)) errors.push(`Duplicate HU id: ${hu.id}`);
97
+ huIds.add(hu.id);
98
+ if (!hu.title) errors.push(`HU ${hu.id}: missing title`);
99
+ if (!VALID_HU_STATUSES.has(hu.status)) errors.push(`HU ${hu.id}: invalid status ${hu.status}`);
100
+ if (hu.task_type && !VALID_TASK_TYPES.has(hu.task_type)) errors.push(`HU ${hu.id}: invalid task_type ${hu.task_type}`);
101
+ // Check blocked_by references exist
102
+ for (const dep of hu.blocked_by || []) {
103
+ if (!huIds.has(dep) && !(plan.hus || []).some(h => h.id === dep)) {
104
+ errors.push(`HU ${hu.id}: blocked_by references unknown HU ${dep}`);
105
+ }
106
+ }
107
+ }
108
+
109
+ // Check for circular dependencies
110
+ try {
111
+ topoSort(plan.hus || []);
112
+ } catch {
113
+ errors.push("Circular dependency detected in HU blocked_by graph");
114
+ }
115
+
116
+ return errors.length === 0 ? { valid: true } : { valid: false, errors };
117
+ }
118
+
119
+ /** Simple topological sort for cycle detection. */
120
+ function topoSort(hus) {
121
+ const visited = new Set();
122
+ const visiting = new Set();
123
+ const order = [];
124
+ const map = new Map(hus.map(h => [h.id, h]));
125
+
126
+ function visit(id) {
127
+ if (visited.has(id)) return;
128
+ if (visiting.has(id)) throw new Error("cycle");
129
+ visiting.add(id);
130
+ const hu = map.get(id);
131
+ for (const dep of hu?.blocked_by || []) visit(dep);
132
+ visiting.delete(id);
133
+ visited.add(id);
134
+ order.push(id);
135
+ }
136
+
137
+ for (const hu of hus) visit(hu.id);
138
+ return order;
139
+ }
@@ -1,11 +1,14 @@
1
1
  /**
2
- * Plan persistence — save, load, list plans generated by kj_plan.
2
+ * Plan persistence — save, load, list, update, delete plans.
3
3
  * Plans are stored at ~/.kj/plans/<projectSlug>/<planId>.json
4
+ * Supports v1 (text-only) and v2 (plans + HUs) schemas.
4
5
  */
5
6
 
6
7
  import fs from "node:fs/promises";
7
8
  import path from "node:path";
8
9
  import os from "node:os";
10
+ import { generatePlanId } from "./plan-id.js";
11
+ import { isPlanV2, migratePlanV1toV2 } from "./plan-schema.js";
9
12
 
10
13
  function getKjHome() {
11
14
  return process.env.KJ_HOME || path.join(os.homedir(), ".kj");
@@ -19,28 +22,30 @@ function projectSlug(projectDir) {
19
22
  .slice(0, 120);
20
23
  }
21
24
 
22
- function plansDir(projectDir) {
25
+ export function plansDir(projectDir) {
23
26
  return path.join(getKjHome(), "plans", projectSlug(projectDir));
24
27
  }
25
28
 
26
- function generatePlanId() {
27
- const now = new Date();
28
- const ts = now.toISOString().replace(/[-:T]/g, "").replace(/\.\d+Z$/, "");
29
- const rand = Math.random().toString(36).slice(2, 6);
30
- return `plan-${ts}-${rand}`;
31
- }
32
-
33
29
  /**
34
- * Save a plan result to disk.
35
- * @param {string} projectDir - Absolute path to the project
36
- * @param {object} planResult - { task, researchContext, architectContext, plan, raw }
30
+ * Save a plan to disk. Accepts both v1 and v2 schemas.
31
+ * @param {string} projectDir
32
+ * @param {object} planResult - v1 { task, plan, raw } or v2 { planId, version, hus, ... }
37
33
  * @returns {Promise<string>} planId
38
34
  */
39
35
  export async function savePlan(projectDir, planResult) {
40
36
  const dir = plansDir(projectDir);
41
37
  await fs.mkdir(dir, { recursive: true });
42
38
 
43
- const planId = generatePlanId();
39
+ // v2 plans already have planId and full schema
40
+ if (isPlanV2(planResult)) {
41
+ const filePath = path.join(dir, `${planResult.planId}.json`);
42
+ planResult.updatedAt = new Date().toISOString();
43
+ await fs.writeFile(filePath, JSON.stringify(planResult, null, 2), "utf8");
44
+ return planResult.planId;
45
+ }
46
+
47
+ // v1: generate planId and wrap
48
+ const planId = planResult.planId || generatePlanId();
44
49
  const record = {
45
50
  planId,
46
51
  task: planResult.task || null,
@@ -57,25 +62,51 @@ export async function savePlan(projectDir, planResult) {
57
62
  }
58
63
 
59
64
  /**
60
- * Load a saved plan by ID.
61
- * @param {string} projectDir
62
- * @param {string} planId
63
- * @returns {Promise<object|null>} { planId, task, researchContext, architectContext, plan, createdAt } or null
65
+ * Load a plan by ID. Auto-migrates v1 to v2 on read (lazy).
66
+ * @returns {Promise<object|null>} v2 plan or null
64
67
  */
65
68
  export async function loadPlan(projectDir, planId) {
66
69
  const filePath = path.join(plansDir(projectDir), `${planId}.json`);
67
70
  try {
68
71
  const data = await fs.readFile(filePath, "utf8");
69
- return JSON.parse(data);
72
+ const plan = JSON.parse(data);
73
+ if (!isPlanV2(plan)) return migratePlanV1toV2(plan);
74
+ return plan;
70
75
  } catch {
71
76
  return null;
72
77
  }
73
78
  }
74
79
 
75
80
  /**
76
- * List all plans for a project, sorted by creation time (newest first).
77
- * @param {string} projectDir
78
- * @returns {Promise<Array<{planId: string, task: string, createdAt: string}>>}
81
+ * Update a plan on disk (partial merge).
82
+ * @returns {Promise<boolean>}
83
+ */
84
+ export async function updatePlan(projectDir, planId, patch) {
85
+ const plan = await loadPlan(projectDir, planId);
86
+ if (!plan) return false;
87
+ Object.assign(plan, patch);
88
+ plan.updatedAt = new Date().toISOString();
89
+ const filePath = path.join(plansDir(projectDir), `${planId}.json`);
90
+ await fs.writeFile(filePath, JSON.stringify(plan, null, 2), "utf8");
91
+ return true;
92
+ }
93
+
94
+ /**
95
+ * Delete a plan from disk.
96
+ * @returns {Promise<boolean>}
97
+ */
98
+ export async function deletePlan(projectDir, planId) {
99
+ const filePath = path.join(plansDir(projectDir), `${planId}.json`);
100
+ try {
101
+ await fs.unlink(filePath);
102
+ return true;
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * List all plans for a project, sorted newest first.
79
110
  */
80
111
  export async function listPlans(projectDir) {
81
112
  const dir = plansDir(projectDir);
@@ -95,9 +126,13 @@ export async function listPlans(projectDir) {
95
126
  plans.push({
96
127
  planId: record.planId,
97
128
  task: record.task,
129
+ name: record.name || null,
130
+ status: record.status || "draft",
131
+ version: record.version || 1,
132
+ huCount: Array.isArray(record.hus) ? record.hus.length : 0,
98
133
  createdAt: record.createdAt
99
134
  });
100
- } catch { /* skip corrupt files */ }
135
+ } catch { /* skip corrupt */ }
101
136
  }
102
137
 
103
138
  plans.sort((a, b) => (b.createdAt || "").localeCompare(a.createdAt || ""));
@@ -105,9 +140,7 @@ export async function listPlans(projectDir) {
105
140
  }
106
141
 
107
142
  /**
108
- * Get the most recent plan for a project.
109
- * @param {string} projectDir
110
- * @returns {Promise<object|null>}
143
+ * Get the most recent plan.
111
144
  */
112
145
  export async function getLatestPlan(projectDir) {
113
146
  const all = await listPlans(projectDir);
@@ -9,7 +9,7 @@ const SUBAGENT_PREAMBLE = [
9
9
 
10
10
  export const VALID_VERDICTS = new Set(["ready", "needs_clarification"]);
11
11
 
12
- export async function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null, domainContext = null, projectDir = null, language = "en" }) {
12
+ export async function buildArchitectPrompt({ task, instructions, researchContext = null, productContext = null, domainContext = null, projectDir = null, language = "en", provider = null }) {
13
13
  const langInstruction = getLanguageInstruction(language);
14
14
  const sections = [SUBAGENT_PREAMBLE, ...(langInstruction ? [langInstruction] : [])];
15
15
 
@@ -51,7 +51,7 @@ export async function buildArchitectPrompt({ task, instructions, researchContext
51
51
 
52
52
  if (projectDir) {
53
53
  const skills = await loadAvailableSkills(projectDir);
54
- const skillSection = buildSkillSection(skills);
54
+ const skillSection = buildSkillSection(skills, { provider, role: "architect" });
55
55
  if (skillSection) {
56
56
  sections.push(skillSection);
57
57
  }
@@ -33,7 +33,7 @@ const SERENA_INSTRUCTIONS = [
33
33
  "Fall back to reading files only when Serena tools are not sufficient."
34
34
  ].join("\n");
35
35
 
36
- export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en" }) {
36
+ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSummary = null, coderRules = null, methodology = "tdd", serenaEnabled = false, rtkAvailable = false, deferredContext = null, productContext = null, domainContext = null, plan = null, projectDir = null, language = "en", provider = null }) {
37
37
  const langInstruction = getLanguageInstruction(language);
38
38
  const sections = [
39
39
  serenaEnabled ? SUBAGENT_PREAMBLE_SERENA : SUBAGENT_PREAMBLE,
@@ -108,7 +108,7 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
108
108
 
109
109
  if (projectDir) {
110
110
  const skills = await loadAvailableSkills(projectDir);
111
- const skillSection = buildSkillSection(skills);
111
+ const skillSection = buildSkillSection(skills, { provider });
112
112
  if (skillSection) {
113
113
  sections.push(skillSection);
114
114
  }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Provider-aware role template resolver.
3
+ *
4
+ * Extends the legacy `resolveRoleMdPath()` contract from base-role.js to
5
+ * support per-provider variants without breaking existing consumers.
6
+ *
7
+ * Lookup order (first match wins):
8
+ *
9
+ * 1. <projectDir>/.karajan/roles/{role}/{provider}.md project override, per provider
10
+ * 2. <projectDir>/.karajan/roles/{role}/default.md project override, default
11
+ * 3. <projectDir>/.karajan/roles/{role}.md project override, legacy flat
12
+ * 4. <karajanHome>/roles/{role}/{provider}.md user home, per provider
13
+ * 5. <karajanHome>/roles/{role}/default.md user home, default
14
+ * 6. <karajanHome>/roles/{role}.md user home, legacy flat
15
+ * 7. <built-in>/templates/roles/{role}/{provider}.md built-in, per provider
16
+ * 8. <built-in>/templates/roles/{role}/default.md built-in, default
17
+ * 9. <built-in>/templates/roles/{role}.md built-in, legacy flat (back-compat)
18
+ *
19
+ * When `provider` is undefined/null the per-provider entries are skipped and
20
+ * only the default + legacy tiers are tried.
21
+ *
22
+ * If a provider is supplied but no per-provider file exists, we emit a
23
+ * one-time `prompt-resolver:fallback` event/log entry so the user knows their
24
+ * config asked for a variant that isn't present.
25
+ */
26
+
27
+ import fs from "node:fs/promises";
28
+ import path from "node:path";
29
+ import { fileURLToPath } from "node:url";
30
+ import { getKarajanHome } from "../utils/paths.js";
31
+ import { normalizeProvider } from "../utils/provider-env.js";
32
+
33
+ /**
34
+ * Compute the full list of candidate file paths for a role template.
35
+ *
36
+ * @param {string} role - role slug (e.g. "coder")
37
+ * @param {string|null|undefined} provider - provider slug or CLI name;
38
+ * normalized internally
39
+ * @param {string|null|undefined} projectDir
40
+ * @returns {{ perProvider: string[], default: string[], legacy: string[], all: string[], provider: string|null }}
41
+ */
42
+ export function resolveRolePromptPaths(role, provider, projectDir) {
43
+ if (!role || typeof role !== "string") {
44
+ throw new Error("resolveRolePromptPaths: role name is required");
45
+ }
46
+ const normalized = provider ? normalizeProvider(provider) : null;
47
+ const canonical = normalized && normalized.length > 0 ? normalized : null;
48
+
49
+ const homes = [];
50
+ if (projectDir) homes.push(path.join(projectDir, ".karajan", "roles"));
51
+ homes.push(path.join(getKarajanHome(), "roles"));
52
+
53
+ const builtInRoot = path.resolve(
54
+ path.dirname(fileURLToPath(import.meta.url)),
55
+ "..",
56
+ "..",
57
+ "templates",
58
+ "roles"
59
+ );
60
+ homes.push(builtInRoot);
61
+
62
+ const perProvider = [];
63
+ const defaults = [];
64
+ const legacy = [];
65
+
66
+ for (const home of homes) {
67
+ if (canonical) {
68
+ perProvider.push(path.join(home, role, `${canonical}.md`));
69
+ }
70
+ defaults.push(path.join(home, role, "default.md"));
71
+ legacy.push(path.join(home, `${role}.md`));
72
+ }
73
+
74
+ // Interleave: for each home, try per-provider first, then default, then
75
+ // legacy. This gives the user's project override precedence over the
76
+ // built-in default even when they only supplied a legacy flat file.
77
+ const all = [];
78
+ for (const home of homes) {
79
+ if (canonical) all.push(path.join(home, role, `${canonical}.md`));
80
+ all.push(path.join(home, role, "default.md"));
81
+ all.push(path.join(home, `${role}.md`));
82
+ }
83
+
84
+ return { perProvider, default: defaults, legacy, all, provider: canonical };
85
+ }
86
+
87
+ /**
88
+ * Return the contents of the first existing candidate path. Returns null if
89
+ * no template is found.
90
+ *
91
+ * @param {string[]} paths
92
+ * @returns {Promise<{content: string, path: string}|null>}
93
+ */
94
+ export async function readFirstExisting(paths) {
95
+ for (const p of paths) {
96
+ try {
97
+ const content = await fs.readFile(p, "utf8");
98
+ return { content, path: p };
99
+ } catch { /* file not found, keep trying */ }
100
+ }
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Load role instructions resolved for a specific provider.
106
+ *
107
+ * @param {Object} args
108
+ * @param {string} args.role
109
+ * @param {string|null|undefined} args.provider
110
+ * @param {string|null|undefined} args.projectDir
111
+ * @param {{ warn?: Function, debug?: Function }} [args.logger]
112
+ * @returns {Promise<{ instructions: string|null, path: string|null, resolvedProvider: string|null, fellBack: boolean }>}
113
+ */
114
+ export async function loadRoleInstructions({ role, provider, projectDir, logger } = {}) {
115
+ const { all, provider: canonical } = resolveRolePromptPaths(role, provider, projectDir);
116
+ const hit = await readFirstExisting(all);
117
+ if (!hit) {
118
+ return { instructions: null, path: null, resolvedProvider: canonical, fellBack: !!canonical };
119
+ }
120
+ // Detect fallback: if the user asked for a provider but we matched a path
121
+ // that is NOT a per-provider file, we consider it a fallback and warn.
122
+ const fellBack = !!canonical && !hit.path.endsWith(`${canonical}.md`);
123
+ if (fellBack && logger?.debug) {
124
+ logger.debug(`prompt-resolver: role "${role}" has no "${canonical}" variant, using ${path.basename(hit.path)}`);
125
+ }
126
+ return { instructions: hit.content, path: hit.path, resolvedProvider: canonical, fellBack };
127
+ }