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.
- package/README.md +20 -4
- package/package.json +5 -2
- package/src/agents/aider-agent.js +1 -2
- package/src/agents/base-agent.js +63 -1
- package/src/agents/claude-agent.js +3 -4
- package/src/agents/codex-agent.js +1 -2
- package/src/agents/gemini-agent.js +1 -2
- package/src/agents/index.js +11 -2
- package/src/agents/opencode-agent.js +1 -2
- package/src/brain/decision-tracker.js +123 -0
- package/src/brain/decisor.js +243 -0
- package/src/brain/solomon-consult.js +163 -0
- package/src/budget/comparison.js +96 -0
- package/src/checks/binaries.js +123 -0
- package/src/checks/ci.js +105 -0
- package/src/checks/config-files.js +217 -0
- package/src/checks/dir-setup.js +63 -0
- package/src/checks/mcp-health.js +157 -0
- package/src/checks/node.js +61 -0
- package/src/checks/ports.js +110 -0
- package/src/checks/rtk.js +36 -0
- package/src/checks/runner.js +267 -0
- package/src/checks/skills.js +49 -0
- package/src/checks/sonar.js +67 -0
- package/src/checks/system.js +53 -0
- package/src/checks/tokens.js +96 -0
- package/src/checks/types.js +105 -0
- package/src/cli.js +90 -7
- package/src/commands/board.js +23 -0
- package/src/commands/doctor.js +130 -362
- package/src/commands/plan.js +221 -37
- package/src/commands/resume.js +1 -1
- package/src/commands/run.js +2 -1
- package/src/commands/skills.js +49 -0
- package/src/config/schema.js +222 -0
- package/src/config.js +32 -3
- package/src/guards/policy-resolver.js +8 -0
- package/src/hu/store.js +9 -0
- package/src/infrastructure/command-runner.js +44 -0
- package/src/infrastructure/environment.js +38 -0
- package/src/infrastructure/file-system-service.js +63 -0
- package/src/infrastructure/mocks.js +182 -0
- package/src/mcp/handlers/management-handlers.js +7 -2
- package/src/mcp/tools.js +10 -3
- package/src/orchestrator/config-init.js +16 -1
- package/src/orchestrator/flow-runner.js +2085 -0
- package/src/orchestrator/pipeline-context.js +17 -0
- package/src/orchestrator/preflight-checks.js +78 -4
- package/src/orchestrator/solomon-escalation.js +21 -0
- package/src/orchestrator/stages/stage-executor.js +155 -0
- package/src/orchestrator.js +19 -1884
- package/src/plan/plan-executor.js +69 -0
- package/src/plan/plan-hu-ops.js +134 -0
- package/src/plan/plan-id.js +25 -0
- package/src/plan/plan-schema.js +139 -0
- package/src/plan/plan-store.js +58 -25
- package/src/prompts/architect.js +2 -2
- package/src/prompts/coder.js +2 -2
- package/src/prompts/prompt-resolver.js +127 -0
- package/src/prompts/reviewer.js +2 -2
- package/src/roles/architect-role.js +3 -1
- package/src/roles/base-role.js +23 -2
- package/src/roles/coder-role.js +3 -1
- package/src/roles/security-role.js +12 -0
- package/src/roles/tester-role.js +11 -0
- package/src/session/journal/decisions-writer.js +200 -0
- package/src/session/journal/iteration-logger.js +194 -0
- package/src/session/journal/summary-writer.js +176 -0
- package/src/session/journal/tree-writer.js +164 -0
- package/src/skills/semantic-detector.js +147 -0
- package/src/skills/skill-detector.js +186 -8
- package/src/skills/skill-loader.js +131 -1
- package/src/skills/skills-cache.js +128 -0
- package/src/types/agent.js +55 -0
- package/src/types/config.js +114 -0
- package/src/types/hu.js +47 -0
- package/src/types/index.js +34 -0
- package/src/types/policy.js +43 -0
- package/src/types/session.js +74 -0
- package/src/types/stage.js +68 -0
- package/src/utils/display/event-handlers.js +389 -0
- package/src/utils/display/formatters.js +132 -0
- package/src/utils/display/header.js +48 -0
- package/src/utils/display/session-summary.js +108 -0
- package/src/utils/display/solomon.js +42 -0
- package/src/utils/port-check.js +45 -0
- package/src/utils/port-occupant.js +95 -0
- package/src/utils/provider-env.js +116 -0
- package/src/utils/with-timeout.js +42 -0
- package/templates/roles/architect/anthropic.md +65 -0
- package/templates/roles/architect/default.md +63 -0
- package/templates/roles/architect/google.md +52 -0
- package/templates/roles/architect/openai.md +51 -0
- package/templates/roles/architect/opencode.md +50 -0
- package/templates/roles/coder/anthropic.md +72 -0
- package/templates/roles/coder/default.md +69 -0
- package/templates/roles/coder/google.md +78 -0
- package/templates/roles/coder/openai.md +59 -0
- package/templates/roles/coder/opencode.md +49 -0
- package/templates/roles/planner/anthropic.md +50 -0
- package/templates/roles/planner/default.md +48 -0
- package/templates/roles/planner/google.md +66 -0
- package/templates/roles/planner/openai.md +46 -0
- package/templates/roles/planner/opencode.md +53 -0
- package/templates/roles/reviewer/anthropic.md +61 -0
- package/templates/roles/reviewer/default.md +62 -0
- package/templates/roles/reviewer/google.md +63 -0
- package/templates/roles/reviewer/openai.md +57 -0
- package/templates/roles/reviewer/opencode.md +42 -0
- package/templates/roles/solomon/anthropic.md +71 -0
- package/templates/roles/solomon/default.md +83 -0
- package/templates/roles/solomon/google.md +61 -0
- package/templates/roles/solomon/openai.md +53 -0
- package/templates/roles/solomon/opencode.md +55 -0
- 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
|
+
}
|
package/src/plan/plan-store.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Plan persistence — save, load, list
|
|
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
|
|
35
|
-
* @param {string} projectDir
|
|
36
|
-
* @param {object} planResult - { task,
|
|
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
|
-
|
|
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
|
|
61
|
-
* @
|
|
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
|
-
|
|
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
|
-
*
|
|
77
|
-
* @
|
|
78
|
-
|
|
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
|
|
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
|
|
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);
|
package/src/prompts/architect.js
CHANGED
|
@@ -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
|
}
|
package/src/prompts/coder.js
CHANGED
|
@@ -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
|
+
}
|