karajan-code 2.7.3 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/package.json +24 -4
- package/scripts/diet-baseline.mjs +170 -0
- package/scripts/regen-arch-stats.sh +71 -0
- package/src/agents/availability.js +15 -9
- package/src/agents/claude-agent.js +143 -45
- package/src/agents/model-registry.js +1 -1
- package/src/audit/agent-readiness.js +240 -0
- package/src/bootstrap.js +26 -1
- package/src/brain/decision-tracker.js +3 -1
- package/src/brain/decisor.js +1 -1
- package/src/canvas/parse-md.js +238 -0
- package/src/canvas/schema.js +101 -0
- package/src/canvas/to-flat.js +85 -0
- package/src/canvas/validate.js +163 -0
- package/src/checks/binaries.js +1 -1
- package/src/checks/ports.js +97 -27
- package/src/checks/runner.js +26 -10
- package/src/checks/sonar.js +1 -1
- package/src/checks/system.js +1 -1
- package/src/checks/tokens.js +77 -24
- package/src/cli/_shared.js +30 -0
- package/src/cli/register-meta.js +204 -0
- package/src/cli/register-pipeline.js +212 -0
- package/src/cli/register-plan.js +152 -0
- package/src/cli/register-roles-skills.js +38 -0
- package/src/cli/register-sonar.js +28 -0
- package/src/cli.js +83 -468
- package/src/commands/agents.js +18 -6
- package/src/commands/architect.js +29 -2
- package/src/commands/audit.js +27 -3
- package/src/commands/board.js +160 -30
- package/src/commands/clean.js +83 -0
- package/src/commands/code.js +7 -2
- package/src/commands/discover.js +7 -2
- package/src/commands/init.js +2 -2
- package/src/commands/plan/_shared.js +40 -0
- package/src/commands/plan/add-hu.js +21 -0
- package/src/commands/plan/delete.js +9 -0
- package/src/commands/plan/generate.js +284 -0
- package/src/commands/plan/index.js +14 -0
- package/src/commands/plan/list.js +21 -0
- package/src/commands/plan/ready.js +46 -0
- package/src/commands/plan/remove-hu.js +17 -0
- package/src/commands/plan/show.js +81 -0
- package/src/commands/plan/validate.js +20 -0
- package/src/commands/plan.js +14 -291
- package/src/commands/researcher.js +7 -2
- package/src/commands/resume.js +24 -1
- package/src/commands/review.js +7 -2
- package/src/commands/run.js +52 -1
- package/src/commands/status.js +1 -1
- package/src/commands/sync.js +75 -0
- package/src/commands/triage.js +7 -2
- package/src/commands/undo.js +1 -1
- package/src/config/defaults.js +213 -0
- package/src/config/loader.js +137 -0
- package/src/config/overrides.js +199 -0
- package/src/config/role-resolver.js +159 -0
- package/src/config/test-harness.js +79 -0
- package/src/config.js +30 -609
- package/src/git/automation.js +2 -2
- package/src/guards/output-guard.js +0 -2
- package/src/hu/acceptance-runner.js +52 -10
- package/src/hu/store.js +11 -0
- package/src/mcp/handlers/direct-handlers.js +2 -5
- package/src/mcp/handlers/management-handlers.js +48 -4
- package/src/mcp/handlers/run-handler.js +9 -8
- package/src/mcp/preflight.js +32 -13
- package/src/mcp/server-handlers.js +4 -3
- package/src/mcp/suggest-handler.js +3 -2
- package/src/mcp/tools.js +15 -0
- package/src/orchestrator/agent-fallback.js +1 -1
- package/src/orchestrator/ci-integration.js +10 -10
- package/src/orchestrator/config-init.js +25 -7
- package/src/orchestrator/direct-actions.js +6 -2
- package/src/orchestrator/drivers/error-recovery.js +287 -0
- package/src/orchestrator/drivers/init-context.js +222 -0
- package/src/orchestrator/drivers/iteration-loop.js +311 -0
- package/src/orchestrator/drivers/iteration-phases/coder-and-refactorer.js +48 -0
- package/src/orchestrator/drivers/iteration-phases/guards.js +84 -0
- package/src/orchestrator/drivers/iteration-phases/handle-approved.js +29 -0
- package/src/orchestrator/drivers/iteration-phases/quality-gates.js +76 -0
- package/src/orchestrator/drivers/iteration-phases/reviewer-gate.js +60 -0
- package/src/orchestrator/drivers/post-loop.js +370 -0
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +116 -0
- package/src/orchestrator/drivers/pre-loop-phases/config-deprecations.js +47 -0
- package/src/orchestrator/drivers/pre-loop-phases/ensure-addyosmani-skills.js +84 -0
- package/src/orchestrator/drivers/pre-loop-phases/inject-loaded-plan.js +268 -0
- package/src/orchestrator/drivers/pre-loop.js +434 -0
- package/src/orchestrator/drivers/run-hu-batch.js +367 -0
- package/src/orchestrator/flow-control.js +4 -3
- package/src/orchestrator/flow-runner.js +121 -2019
- package/src/orchestrator/fs-leak-detector.js +156 -0
- package/src/orchestrator/hu-sub-pipeline.js +197 -9
- package/src/orchestrator/integrations.js +88 -0
- package/src/orchestrator/post-loop-stages.js +35 -8
- package/src/orchestrator/preflight-checks.js +30 -3
- package/src/orchestrator/repair/repair-detector.js +117 -0
- package/src/orchestrator/repair/repair-runner.js +142 -0
- package/src/orchestrator/reviewer-fallback.js +1 -1
- package/src/orchestrator/solomon-escalation.js +3 -2
- package/src/orchestrator/solomon-rules.js +7 -3
- package/src/orchestrator/stages/architect-stage.js +12 -11
- package/src/orchestrator/stages/coder-stage.js +56 -7
- package/src/orchestrator/stages/hu-reviewer-phases/detect-and-apply-splits.js +127 -0
- package/src/orchestrator/stages/hu-reviewer-stage.js +6 -79
- package/src/orchestrator/stages/planner-stage.js +1 -1
- package/src/orchestrator/stages/research-stage.js +1 -1
- package/src/orchestrator/stages/reviewer-stage.js +7 -7
- package/src/orchestrator/stages/sonar-stage.js +94 -10
- package/src/orchestrator/stages/stage-classes.js +86 -0
- package/src/orchestrator/stages/stage-executor.js +1 -1
- package/src/orchestrator/stages/triage-stage.js +1 -1
- package/src/orchestrator/standby.js +3 -2
- package/src/orchestrator/verification-gate.js +20 -12
- package/src/plan/adr-generator.js +170 -0
- package/src/plan/adr-loader.js +60 -0
- package/src/plan/plan-executor.js +19 -1
- package/src/plan/plan-hu-ops.js +170 -1
- package/src/plan/plan-reviewer.js +199 -0
- package/src/plan/plan-schema.js +69 -1
- package/src/plan/plan-store.js +40 -3
- package/src/plan/spec-detector.js +61 -0
- package/src/plan/tests-synthesizer.js +116 -0
- package/src/planning-game/client.js +3 -14
- package/src/planning-game/decomposition.js +1 -1
- package/src/planning-game/pipeline-adapter.js +36 -5
- package/src/prompts/coder.js +172 -1
- package/src/prompts/hu-reviewer.js +0 -1
- package/src/prompts/planner.js +81 -4
- package/src/review/diff-generator.js +35 -22
- package/src/review/snapshot-diff.js +1 -1
- package/src/roles/coder-role.js +25 -3
- package/src/roles/planner-role.js +42 -1
- package/src/roles/repairer-role.js +181 -0
- package/src/roles/tester-role.js +70 -4
- package/src/session/mutators.js +304 -0
- package/src/session/runtime-overrides.js +59 -0
- package/src/{session-store.js → session/store.js} +8 -2
- package/src/skills/semantic-detector.js +10 -5
- package/src/skills/skill-detector.js +1 -1
- package/src/sonar/cloud-scanner.js +0 -1
- package/src/sonar/credentials.js +14 -6
- package/src/sonar/discovery.js +123 -0
- package/src/sonar/manager.js +18 -0
- package/src/sync/detect-drift.js +113 -0
- package/src/tracker-bootstrap.js +44 -0
- package/src/utils/agent-detect.js +15 -11
- package/src/utils/board-prompt-bridge.js +109 -0
- package/src/utils/cli-progress.js +161 -0
- package/src/utils/cwd-confirm.js +39 -0
- package/src/utils/derive-project-name-from-cwd.js +119 -0
- package/src/utils/garbage-collector.js +396 -0
- package/src/utils/git.js +16 -4
- package/src/utils/locale.js +1 -1
- package/src/utils/paths.js +21 -1
- package/src/utils/process.js +1 -1
- package/src/utils/prompt-project-name.js +35 -0
- package/src/utils/run-context.js +75 -0
- package/src/utils/run-log.js +12 -0
- package/src/utils/task-file.js +1 -1
- package/src/utils/telemetry.js +13 -1
- package/src/webperf/devtools-detect.js +1 -1
- package/templates/coder-rules.md +13 -0
- package/templates/roles/coder.md +34 -0
- package/templates/roles/repairer.md +94 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Karajan garbage collector — keeps `~/.kj/` and `~/.karajan/` from
|
|
3
|
+
* accumulating dead state across runs.
|
|
4
|
+
*
|
|
5
|
+
* Dogfood motivation: after ~1 year the user's `~/.kj/plans/` had 2 464
|
|
6
|
+
* plans in `home_manu_ws_npm-packages_karajan-code/` plus 35 plans from
|
|
7
|
+
* worktrees that no longer exist. Same story for `~/.karajan/sessions/`
|
|
8
|
+
* and `hu-stories/`. None ever got cleaned because no command did it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Two entry points:
|
|
13
|
+
* - `runAutoGC(opts)` — silent, called at the top of every `kj run`
|
|
14
|
+
* and `kj plan`. Prints a single one-liner ONLY when something was
|
|
15
|
+
* removed.
|
|
16
|
+
* - `runManualGC(opts)` — backs `kj clean`. Caller decides `dryRun`;
|
|
17
|
+
* returns the full `{ removed, bytesFreed, errors }` record.
|
|
18
|
+
*
|
|
19
|
+
* Retention policy (all adjustable via opts):
|
|
20
|
+
* • Plans whose stamped `projectDir` no longer exists → delete.
|
|
21
|
+
* • Plans with final status (approved/rejected/executed) older than
|
|
22
|
+
* `planRetentionDays` (30) → delete.
|
|
23
|
+
* • Draft plans older than `draftRetentionDays` (60) → delete.
|
|
24
|
+
* • Finalised sessions older than `sessionRetentionDays` (7) → delete.
|
|
25
|
+
* • HU story batches older than `huRetentionDays` (14) → delete.
|
|
26
|
+
*
|
|
27
|
+
* Failures are NEVER fatal — every op is try/catch and non-blocking.
|
|
28
|
+
* Tested against an isolated tmp KJ_HOME so production data is safe.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from "node:fs/promises";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
import os from "node:os";
|
|
34
|
+
|
|
35
|
+
const FINAL_PLAN_STATUSES = new Set([
|
|
36
|
+
// Terminal plan states the user explicitly opted into — retention applies.
|
|
37
|
+
"approved", "rejected", "executed", "completed", "ready",
|
|
38
|
+
]);
|
|
39
|
+
const KNOWN_PLAN_STATUSES = new Set([...FINAL_PLAN_STATUSES, "draft", "running"]);
|
|
40
|
+
const FINAL_SESSION_STATUSES = new Set(["approved", "failed", "stopped", "rejected", "completed"]);
|
|
41
|
+
|
|
42
|
+
function getKjHome() {
|
|
43
|
+
return process.env.KJ_HOME || path.join(os.homedir(), ".kj");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getKarajanHome() {
|
|
47
|
+
return process.env.KARAJAN_HOME || path.join(os.homedir(), ".karajan");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} GCOptions
|
|
54
|
+
* @property {number} [planRetentionDays=30]
|
|
55
|
+
* @property {number} [draftRetentionDays=60]
|
|
56
|
+
* @property {number} [sessionRetentionDays=7]
|
|
57
|
+
* @property {number} [huRetentionDays=14]
|
|
58
|
+
* @property {boolean} [dryRun=false] - only report what would be removed
|
|
59
|
+
* @property {Date} [now] - clock injection for tests
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @typedef {Object} GCResult
|
|
64
|
+
* @property {Array<{path: string, reason: string, kind: string, bytes?: number}>} removed
|
|
65
|
+
* @property {number} bytesFreed
|
|
66
|
+
* @property {Array<{path: string, error: string}>} errors
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
async function tryReadJson(file) {
|
|
70
|
+
try {
|
|
71
|
+
const txt = await fs.readFile(file, "utf8");
|
|
72
|
+
return JSON.parse(txt);
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function tryStat(p) {
|
|
79
|
+
try { return await fs.stat(p); } catch { return null; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function exists(p) {
|
|
83
|
+
return Boolean(await tryStat(p));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function listDir(dir) {
|
|
87
|
+
try { return await fs.readdir(dir, { withFileTypes: true }); } catch { return []; }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function unlinkOrRm(p, dryRun) {
|
|
91
|
+
if (dryRun) return;
|
|
92
|
+
try {
|
|
93
|
+
const st = await fs.stat(p);
|
|
94
|
+
if (st.isDirectory()) await fs.rm(p, { recursive: true, force: true });
|
|
95
|
+
else await fs.unlink(p);
|
|
96
|
+
} catch { /* best-effort */ }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ageInDays(stat, now) {
|
|
100
|
+
return (now.getTime() - stat.mtimeMs) / DAY_MS;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Slugs are lossy — we CAN'T recover a path from one because `_` in
|
|
104
|
+
// the slug could have come from either `/` in the path or a literal
|
|
105
|
+
// `_` in a directory name (e.g. `ws_ai` vs `ws/ai`). That's why plans
|
|
106
|
+
// saved via `savePlan()` now stamp their absolute `projectDir`; the GC
|
|
107
|
+
// reads it from there. Legacy plans without the field fall back to
|
|
108
|
+
// age-only retention — never marked as orphans on the way up.
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Per-plan classifier. Pulled out so the surrounding GC can evaluate
|
|
112
|
+
* candidates in parallel — every plan is independent (no shared state
|
|
113
|
+
* across iterations except the result accumulator, which is folded back
|
|
114
|
+
* after the bursts settle).
|
|
115
|
+
*/
|
|
116
|
+
async function classifyPlan(filePath, opts, now) {
|
|
117
|
+
const stat = await tryStat(filePath);
|
|
118
|
+
if (!stat) return { skip: true };
|
|
119
|
+
const data = await tryReadJson(filePath);
|
|
120
|
+
const status = data?.status || "draft";
|
|
121
|
+
const storedDir = typeof data?.projectDir === "string" ? data.projectDir : null;
|
|
122
|
+
const days = ageInDays(stat, now);
|
|
123
|
+
|
|
124
|
+
let reason = null;
|
|
125
|
+
// Orphan check: only when the plan stamped its origin projectDir.
|
|
126
|
+
// Plans older than the savePlan-stamping change don't have it, so
|
|
127
|
+
// we refuse to guess and just apply age-based retention.
|
|
128
|
+
if (storedDir && !(await exists(storedDir))) {
|
|
129
|
+
reason = "orphaned (project dir no longer exists)";
|
|
130
|
+
} else if (FINAL_PLAN_STATUSES.has(status) && days > opts.planRetentionDays) {
|
|
131
|
+
reason = `${status} plan older than ${opts.planRetentionDays}d (${Math.round(days)}d)`;
|
|
132
|
+
} else if (status === "draft" && days > opts.draftRetentionDays) {
|
|
133
|
+
reason = `stale draft older than ${opts.draftRetentionDays}d (${Math.round(days)}d)`;
|
|
134
|
+
} else if (!KNOWN_PLAN_STATUSES.has(status) && days > opts.draftRetentionDays) {
|
|
135
|
+
// Unknown status (corrupt/legacy/plan from a future version) —
|
|
136
|
+
// apply the draft window. Doing nothing would leak them forever.
|
|
137
|
+
reason = `plan with unknown status "${status}" older than ${opts.draftRetentionDays}d (${Math.round(days)}d)`;
|
|
138
|
+
}
|
|
139
|
+
return { skip: false, stat, reason };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function gcPlans(opts) {
|
|
143
|
+
const result = { removed: [], bytesFreed: 0, errors: [] };
|
|
144
|
+
const now = opts.now || new Date();
|
|
145
|
+
const plansRoot = path.join(getKjHome(), "plans");
|
|
146
|
+
if (!(await exists(plansRoot))) return result;
|
|
147
|
+
|
|
148
|
+
// Audit follow-up: was nested sequential for-of with multiple awaits per
|
|
149
|
+
// iteration. Plans are independent files so we now classify them in
|
|
150
|
+
// parallel per project directory. Each project dir's bursts complete
|
|
151
|
+
// before we move to the next so the order of `result.removed` stays
|
|
152
|
+
// deterministic for tests; cross-project parallelism wasn't worth the
|
|
153
|
+
// complexity given KJ_HOME rarely has more than a handful of projects.
|
|
154
|
+
const projEntries = (await listDir(plansRoot)).filter((e) => e.isDirectory());
|
|
155
|
+
for (const projEntry of projEntries) {
|
|
156
|
+
const projDir = path.join(plansRoot, projEntry.name);
|
|
157
|
+
const planEntries = (await listDir(projDir))
|
|
158
|
+
.filter((e) => e.isFile() && e.name.endsWith(".json"));
|
|
159
|
+
|
|
160
|
+
const verdicts = await Promise.all(
|
|
161
|
+
planEntries.map(async (planEntry) => {
|
|
162
|
+
const filePath = path.join(projDir, planEntry.name);
|
|
163
|
+
const verdict = await classifyPlan(filePath, opts, now);
|
|
164
|
+
return { filePath, verdict };
|
|
165
|
+
})
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
let removedCountInDir = 0;
|
|
169
|
+
const totalCountInDir = planEntries.length;
|
|
170
|
+
for (const { filePath, verdict } of verdicts) {
|
|
171
|
+
if (verdict.skip || !verdict.reason) continue;
|
|
172
|
+
try {
|
|
173
|
+
await unlinkOrRm(filePath, opts.dryRun);
|
|
174
|
+
result.removed.push({ path: filePath, reason: verdict.reason, kind: "plan", bytes: verdict.stat.size });
|
|
175
|
+
result.bytesFreed += verdict.stat.size;
|
|
176
|
+
removedCountInDir += 1;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
result.errors.push({ path: filePath, error: err.message });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// If we just emptied the project dir, remove it too.
|
|
183
|
+
if (removedCountInDir === totalCountInDir && totalCountInDir > 0) {
|
|
184
|
+
try {
|
|
185
|
+
await unlinkOrRm(projDir, opts.dryRun);
|
|
186
|
+
} catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return result;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function gcSessions(opts) {
|
|
193
|
+
const result = { removed: [], bytesFreed: 0, errors: [] };
|
|
194
|
+
const now = opts.now || new Date();
|
|
195
|
+
const sessionsRoot = path.join(getKarajanHome(), "sessions");
|
|
196
|
+
if (!(await exists(sessionsRoot))) return result;
|
|
197
|
+
|
|
198
|
+
// Pipelines that get SIGKILL'd (power off, terminal closed, OOM…) leave
|
|
199
|
+
// their session.json stuck at status="running" forever. The original GC
|
|
200
|
+
// only reclaimed FINAL statuses and these zombies accumulated on the
|
|
201
|
+
// board. After `staleRunningDays` with no activity we assume the run is
|
|
202
|
+
// dead and sweep them. Default mirrors sessionRetentionDays so there is
|
|
203
|
+
// only one knob to reason about.
|
|
204
|
+
const staleRunningDays = opts.staleRunningDays ?? opts.sessionRetentionDays;
|
|
205
|
+
|
|
206
|
+
// Audit follow-up: parallelise the per-session classification (read
|
|
207
|
+
// session.json + stat the dir). Deletion stays sequential so the
|
|
208
|
+
// result.removed order is deterministic for tests.
|
|
209
|
+
const sessEntries = (await listDir(sessionsRoot)).filter((e) => e.isDirectory());
|
|
210
|
+
const verdicts = await Promise.all(
|
|
211
|
+
sessEntries.map(async (sessEntry) => {
|
|
212
|
+
const sessDir = path.join(sessionsRoot, sessEntry.name);
|
|
213
|
+
const sessionFile = path.join(sessDir, "session.json");
|
|
214
|
+
const stat = await tryStat(sessDir);
|
|
215
|
+
if (!stat) return { sessDir, reason: null };
|
|
216
|
+
const data = await tryReadJson(sessionFile);
|
|
217
|
+
const status = data?.status;
|
|
218
|
+
const days = ageInDays(stat, now);
|
|
219
|
+
|
|
220
|
+
let reason = null;
|
|
221
|
+
if (status && FINAL_SESSION_STATUSES.has(status) && days > opts.sessionRetentionDays) {
|
|
222
|
+
reason = `${status} session older than ${opts.sessionRetentionDays}d (${Math.round(days)}d)`;
|
|
223
|
+
} else if ((!status || status === "running") && days > staleRunningDays) {
|
|
224
|
+
reason = `zombie ${status || "unknown-status"} session older than ${staleRunningDays}d (${Math.round(days)}d)`;
|
|
225
|
+
}
|
|
226
|
+
return { sessDir, reason };
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
for (const { sessDir, reason } of verdicts) {
|
|
231
|
+
if (!reason) continue;
|
|
232
|
+
try {
|
|
233
|
+
await unlinkOrRm(sessDir, opts.dryRun);
|
|
234
|
+
result.removed.push({ path: sessDir, reason, kind: "session" });
|
|
235
|
+
} catch (err) {
|
|
236
|
+
result.errors.push({ path: sessDir, error: err.message });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return result;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function gcHuStories(opts) {
|
|
243
|
+
const result = { removed: [], bytesFreed: 0, errors: [] };
|
|
244
|
+
const now = opts.now || new Date();
|
|
245
|
+
const huRoot = path.join(getKarajanHome(), "hu-stories");
|
|
246
|
+
if (!(await exists(huRoot))) return result;
|
|
247
|
+
|
|
248
|
+
// Audit follow-up: stat-then-classify in parallel; deletion stays
|
|
249
|
+
// sequential for deterministic ordering.
|
|
250
|
+
const batchEntries = (await listDir(huRoot)).filter((e) => e.isDirectory());
|
|
251
|
+
const verdicts = await Promise.all(
|
|
252
|
+
batchEntries.map(async (batchEntry) => {
|
|
253
|
+
const batchDir = path.join(huRoot, batchEntry.name);
|
|
254
|
+
const stat = await tryStat(batchDir);
|
|
255
|
+
if (!stat) return null;
|
|
256
|
+
const days = ageInDays(stat, now);
|
|
257
|
+
if (days <= opts.huRetentionDays) return null;
|
|
258
|
+
return {
|
|
259
|
+
batchDir,
|
|
260
|
+
reason: `HU batch older than ${opts.huRetentionDays}d (${Math.round(days)}d)`,
|
|
261
|
+
};
|
|
262
|
+
})
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
for (const v of verdicts) {
|
|
266
|
+
if (!v) continue;
|
|
267
|
+
try {
|
|
268
|
+
await unlinkOrRm(v.batchDir, opts.dryRun);
|
|
269
|
+
result.removed.push({ path: v.batchDir, reason: v.reason, kind: "hu-batch" });
|
|
270
|
+
} catch (err) {
|
|
271
|
+
result.errors.push({ path: v.batchDir, error: err.message });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function mergeResults(...parts) {
|
|
278
|
+
const out = { removed: [], bytesFreed: 0, errors: [] };
|
|
279
|
+
for (const r of parts) {
|
|
280
|
+
out.removed.push(...r.removed);
|
|
281
|
+
out.bytesFreed += r.bytesFreed;
|
|
282
|
+
out.errors.push(...r.errors);
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function defaultOpts(opts = {}) {
|
|
288
|
+
return {
|
|
289
|
+
planRetentionDays: opts.planRetentionDays ?? 30,
|
|
290
|
+
draftRetentionDays: opts.draftRetentionDays ?? 60,
|
|
291
|
+
sessionRetentionDays: opts.sessionRetentionDays ?? 7,
|
|
292
|
+
huRetentionDays: opts.huRetentionDays ?? 14,
|
|
293
|
+
dryRun: Boolean(opts.dryRun),
|
|
294
|
+
now: opts.now || new Date(),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Manual GC — backs `kj clean`. Caller decides dryRun via opts.
|
|
300
|
+
*
|
|
301
|
+
* @param {GCOptions} [opts]
|
|
302
|
+
* @returns {Promise<GCResult>}
|
|
303
|
+
*/
|
|
304
|
+
export async function runManualGC(opts = {}) {
|
|
305
|
+
const o = defaultOpts(opts);
|
|
306
|
+
// Audit follow-up: was 3 sequential awaits. Plans, sessions and HU
|
|
307
|
+
// stories live in different directory subtrees and don't share state,
|
|
308
|
+
// so Promise.all is safe and roughly halves end-to-end GC latency on
|
|
309
|
+
// a populated KJ_HOME.
|
|
310
|
+
const [plans, sessions, huStories] = await Promise.all([
|
|
311
|
+
gcPlans(o),
|
|
312
|
+
gcSessions(o),
|
|
313
|
+
gcHuStories(o),
|
|
314
|
+
]);
|
|
315
|
+
return mergeResults(plans, sessions, huStories);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Auto GC — silent, called at the top of `kj run` / `kj plan`. Always
|
|
320
|
+
* destructive (dryRun ignored). Returns the same result so the caller
|
|
321
|
+
* can print a one-liner summary if anything was removed.
|
|
322
|
+
*
|
|
323
|
+
* @param {Omit<GCOptions, 'dryRun'>} [opts]
|
|
324
|
+
* @returns {Promise<GCResult>}
|
|
325
|
+
*/
|
|
326
|
+
export async function runAutoGC(opts = {}) {
|
|
327
|
+
return runManualGC({ ...opts, dryRun: false });
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Wipe the HU board SQLite database (+WAL + SHM + pidfile). Used by
|
|
332
|
+
* `kj clean --nuke` as the "reset everything" button.
|
|
333
|
+
*
|
|
334
|
+
* When the caller also stops the board process (we do so best-effort
|
|
335
|
+
* here via the pidfile), re-starting the board with `kj board start`
|
|
336
|
+
* or via the post-`kj plan` autostart recreates the DB empty.
|
|
337
|
+
*
|
|
338
|
+
* @param {{ dryRun?: boolean }} [opts]
|
|
339
|
+
* @returns {Promise<GCResult>}
|
|
340
|
+
*/
|
|
341
|
+
export async function nukeBoardDb(opts = {}) {
|
|
342
|
+
const result = { removed: [], bytesFreed: 0, errors: [] };
|
|
343
|
+
const dryRun = Boolean(opts.dryRun);
|
|
344
|
+
const kHome = getKarajanHome();
|
|
345
|
+
|
|
346
|
+
// Stop the board if we find a live pidfile — UNLESS we're being
|
|
347
|
+
// invoked from inside the board itself (the user clicked ⚡ →
|
|
348
|
+
// `kj clean --nuke` in the launcher). In that case killing the
|
|
349
|
+
// board would kill the page they're looking at, mid-confirm.
|
|
350
|
+
// command-runner.js sets KJ_INSIDE_BOARD=1 on every spawn so we
|
|
351
|
+
// can detect this. The nuke still wipes the DB; the board picks
|
|
352
|
+
// up the empty DB on its next chokidar tick.
|
|
353
|
+
const insideBoard = process.env.KJ_INSIDE_BOARD === "1";
|
|
354
|
+
const pidFile = path.join(kHome, "hu-board.pid");
|
|
355
|
+
try {
|
|
356
|
+
const pid = Number.parseInt(await fs.readFile(pidFile, "utf8"), 10);
|
|
357
|
+
if (!dryRun && Number.isFinite(pid) && !insideBoard) {
|
|
358
|
+
try { process.kill(pid, 0); process.kill(pid, "SIGTERM"); } catch { /* already dead */ }
|
|
359
|
+
}
|
|
360
|
+
} catch { /* no pidfile */ }
|
|
361
|
+
|
|
362
|
+
// The DB (+ WAL / SHM) can be hundreds of MB on old installs; report
|
|
363
|
+
// the bytes freed so the user sees the reward.
|
|
364
|
+
for (const name of ["hu-board.db", "hu-board.db-wal", "hu-board.db-shm", "hu-board.pid"]) {
|
|
365
|
+
const p = path.join(kHome, name);
|
|
366
|
+
const stat = await tryStat(p);
|
|
367
|
+
if (!stat) continue;
|
|
368
|
+
try {
|
|
369
|
+
await unlinkOrRm(p, dryRun);
|
|
370
|
+
result.removed.push({ path: p, reason: "board db wiped (--nuke)", kind: "board", bytes: stat.size });
|
|
371
|
+
result.bytesFreed += stat.size;
|
|
372
|
+
} catch (err) {
|
|
373
|
+
result.errors.push({ path: p, error: err.message });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Format a GC result as a single human-readable line. Used by the
|
|
381
|
+
* silent auto-GC path so the user only sees one line in the rare case
|
|
382
|
+
* something was actually cleaned.
|
|
383
|
+
*
|
|
384
|
+
* @param {GCResult} result
|
|
385
|
+
* @returns {string|null} null when nothing was removed
|
|
386
|
+
*/
|
|
387
|
+
export function summarizeGC(result) {
|
|
388
|
+
if (!result.removed.length) return null;
|
|
389
|
+
const byKind = {};
|
|
390
|
+
for (const r of result.removed) {
|
|
391
|
+
byKind[r.kind] = (byKind[r.kind] || 0) + 1;
|
|
392
|
+
}
|
|
393
|
+
const parts = Object.entries(byKind).map(([k, n]) => `${n} ${k}${n === 1 ? "" : "s"}`);
|
|
394
|
+
const kb = Math.round(result.bytesFreed / 1024);
|
|
395
|
+
return `[gc] removed ${parts.join(", ")}${kb ? ` (${kb} KiB)` : ""}`;
|
|
396
|
+
}
|
package/src/utils/git.js
CHANGED
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
import { runCommand } from "./process.js";
|
|
2
|
+
import { getRunContext } from "./run-context.js";
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Module-scoped runner — legacy back-compat for callers outside a
|
|
6
|
+
* `withRunContext` scope (CLI bootstrap, checks/system.js). When running
|
|
7
|
+
* inside a pipeline, `getRunContext()?.runner` takes precedence and
|
|
8
|
+
* provides per-run isolation (TSK-0338). When neither is set we fall
|
|
9
|
+
* back to the bare `runCommand`.
|
|
10
|
+
*
|
|
11
|
+
* @type {((command: string, args?: string[], options?: object) => Promise<object>)|null}
|
|
12
|
+
*/
|
|
4
13
|
let _runner = null;
|
|
5
14
|
|
|
6
15
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
16
|
+
* Set the module-scope runner. Retained for back-compat: concurrent
|
|
17
|
+
* `runFlow` invocations should rely on `withRunContext` for isolation,
|
|
18
|
+
* not on this setter (the setter is process-wide and would cause the
|
|
19
|
+
* exact cross-run contamination TSK-0338 eliminates).
|
|
9
20
|
*/
|
|
10
21
|
export function setRunner(runner) {
|
|
11
22
|
_runner = runner;
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
function run(command, args, ...rest) {
|
|
15
|
-
|
|
26
|
+
const ctxRunner = getRunContext()?.runner;
|
|
27
|
+
return (ctxRunner || _runner || runCommand)(command, args, ...rest);
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
function slugifyTask(task) {
|
package/src/utils/locale.js
CHANGED
|
@@ -35,7 +35,7 @@ function extractLangCode(locale) {
|
|
|
35
35
|
const trimmed = locale.trim();
|
|
36
36
|
if (!trimmed || trimmed === "C" || trimmed === "POSIX") return null;
|
|
37
37
|
// Match first 2 lowercase letters before _, ., or end
|
|
38
|
-
const match = /^([a-z]{2})(?:[_
|
|
38
|
+
const match = /^([a-z]{2})(?:[_.-]|$)/i.exec(trimmed);
|
|
39
39
|
if (match) return match[1].toLowerCase();
|
|
40
40
|
return null;
|
|
41
41
|
}
|
package/src/utils/paths.js
CHANGED
|
@@ -1,11 +1,31 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
|
+
// Per-process tmp dir for VITEST runs that forget to set KJ_HOME. Same
|
|
5
|
+
// rationale as src/plan/plan-store.js — without this, tests that use
|
|
6
|
+
// real session/board paths leak files into the developer's real
|
|
7
|
+
// `~/.karajan/` and pollute the HU Board's SQLite DB. Memoised so
|
|
8
|
+
// every helper in the same process agrees on the path. The trailing
|
|
9
|
+
// segment is `.karajan` to keep semantic parity with non-VITEST
|
|
10
|
+
// defaults (existing tests assert paths contain ".karajan/...").
|
|
11
|
+
let _vitestKarajanHome = null;
|
|
12
|
+
function vitestTmpKarajanHome() {
|
|
13
|
+
if (_vitestKarajanHome) return _vitestKarajanHome;
|
|
14
|
+
_vitestKarajanHome = path.join(
|
|
15
|
+
os.tmpdir(),
|
|
16
|
+
`karajan-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
|
|
17
|
+
".karajan"
|
|
18
|
+
);
|
|
19
|
+
return _vitestKarajanHome;
|
|
20
|
+
}
|
|
21
|
+
|
|
4
22
|
export function getKarajanHome() {
|
|
5
23
|
if (process.env.KJ_HOME) {
|
|
6
24
|
return path.resolve(process.env.KJ_HOME);
|
|
7
25
|
}
|
|
8
|
-
|
|
26
|
+
if (process.env.VITEST) {
|
|
27
|
+
return vitestTmpKarajanHome();
|
|
28
|
+
}
|
|
9
29
|
return path.join(os.homedir(), ".karajan");
|
|
10
30
|
}
|
|
11
31
|
|
package/src/utils/process.js
CHANGED
|
@@ -149,7 +149,7 @@ export async function runCommand(command, args = [], options = {}) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
function enrichResult(result, stdoutAccum,
|
|
152
|
+
function enrichResult(result, stdoutAccum, _stderrAccum) {
|
|
153
153
|
if (result.timedOut) return result;
|
|
154
154
|
|
|
155
155
|
const killed = result.killed || !!result.signal;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR-H: ask the user "Nombre del proyecto?" before saving a plan.
|
|
3
|
+
*
|
|
4
|
+
* The user's words: "El nombre del board debería de deducirse del
|
|
5
|
+
* cwd o del nombre del plan, pero no darlo por hecho, preguntar al
|
|
6
|
+
* usuario siempre, por si quiere cambiarlo."
|
|
7
|
+
*
|
|
8
|
+
* Pre-fills the prompt with the suggested name (derived from the
|
|
9
|
+
* task / cwd) so accepting the default is one ENTER away. Returns
|
|
10
|
+
* the trimmed answer; falls back to the default when the user just
|
|
11
|
+
* hits ENTER (empty input).
|
|
12
|
+
*
|
|
13
|
+
* Caller is responsible for skipping this when stdin/stdout are not
|
|
14
|
+
* a TTY, when --yes is set, or when --json is set.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import readline from "node:readline";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {string} defaultName — pre-filled suggestion shown in [brackets]
|
|
21
|
+
* @returns {Promise<string>} the user's answer (defaults to defaultName)
|
|
22
|
+
*/
|
|
23
|
+
export async function promptProjectName(defaultName) {
|
|
24
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
25
|
+
const fallback = String(defaultName || "").trim();
|
|
26
|
+
const hint = fallback ? ` [${fallback}]` : "";
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
rl.question(`Nombre del proyecto${hint}: `, (answer) => {
|
|
29
|
+
rl.close();
|
|
30
|
+
const trimmed = String(answer || "").trim();
|
|
31
|
+
resolve(trimmed || fallback);
|
|
32
|
+
});
|
|
33
|
+
rl.on("SIGINT", () => { rl.close(); resolve(fallback); });
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-run context store — makes module-level state in `utils/git.js`,
|
|
3
|
+
* `review/diff-generator.js`, etc. safe under concurrent `runFlow` calls.
|
|
4
|
+
*
|
|
5
|
+
* Pre-v2.7.5 the orchestrator mutated module-scope variables at pipeline
|
|
6
|
+
* start:
|
|
7
|
+
* setDiffRunner(rtkRunner); setDiffProjectDir(diffScope);
|
|
8
|
+
* setGitRunner(rtkRunner);
|
|
9
|
+
* // …later…
|
|
10
|
+
* // every git/diff op reads those globals
|
|
11
|
+
*
|
|
12
|
+
* That is fine for a single-threaded CLI invocation but corrupts when the
|
|
13
|
+
* MCP server fields two `kj_run` tool calls in parallel: each run would
|
|
14
|
+
* overwrite the others' runner/projectDir during setup, and subsequent
|
|
15
|
+
* reads would see a mix.
|
|
16
|
+
*
|
|
17
|
+
* TSK-0338 isolates that state to a per-run AsyncLocalStorage scope.
|
|
18
|
+
* `runFlow` wraps its execution in `withRunContext(ctx, fn)`, and the
|
|
19
|
+
* read paths in git.js / diff-generator.js look up
|
|
20
|
+
* `getRunContext()?.runner` before falling back to the module-level
|
|
21
|
+
* `_runner` (kept as a back-compat entry for callers outside a
|
|
22
|
+
* `runFlow` — CLI helpers, tests — that set state directly).
|
|
23
|
+
*
|
|
24
|
+
* What lives here vs. elsewhere:
|
|
25
|
+
* - `runContext` (this module) — runner, projectDir, snapshot — the
|
|
26
|
+
* pieces git/diff touches synchronously on every op.
|
|
27
|
+
* - `PipelineContext` (src/orchestrator/pipeline-context.js) — the
|
|
28
|
+
* richer structure ferried between stages. The two could merge
|
|
29
|
+
* eventually, but PipelineContext is created after preflight, and
|
|
30
|
+
* the runner/projectDir must be in scope BEFORE preflight runs
|
|
31
|
+
* (which itself calls git commands).
|
|
32
|
+
*
|
|
33
|
+
* The invariant `tests/orchestrator/concurrent-runs.test.js` exercises
|
|
34
|
+
* the isolation: two parallel `withRunContext(A, …)` and
|
|
35
|
+
* `withRunContext(B, …)` see their own runner, not each other's.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} RunContext
|
|
42
|
+
* @property {((command: string, args?: string[], options?: object) => Promise<object>)|null} [runner]
|
|
43
|
+
* Pipeline-scoped command runner. When present, git.js + diff-generator.js
|
|
44
|
+
* route commands through it instead of the default `runCommand`.
|
|
45
|
+
* @property {string|null} [projectDir]
|
|
46
|
+
* Directory scope for `git diff`. Prevents leaking unrelated subdir
|
|
47
|
+
* changes when `kj run` is invoked from a subpath of a monorepo.
|
|
48
|
+
* @property {Map<string, string>|null} [snapshot]
|
|
49
|
+
* Pre-coder filesystem snapshot for snapshot-based diff when git is
|
|
50
|
+
* unavailable (greenfield projects with no initial commit).
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
const runStore = new AsyncLocalStorage();
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Run `fn` with `ctx` as the active RunContext. All reads of
|
|
57
|
+
* `getRunContext()` inside (including from awaited callees) see `ctx`.
|
|
58
|
+
* Concurrent `withRunContext` invocations are fully isolated from each
|
|
59
|
+
* other — that's what AsyncLocalStorage guarantees.
|
|
60
|
+
*
|
|
61
|
+
* @param {RunContext} ctx
|
|
62
|
+
* @param {() => Promise<any>} fn
|
|
63
|
+
* @returns {Promise<any>}
|
|
64
|
+
*/
|
|
65
|
+
export function withRunContext(ctx, fn) {
|
|
66
|
+
return runStore.run(ctx ?? {}, fn);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* @returns {RunContext|undefined} the active RunContext, or undefined when
|
|
71
|
+
* called outside any `withRunContext` scope (legacy CLI paths, tests).
|
|
72
|
+
*/
|
|
73
|
+
export function getRunContext() {
|
|
74
|
+
return runStore.getStore();
|
|
75
|
+
}
|
package/src/utils/run-log.js
CHANGED
|
@@ -57,6 +57,18 @@ export function createRunLog(projectDir) {
|
|
|
57
57
|
// Truncate/create the log file
|
|
58
58
|
fs.writeFileSync(logPath, `--- Karajan run started at ${new Date().toISOString()} ---\n`);
|
|
59
59
|
|
|
60
|
+
// Audit recommendation (perf-LOW): suggested createWriteStream + queue.
|
|
61
|
+
// Tried it; broke the contract. `readRunLog` (used by `kj_status`,
|
|
62
|
+
// `kj-tail`, and the HU board's chokidar tick) reads the file with
|
|
63
|
+
// `fs.readFileSync` and expects every prior write to be visible the
|
|
64
|
+
// moment the writer's `logEvent()` returns — which is what
|
|
65
|
+
// `fs.writeSync(fd, ...)` guarantees and `stream.write()` does NOT.
|
|
66
|
+
//
|
|
67
|
+
// The current shape is already opened-once: `fs.openSync(logPath, "a")`
|
|
68
|
+
// returns an fd that every subsequent `fs.writeSync` reuses (no extra
|
|
69
|
+
// open/close per call). The hot-path complaint was about the FALLBACK
|
|
70
|
+
// `fs.appendFileSync` line, which only fires when openSync itself
|
|
71
|
+
// fails (rare; FS error or RO mount).
|
|
60
72
|
let fd = null;
|
|
61
73
|
try {
|
|
62
74
|
fd = fs.openSync(logPath, "a");
|
package/src/utils/task-file.js
CHANGED
|
@@ -49,7 +49,7 @@ export async function readTaskFile(filePath, opts = {}) {
|
|
|
49
49
|
try {
|
|
50
50
|
stat = await fs.stat(resolved);
|
|
51
51
|
} catch (err) {
|
|
52
|
-
throw new Error(`Task file not found: ${resolved} (${err.code || err.message})
|
|
52
|
+
throw new Error(`Task file not found: ${resolved} (${err.code || err.message})`, { cause: err });
|
|
53
53
|
}
|
|
54
54
|
if (!stat.isFile()) {
|
|
55
55
|
throw new Error(`Task file is not a regular file: ${resolved}`);
|
package/src/utils/telemetry.js
CHANGED
|
@@ -32,7 +32,19 @@ export async function sendTelemetryEvent(eventName, data, config) {
|
|
|
32
32
|
} finally {
|
|
33
33
|
clearTimeout(timeout);
|
|
34
34
|
}
|
|
35
|
-
} catch {
|
|
35
|
+
} catch (err) {
|
|
36
|
+
// Audit follow-up: was a silent catch{}. Telemetry MUST stay
|
|
37
|
+
// fire-and-forget (never block the user's pipeline, never throw),
|
|
38
|
+
// but completely silent failures hide DNS/network bugs that matter
|
|
39
|
+
// when debugging the telemetry pipeline itself. Surface the error
|
|
40
|
+
// to stderr only when KJ_DEBUG=1 is explicitly set, so normal runs
|
|
41
|
+
// are still silent.
|
|
42
|
+
if (process.env.KJ_DEBUG === "1") {
|
|
43
|
+
// Stderr (not console — telemetry runs from CLI commands and is
|
|
44
|
+
// legitimate user-visible diagnostic output under KJ_DEBUG).
|
|
45
|
+
process.stderr.write(`[telemetry-debug] ${eventName}: ${err.message}\n`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
36
48
|
}
|
|
37
49
|
|
|
38
50
|
/**
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import fs from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import os from "node:os";
|
|
6
|
-
import { isOpenSkillsAvailable,
|
|
6
|
+
import { isOpenSkillsAvailable, listSkills } from "../skills/openskills-client.js";
|
|
7
7
|
|
|
8
8
|
/** Skill names relevant for WebPerf analysis. Already installed globally via openskills. */
|
|
9
9
|
export const WEBPERF_SKILLS = [
|
package/templates/coder-rules.md
CHANGED
|
@@ -22,3 +22,16 @@
|
|
|
22
22
|
- Keep changes minimal and focused on the task.
|
|
23
23
|
- Do not modify code unrelated to the task.
|
|
24
24
|
- Follow existing code conventions and patterns in the repository.
|
|
25
|
+
|
|
26
|
+
## Tests location (MANDATORY)
|
|
27
|
+
|
|
28
|
+
- Tests live in a top-level `tests/` directory mirroring `src/` (e.g. a function in
|
|
29
|
+
`src/services/foo.js` is tested by `tests/services/foo.test.js`).
|
|
30
|
+
- DO NOT scatter `*.test.js` files inside `src/` unless the project's local
|
|
31
|
+
`.karajan/coder-rules.md` explicitly overrides this.
|
|
32
|
+
- When generating a project from scratch:
|
|
33
|
+
- Create `tests/` and point the test runner at it (`include: ['tests/**/*.test.{js,ts}']`).
|
|
34
|
+
- Exclude `tests/` from the production bundle.
|
|
35
|
+
- If you write `sonar-project.properties`, set `sonar.tests=tests` AND make sure
|
|
36
|
+
that directory exists. Never declare a path that doesn't exist — Sonar will
|
|
37
|
+
refuse to scan and the run will fail every iteration with the same error.
|