karajan-code 2.18.0 → 2.18.1
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 +3 -1
- package/package.json +1 -1
- package/src/brain/with-brain-recovery.js +65 -8
- package/src/commands/resume.js +48 -27
- package/src/hu/auto-generator.js +170 -39
- package/src/orchestrator/drivers/pre-loop-phases/auto-hu-batch.js +22 -2
- package/src/plan/tests-synthesizer.js +16 -6
- package/src/prompts/coder.js +27 -4
- package/src/roles/coder-role.js +14 -1
package/README.md
CHANGED
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
> **v2.18.
|
|
26
|
+
> **v2.18.1 released** — Patch. Six user-feedback follow-ups to v2.18.0: **`kj-tail` after `kj resume`** (#772, was silent because `resume.js` skipped `withCliRunLog`); **standby waits in-process instead of exiting on a short cooldown** (#773, kj stays alive for waits ≤ 12 h and retries on its own; Ctrl+C during the wait prints `kj standby resume <id>`); **closed KJC-BUG-0040 — SEA linux binaries** (#774, was a race between `gh release create` and softprops, not `better-sqlite3` as the memory said — 60 s poll fix + `make_latest:false`); **stack bias — Python repos no longer get vitest** (#775 + #776 + #777, `detectProjectStack` finally cabled into the coder prompt, the HU auto-generator templates by language, and the synthesizer + `auto-hu-batch` take the stack from the filesystem). 4 971/4 971 tests passing across 416 files. Safe upgrade from 2.18.0.
|
|
27
|
+
>
|
|
28
|
+
> **v2.18.0** — Minor. Closes the **resilience audit** triggered by the public launch: 15 PRs across 5 phases hardening Karajan against the silent-failure family of bugs — *the problem is not that something fails, the problem is failing without telling the user why.* **Phase 1 — hibernation end to end** (#756–#759): a quota cap is classified as a recoverable quota class (incl. Claude Code's `"You've hit your session limit · resets 10:10pm"`); `withBrainRecovery` persists a standby JSON; the orchestrator consumes `action:"hibernate"`, seals the session `hibernated` (resumable) instead of `failed`; the last line printed is the exact resume command (`kj standby resume <id>`). **Phase 2 — don't lie** (#761–#763): `runCommand` surfaces ENOENT so a missing agent CLI gets an actionable error (not an empty one); `silenceTimeoutMs` is forwarded to every role so a hung agent is killed, not waited on forever; all 6 state-file writers go through `writeJsonAtomic` (write-temp + rename) — interrupted writes can no longer truncate plans, sessions, standby snapshots. **Phase 3 — don't lose or block** (#764–#767): a corrupt plan JSON is renamed aside with a loud warn (was silently dropped from `kj plan list`); `kj.config.yml` parse errors throw `Invalid YAML in <path>` (used to brick every kj command including `kj doctor`); `injectLoadedPlan` reconciles HUs left in `coding`/`reviewing`/`running` by a killed `kj run` (cross-checked against the run-registry); board SQLite gets `busy_timeout`, a `user_version` schema gate and corruption recovery. **Phase 4 — don't degrade silently** (#768–#769): `TriageRole` warns loudly when LLM output is unparseable (used to skip researcher/architect/security/tester with a chirpy "fallback defaults" summary); `verifyCoderOutput` distinguishes a git failure from "the coder did nothing" (`gitError` + `retryStrategy: null`) — no more iterations blaming the agent for infra. **Phase 5 — safety net** (#770): a `tests/resilience/` suite indexes every silent-failure mode and pins each one with a test, plus an end-to-end tripwire for the whole quota → hibernate → resume flow. Plus CI runs the `packages/hu-board` suite on every PR (#755). 4 959/4 959 tests passing across 416 files. Safe upgrade from 2.17.2.
|
|
27
29
|
>
|
|
28
30
|
> **v2.17.2** — Patch. Wires quota-exhaustion **hibernation end to end**. A `kj run` / `kj plan` that hits a provider session or usage cap used to abort the task with an opaque `UNKNOWN_FATAL`; now it suspends and tells you how to resume. **Session-limit classification (#756)**: `"You've hit your session limit · resets 10:10pm"` matched no rate-limit pattern → `session limit`/`weekly limit` added, and `parseCooldown` learns the 12-hour `resets 10:10pm` clock. **Hibernation persists (#757)**: `withBrainRecovery` only wrote `~/.kj/standby/<id>.json` with a `sessionState`, which no caller passed — new `buildStandbyState()` assembles it with an allowlisted env subset (never the full `process.env`). **Orchestrator consumes `action:"hibernate"` (#758)**: no code checked for it, so a hibernation sealed the HU `failed`; the coder/refactorer stages now stop cleanly and the session is sealed `hibernated` (resumable). **Resume hint (#759)**: a stopped run's last line is now the exact command — `kj standby resume <id>` for a hibernation, `kj resume <id>` otherwise. Plus CI now runs the `packages/hu-board` suite (#755). 4 931/4 931 tests passing across 410 files. Safe upgrade from 2.17.1.
|
|
29
31
|
>
|
package/package.json
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// puede ignorarlo (fallback a long standby capped) hasta que llegue 0414.
|
|
8
8
|
|
|
9
9
|
import { classifyAgentError, ERROR_CLASS } from "./agent-error-classifier.js";
|
|
10
|
-
import { persistStandby } from "./standby-store.js";
|
|
10
|
+
import { persistStandby, markStandbyDone } from "./standby-store.js";
|
|
11
11
|
|
|
12
12
|
const ONE_MIN = 60 * 1000;
|
|
13
13
|
|
|
@@ -20,6 +20,13 @@ export const DEFAULT_RECOVERY_POLICY = Object.freeze({
|
|
|
20
20
|
// KJC-TSK-0415: threshold de fallback. Si retryAfter > esto Y el role
|
|
21
21
|
// tiene fallback configurado → switch provider en vez de hibernar.
|
|
22
22
|
fallbackWaitHoursDefault: DEFAULT_FALLBACK_WAIT_HOURS,
|
|
23
|
+
// Standby-in-process: when the cooldown is short enough we keep the
|
|
24
|
+
// process alive and `await sleepFn(retryAfter)`, then retry the
|
|
25
|
+
// agent. The user sees nothing terminal — kj just pauses. A SIGINT /
|
|
26
|
+
// SIGTERM during the wait prints `kj standby resume <id>` and exits
|
|
27
|
+
// cleanly. For waits longer than this the process exits straight
|
|
28
|
+
// away (a multi-day weekly cap is not worth keeping kj running).
|
|
29
|
+
standbyWaitHoursMax: DEFAULT_FALLBACK_WAIT_HOURS,
|
|
23
30
|
classes: {
|
|
24
31
|
[ERROR_CLASS.RATE_LIMIT_SHORT]: { mode: "standby", maxRetries: 3 },
|
|
25
32
|
[ERROR_CLASS.QUOTA_EXHAUSTED_DAILY]: { mode: "hibernate", maxRetries: 1, fallbackEligible: true },
|
|
@@ -132,10 +139,15 @@ export async function withBrainRecovery({
|
|
|
132
139
|
}
|
|
133
140
|
}
|
|
134
141
|
|
|
135
|
-
// HIBERNATE:
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
142
|
+
// HIBERNATE: the cooldown is too long for a short standby. Persist
|
|
143
|
+
// the snapshot first so a SIGINT / SIGTERM during the wait leaves a
|
|
144
|
+
// recoverable session on disk. Then either:
|
|
145
|
+
// (a) wait in-process until cooldownUntil and retry (the common
|
|
146
|
+
// case — daily quotas reset in a few hours), or
|
|
147
|
+
// (b) when the wait would exceed `standbyWaitHoursMax`, return
|
|
148
|
+
// action="hibernate" so the caller exits and the user
|
|
149
|
+
// resumes later with `kj standby resume <id>` (weekly /
|
|
150
|
+
// monthly caps).
|
|
139
151
|
if (classPolicy.mode === "hibernate" && cls.retryAfter && cls.retryAfter > policy.hibernateThresholdMs) {
|
|
140
152
|
let standbyFile = null;
|
|
141
153
|
if (sessionState && sessionState.sessionId) {
|
|
@@ -147,13 +159,58 @@ export async function withBrainRecovery({
|
|
|
147
159
|
role, provider: effectiveProvider,
|
|
148
160
|
classifierMessage: cls.message,
|
|
149
161
|
});
|
|
150
|
-
logger?.info?.(`[brain] hibernated session ${sessionState.sessionId} → ${standbyFile} (resume at ${cls.retryUntil})`);
|
|
151
162
|
} catch (err) {
|
|
152
163
|
logger?.warn?.(`[brain] failed to persist standby: ${err.message}`);
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
|
-
|
|
156
|
-
|
|
166
|
+
|
|
167
|
+
const maxWaitMs = (policy.standbyWaitHoursMax ?? DEFAULT_FALLBACK_WAIT_HOURS) * ONE_HOUR;
|
|
168
|
+
const tooLongToWait = cls.retryAfter > maxWaitMs;
|
|
169
|
+
|
|
170
|
+
if (tooLongToWait) {
|
|
171
|
+
logger?.info?.(`[brain] hibernated session ${sessionState?.sessionId ?? "(no id)"} → ${standbyFile} (resume at ${cls.retryUntil}; wait > ${Math.round(maxWaitMs/ONE_HOUR)}h, exiting)`);
|
|
172
|
+
emit(emitter, "brain:hibernate-request", eventBase, { role, class: cls.class, retryUntil: cls.retryUntil, retryAfter: cls.retryAfter, standbyFile });
|
|
173
|
+
return { ok: false, action: "hibernate", recovery: cls, standbyFile };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Standby-in-process: keep kj alive, sleep until the cooldown,
|
|
177
|
+
// then retry. A SIGINT / SIGTERM during the wait prints the
|
|
178
|
+
// resume command and exits — the standby JSON is already on
|
|
179
|
+
// disk so the user can pick it up later.
|
|
180
|
+
const sid = sessionState?.sessionId ?? null;
|
|
181
|
+
const onSignal = (signal) => {
|
|
182
|
+
// eslint-disable-next-line no-console
|
|
183
|
+
console.log(`\n⏸ Standby interrupted (${signal}). Resume with:`);
|
|
184
|
+
// eslint-disable-next-line no-console
|
|
185
|
+
if (sid) console.log(` kj standby resume ${sid}`);
|
|
186
|
+
// eslint-disable-next-line no-console
|
|
187
|
+
else console.log(` kj standby list (no session id was assigned)`);
|
|
188
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
189
|
+
};
|
|
190
|
+
const detach = () => {
|
|
191
|
+
process.removeListener("SIGINT", onSignal);
|
|
192
|
+
process.removeListener("SIGTERM", onSignal);
|
|
193
|
+
};
|
|
194
|
+
process.once("SIGINT", onSignal);
|
|
195
|
+
process.once("SIGTERM", onSignal);
|
|
196
|
+
|
|
197
|
+
const hours = Math.round((cls.retryAfter / ONE_HOUR) * 10) / 10;
|
|
198
|
+
logger?.info?.(`[brain] ${role}: standby for ~${hours}h until ${cls.retryUntil} (Ctrl+C to detach; will resume with 'kj standby resume ${sid}').`);
|
|
199
|
+
emit(emitter, "brain:standby-wait", eventBase, { role, class: cls.class, waitMs: cls.retryAfter, retryUntil: cls.retryUntil, standbyFile });
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
await sleepFn(cls.retryAfter);
|
|
203
|
+
} finally {
|
|
204
|
+
detach();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Wait finished without an interrupt — mark the standby JSON
|
|
208
|
+
// done so it does not auto-resume from disk later.
|
|
209
|
+
if (sid) {
|
|
210
|
+
try { markStandbyDone(sid); } catch { /* ignore */ }
|
|
211
|
+
}
|
|
212
|
+
emit(emitter, "brain:standby-resumed", eventBase, { role, class: cls.class });
|
|
213
|
+
continue;
|
|
157
214
|
}
|
|
158
215
|
|
|
159
216
|
// STANDBY: espera hasta cooldownUntil (capado por longStandbyCapMs).
|
package/src/commands/resume.js
CHANGED
|
@@ -3,6 +3,8 @@ import readline from "node:readline";
|
|
|
3
3
|
import { resumeFlow } from "../orchestrator.js";
|
|
4
4
|
import { createActivityLog } from "../activity-log.js";
|
|
5
5
|
import { printEvent } from "../utils/display/event-handlers.js";
|
|
6
|
+
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
7
|
+
import { registerRun, unregisterRun } from "../utils/run-registry.js";
|
|
6
8
|
|
|
7
9
|
function createCliAskQuestion(opts = {}) {
|
|
8
10
|
const { sessionId = null } = opts;
|
|
@@ -51,36 +53,55 @@ export async function resumeCommand({ sessionId, answer, config, logger, flags }
|
|
|
51
53
|
const jsonMode = flags?.json;
|
|
52
54
|
const quietMode = config.output?.quiet !== false;
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
// Same wrapper as every other CLI command — without it `.kj/run.log`
|
|
57
|
+
// is never opened during the resume and `kj-tail` stays silent
|
|
58
|
+
// (every line of the resumed run goes only to the per-session
|
|
59
|
+
// activity log + the local console). Also registers the run so the
|
|
60
|
+
// HU Board shows it as live and can offer its Stop button.
|
|
61
|
+
return withCliRunLog("resume", { projectDir: config?.projectDir, logger }, async ({ forwardProgress }) => {
|
|
62
|
+
const runId = registerRun({
|
|
63
|
+
pid: process.pid,
|
|
64
|
+
planId: flags?.plan || null,
|
|
65
|
+
huIds: null,
|
|
66
|
+
projectDir: config?.projectDir || process.cwd(),
|
|
67
|
+
source: "cli-resume",
|
|
68
|
+
});
|
|
69
|
+
const cleanupRegistry = () => { if (runId) unregisterRun(runId); };
|
|
70
|
+
process.once("exit", cleanupRegistry);
|
|
71
|
+
process.once("SIGTERM", () => { cleanupRegistry(); process.exit(143); });
|
|
72
|
+
process.once("SIGINT", () => { cleanupRegistry(); process.exit(130); });
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
logger.onLog((entry) => activityLog.write(entry));
|
|
61
|
-
}
|
|
74
|
+
const emitter = new EventEmitter();
|
|
75
|
+
let activityLog = null;
|
|
76
|
+
forwardProgress(emitter);
|
|
62
77
|
|
|
63
|
-
|
|
64
|
-
activityLog.
|
|
65
|
-
|
|
78
|
+
emitter.on("progress", (event) => {
|
|
79
|
+
if (!activityLog && event.sessionId) {
|
|
80
|
+
activityLog = createActivityLog(event.sessionId);
|
|
81
|
+
logger.onLog((entry) => activityLog.write(entry));
|
|
82
|
+
}
|
|
83
|
+
if (activityLog) {
|
|
84
|
+
activityLog.writeEvent(event);
|
|
85
|
+
}
|
|
86
|
+
if (!jsonMode) {
|
|
87
|
+
printEvent(event, { quiet: quietMode });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
66
90
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
91
|
+
const askQuestion = createCliAskQuestion();
|
|
92
|
+
const result = await resumeFlow({
|
|
93
|
+
sessionId,
|
|
94
|
+
answer: answer || null,
|
|
95
|
+
config,
|
|
96
|
+
logger,
|
|
97
|
+
flags: flags || {},
|
|
98
|
+
emitter,
|
|
99
|
+
askQuestion
|
|
100
|
+
});
|
|
71
101
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
config,
|
|
77
|
-
logger,
|
|
78
|
-
flags: flags || {},
|
|
79
|
-
emitter,
|
|
80
|
-
askQuestion
|
|
102
|
+
if (jsonMode || !answer) {
|
|
103
|
+
console.log(JSON.stringify(result, null, 2));
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
81
106
|
});
|
|
82
|
-
|
|
83
|
-
if (jsonMode || !answer) {
|
|
84
|
-
console.log(JSON.stringify(result, null, 2));
|
|
85
|
-
}
|
|
86
107
|
}
|
package/src/hu/auto-generator.js
CHANGED
|
@@ -66,64 +66,191 @@ export function needsSetupHu({ isNewProject = false, stackHints = [], subtasks =
|
|
|
66
66
|
return subtasks.some(s => setupKeywords.test(s));
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Stack-hint keyword sets. Used by filterConflictingHints (drop hints
|
|
70
|
+
// from other ecosystems once one is detected) and by
|
|
71
|
+
// resolveLanguageFromHints (pick the canonical language for setup +
|
|
72
|
+
// acceptance_tests templates).
|
|
73
|
+
const HINT_NODE = new Set(["express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces"]);
|
|
74
|
+
const HINT_PYTHON = new Set(["pytest", "flask", "fastapi", "django", "numpy", "pandas"]);
|
|
75
|
+
const HINT_GO = new Set(["gin", "fiber", "go"]);
|
|
76
|
+
const HINT_RUST = new Set(["cargo", "rust"]);
|
|
77
|
+
|
|
69
78
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
79
|
+
* Drop hints from OTHER ecosystems when one is clearly the project's.
|
|
80
|
+
* Symmetric: previously only node-vs-go was filtered, so a Python
|
|
81
|
+
* project that also had "vitest" in a stale gitignore would still
|
|
82
|
+
* receive vitest in its setup HU.
|
|
73
83
|
*/
|
|
74
84
|
function filterConflictingHints(hints) {
|
|
75
85
|
if (!hints || hints.length === 0) return hints;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const setForEachLang = [
|
|
87
|
+
["python", HINT_PYTHON],
|
|
88
|
+
["go", HINT_GO],
|
|
89
|
+
["rust", HINT_RUST],
|
|
90
|
+
["node", HINT_NODE],
|
|
91
|
+
];
|
|
92
|
+
const present = setForEachLang.filter(([, set]) => hints.some((h) => set.has(h)));
|
|
93
|
+
if (present.length <= 1) return hints;
|
|
94
|
+
// First ecosystem found in the original hints order wins. We keep
|
|
95
|
+
// ambiguous keywords (those not in any set) and the winner's own
|
|
96
|
+
// keywords; we drop the explicit losers from competing ecosystems.
|
|
97
|
+
const winnerSet = present[0][1];
|
|
98
|
+
const losers = present.slice(1).flatMap(([, s]) => [...s]);
|
|
99
|
+
return hints.filter((h) => winnerSet.has(h) || !losers.includes(h));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pick the canonical language for setup + acceptance_test templates
|
|
104
|
+
* based on the (already filtered) stack hints. Falls back to
|
|
105
|
+
* javascript so existing Node-centric runs keep their previous shape.
|
|
106
|
+
* @param {string[]} hints
|
|
107
|
+
* @returns {"python"|"go"|"rust"|"javascript"}
|
|
108
|
+
*/
|
|
109
|
+
export function resolveLanguageFromHints(hints) {
|
|
110
|
+
if (!hints || hints.length === 0) return "javascript";
|
|
111
|
+
if (hints.some((h) => HINT_PYTHON.has(h))) return "python";
|
|
112
|
+
if (hints.some((h) => HINT_GO.has(h))) return "go";
|
|
113
|
+
if (hints.some((h) => HINT_RUST.has(h))) return "rust";
|
|
114
|
+
return "javascript";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// HU templates per language. The JS template is the legacy default;
|
|
118
|
+
// the others were missing — every autogenerated HU got vitest /
|
|
119
|
+
// npm-install hardcoded acceptance_tests regardless of stack.
|
|
120
|
+
const HU_TEMPLATES = {
|
|
121
|
+
javascript: {
|
|
122
|
+
pkgManager: "npm",
|
|
123
|
+
setupBullets: [
|
|
124
|
+
"- Create package.json (with workspaces if monorepo detected from stack hints)",
|
|
125
|
+
"- Install all runtime + dev dependencies listed in stack hints",
|
|
126
|
+
"- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
|
|
127
|
+
"- Configure vitest.config.js with coverage.enabled = true",
|
|
128
|
+
"- Create .env.example with placeholder variables",
|
|
129
|
+
],
|
|
130
|
+
setupCriteria: [
|
|
131
|
+
"npm install succeeds without errors",
|
|
132
|
+
"npm test runs without error",
|
|
133
|
+
"npm run test:coverage runs without error",
|
|
134
|
+
".env.example exists",
|
|
135
|
+
],
|
|
136
|
+
setupTests: [
|
|
137
|
+
"npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
|
|
138
|
+
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
139
|
+
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
140
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
141
|
+
],
|
|
142
|
+
taskTests: [
|
|
143
|
+
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
144
|
+
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
python: {
|
|
148
|
+
pkgManager: "pip",
|
|
149
|
+
setupBullets: [
|
|
150
|
+
"- Create pyproject.toml (preferred) or requirements.txt",
|
|
151
|
+
"- Create a virtual env: python -m venv .venv",
|
|
152
|
+
"- Install runtime + dev dependencies (pip install -e . or pip install -r requirements.txt)",
|
|
153
|
+
"- Install pytest with coverage (pip install pytest pytest-cov)",
|
|
154
|
+
"- Create .env.example with placeholder variables",
|
|
155
|
+
],
|
|
156
|
+
setupCriteria: [
|
|
157
|
+
"Project metadata file exists (pyproject.toml or requirements.txt)",
|
|
158
|
+
"pytest runs without error",
|
|
159
|
+
"pytest --cov reports a total",
|
|
160
|
+
".env.example exists",
|
|
161
|
+
],
|
|
162
|
+
setupTests: [
|
|
163
|
+
"(test -f pyproject.toml || test -f requirements.txt) && echo PASS || echo FAIL",
|
|
164
|
+
"python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
165
|
+
"python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
|
|
166
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
167
|
+
],
|
|
168
|
+
taskTests: [
|
|
169
|
+
"python -m pytest 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
170
|
+
"python -m pytest --cov 2>&1 | grep -q 'TOTAL\\|coverage' && echo PASS || echo FAIL",
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
go: {
|
|
174
|
+
pkgManager: "go",
|
|
175
|
+
setupBullets: [
|
|
176
|
+
"- Create go.mod with `go mod init <module>`",
|
|
177
|
+
"- Install dependencies with `go get` per import",
|
|
178
|
+
"- Create .env.example with placeholder variables",
|
|
179
|
+
],
|
|
180
|
+
setupCriteria: [
|
|
181
|
+
"go.mod exists",
|
|
182
|
+
"go test ./... runs without error",
|
|
183
|
+
"go test -cover reports coverage",
|
|
184
|
+
".env.example exists",
|
|
185
|
+
],
|
|
186
|
+
setupTests: [
|
|
187
|
+
"test -f go.mod && echo PASS || echo FAIL",
|
|
188
|
+
"go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
189
|
+
"go test -cover ./... 2>&1 | grep -q 'coverage:' && echo PASS || echo FAIL",
|
|
190
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
191
|
+
],
|
|
192
|
+
taskTests: [
|
|
193
|
+
"go test ./... 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
194
|
+
],
|
|
195
|
+
},
|
|
196
|
+
rust: {
|
|
197
|
+
pkgManager: "cargo",
|
|
198
|
+
setupBullets: [
|
|
199
|
+
"- Initialise the crate with `cargo init`",
|
|
200
|
+
"- Add dependencies with `cargo add`",
|
|
201
|
+
"- Create .env.example with placeholder variables",
|
|
202
|
+
],
|
|
203
|
+
setupCriteria: [
|
|
204
|
+
"Cargo.toml exists",
|
|
205
|
+
"cargo test runs without error",
|
|
206
|
+
".env.example exists",
|
|
207
|
+
],
|
|
208
|
+
setupTests: [
|
|
209
|
+
"test -f Cargo.toml && echo PASS || echo FAIL",
|
|
210
|
+
"cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
211
|
+
"test -f .env.example && echo PASS || echo FAIL",
|
|
212
|
+
],
|
|
213
|
+
taskTests: [
|
|
214
|
+
"cargo test 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
215
|
+
],
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
function templateFor(language) {
|
|
220
|
+
return HU_TEMPLATES[language] || HU_TEMPLATES.javascript;
|
|
81
221
|
}
|
|
82
222
|
|
|
83
223
|
/**
|
|
84
224
|
* Build a MINIMAL setup HU — project structure + deps only.
|
|
85
225
|
* NEVER includes the full original task. The coder must only do setup.
|
|
86
226
|
*/
|
|
87
|
-
function buildSetupHu({ stackHints }) {
|
|
227
|
+
function buildSetupHu({ stackHints, language = "javascript" }) {
|
|
228
|
+
const tpl = templateFor(language);
|
|
88
229
|
const deps = stackHints.length > 0
|
|
89
230
|
? stackHints.map(h => `- ${h}`).join("\n")
|
|
90
231
|
: "- (auto-detect from subsequent HUs)";
|
|
91
232
|
const certifiedText = [
|
|
92
|
-
|
|
233
|
+
`**Setup: initialise the ${language} project + install dependencies (package manager: ${tpl.pkgManager}).**`,
|
|
93
234
|
"",
|
|
94
235
|
"SCOPE (do ONLY this, nothing else):",
|
|
95
|
-
|
|
96
|
-
"- Install all runtime + dev dependencies listed in stack hints",
|
|
97
|
-
"- Install test framework WITH coverage reporter (e.g. vitest + @vitest/coverage-v8)",
|
|
98
|
-
"- Configure vitest.config.js with coverage.enabled = true",
|
|
99
|
-
"- Create .env.example with placeholder variables",
|
|
236
|
+
...tpl.setupBullets,
|
|
100
237
|
"- Verify by running each acceptance_test command below",
|
|
101
238
|
"",
|
|
102
239
|
"DO NOT implement any business logic, API routes, components, or features.",
|
|
103
240
|
"This HU is ONLY project scaffolding.",
|
|
104
241
|
"",
|
|
105
242
|
"Stack hints:",
|
|
106
|
-
deps
|
|
243
|
+
deps,
|
|
107
244
|
].join("\n");
|
|
108
245
|
return {
|
|
109
246
|
id: "HU-01",
|
|
110
|
-
title:
|
|
247
|
+
title: `Setup: ${language} project structure + dependencies`,
|
|
111
248
|
task_type: "infra",
|
|
112
249
|
status: "certified",
|
|
113
250
|
blocked_by: [],
|
|
114
251
|
certified: { text: certifiedText },
|
|
115
|
-
acceptance_criteria:
|
|
116
|
-
|
|
117
|
-
"npm test runs without error",
|
|
118
|
-
"npm run test:coverage runs without error",
|
|
119
|
-
".env.example exists"
|
|
120
|
-
],
|
|
121
|
-
acceptance_tests: [
|
|
122
|
-
"npm install --ignore-scripts 2>&1 && echo PASS || echo FAIL",
|
|
123
|
-
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
124
|
-
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL",
|
|
125
|
-
"test -f .env.example && echo PASS || echo FAIL"
|
|
126
|
-
]
|
|
252
|
+
acceptance_criteria: tpl.setupCriteria,
|
|
253
|
+
acceptance_tests: tpl.setupTests,
|
|
127
254
|
};
|
|
128
255
|
}
|
|
129
256
|
|
|
@@ -131,8 +258,9 @@ function buildSetupHu({ stackHints }) {
|
|
|
131
258
|
* Build a MINIMAL task HU — one specific, focused piece of work.
|
|
132
259
|
* Includes a short goal reference (max 80 chars) NOT the full task.
|
|
133
260
|
*/
|
|
134
|
-
function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
261
|
+
function buildTaskHu({ id, subtask, projectName, blockedBy, language = "javascript" }) {
|
|
135
262
|
const taskType = classifyTaskType(subtask);
|
|
263
|
+
const tpl = templateFor(language);
|
|
136
264
|
const certifiedText = [
|
|
137
265
|
`**${subtask}**`,
|
|
138
266
|
"",
|
|
@@ -143,7 +271,7 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
|
143
271
|
"- Add unit tests for the new code",
|
|
144
272
|
"- Run ALL acceptance_tests listed below and ensure they pass",
|
|
145
273
|
"- Do NOT touch code outside this subtask's scope",
|
|
146
|
-
"- Target: <200 lines changed (like an atomic PR)"
|
|
274
|
+
"- Target: <200 lines changed (like an atomic PR)",
|
|
147
275
|
].join("\n");
|
|
148
276
|
return {
|
|
149
277
|
id,
|
|
@@ -155,12 +283,9 @@ function buildTaskHu({ id, subtask, projectName, blockedBy }) {
|
|
|
155
283
|
acceptance_criteria: [
|
|
156
284
|
`${subtask} is implemented and working`,
|
|
157
285
|
"Unit tests cover the new code",
|
|
158
|
-
"All acceptance_tests pass"
|
|
286
|
+
"All acceptance_tests pass",
|
|
159
287
|
],
|
|
160
|
-
acceptance_tests:
|
|
161
|
-
"npx vitest run 2>&1; test $? -eq 0 && echo PASS || echo FAIL",
|
|
162
|
-
"npx vitest run --coverage 2>&1 | grep -q 'All files\\|% Stmts' && echo PASS || echo FAIL"
|
|
163
|
-
]
|
|
288
|
+
acceptance_tests: tpl.taskTests,
|
|
164
289
|
};
|
|
165
290
|
}
|
|
166
291
|
|
|
@@ -182,7 +307,8 @@ export function generateHuBatch({
|
|
|
182
307
|
stackHints = [],
|
|
183
308
|
isNewProject = false,
|
|
184
309
|
researcherContext = null,
|
|
185
|
-
architectContext = null
|
|
310
|
+
architectContext = null,
|
|
311
|
+
language = null,
|
|
186
312
|
}) {
|
|
187
313
|
if (!originalTask || typeof originalTask !== "string") {
|
|
188
314
|
throw new Error("generateHuBatch: originalTask is required");
|
|
@@ -194,12 +320,17 @@ export function generateHuBatch({
|
|
|
194
320
|
const stories = [];
|
|
195
321
|
const filteredHints = filterConflictingHints(stackHints);
|
|
196
322
|
const needsSetup = needsSetupHu({ isNewProject, stackHints: filteredHints, subtasks });
|
|
323
|
+
// Caller may pass a language explicitly (when it has a real
|
|
324
|
+
// detectProjectStack result from the filesystem); otherwise fall
|
|
325
|
+
// back to inferring from the hint keywords. Default 'javascript'
|
|
326
|
+
// matches the previous behaviour for Node-centric runs.
|
|
327
|
+
const resolvedLanguage = language || resolveLanguageFromHints(filteredHints);
|
|
197
328
|
let nextId = 1;
|
|
198
329
|
|
|
199
330
|
const projectName = deriveProjectName(originalTask);
|
|
200
331
|
|
|
201
332
|
if (needsSetup) {
|
|
202
|
-
stories.push(buildSetupHu({ stackHints: filteredHints }));
|
|
333
|
+
stories.push(buildSetupHu({ stackHints: filteredHints, language: resolvedLanguage }));
|
|
203
334
|
nextId = 2;
|
|
204
335
|
}
|
|
205
336
|
|
|
@@ -211,7 +342,7 @@ export function generateHuBatch({
|
|
|
211
342
|
const blockedBy = [];
|
|
212
343
|
if (setupId) blockedBy.push(setupId);
|
|
213
344
|
if (previousId && previousId !== setupId) blockedBy.push(previousId);
|
|
214
|
-
stories.push(buildTaskHu({ id, subtask, projectName, blockedBy }));
|
|
345
|
+
stories.push(buildTaskHu({ id, subtask, projectName, blockedBy, language: resolvedLanguage }));
|
|
215
346
|
previousId = id;
|
|
216
347
|
nextId += 1;
|
|
217
348
|
}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { emitProgress, makeEvent } from "#utils/events.js";
|
|
19
|
+
import { detectProjectStack } from "#utils/stack-detect.js";
|
|
19
20
|
|
|
20
21
|
export async function maybeGenerateAutoHuBatch({
|
|
21
22
|
flags, stageResults, task, logger, emitter, eventBase, projectDir, session,
|
|
@@ -40,19 +41,38 @@ export async function maybeGenerateAutoHuBatch({
|
|
|
40
41
|
isNewProject = relevant.length === 0;
|
|
41
42
|
} catch { /* ignore */ }
|
|
42
43
|
|
|
43
|
-
//
|
|
44
|
+
// Stack hints from planner + architect text. Now multi-ecosystem
|
|
45
|
+
// (was 13/16 JS keywords — Python/Go/Rust barely existed). Combined
|
|
46
|
+
// with `detectProjectStack(projectDir)` below to override the text
|
|
47
|
+
// heuristic with the actual filesystem reality when available.
|
|
44
48
|
const stackHints = [];
|
|
45
49
|
const combined = `${stageResults.planner?.plan || ""} ${stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : ""} ${task}`.toLowerCase();
|
|
46
|
-
const stackKeywords = [
|
|
50
|
+
const stackKeywords = [
|
|
51
|
+
"express", "vite", "vitest", "jest", "next", "astro", "react", "vue", "svelte", "nestjs", "monorepo", "workspaces",
|
|
52
|
+
"pytest", "flask", "fastapi", "django", "numpy", "pandas",
|
|
53
|
+
"gin", "fiber", "go",
|
|
54
|
+
"cargo", "rust",
|
|
55
|
+
"spring",
|
|
56
|
+
];
|
|
47
57
|
for (const kw of stackKeywords) {
|
|
48
58
|
if (combined.includes(kw)) stackHints.push(kw);
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
// Filesystem trumps text. detectProjectStack inspects package.json /
|
|
62
|
+
// pyproject.toml / go.mod / Cargo.toml, so a pure-python repo gets
|
|
63
|
+
// `language: "python"` regardless of what the task text says.
|
|
64
|
+
let detectedLanguage = null;
|
|
65
|
+
try {
|
|
66
|
+
const fsStack = await detectProjectStack(projectDir);
|
|
67
|
+
if (fsStack?.language) detectedLanguage = fsStack.language;
|
|
68
|
+
} catch { /* best-effort */ }
|
|
69
|
+
|
|
51
70
|
const batch = generateHuBatch({
|
|
52
71
|
originalTask: task,
|
|
53
72
|
subtasks,
|
|
54
73
|
stackHints,
|
|
55
74
|
isNewProject,
|
|
75
|
+
language: detectedLanguage,
|
|
56
76
|
researcherContext: stageResults.researcher?.summary || null,
|
|
57
77
|
architectContext: stageResults.architect?.architecture ? JSON.stringify(stageResults.architect.architecture) : null
|
|
58
78
|
});
|
|
@@ -33,7 +33,7 @@ import { withBrainRecovery } from "../brain/with-brain-recovery.js";
|
|
|
33
33
|
* @param {Array<{ id: string, title?: string, scope?: string }>} args.husNeedingTests
|
|
34
34
|
* @returns {string}
|
|
35
35
|
*/
|
|
36
|
-
export function buildSynthesizerPrompt({ task, husNeedingTests }) {
|
|
36
|
+
export function buildSynthesizerPrompt({ task, husNeedingTests, stack = null, testFramework = null }) {
|
|
37
37
|
const lines = [
|
|
38
38
|
"You previously generated an implementation plan for the task below, but some HUs were left without acceptance_tests.",
|
|
39
39
|
"Your ONLY job in this call is to fill that gap.",
|
|
@@ -59,12 +59,22 @@ export function buildSynthesizerPrompt({ task, husNeedingTests }) {
|
|
|
59
59
|
"- Tests must be writable BEFORE the HU is implemented (they should fail until done).",
|
|
60
60
|
"- Return ONLY the JSON — no prose, no markdown fences around it.",
|
|
61
61
|
"",
|
|
62
|
-
"## Task",
|
|
63
|
-
task,
|
|
64
|
-
"",
|
|
65
|
-
"## HUs that need acceptance_tests",
|
|
66
|
-
"",
|
|
67
62
|
];
|
|
63
|
+
// Stack-aware hint: tell the LLM what test framework / language to
|
|
64
|
+
// emit shell commands for. Without this it defaulted to vitest /
|
|
65
|
+
// npm even on Python / Go / Rust projects (sesgo-de-stack audit).
|
|
66
|
+
if (stack || testFramework) {
|
|
67
|
+
lines.push("## Project Stack — generate shell tests for THIS stack, not generic JS");
|
|
68
|
+
if (stack?.language) lines.push(`- Language: **${stack.language}**`);
|
|
69
|
+
if (stack?.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
|
|
70
|
+
if (testFramework?.hasTests && testFramework?.framework) {
|
|
71
|
+
lines.push(`- Test framework already in use: **${testFramework.framework}** — use it for the shell commands.`);
|
|
72
|
+
} else if (stack?.language && stack.language !== "javascript" && stack.language !== "typescript") {
|
|
73
|
+
lines.push(`- Use the canonical test runner for ${stack.language} (pytest / go test / cargo test / rspec). Do NOT emit vitest, jest, or npm commands.`);
|
|
74
|
+
}
|
|
75
|
+
lines.push("");
|
|
76
|
+
}
|
|
77
|
+
lines.push("## Task", task, "", "## HUs that need acceptance_tests", "");
|
|
68
78
|
for (const hu of husNeedingTests) {
|
|
69
79
|
lines.push(`### ${hu.id}: ${hu.title || ""}`);
|
|
70
80
|
lines.push(hu.scope ? `Scope: ${hu.scope}` : "(no scope)");
|
package/src/prompts/coder.js
CHANGED
|
@@ -158,7 +158,28 @@ function renderReviewerFindingsSection(findings, huId) {
|
|
|
158
158
|
].join("\n");
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
// KJC sesgo-de-stack: Karajan used to default every prompt to JS/TS
|
|
162
|
+
// (vitest, npm install, console.log, JSDoc, httpOnly cookies). When
|
|
163
|
+
// the project is Python / Go / Rust / Ruby, that produced absurd
|
|
164
|
+
// outputs (e.g. installing vitest in a pure-python repo to satisfy a
|
|
165
|
+
// hardcoded `npx vitest run` acceptance test). When we know the
|
|
166
|
+
// stack we tell the coder explicitly so it stops assuming.
|
|
167
|
+
function buildStackSection({ stack, testFramework }) {
|
|
168
|
+
if (!stack && !testFramework) return null;
|
|
169
|
+
const lines = ["## Project Stack — match this, don't assume JavaScript"];
|
|
170
|
+
if (stack?.language) lines.push(`- Language: **${stack.language}**`);
|
|
171
|
+
if (stack?.frameworks?.length) lines.push(`- Frameworks: ${stack.frameworks.join(", ")}`);
|
|
172
|
+
if (testFramework?.hasTests && testFramework?.framework) {
|
|
173
|
+
lines.push(`- Test framework already in use: **${testFramework.framework}** (${testFramework.language || stack?.language || "unknown"}). Use it for new tests; do NOT add a JS test framework on top.`);
|
|
174
|
+
} else if (stack?.language && stack.language !== "javascript" && stack.language !== "typescript") {
|
|
175
|
+
lines.push(`- No test framework detected yet. Pick the canonical one for ${stack.language} (e.g. pytest for python, \`go test\` for go, \`cargo test\` for rust, rspec for ruby). Do NOT use vitest / jest / \`npm install\` in a non-JS project.`);
|
|
176
|
+
}
|
|
177
|
+
lines.push("");
|
|
178
|
+
lines.push("If an acceptance_test command on this HU points at a foreign framework (e.g. `npx vitest run` on a python project), REPLACE the command with the equivalent for the project's actual stack and use that to verify your work.");
|
|
179
|
+
return lines.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
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, acceptanceTests = null, adrs = null, specSection = null, reviewerFindings = null, huId = null, stack = null, testFramework = null }) {
|
|
162
183
|
const langInstruction = getLanguageInstruction(language);
|
|
163
184
|
// KJC-BUG-0032 (PR-I): hard rule about file paths. Real bug: a HU
|
|
164
185
|
// titled "Initialize project skeleton: create assistant/ directory…"
|
|
@@ -190,10 +211,12 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
|
|
|
190
211
|
"Keep changes minimal and production-ready.",
|
|
191
212
|
"Follow SOLID principles. Write small, focused functions (< 30 lines).",
|
|
192
213
|
"Make atomic commits: 1 logical change = 1 commit. Keep PRs small and reviewable.",
|
|
193
|
-
"Security:
|
|
194
|
-
"
|
|
214
|
+
"Security: validate all input, parameterize queries, never expose secrets. Use the auth pattern that matches the stack (e.g. httpOnly cookies for web sessions).",
|
|
215
|
+
"Use the project's standard logger and type system (no console.log when a structured logger exists; use the project's type idiom — TS types, JSDoc, Python type hints, Go interfaces…).",
|
|
195
216
|
SUBPROCESS_CONSTRAINTS
|
|
196
217
|
];
|
|
218
|
+
const stackRule = buildStackSection({ stack, testFramework });
|
|
219
|
+
if (stackRule) sections.push(stackRule);
|
|
197
220
|
if (projectDirRule) sections.push(projectDirRule);
|
|
198
221
|
|
|
199
222
|
if (serenaEnabled) {
|
|
@@ -268,7 +291,7 @@ export async function buildCoderPrompt({ task, reviewerFeedback = null, sonarSum
|
|
|
268
291
|
"1. Find the relevant file(s) in the project",
|
|
269
292
|
"2. Make the specific code change needed to resolve the issue",
|
|
270
293
|
"3. If tests are missing, write them",
|
|
271
|
-
"4. If dependencies are needed, install them (npm install)",
|
|
294
|
+
"4. If dependencies are needed, install them with the project's package manager (npm install / pip install / go get / cargo add — pick the one that matches the stack above)",
|
|
272
295
|
"5. Do NOT skip any issue — all must be resolved"
|
|
273
296
|
);
|
|
274
297
|
}
|
package/src/roles/coder-role.js
CHANGED
|
@@ -2,6 +2,8 @@ import { AgentRole } from "./agent-role.js";
|
|
|
2
2
|
import { buildCoderPrompt } from "../prompts/coder.js";
|
|
3
3
|
import { isHostAgent } from "../utils/agent-detect.js";
|
|
4
4
|
import { HostAgent } from "../agents/host-agent.js";
|
|
5
|
+
import { detectProjectStack } from "../utils/stack-detect.js";
|
|
6
|
+
import { detectTestFramework } from "../utils/project-detect.js";
|
|
5
7
|
|
|
6
8
|
export class CoderRole extends AgentRole {
|
|
7
9
|
constructor(opts) {
|
|
@@ -54,6 +56,16 @@ export class CoderRole extends AgentRole {
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
async buildPrompt({ task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests, adrs, specSection, reviewerFindings, huId }) {
|
|
59
|
+
const projectDir = this.config?.projectDir || null;
|
|
60
|
+
// KJC sesgo-de-stack: pasar stack/testFramework al prompt para que
|
|
61
|
+
// el coder no asuma JS/vitest en proyectos Python / Go / Rust /
|
|
62
|
+
// Ruby. audit-role.js y tester-role.js ya lo hacen así.
|
|
63
|
+
let stack = null;
|
|
64
|
+
let testFramework = null;
|
|
65
|
+
if (projectDir) {
|
|
66
|
+
try { stack = await detectProjectStack(projectDir); } catch { /* best-effort */ }
|
|
67
|
+
try { testFramework = await detectTestFramework(projectDir); } catch { /* best-effort */ }
|
|
68
|
+
}
|
|
57
69
|
const prompt = await buildCoderPrompt({
|
|
58
70
|
task, reviewerFeedback, sonarSummary, deferredContext, acceptanceTests,
|
|
59
71
|
adrs, specSection, reviewerFindings, huId,
|
|
@@ -63,7 +75,8 @@ export class CoderRole extends AgentRole {
|
|
|
63
75
|
rtkAvailable: Boolean(this.config?.rtk?.available),
|
|
64
76
|
productContext: this.config?.productContext || null,
|
|
65
77
|
domainContext: this.config?.domainContext || null,
|
|
66
|
-
projectDir
|
|
78
|
+
projectDir,
|
|
79
|
+
stack, testFramework,
|
|
67
80
|
provider: this._resolvedProvider || (typeof this.resolveProvider === "function" ? this.resolveProvider() : null)
|
|
68
81
|
});
|
|
69
82
|
return { prompt };
|