karajan-code 2.10.2 → 2.12.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 +5 -1
- package/package.json +1 -1
- package/src/audit/plan-adherence.js +222 -0
- package/src/audit/sonar-findings.js +12 -0
- package/src/cli/register-meta.js +4 -4
- package/src/cli/register-pipeline.js +3 -3
- package/src/cli/register-plan.js +1 -1
- package/src/commands/board.js +28 -4
- package/src/commands/init.js +24 -5
- package/src/config/loader.js +32 -1
- package/src/git/automation.js +94 -4
- package/src/git/hu-automation.js +35 -3
- package/src/golden/asserter.js +90 -0
- package/src/golden/loader.js +52 -0
- package/src/golden/runner.js +92 -0
- package/src/golden/schema.js +41 -0
- package/src/orchestrator/config-init.js +17 -0
- package/src/orchestrator/drivers/post-loop.js +64 -3
- package/src/orchestrator/flow-runner.js +41 -0
- package/src/orchestrator/stages/sonar-stage.js +19 -0
- package/src/session/journal/summary-writer.js +39 -0
- package/src/skills/addyosmani-catalog.js +29 -7
- package/src/sonar/project-key.js +37 -0
- package/src/types/session.js +2 -1
- package/src/utils/display/session-summary.js +12 -2
- package/src/utils/git.js +132 -1
- package/templates/roles/architect/anthropic.md +9 -0
- package/templates/roles/architect/default.md +9 -0
- package/templates/roles/architect/google.md +9 -0
- package/templates/roles/architect/openai.md +9 -0
- package/templates/roles/architect/opencode.md +9 -0
- package/templates/roles/architect.md +9 -0
- package/templates/roles/coder/anthropic.md +9 -0
- package/templates/roles/coder/default.md +9 -0
- package/templates/roles/coder/google.md +9 -0
- package/templates/roles/coder/openai.md +9 -0
- package/templates/roles/coder/opencode.md +9 -0
- package/templates/roles/coder.md +9 -0
- package/templates/roles/planner/anthropic.md +9 -0
- package/templates/roles/planner/default.md +9 -0
- package/templates/roles/planner/google.md +9 -0
- package/templates/roles/planner/openai.md +9 -0
- package/templates/roles/planner/opencode.md +9 -0
- package/templates/roles/planner.md +9 -0
- package/templates/roles/refactorer.md +9 -0
- package/templates/roles/repairer.md +9 -0
- package/templates/roles/tester.md +9 -0
package/README.md
CHANGED
|
@@ -23,7 +23,11 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.
|
|
26
|
+
> **v2.12.0 released** — Quality-measurement release. Two new features land together: every `kj run` against a known plan now scores how faithfully the coder followed it (deterministic 0–100 **plan adherence** metric, four weighted components, rendered in `summary.md`), and a small **golden-tasks** regression suite (`todo-rest-api`, `npm-package-cli`, `react-counter-component`) catches output-quality drops between Karajan versions before npm publish. Plus the shrink-budget CI gate now exempts human-facing docs from its 200-LOC ceiling while keeping AI-rule files (CLAUDE.md, AGENTS.md, role prompts) capped. 3 PRs for plan adherence (#645–#647), 4 for golden tasks (#648, #650–#652), 1 for the CI policy (#649). 4522/4522 tests passing. Safe upgrade from 2.11.0.
|
|
27
|
+
>
|
|
28
|
+
> **v2.11.0** — Dogfooding pass release. Two-day pass through a 10-level test plan surfaced and fixed a long tail of UX papercuts and three latent bugs that only show up on fresh `/tmp` repos: the `SonarStage` no longer loops on remoteless projects (was burning iterations until `max_iterations`-fallback-approval), the post-loop `commitAll` now tolerates the locale-specific "nothing to commit" race, the HU sub-pipeline branches off `master`/`HEAD` when the configured `main` doesn't exist, and `runFlow` now seals `session.status` at the boundary so `kj status` never shows zombi `running` runs again. Plus `hu-board` gains automatic ephemeral-project cleanup and an in-UI help modal for the five views. 14 PRs (#624–#637), 4452/4452 tests passing. Safe upgrade from 2.10.2.
|
|
29
|
+
>
|
|
30
|
+
> **v2.10.2** — Patch release. `kj init` wizard expanded from 9 prompts to a full setup: per-role provider selection (10 roles, "inherit / pick CLI / disable"), automatic SonarQube token generation via REST API (no more web UI walkthrough), git automation flags (`auto_commit/push/pr`) and HU Board security (bind host + port). +16 new tests. Safe upgrade from 2.10.1.
|
|
27
31
|
>
|
|
28
32
|
> **v2.10.1** — Patch release. One-line fix for a stdout contamination bug in `kj audit --agent-readiness --json` (the `[info]` banner was breaking downstream `jq` pipes), plus polish in the asciinema demo scripts. Safe upgrade from 2.10.0.
|
|
29
33
|
>
|
package/package.json
CHANGED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan adherence metric — KJC-TSK-0376 (deepeval-inspired).
|
|
3
|
+
* Pure offline 0-100 score: how faithfully did the coder follow the plan?
|
|
4
|
+
* Components (weighted): commit_attribution 40%, acceptance_tests 30%,
|
|
5
|
+
* scope_discipline 20%, dependency_order 10%. Components return null
|
|
6
|
+
* when no data; aggregator redistributes the weight.
|
|
7
|
+
* See docs/plan-adherence.md (PR-C) for the full spec.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const WEIGHTS = {
|
|
11
|
+
commit_attribution: 40,
|
|
12
|
+
acceptance_tests: 30,
|
|
13
|
+
scope_discipline: 20,
|
|
14
|
+
dependency_order: 10,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a regex that matches the HU id anywhere in a commit message
|
|
19
|
+
* (case-insensitive, word-boundary friendly). The `_` and `-` characters
|
|
20
|
+
* appear inside HU ids so we anchor on whole-token rather than \b.
|
|
21
|
+
*/
|
|
22
|
+
function huIdMatcher(huId) {
|
|
23
|
+
const escaped = String(huId).replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
24
|
+
return new RegExp(`(^|[^a-z0-9])${escaped}([^a-z0-9]|$)`, "i");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Slugify an HU title for branch-name / scope matching. Mirrors the
|
|
29
|
+
* `buildHuBranchName` slug rule (lowercase, non-alnum → "-").
|
|
30
|
+
*/
|
|
31
|
+
function slugify(text) {
|
|
32
|
+
return String(text || "")
|
|
33
|
+
.toLowerCase()
|
|
34
|
+
.replaceAll(/[^a-z0-9]+/g, "-")
|
|
35
|
+
.replaceAll(/(^-+)|(-+$)/g, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Try to attribute a single commit to one of the plan's HUs.
|
|
40
|
+
*
|
|
41
|
+
* Order of evidence, strongest first:
|
|
42
|
+
* 1. The HU id appears in the commit message.
|
|
43
|
+
* 2. The HU id appears in the branch name where the commit lives
|
|
44
|
+
* (commit.branch is supplied by listCommitsBetween's caller when
|
|
45
|
+
* it knows the per-HU branches).
|
|
46
|
+
* 3. The HU title slug matches the commit message.
|
|
47
|
+
*
|
|
48
|
+
* Returns the matched huId, or null when none of the above fires.
|
|
49
|
+
*/
|
|
50
|
+
export function attributeCommitToHu(commit, hus) {
|
|
51
|
+
if (!commit || !Array.isArray(hus) || hus.length === 0) return null;
|
|
52
|
+
const msg = String(commit.message || "");
|
|
53
|
+
const branch = String(commit.branch || "");
|
|
54
|
+
for (const hu of hus) {
|
|
55
|
+
if (!hu?.id) continue;
|
|
56
|
+
const matcher = huIdMatcher(hu.id);
|
|
57
|
+
if (matcher.test(msg)) return hu.id;
|
|
58
|
+
if (branch && matcher.test(branch)) return hu.id;
|
|
59
|
+
}
|
|
60
|
+
// Fallback: title slug
|
|
61
|
+
for (const hu of hus) {
|
|
62
|
+
const slug = slugify(hu.title);
|
|
63
|
+
if (!slug || slug.length < 6) continue;
|
|
64
|
+
if (slugify(msg).includes(slug)) return hu.id;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Component 1: commit attribution. Returns null when the run produced
|
|
71
|
+
* no commits at all (avoids penalising analysis-only or doc-only runs).
|
|
72
|
+
*/
|
|
73
|
+
export function computeCommitAttribution(commits, plan) {
|
|
74
|
+
if (!Array.isArray(commits) || commits.length === 0) return null;
|
|
75
|
+
if (!plan?.hus || plan.hus.length === 0) return null;
|
|
76
|
+
let attributed = 0;
|
|
77
|
+
for (const commit of commits) {
|
|
78
|
+
if (attributeCommitToHu(commit, plan.hus)) attributed += 1;
|
|
79
|
+
}
|
|
80
|
+
return Math.round((attributed / commits.length) * 100);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Component 2: acceptance test coverage. Heuristic: each HU declares
|
|
85
|
+
* `acceptance_tests`. We check whether at least one commit from that
|
|
86
|
+
* HU's pool has a `test:`-prefixed message. Per-HU score (1 or 0),
|
|
87
|
+
* averaged across HUs that DO declare tests. Returns null when no HU
|
|
88
|
+
* declares acceptance tests.
|
|
89
|
+
*/
|
|
90
|
+
export function computeAcceptanceTestCoverage(commits, plan) {
|
|
91
|
+
if (!plan?.hus || plan.hus.length === 0) return null;
|
|
92
|
+
if (!Array.isArray(commits) || commits.length === 0) return null;
|
|
93
|
+
const husWithTests = plan.hus.filter(
|
|
94
|
+
(hu) => Array.isArray(hu?.acceptance_tests) && hu.acceptance_tests.length > 0,
|
|
95
|
+
);
|
|
96
|
+
if (husWithTests.length === 0) return null;
|
|
97
|
+
|
|
98
|
+
let covered = 0;
|
|
99
|
+
for (const hu of husWithTests) {
|
|
100
|
+
const huCommits = (commits || []).filter(
|
|
101
|
+
(c) => attributeCommitToHu(c, [hu]) === hu.id,
|
|
102
|
+
);
|
|
103
|
+
const hasTestCommit = huCommits.some((c) =>
|
|
104
|
+
/^test[:(]/i.test(String(c.message || "").trim()),
|
|
105
|
+
);
|
|
106
|
+
if (hasTestCommit) covered += 1;
|
|
107
|
+
}
|
|
108
|
+
return Math.round((covered / husWithTests.length) * 100);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Component 3: scope discipline. For each file changed by the run, we
|
|
113
|
+
* try to match it against at least one HU's id slug, title slug, or
|
|
114
|
+
* `scope` text. A file that matches no HU is flagged as out-of-scope.
|
|
115
|
+
* Returns null when the run changed no files.
|
|
116
|
+
*/
|
|
117
|
+
export function computeScopeDiscipline(filesChanged, plan) {
|
|
118
|
+
if (!Array.isArray(filesChanged) || filesChanged.length === 0) return null;
|
|
119
|
+
if (!plan?.hus || plan.hus.length === 0) return null;
|
|
120
|
+
const huKeywords = plan.hus.map((hu) => ({
|
|
121
|
+
id: hu.id,
|
|
122
|
+
matchers: [
|
|
123
|
+
slugify(hu.id),
|
|
124
|
+
slugify(hu.title),
|
|
125
|
+
// Pull alpha-rich words from `scope` text (length >= 4).
|
|
126
|
+
...String(hu.scope || "")
|
|
127
|
+
.toLowerCase()
|
|
128
|
+
.split(/[^a-z]+/)
|
|
129
|
+
.filter((w) => w.length >= 4),
|
|
130
|
+
].filter(Boolean),
|
|
131
|
+
}));
|
|
132
|
+
let inScope = 0;
|
|
133
|
+
for (const file of filesChanged) {
|
|
134
|
+
const slug = slugify(file);
|
|
135
|
+
const matched = huKeywords.some((hk) =>
|
|
136
|
+
hk.matchers.some((m) => slug.includes(m) || file.includes(m)),
|
|
137
|
+
);
|
|
138
|
+
if (matched) inScope += 1;
|
|
139
|
+
}
|
|
140
|
+
return Math.round((inScope / filesChanged.length) * 100);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Component 4: dependency order. For every `(parent, child)` edge
|
|
145
|
+
* declared via `child.blocked_by`, check that the EARLIEST commit
|
|
146
|
+
* attributed to `parent` is older than the EARLIEST commit attributed
|
|
147
|
+
* to `child`. Returns null when no dependency edges exist.
|
|
148
|
+
*
|
|
149
|
+
* `commits` are expected oldest-first (the shape `listCommitsBetween`
|
|
150
|
+
* returns). When that's not the case the score still makes sense — we
|
|
151
|
+
* just compare relative positions in the array.
|
|
152
|
+
*/
|
|
153
|
+
export function computeDependencyOrder(commits, plan) {
|
|
154
|
+
if (!plan?.hus || plan.hus.length === 0) return null;
|
|
155
|
+
const edges = [];
|
|
156
|
+
for (const hu of plan.hus) {
|
|
157
|
+
for (const parentId of hu.blocked_by || []) {
|
|
158
|
+
edges.push([parentId, hu.id]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (edges.length === 0) return null;
|
|
162
|
+
if (!Array.isArray(commits) || commits.length === 0) return null;
|
|
163
|
+
|
|
164
|
+
// Build huId → first commit index (oldest first attribution).
|
|
165
|
+
const firstIndex = new Map();
|
|
166
|
+
commits.forEach((commit, idx) => {
|
|
167
|
+
const huId = attributeCommitToHu(commit, plan.hus);
|
|
168
|
+
if (huId && !firstIndex.has(huId)) firstIndex.set(huId, idx);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
let respected = 0;
|
|
172
|
+
let evaluable = 0;
|
|
173
|
+
for (const [parentId, childId] of edges) {
|
|
174
|
+
const p = firstIndex.get(parentId);
|
|
175
|
+
const c = firstIndex.get(childId);
|
|
176
|
+
if (p === undefined || c === undefined) continue; // can't evaluate
|
|
177
|
+
evaluable += 1;
|
|
178
|
+
if (p < c) respected += 1;
|
|
179
|
+
}
|
|
180
|
+
if (evaluable === 0) return null;
|
|
181
|
+
return Math.round((respected / evaluable) * 100);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Aggregator: compute every component and weight them. Components
|
|
186
|
+
* that return `null` are excluded and their weight redistributed
|
|
187
|
+
* proportionally across the rest, so a run that legitimately has no
|
|
188
|
+
* acceptance tests or no dependencies isn't penalised.
|
|
189
|
+
*
|
|
190
|
+
* Returns the full structured result (see file header for shape).
|
|
191
|
+
*/
|
|
192
|
+
export function computePlanAdherenceScore({ commits, plan, filesChanged }) {
|
|
193
|
+
const breakdown = {
|
|
194
|
+
commit_attribution: computeCommitAttribution(commits, plan),
|
|
195
|
+
acceptance_tests: computeAcceptanceTestCoverage(commits, plan),
|
|
196
|
+
scope_discipline: computeScopeDiscipline(filesChanged, plan),
|
|
197
|
+
dependency_order: computeDependencyOrder(commits, plan),
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// Weighted average over non-null components only.
|
|
201
|
+
let totalWeight = 0;
|
|
202
|
+
let weightedSum = 0;
|
|
203
|
+
for (const [key, weight] of Object.entries(WEIGHTS)) {
|
|
204
|
+
const value = breakdown[key];
|
|
205
|
+
if (value === null || value === undefined) continue;
|
|
206
|
+
totalWeight += weight;
|
|
207
|
+
weightedSum += value * weight;
|
|
208
|
+
}
|
|
209
|
+
const score = totalWeight === 0 ? null : Math.round(weightedSum / totalWeight);
|
|
210
|
+
|
|
211
|
+
// Per-HU detail: was the HU "touched" (attributed at least one commit)?
|
|
212
|
+
const hu_scores = (plan?.hus || []).map((hu) => {
|
|
213
|
+
const huCommits = (commits || []).filter(
|
|
214
|
+
(c) => attributeCommitToHu(c, [hu]) === hu.id,
|
|
215
|
+
);
|
|
216
|
+
const issues = [];
|
|
217
|
+
if (huCommits.length === 0) issues.push("no commit attributed");
|
|
218
|
+
return { huId: hu.id, attributed: huCommits.length > 0, issues };
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return { score, breakdown, hu_scores };
|
|
222
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
import { isSonarReachable } from "../sonar/manager.js";
|
|
24
24
|
import { getOpenIssues, getQualityGateStatus } from "../sonar/api.js";
|
|
25
|
+
import { canResolveSonarProjectKey } from "../sonar/project-key.js";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Collect deterministic Sonar findings for the audit prompt.
|
|
@@ -43,6 +44,17 @@ export async function collectSonarFindings(config, logger = null) {
|
|
|
43
44
|
return { available: false, reason: `sonar host ${host} not reachable` };
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
// No git remote and no explicit project_key → silent skip. This is the
|
|
48
|
+
// typical `kj run` in a fresh /tmp/... folder. Reporting it as a
|
|
49
|
+
// warning treats it like an error (which it isn't — sonar simply
|
|
50
|
+
// doesn't apply to a remoteless repo).
|
|
51
|
+
if (!(await canResolveSonarProjectKey(config))) {
|
|
52
|
+
return {
|
|
53
|
+
available: false,
|
|
54
|
+
reason: "not applicable: no git remote and no sonarqube.project_key configured",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
46
58
|
let issuesResult;
|
|
47
59
|
try {
|
|
48
60
|
issuesResult = await getOpenIssues(config);
|
package/src/cli/register-meta.js
CHANGED
|
@@ -24,7 +24,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
24
24
|
program
|
|
25
25
|
.command("discover")
|
|
26
26
|
.description("Analyze task for gaps, ambiguities and missing info")
|
|
27
|
-
.argument("[task]", "Task description (or
|
|
27
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
28
28
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
29
29
|
.option("--mode <name>", "Discovery mode: gaps|momtest|wendel|classify|jtbd", "gaps")
|
|
30
30
|
.option("--discover <name>", "Override discover agent")
|
|
@@ -41,7 +41,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
41
41
|
program
|
|
42
42
|
.command("triage")
|
|
43
43
|
.description("Classify task complexity and recommend pipeline roles")
|
|
44
|
-
.argument("[task]", "Task description (or
|
|
44
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
45
45
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
46
46
|
.option("--triage <name>", "Override triage agent")
|
|
47
47
|
.option("--triage-model <name>", "Override triage model")
|
|
@@ -57,7 +57,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
57
57
|
program
|
|
58
58
|
.command("researcher")
|
|
59
59
|
.description("Research codebase for a task (files, patterns, constraints)")
|
|
60
|
-
.argument("[task]", "Task description (or
|
|
60
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
61
61
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
62
62
|
.option("--researcher <name>", "Override researcher agent")
|
|
63
63
|
.option("--researcher-model <name>", "Override researcher model")
|
|
@@ -72,7 +72,7 @@ export function registerMeta(program, { pkgVersion }) {
|
|
|
72
72
|
program
|
|
73
73
|
.command("architect")
|
|
74
74
|
.description("Design solution architecture (layers, patterns, contracts)")
|
|
75
|
-
.argument("[task]", "Task description (or
|
|
75
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
76
76
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
77
77
|
.option("--architect <name>", "Override architect agent")
|
|
78
78
|
.option("--architect-model <name>", "Override architect model")
|
|
@@ -40,7 +40,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
40
40
|
program
|
|
41
41
|
.command("run")
|
|
42
42
|
.description("Run coder+sonar+reviewer loop")
|
|
43
|
-
.argument("[task]", "Task description (
|
|
43
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
44
44
|
.option("--task-file <path>", "Read the task from a file (e.g. .md). Alternative to the positional task argument.")
|
|
45
45
|
.option("-y, --yes", "Skip the cwd confirmation prompt (CI / scripted runs).")
|
|
46
46
|
.option("--planner <name>")
|
|
@@ -135,7 +135,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
135
135
|
program
|
|
136
136
|
.command("code")
|
|
137
137
|
.description("Run only coder")
|
|
138
|
-
.argument("[task]", "Task description (or
|
|
138
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
139
139
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
140
140
|
.option("--coder <name>")
|
|
141
141
|
.option("--coder-model <name>")
|
|
@@ -150,7 +150,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
150
150
|
program
|
|
151
151
|
.command("review")
|
|
152
152
|
.description("Run only reviewer")
|
|
153
|
-
.argument("[task]", "Task description (or
|
|
153
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
154
154
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
155
155
|
.option("--reviewer <name>")
|
|
156
156
|
.option("--reviewer-model <name>")
|
package/src/cli/register-plan.js
CHANGED
|
@@ -26,7 +26,7 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
26
26
|
plan
|
|
27
27
|
.command("generate", { isDefault: true })
|
|
28
28
|
.description("Generate implementation plan with HUs")
|
|
29
|
-
.argument("[task]", "Task description (or
|
|
29
|
+
.argument("[task]", "Task description (REQUIRED — provide as argument or via --task-file)")
|
|
30
30
|
.option("--task-file <path>", "Read the task from a file (e.g. .md)")
|
|
31
31
|
.option("--planner <name>")
|
|
32
32
|
.option("--planner-model <name>")
|
package/src/commands/board.js
CHANGED
|
@@ -126,14 +126,29 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
126
126
|
const karajanHome = getKarajanHome();
|
|
127
127
|
fs.mkdirSync(karajanHome, { recursive: true });
|
|
128
128
|
|
|
129
|
+
// Open a log file for the daemon. Pre-fix the daemon was spawned
|
|
130
|
+
// with `stdio: "ignore"`, so every `console.log` from server.js
|
|
131
|
+
// (including the new [zombie-reaper] messages) went straight to
|
|
132
|
+
// /dev/null and the user had no way to see what was happening.
|
|
133
|
+
// The file is opened in append-only mode so successive starts
|
|
134
|
+
// don't truncate the history.
|
|
135
|
+
const logPath = path.join(karajanHome, "hu-board.log");
|
|
136
|
+
const logFd = fs.openSync(logPath, "a");
|
|
137
|
+
fs.writeSync(
|
|
138
|
+
logFd,
|
|
139
|
+
`\n--- kj board start at ${new Date().toISOString()} (port ${port}, bind ${bind}) ---\n`,
|
|
140
|
+
);
|
|
141
|
+
|
|
129
142
|
// Use process.execPath (absolute path to current node binary) instead of "node".
|
|
130
143
|
// Fixes ENOENT on nvm setups where `node` is not in the spawned process's PATH.
|
|
131
144
|
const child = spawn(process.execPath, [serverPath], {
|
|
132
145
|
env: { ...process.env, PORT: String(port), BIND_HOST: bind },
|
|
133
146
|
detached: true,
|
|
134
|
-
stdio: "ignore",
|
|
147
|
+
stdio: ["ignore", logFd, logFd],
|
|
135
148
|
cwd: BOARD_DIR
|
|
136
149
|
});
|
|
150
|
+
// The parent doesn't need the fd anymore — the child inherited it.
|
|
151
|
+
fs.closeSync(logFd);
|
|
137
152
|
// Swallow spawn errors so an HU Board failure never crashes the pipeline.
|
|
138
153
|
child.on("error", () => { /* non-blocking — caller logs via tryAutoStartBoard catch */ });
|
|
139
154
|
if (!child.pid) {
|
|
@@ -143,10 +158,16 @@ export async function startBoard(desiredPort = 4000, opts = {}) {
|
|
|
143
158
|
// Detach for real: `detached: true` only sets up the new session;
|
|
144
159
|
// without `unref()` the parent's event loop still holds a reference
|
|
145
160
|
// to the child handle and `kj board start` hangs at the prompt
|
|
146
|
-
// instead of returning.
|
|
147
|
-
// to ensure the parent isn't waiting on any of the child's pipes.
|
|
161
|
+
// instead of returning.
|
|
148
162
|
child.unref();
|
|
149
|
-
return {
|
|
163
|
+
return {
|
|
164
|
+
ok: true,
|
|
165
|
+
alreadyRunning: false,
|
|
166
|
+
pid: child.pid,
|
|
167
|
+
url: buildBoardUrl(port, projectSlug),
|
|
168
|
+
port,
|
|
169
|
+
logPath,
|
|
170
|
+
};
|
|
150
171
|
}
|
|
151
172
|
|
|
152
173
|
export async function stopBoard(opts = {}) {
|
|
@@ -273,6 +294,9 @@ export async function boardCommand({ action = "start", port = 4000, bind = "127.
|
|
|
273
294
|
logger.info(`HU Board already running (PID ${result.pid}) at ${result.url}`);
|
|
274
295
|
} else {
|
|
275
296
|
logger.info(`HU Board started (PID ${result.pid}) at ${result.url}`);
|
|
297
|
+
if (result.logPath) {
|
|
298
|
+
logger.info(`Daemon logs: tail -f ${result.logPath}`);
|
|
299
|
+
}
|
|
276
300
|
if (bind !== "127.0.0.1" && bind !== "localhost") {
|
|
277
301
|
logger.warn(`HU Board bound to ${bind} — token auth enforced for non-loopback peers. Token: ~/.karajan/hu-board/token`);
|
|
278
302
|
}
|
package/src/commands/init.js
CHANGED
|
@@ -277,6 +277,22 @@ async function runWizard(config, logger) {
|
|
|
277
277
|
return config;
|
|
278
278
|
}
|
|
279
279
|
|
|
280
|
+
/**
|
|
281
|
+
* Backwards-compatible alias for `writeConfig`. Pre-KJC-BUG-0036 this
|
|
282
|
+
* wrapper was the *only* writer that stripped `sonarqube.enabled`
|
|
283
|
+
* before serialising — we needed it because the global `writeConfig`
|
|
284
|
+
* would happily persist the wizard's transient hint. KJC-BUG-0036
|
|
285
|
+
* moved that strip (and the equivalent `_deprecated` strip) into
|
|
286
|
+
* `writeConfig` itself, so this is now a thin alias kept around to
|
|
287
|
+
* keep callers and tests stable. Prefer `writeConfig` in new code.
|
|
288
|
+
*
|
|
289
|
+
* @param {string} configPath
|
|
290
|
+
* @param {object} config
|
|
291
|
+
*/
|
|
292
|
+
export async function writeInitConfig(configPath, config) {
|
|
293
|
+
await writeConfig(configPath, config);
|
|
294
|
+
}
|
|
295
|
+
|
|
280
296
|
async function handleConfigSetup({ config, configExists, interactive, configPath, logger }) {
|
|
281
297
|
if (configExists && interactive) {
|
|
282
298
|
const wizard = createWizard();
|
|
@@ -284,7 +300,7 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
|
|
|
284
300
|
const reconfigure = await wizard.confirm("Config already exists. Reconfigure?", false);
|
|
285
301
|
if (reconfigure) {
|
|
286
302
|
const updated = await runWizard(config, logger);
|
|
287
|
-
await
|
|
303
|
+
await writeInitConfig(configPath, updated);
|
|
288
304
|
logger.info(`Updated ${configPath}`);
|
|
289
305
|
} else {
|
|
290
306
|
logger.info("Keeping existing config.");
|
|
@@ -294,10 +310,10 @@ async function handleConfigSetup({ config, configExists, interactive, configPath
|
|
|
294
310
|
}
|
|
295
311
|
} else if (!configExists && interactive) {
|
|
296
312
|
const updated = await runWizard(config, logger);
|
|
297
|
-
await
|
|
313
|
+
await writeInitConfig(configPath, updated);
|
|
298
314
|
logger.info(`Created ${configPath}`);
|
|
299
315
|
} else if (!configExists) {
|
|
300
|
-
await
|
|
316
|
+
await writeInitConfig(configPath, config);
|
|
301
317
|
logger.info(`Created ${configPath}`);
|
|
302
318
|
}
|
|
303
319
|
}
|
|
@@ -561,7 +577,7 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
561
577
|
config.development.methodology = config.development.methodology || "tdd";
|
|
562
578
|
}
|
|
563
579
|
|
|
564
|
-
await
|
|
580
|
+
await writeInitConfig(configPath, config);
|
|
565
581
|
} else {
|
|
566
582
|
logger.info("No project stack detected — using default configuration.");
|
|
567
583
|
}
|
|
@@ -582,7 +598,10 @@ export async function initCommand({ logger, flags = {} }) {
|
|
|
582
598
|
await scaffoldCiGateway(config, flags, logger);
|
|
583
599
|
|
|
584
600
|
// Persist any changes setupSonarQube made (token, etc.) back to disk.
|
|
585
|
-
|
|
601
|
+
// Use writeInitConfig so the deprecated `sonarqube.enabled` key —
|
|
602
|
+
// which setupSonarQube still mutates as an in-memory hint — never
|
|
603
|
+
// reaches the YAML file.
|
|
604
|
+
await writeInitConfig(configPath, config);
|
|
586
605
|
|
|
587
606
|
// Telemetry: anonymous install event (non-blocking)
|
|
588
607
|
const { sendTelemetryEvent } = await import("../utils/telemetry.js");
|
package/src/config/loader.js
CHANGED
|
@@ -128,9 +128,40 @@ export async function loadConfig(projectDir) {
|
|
|
128
128
|
return { config: merged, path: configPath, exists: globalExists, hasProjectConfig: !!projectConfig };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Runtime-only keys that the loader synthesises on every load (or that
|
|
133
|
+
* a wizard uses as transient in-memory hints). They MUST NOT survive
|
|
134
|
+
* to the on-disk YAML — otherwise the `kj run` deprecation warning
|
|
135
|
+
* fossilises (KJC-BUG-0036, observed in dogfooding 2026-05-07: the
|
|
136
|
+
* `_deprecated.sonarqubeEnabledKey: true` block ended up baked into
|
|
137
|
+
* `~/.karajan/kj.config.yml` and re-fired the warning on every run
|
|
138
|
+
* even after the user cleaned `sonarqube.enabled`).
|
|
139
|
+
*
|
|
140
|
+
* Pure: returns a shallow-copied config without the runtime keys —
|
|
141
|
+
* the caller's object is left untouched.
|
|
142
|
+
*/
|
|
143
|
+
function stripRuntimeOnlyKeys(config) {
|
|
144
|
+
if (!config || typeof config !== "object") return config;
|
|
145
|
+
const out = { ...config };
|
|
146
|
+
// _deprecated is loader-synthesised — never written intentionally.
|
|
147
|
+
if ("_deprecated" in out) delete out._deprecated;
|
|
148
|
+
// sonarqube.enabled was deprecated in v2.7.4. The init wizard still
|
|
149
|
+
// uses it as a hint to drive setupSonarQube during install, but the
|
|
150
|
+
// runtime ignores it. Persisting it would re-trigger the deprecation
|
|
151
|
+
// warning on every subsequent kj run.
|
|
152
|
+
if (out.sonarqube && typeof out.sonarqube === "object"
|
|
153
|
+
&& Object.prototype.hasOwnProperty.call(out.sonarqube, "enabled")) {
|
|
154
|
+
const { enabled: _drop, ...rest } = out.sonarqube;
|
|
155
|
+
void _drop;
|
|
156
|
+
out.sonarqube = rest;
|
|
157
|
+
}
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
131
161
|
export async function writeConfig(configPath, config) {
|
|
132
162
|
await ensureDir(path.dirname(configPath));
|
|
133
|
-
|
|
163
|
+
const sanitized = stripRuntimeOnlyKeys(config);
|
|
164
|
+
await fs.writeFile(configPath, yaml.dump(sanitized, { lineWidth: 120 }), "utf8");
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
// Declarative mappings for applyRunOverrides to reduce cognitive complexity.
|
package/src/git/automation.js
CHANGED
|
@@ -15,14 +15,88 @@ import {
|
|
|
15
15
|
buildBranchName,
|
|
16
16
|
commitAll,
|
|
17
17
|
pushBranch,
|
|
18
|
-
createPullRequest
|
|
18
|
+
createPullRequest,
|
|
19
|
+
listPendingPaths
|
|
19
20
|
} from "../utils/git.js";
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Karajan writes / updates several internal directories during a pipeline
|
|
24
|
+
* run (the workspace scaffold + the session journal). When they're the
|
|
25
|
+
* ONLY thing pending at finalization, the post-loop commit shouldn't
|
|
26
|
+
* inherit the user task's `feat:` message — it should be a clear
|
|
27
|
+
* `chore: scaffold karajan workspace` so `git log --oneline` reads:
|
|
28
|
+
*
|
|
29
|
+
* 2d770aa docs: add JSDoc comment to index.js
|
|
30
|
+
* abc1234 chore: scaffold karajan workspace
|
|
31
|
+
* 8a95ef0 initial
|
|
32
|
+
*
|
|
33
|
+
* A path counts as scaffolding when it lives at one of these prefixes
|
|
34
|
+
* OR is the project's root `.gitignore` (we extend it to include
|
|
35
|
+
* stack-specific entries during triage).
|
|
36
|
+
*/
|
|
37
|
+
const KARAJAN_SCAFFOLD_PREFIXES = [
|
|
38
|
+
".karajan/",
|
|
39
|
+
".reviews/",
|
|
40
|
+
".kj/",
|
|
41
|
+
".agent/",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function isKarajanScaffoldPath(p) {
|
|
45
|
+
if (p === ".gitignore") return true;
|
|
46
|
+
return KARAJAN_SCAFFOLD_PREFIXES.some((prefix) => p.startsWith(prefix));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string[]} paths
|
|
51
|
+
* @returns {boolean} true when every pending path is a Karajan internal
|
|
52
|
+
*/
|
|
53
|
+
export function pendingPathsAreOnlyKarajanScaffold(paths) {
|
|
54
|
+
if (!Array.isArray(paths) || paths.length === 0) return false;
|
|
55
|
+
return paths.every(isKarajanScaffoldPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map the taskType (as classified by triage) to the matching
|
|
60
|
+
* Conventional Commits prefix. When triage hasn't run or the type is
|
|
61
|
+
* unknown we default to `feat:` (the pre-fix behaviour).
|
|
62
|
+
*/
|
|
63
|
+
const TASK_TYPE_TO_PREFIX = {
|
|
64
|
+
doc: "docs",
|
|
65
|
+
"add-tests": "test",
|
|
66
|
+
refactor: "refactor",
|
|
67
|
+
infra: "chore",
|
|
68
|
+
sw: "feat",
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function prefixForTaskType(taskType) {
|
|
72
|
+
return TASK_TYPE_TO_PREFIX[taskType] || "feat";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function commitMessageFromTask(task, taskType = null) {
|
|
22
76
|
const clean = String(task || "")
|
|
23
77
|
.replaceAll(/\s+/g, " ")
|
|
24
78
|
.trim();
|
|
25
|
-
|
|
79
|
+
const prefix = prefixForTaskType(taskType);
|
|
80
|
+
return `${prefix}: ${clean.slice(0, 72) || "karajan update"}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Build the message used by `finalizeGitAutomation` for the post-loop
|
|
85
|
+
* commit. Two paths:
|
|
86
|
+
* - When the only pending changes are Karajan scaffold files
|
|
87
|
+
* (.gitignore, .karajan/, .reviews/, ...), use a fixed
|
|
88
|
+
* `chore: scaffold karajan workspace` message. This avoids the
|
|
89
|
+
* "duplicate feat:" git log seen when triage extends .gitignore
|
|
90
|
+
* for a freshly detected stack and the coder already committed
|
|
91
|
+
* the user-visible change.
|
|
92
|
+
* - Otherwise, fall back to `commitMessageFromTask(task, taskType)`,
|
|
93
|
+
* respecting the type for the prefix.
|
|
94
|
+
*/
|
|
95
|
+
export function buildFinalizeCommitMessage({ task, taskType, pendingPaths }) {
|
|
96
|
+
if (pendingPathsAreOnlyKarajanScaffold(pendingPaths)) {
|
|
97
|
+
return "chore: scaffold karajan workspace";
|
|
98
|
+
}
|
|
99
|
+
return commitMessageFromTask(task, taskType);
|
|
26
100
|
}
|
|
27
101
|
|
|
28
102
|
export async function prepareGitAutomation({ config, task, logger, session }) {
|
|
@@ -173,7 +247,23 @@ export async function incrementalPush({ gitCtx, task, logger, session }) {
|
|
|
173
247
|
export async function finalizeGitAutomation({ config, gitCtx, task, logger, session, stageResults = null }) {
|
|
174
248
|
if (!gitCtx?.enabled) return { git: "disabled", commits: [] };
|
|
175
249
|
|
|
176
|
-
|
|
250
|
+
// Take a snapshot of pending paths BEFORE staging so we can decide
|
|
251
|
+
// whether what's about to be committed is just Karajan scaffolding
|
|
252
|
+
// (in which case we use `chore: scaffold karajan workspace`) or a
|
|
253
|
+
// real, user-visible change (in which case we honour the task's
|
|
254
|
+
// taskType for the Conventional Commits prefix).
|
|
255
|
+
const taskType = stageResults?.triage?.taskType ?? null;
|
|
256
|
+
let pendingPaths = [];
|
|
257
|
+
if (config.git.auto_commit) {
|
|
258
|
+
try {
|
|
259
|
+
pendingPaths = await listPendingPaths();
|
|
260
|
+
} catch { /* best-effort — fall back to default message */ }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const commitMsg =
|
|
264
|
+
config.git.commit_message
|
|
265
|
+
|| buildFinalizeCommitMessage({ task, taskType, pendingPaths });
|
|
266
|
+
|
|
177
267
|
let committed = false;
|
|
178
268
|
const commits = [];
|
|
179
269
|
if (config.git.auto_commit) {
|