karajan-code 2.18.1 → 2.19.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 +53 -34
- package/docs/README.es.md +13 -2
- package/package.json +9 -1
- package/packages/hu-board/README.md +69 -0
- package/packages/hu-board/package.json +23 -0
- package/packages/hu-board/public/app.js +4038 -0
- package/packages/hu-board/public/index.html +55 -0
- package/packages/hu-board/public/pipeline.css +132 -0
- package/packages/hu-board/public/pipeline.html +48 -0
- package/packages/hu-board/public/pipeline.js +200 -0
- package/packages/hu-board/public/styles.css +918 -0
- package/packages/hu-board/src/auth.js +92 -0
- package/packages/hu-board/src/boot-info.js +16 -0
- package/packages/hu-board/src/cleanup-zombies.js +142 -0
- package/packages/hu-board/src/command-runner.js +134 -0
- package/packages/hu-board/src/config-yaml.js +285 -0
- package/packages/hu-board/src/db.js +739 -0
- package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
- package/packages/hu-board/src/event-bus.js +58 -0
- package/packages/hu-board/src/format.js +83 -0
- package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
- package/packages/hu-board/src/journal-parser.js +180 -0
- package/packages/hu-board/src/plan-mutations.js +422 -0
- package/packages/hu-board/src/preflight.js +285 -0
- package/packages/hu-board/src/routes/api.js +1271 -0
- package/packages/hu-board/src/routes/pipeline.js +45 -0
- package/packages/hu-board/src/run-tracker.js +121 -0
- package/packages/hu-board/src/server.js +435 -0
- package/packages/hu-board/src/sync.js +626 -0
- package/packages/hu-board/src/token-store.js +57 -0
- package/packages/hu-board/src/zombie-reaper.js +207 -0
- package/src/audit/webperf-input.js +4 -7
- package/src/brain/standby-store.js +6 -4
- package/src/checks/dir-setup.js +39 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -0
- package/src/cli.js +13 -0
- package/src/commands/plan/generate.js +15 -0
- package/src/commands/run.js +19 -1
- package/src/commands/webperf.js +4 -6
- package/src/config/role-resolver.js +3 -1
- package/src/mcp/handlers/direct-handlers.js +3 -0
- package/src/mcp/handlers/run-handler.js +5 -0
- package/src/mcp/tools.js +4 -2
- package/src/plan/plan-store.js +7 -27
- package/src/prompts/spec-reviewer.js +66 -0
- package/src/roles/perf-role.js +4 -4
- package/src/roles/spec-reviewer-role.js +106 -0
- package/src/spec-review/refine-loop.js +58 -0
- package/src/spec-review/run-spec-review.js +70 -0
- package/src/utils/board-prompt-bridge.js +4 -5
- package/src/utils/display/spec-findings.js +44 -0
- package/src/utils/garbage-collector.js +8 -6
- package/src/utils/home-migration.js +87 -0
- package/src/utils/paths.js +85 -17
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zombie session reaper for the HU Board (board-polish #2,
|
|
3
|
+
* KJC dogfooding 2026-05-07).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists
|
|
6
|
+
* ===============
|
|
7
|
+
*
|
|
8
|
+
* Sessions in the SQLite db can stay "in flight" forever when the
|
|
9
|
+
* orchestrator process dies between iterations: their `status` stays
|
|
10
|
+
* `coding` / `stalled` / `paused`, the UI keeps surfacing them as
|
|
11
|
+
* actionable, and `kj board start` weeks later still pops a
|
|
12
|
+
* "Karajan needs an answer" modal for a checkpoint nobody owns
|
|
13
|
+
* anymore.
|
|
14
|
+
*
|
|
15
|
+
* Concretely, on 2026-05-07 the user opened the board on a fresh
|
|
16
|
+
* `kj board start` and saw the popup of an HU from 2026-04-27 — 9
|
|
17
|
+
* days dead, status still `paused`. The fix per his explicit
|
|
18
|
+
* mandate:
|
|
19
|
+
*
|
|
20
|
+
* "No puede quedarse nada zombie. Debe haber algun sistema que lo
|
|
21
|
+
* compruebe al arrancar y mate esos zombies."
|
|
22
|
+
*
|
|
23
|
+
* Thresholds (per his decision in the same session):
|
|
24
|
+
* - status in (coding | stalled) and >= 6h since updated_at → reap
|
|
25
|
+
* - status === paused and >= 24h since updated_at → reap
|
|
26
|
+
* - everything else → skip
|
|
27
|
+
*
|
|
28
|
+
* What "reap" means: write `status: "failed"` BOTH to the DB row
|
|
29
|
+
* AND to the underlying `~/.karajan/sessions/<id>/session.json`,
|
|
30
|
+
* append a structured checkpoint of the form
|
|
31
|
+
* { stage: 'zombie-reap', reason: <reason>, at: <iso> }
|
|
32
|
+
* so the audit trail survives. The session row stays in place
|
|
33
|
+
* (we don't DELETE — the user can still inspect what happened).
|
|
34
|
+
*
|
|
35
|
+
* Why update the JSON on disk too
|
|
36
|
+
* -------------------------------
|
|
37
|
+
*
|
|
38
|
+
* The board's `fullScan()` reads every session.json on startup and
|
|
39
|
+
* upserts the row, OVERWRITING any DB-only mutation. If the reaper
|
|
40
|
+
* only updated the DB, the next reboot would reset the status back
|
|
41
|
+
* to `paused`/`stalled` because the JSON still says so. Persisting
|
|
42
|
+
* to disk makes the reap permanent on the first reboot — the user
|
|
43
|
+
* sees the cleanup once, and never again.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
import fs from "node:fs";
|
|
47
|
+
import path from "node:path";
|
|
48
|
+
import os from "node:os";
|
|
49
|
+
|
|
50
|
+
const DEFAULT_CODING_HOURS = 6;
|
|
51
|
+
const DEFAULT_PAUSED_HOURS = 24;
|
|
52
|
+
const CODING_LIKE_STATUSES = new Set(["coding", "stalled", "running", "in_progress"]);
|
|
53
|
+
const PAUSED_STATUSES = new Set(["paused", "checkpoint", "awaiting_answer"]);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether a single session row qualifies as zombie at `now`.
|
|
57
|
+
* Pure function — no I/O. Used by both the reaper itself and the
|
|
58
|
+
* unit tests.
|
|
59
|
+
*
|
|
60
|
+
* @param {{ id: string, status: string, updated_at?: string|null,
|
|
61
|
+
* created_at?: string|null }} session
|
|
62
|
+
* @param {{ now: number, codingHours: number, pausedHours: number }} ctx
|
|
63
|
+
* @returns {{ zombie: boolean, reason?: string }}
|
|
64
|
+
*/
|
|
65
|
+
export function classifyZombie(session, ctx) {
|
|
66
|
+
if (!session || !session.status) return { zombie: false };
|
|
67
|
+
const status = String(session.status).toLowerCase();
|
|
68
|
+
// Reference timestamp: prefer updated_at (the daemon writes that on
|
|
69
|
+
// every checkpoint sync), fall back to created_at (in case the row
|
|
70
|
+
// was inserted but never touched again).
|
|
71
|
+
const refIso = session.updated_at || session.created_at;
|
|
72
|
+
if (!refIso) return { zombie: false };
|
|
73
|
+
const refTs = new Date(refIso).getTime();
|
|
74
|
+
if (Number.isNaN(refTs)) return { zombie: false };
|
|
75
|
+
const ageHours = (ctx.now - refTs) / 3_600_000;
|
|
76
|
+
if (ageHours <= 0) return { zombie: false };
|
|
77
|
+
if (CODING_LIKE_STATUSES.has(status) && ageHours >= ctx.codingHours) {
|
|
78
|
+
return { zombie: true, reason: `${status} ${ageHours.toFixed(1)}h inactive (>= ${ctx.codingHours}h)` };
|
|
79
|
+
}
|
|
80
|
+
if (PAUSED_STATUSES.has(status) && ageHours >= ctx.pausedHours) {
|
|
81
|
+
return { zombie: true, reason: `${status} ${ageHours.toFixed(1)}h inactive (>= ${ctx.pausedHours}h)` };
|
|
82
|
+
}
|
|
83
|
+
return { zombie: false };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Filter a list of session rows down to the zombie ones. Pure.
|
|
88
|
+
*
|
|
89
|
+
* @param {Array<object>} sessions
|
|
90
|
+
* @param {{ now?: number, codingHours?: number, pausedHours?: number }} [opts]
|
|
91
|
+
* @returns {Array<{ session: object, reason: string }>}
|
|
92
|
+
*/
|
|
93
|
+
export function findZombieSessions(sessions, opts = {}) {
|
|
94
|
+
const ctx = {
|
|
95
|
+
now: opts.now ?? Date.now(),
|
|
96
|
+
codingHours: opts.codingHours ?? DEFAULT_CODING_HOURS,
|
|
97
|
+
pausedHours: opts.pausedHours ?? DEFAULT_PAUSED_HOURS,
|
|
98
|
+
};
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const session of sessions || []) {
|
|
101
|
+
const verdict = classifyZombie(session, ctx);
|
|
102
|
+
if (verdict.zombie) out.push({ session, reason: verdict.reason });
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Append a `zombie-reap` checkpoint to a JSON-encoded checkpoints
|
|
109
|
+
* blob (the on-disk format). Returns the new JSON string.
|
|
110
|
+
*
|
|
111
|
+
* Defensive about malformed input: if the existing blob isn't a
|
|
112
|
+
* JSON array, replaces it with a fresh array containing the new
|
|
113
|
+
* entry — never throws.
|
|
114
|
+
*/
|
|
115
|
+
export function appendZombieCheckpoint(existingJson, reason, isoTimestamp) {
|
|
116
|
+
let arr = [];
|
|
117
|
+
if (existingJson) {
|
|
118
|
+
try {
|
|
119
|
+
const parsed = JSON.parse(existingJson);
|
|
120
|
+
if (Array.isArray(parsed)) arr = parsed;
|
|
121
|
+
} catch { /* fall through with empty arr */ }
|
|
122
|
+
}
|
|
123
|
+
arr.push({ stage: "zombie-reap", reason, at: isoTimestamp });
|
|
124
|
+
return JSON.stringify(arr);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Default location of the on-disk session JSON files. Delegates to
|
|
129
|
+
* db.js::getKjHome (KJC-TSK-0420): KARAJAN_HOME > KJ_HOME > VITEST > default.
|
|
130
|
+
* @returns {string}
|
|
131
|
+
*/
|
|
132
|
+
import { getKjHome } from "./db.js";
|
|
133
|
+
export function getSessionsDir() {
|
|
134
|
+
return path.join(getKjHome(), "sessions");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Persist `status: "failed"` and the new zombie-reap checkpoint to
|
|
139
|
+
* the on-disk session.json. This stops the next `fullScan()` from
|
|
140
|
+
* resurrecting the zombi.
|
|
141
|
+
*
|
|
142
|
+
* Safe-by-default: if the file is missing, malformed, or the write
|
|
143
|
+
* fails, this returns `false` and the caller logs a warning. We
|
|
144
|
+
* never throw and we never block the daemon's startup.
|
|
145
|
+
*
|
|
146
|
+
* @param {string} sessionId
|
|
147
|
+
* @param {string} reason
|
|
148
|
+
* @param {string} reapedAtIso
|
|
149
|
+
* @param {{ sessionsDir?: string }} [opts]
|
|
150
|
+
* @returns {boolean} true when the file was rewritten successfully
|
|
151
|
+
*/
|
|
152
|
+
export function markSessionFailedOnDisk(sessionId, reason, reapedAtIso, opts = {}) {
|
|
153
|
+
const dir = opts.sessionsDir || getSessionsDir();
|
|
154
|
+
const file = path.join(dir, sessionId, "session.json");
|
|
155
|
+
let raw;
|
|
156
|
+
try { raw = fs.readFileSync(file, "utf8"); } catch { return false; }
|
|
157
|
+
let parsed;
|
|
158
|
+
try { parsed = JSON.parse(raw); } catch { return false; }
|
|
159
|
+
if (!parsed || typeof parsed !== "object") return false;
|
|
160
|
+
parsed.status = "failed";
|
|
161
|
+
parsed.updated_at = reapedAtIso;
|
|
162
|
+
parsed.checkpoints = Array.isArray(parsed.checkpoints) ? parsed.checkpoints : [];
|
|
163
|
+
parsed.checkpoints.push({ stage: "zombie-reap", reason, at: reapedAtIso });
|
|
164
|
+
try {
|
|
165
|
+
fs.writeFileSync(file, JSON.stringify(parsed, null, 2));
|
|
166
|
+
return true;
|
|
167
|
+
} catch { return false; }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reap every zombie session in the DB AND in the underlying
|
|
172
|
+
* session.json files. Side-effecting. Returns the list of reaped
|
|
173
|
+
* sessions so the server can log them.
|
|
174
|
+
*
|
|
175
|
+
* @param {object} deps
|
|
176
|
+
* @param {object} deps.db better-sqlite3 Database handle
|
|
177
|
+
* @param {object} [deps.opts] threshold + sessionsDir overrides
|
|
178
|
+
* @param {Function} [deps.now] () => number, for tests
|
|
179
|
+
* @returns {Array<{ id: string, status_before: string, reason: string, disk_persisted: boolean }>}
|
|
180
|
+
*/
|
|
181
|
+
export function reapZombieSessions({ db, opts = {}, now = () => Date.now() }) {
|
|
182
|
+
if (!db) return [];
|
|
183
|
+
const allSessions = db.prepare(
|
|
184
|
+
"SELECT id, status, updated_at, created_at, checkpoints FROM sessions WHERE status NOT IN ('failed', 'approved', 'cancelled', 'done')",
|
|
185
|
+
).all();
|
|
186
|
+
const zombies = findZombieSessions(allSessions, { ...opts, now: now() });
|
|
187
|
+
if (zombies.length === 0) return [];
|
|
188
|
+
const update = db.prepare(
|
|
189
|
+
"UPDATE sessions SET status = 'failed', checkpoints = ?, updated_at = ? WHERE id = ?",
|
|
190
|
+
);
|
|
191
|
+
const reapedAt = new Date(now()).toISOString();
|
|
192
|
+
const reaped = [];
|
|
193
|
+
for (const { session, reason } of zombies) {
|
|
194
|
+
const newCheckpoints = appendZombieCheckpoint(session.checkpoints, reason, reapedAt);
|
|
195
|
+
update.run(newCheckpoints, reapedAt, session.id);
|
|
196
|
+
const diskPersisted = markSessionFailedOnDisk(session.id, reason, reapedAt, {
|
|
197
|
+
sessionsDir: opts.sessionsDir,
|
|
198
|
+
});
|
|
199
|
+
reaped.push({
|
|
200
|
+
id: session.id,
|
|
201
|
+
status_before: session.status,
|
|
202
|
+
reason,
|
|
203
|
+
disk_persisted: diskPersisted,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return reaped;
|
|
207
|
+
}
|
|
@@ -28,15 +28,12 @@
|
|
|
28
28
|
|
|
29
29
|
import fs from "node:fs";
|
|
30
30
|
import path from "node:path";
|
|
31
|
-
import os from "node:os";
|
|
32
31
|
import { evaluateCwv, CWV_THRESHOLDS } from "../webperf/cwv-gate.js";
|
|
32
|
+
import { getWebperfDir } from "../utils/paths.js";
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
function webperfHome() {
|
|
38
|
-
return path.join(os.homedir(), ".karajan", "webperf");
|
|
39
|
-
}
|
|
34
|
+
// Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
|
|
35
|
+
// KARAJAN_HOME / KJ_HOME (with deprecation warning) / VITEST tmp.
|
|
36
|
+
function webperfHome() { return getWebperfDir(); }
|
|
40
37
|
|
|
41
38
|
function projectSlug(projectDir) {
|
|
42
39
|
return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
@@ -14,15 +14,17 @@
|
|
|
14
14
|
|
|
15
15
|
import fs from "node:fs";
|
|
16
16
|
import path from "node:path";
|
|
17
|
-
import os from "node:os";
|
|
18
17
|
import { writeJsonAtomicSync } from "../utils/atomic-write.js";
|
|
18
|
+
import { resolveHome } from "../utils/paths.js";
|
|
19
19
|
|
|
20
20
|
const FILENAME_RE = /^[a-zA-Z0-9._-]+\.json$/;
|
|
21
21
|
|
|
22
|
+
// Delegates to the unified resolver. PR 3 of KJC-PCS-0047 switched
|
|
23
|
+
// the default to `.karajan` — hibernated sessions now persist under
|
|
24
|
+
// `~/.karajan/standby/`, migrated automatically from the legacy
|
|
25
|
+
// `~/.kj/standby/` on the next `kj` invocation.
|
|
22
26
|
function getKjHome() {
|
|
23
|
-
|
|
24
|
-
if (process.env.VITEST) return path.join(os.tmpdir(), `kj-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`, ".kj");
|
|
25
|
-
return path.join(os.homedir(), ".kj");
|
|
27
|
+
return resolveHome({ defaultSegment: ".karajan" });
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export function standbyDir() {
|
package/src/checks/dir-setup.js
CHANGED
|
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import { STRATEGY } from "./types.js";
|
|
10
|
+
import { getKarajanHome } from "../utils/paths.js";
|
|
10
11
|
|
|
11
12
|
const REQUIRED_DIRS = [
|
|
12
13
|
"", // ~/.karajan/
|
|
@@ -26,8 +27,9 @@ export function createKarajanDirsCheck() {
|
|
|
26
27
|
strategy: STRATEGY.AUTO,
|
|
27
28
|
describe: "Create missing ~/.karajan subdirectories",
|
|
28
29
|
async detect() {
|
|
29
|
-
|
|
30
|
-
const root =
|
|
30
|
+
// KJC-TSK-0420: KARAJAN_HOME / KJ_HOME aware via getKarajanHome().
|
|
31
|
+
const root = getKarajanHome();
|
|
32
|
+
const home = os.homedir(); // still needed for path.relative() below
|
|
31
33
|
const missing = [];
|
|
32
34
|
for (const sub of REQUIRED_DIRS) {
|
|
33
35
|
const full = sub ? path.join(root, sub) : root;
|
|
@@ -62,6 +64,40 @@ export function createKarajanDirsCheck() {
|
|
|
62
64
|
};
|
|
63
65
|
}
|
|
64
66
|
|
|
67
|
+
/**
|
|
68
|
+
* Detect a populated legacy `~/.kj/` left over from before
|
|
69
|
+
* KJC-PCS-0047 PR 3. The auto-migrator (src/utils/home-migration.js)
|
|
70
|
+
* moves its contents to `~/.karajan/` on the next `kj` invocation,
|
|
71
|
+
* so the fix is simply "run any kj command". Severity is `warn`
|
|
72
|
+
* (informational) because the legacy dir does not break anything —
|
|
73
|
+
* it just won't get read by the board until the migration runs.
|
|
74
|
+
* @internal Exported for dynamic import from tests.
|
|
75
|
+
*/
|
|
76
|
+
export function createLegacyKjHomeCheck() {
|
|
77
|
+
return {
|
|
78
|
+
name: "legacy-kj-home",
|
|
79
|
+
label: "legacy ~/.kj/ directory",
|
|
80
|
+
strategy: STRATEGY.MANUAL,
|
|
81
|
+
describe: "Detect unmigrated ~/.kj/ left over from pre-2.19 layout",
|
|
82
|
+
async detect() {
|
|
83
|
+
const legacy = path.join(os.homedir(), ".kj");
|
|
84
|
+
try {
|
|
85
|
+
const entries = await fs.readdir(legacy);
|
|
86
|
+
if (entries.length === 0) return { ok: true, severity: "info", detail: "Empty legacy ~/.kj/" };
|
|
87
|
+
const marker = path.join(getKarajanHome(), ".kj-migrated.json"); // KJC-TSK-0420
|
|
88
|
+
try { await fs.access(marker); return { ok: true, severity: "info", detail: "Already migrated" }; }
|
|
89
|
+
catch { /* no marker, still pending */ }
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
severity: "warn",
|
|
93
|
+
detail: `~/.kj/ still has ${entries.length} entries; will be migrated on the next kj invocation`,
|
|
94
|
+
fix: "Run any kj command (e.g. `kj doctor`) — the migrator runs automatically",
|
|
95
|
+
};
|
|
96
|
+
} catch { return { ok: true, severity: "info", detail: "No legacy ~/.kj/" }; }
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
65
101
|
export function getDirSetupChecks() {
|
|
66
|
-
return [createKarajanDirsCheck()];
|
|
102
|
+
return [createKarajanDirsCheck(), createLegacyKjHomeCheck()];
|
|
67
103
|
}
|
|
@@ -105,6 +105,7 @@ export function registerPipeline(program, { pkgVersion }) {
|
|
|
105
105
|
.option("--brain <mode>", "Brain decisor: on|off (default: on). When off, only legacy flags drive routing.")
|
|
106
106
|
.option("--skip-role <role...>", "Force one or more roles OFF regardless of triage (e.g. --skip-role tester security)")
|
|
107
107
|
.option("--force-role <role...>", "Force one or more roles ON regardless of triage")
|
|
108
|
+
.option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
|
|
108
109
|
.option("--dry-run", "Show what would be executed without running anything")
|
|
109
110
|
.option("--json", "Output JSON only (no styled display)")
|
|
110
111
|
.option("-q, --quiet", "Show only stage status lines, suppress raw agent output (default)")
|
package/src/cli/register-plan.js
CHANGED
|
@@ -40,6 +40,7 @@ export function registerPlan(program, { pkgVersion }) {
|
|
|
40
40
|
// flags only if you explicitly want a fast / sketch plan.
|
|
41
41
|
.option("--no-tests-synth", "Skip the tests-synthesizer pass that fills in missing acceptance_tests")
|
|
42
42
|
.option("--no-plan-review", "Skip the high-level plan reviewer pass (gaps / deps / overlap / order)")
|
|
43
|
+
.option("--skip-spec-review", "Bypass the spec-reviewer pre-pipeline audit")
|
|
43
44
|
.option("--quick", "Sketch mode — skip every quality pass after the initial planner call")
|
|
44
45
|
.option("-y, --yes", "Skip the project-name prompt (use the auto-derived default).")
|
|
45
46
|
.option("--no-interactive", "Force non-interactive mode (no prompts, use defaults).")
|
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { registerSonar } from "./cli/register-sonar.js";
|
|
|
12
12
|
import { registerStandby } from "./cli/register-standby.js";
|
|
13
13
|
import { printUpdateNotice } from "./utils/update-check.js";
|
|
14
14
|
import { printWelcomeScreen } from "./utils/welcome.js";
|
|
15
|
+
import { migrateKjToKarajan } from "./utils/home-migration.js";
|
|
15
16
|
|
|
16
17
|
const PKG_PATH = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json");
|
|
17
18
|
const PKG_VERSION = JSON.parse(readFileSync(PKG_PATH, "utf8")).version;
|
|
@@ -107,6 +108,18 @@ program.action(async (_opts, command) => {
|
|
|
107
108
|
printWelcomeScreen({ version: PKG_VERSION, config });
|
|
108
109
|
});
|
|
109
110
|
|
|
111
|
+
// One-shot ~/.kj/ → ~/.karajan/ consolidation. Idempotent via marker
|
|
112
|
+
// file; failures non-blocking. See src/utils/home-migration.js.
|
|
113
|
+
// Static import — the migrator runs on every kj invocation, no
|
|
114
|
+
// lazy-load benefit + the architectural dynamic-imports budget would
|
|
115
|
+
// otherwise grow by one for a permanent caller.
|
|
116
|
+
try {
|
|
117
|
+
await migrateKjToKarajan();
|
|
118
|
+
} catch (err) {
|
|
119
|
+
// eslint-disable-next-line no-console
|
|
120
|
+
console.warn(`\x1b[33m[warn]\x1b[0m home migration skipped: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
try {
|
|
111
124
|
await program.parseAsync();
|
|
112
125
|
} catch (error) {
|
|
@@ -13,6 +13,7 @@ import { recommendModelsForHu, complexityFromTaskType } from "../../hu/model-rou
|
|
|
13
13
|
import { withBrainRecovery } from "../../brain/with-brain-recovery.js";
|
|
14
14
|
import { buildStandbyState } from "../../brain/standby-store.js";
|
|
15
15
|
import { printResumeHint } from "../../utils/display/resume-hint.js";
|
|
16
|
+
import { runSpecReview } from "../../spec-review/run-spec-review.js";
|
|
16
17
|
import { formatPlan, formatHuTable } from "./_shared.js";
|
|
17
18
|
|
|
18
19
|
/**
|
|
@@ -40,6 +41,20 @@ async function planGenerateImpl({ task, config, logger, json, context, runLog, f
|
|
|
40
41
|
}
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
// Spec-reviewer pre-planner audit (KJC-PCS-0048). Static import —
|
|
45
|
+
// role runs every invocation (modulo bypass); no lazy-load benefit.
|
|
46
|
+
const reviewResult = await runSpecReview({
|
|
47
|
+
spec: task, config, logger, flags,
|
|
48
|
+
askQuestion: json ? null : (typeof flags?.askQuestion === "function" ? flags.askQuestion : null),
|
|
49
|
+
});
|
|
50
|
+
if (!reviewResult.proceed) {
|
|
51
|
+
logger?.info?.("Aborted by user after spec review.");
|
|
52
|
+
return { ok: false, aborted: true, reason: "spec-review-cancelled" };
|
|
53
|
+
}
|
|
54
|
+
if (reviewResult.refined && reviewResult.finalSpec) {
|
|
55
|
+
task = reviewResult.finalSpec; // downstream runs the refined spec
|
|
56
|
+
}
|
|
57
|
+
|
|
43
58
|
const plannerRole = resolveRole(config, "planner");
|
|
44
59
|
await assertAgentsAvailable([plannerRole.provider]);
|
|
45
60
|
runLog.logText(`[planner] provider=${plannerRole.provider}`);
|
package/src/commands/run.js
CHANGED
|
@@ -11,6 +11,7 @@ import { printResumeHint } from "../utils/display/resume-hint.js";
|
|
|
11
11
|
import { resolveRole } from "../config.js";
|
|
12
12
|
import { parseCardId } from "../planning-game/adapter.js";
|
|
13
13
|
import { confirmCwd } from "../utils/cwd-confirm.js";
|
|
14
|
+
import { runSpecReview } from "../spec-review/run-spec-review.js";
|
|
14
15
|
|
|
15
16
|
function createCliAskQuestion(opts = {}) {
|
|
16
17
|
const { sessionId = null } = opts;
|
|
@@ -163,9 +164,26 @@ export async function runCommandHandler({ task, config, logger, flags }) {
|
|
|
163
164
|
}
|
|
164
165
|
|
|
165
166
|
const askQuestion = createCliAskQuestion();
|
|
167
|
+
|
|
168
|
+
// Spec-reviewer pre-pipeline audit (KJC-PCS-0048). Runs BEFORE
|
|
169
|
+
// anything else — surfaces ambiguity / missing scope / missing AC
|
|
170
|
+
// and lets the user bail out cheap, before any tokens burn.
|
|
171
|
+
// Bypass with --skip-spec-review. Static import — the role runs
|
|
172
|
+
// on every kj invocation (modulo bypass), no lazy-load benefit.
|
|
173
|
+
const reviewResult = await runSpecReview({ spec: task, config, logger, askQuestion, flags });
|
|
174
|
+
if (!reviewResult.proceed) {
|
|
175
|
+
logger.info("Aborted by user after spec review.");
|
|
176
|
+
cleanupRegistry();
|
|
177
|
+
return { ok: false, aborted: true, reason: "spec-review-cancelled" };
|
|
178
|
+
}
|
|
179
|
+
// If the user refined the spec via [r]efine, downstream runs the
|
|
180
|
+
// REFINED text (not the original) so the pipeline acts on what the
|
|
181
|
+
// user actually approved.
|
|
182
|
+
const effectiveTask = reviewResult.refined && reviewResult.finalSpec ? reviewResult.finalSpec : task;
|
|
183
|
+
|
|
166
184
|
let result;
|
|
167
185
|
try {
|
|
168
|
-
result = await runFlow({ task:
|
|
186
|
+
result = await runFlow({ task: effectiveTask, config, logger, flags, emitter, askQuestion, pgTaskId: pgCardId || null, pgProject: pgProject || null });
|
|
169
187
|
} finally {
|
|
170
188
|
// KJC-TSK-0396: limpia el registro pase lo que pase (éxito,
|
|
171
189
|
// throw, o paused). Los handlers de SIGINT/SIGTERM/exit son
|
package/src/commands/webperf.js
CHANGED
|
@@ -9,16 +9,14 @@
|
|
|
9
9
|
|
|
10
10
|
import fs from "node:fs/promises";
|
|
11
11
|
import path from "node:path";
|
|
12
|
-
import os from "node:os";
|
|
13
12
|
import yaml from "js-yaml";
|
|
14
13
|
import { runWebperfScan } from "../webperf/scanner.js";
|
|
15
14
|
import { withCliRunLog } from "../utils/cli-run-log.js";
|
|
16
15
|
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
16
|
+
// Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
|
|
17
|
+
// KARAJAN_HOME / KJ_HOME / VITEST tmp / default.
|
|
18
|
+
import { getWebperfDir } from "../utils/paths.js";
|
|
19
|
+
function defaultReportDir() { return getWebperfDir(); }
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* Slug a project directory the same way audit/basal-cost.js and
|
|
@@ -49,7 +49,9 @@ function isModelCompatible(agent, model) {
|
|
|
49
49
|
// Roles that inherit provider/model from the coder when not explicitly configured
|
|
50
50
|
const CODER_INHERITED_ROLES = new Set([
|
|
51
51
|
"planner", "refactorer", "solomon", "researcher", "tester", "security",
|
|
52
|
-
"impeccable", "triage", "discover", "architect", "audit",
|
|
52
|
+
"impeccable", "triage", "discover", "architect", "audit",
|
|
53
|
+
"hu_reviewer", "hu-reviewer",
|
|
54
|
+
"spec_reviewer", "spec-reviewer",
|
|
53
55
|
]);
|
|
54
56
|
|
|
55
57
|
function resolveProvider(roleConfig, role, roles, legacyCoder, legacyReviewer) {
|
|
@@ -47,6 +47,9 @@ function applySessionOverrides(a, roleKeys) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
export async function handlePlanDirect(a, server, extra) {
|
|
50
|
+
// KJC-PCS-0048 PR 3b: same `specReviewMode: skip` → skipSpecReview
|
|
51
|
+
// mapping as kj_run. Default "auto" keeps the reviewer enabled.
|
|
52
|
+
if (a.specReviewMode === "skip") a.skipSpecReview = true;
|
|
50
53
|
const { normalizePlanArgs } = await import("../tool-arg-normalizers.js");
|
|
51
54
|
const options = normalizePlanArgs(a);
|
|
52
55
|
const config = await buildConfig(options, "plan");
|
|
@@ -113,6 +113,11 @@ function collectRequiredProviders(config) {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export async function handleRunDirect(a, server, extra) {
|
|
116
|
+
// KJC-PCS-0048 PR 3b: respect specReviewMode without changing the
|
|
117
|
+
// existing handler contract. "skip" maps to the same flag the CLI
|
|
118
|
+
// honours; "auto" (default) leaves the reviewer enabled but the
|
|
119
|
+
// non-TTY runSpecReview path never blocks the pipeline.
|
|
120
|
+
if (a.specReviewMode === "skip") a.skipSpecReview = true;
|
|
116
121
|
const config = await buildConfig(a);
|
|
117
122
|
await assertNotOnBaseBranch(config, { allowForRun: true });
|
|
118
123
|
const logger = createLogger(config.output.log_level, "mcp");
|
package/src/mcp/tools.js
CHANGED
|
@@ -113,7 +113,8 @@ export const tools = [
|
|
|
113
113
|
domain: { type: "string", description: "Domain knowledge: inline text describing the project domain, or absolute path to a .md file. Auto-saved to .karajan/domains/ for the curator." },
|
|
114
114
|
brain: { type: "string", enum: ["on", "off"], description: "Brain decisor: on|off. Controls whether intent-driven routing overrides pipeline flags." },
|
|
115
115
|
forceRole: { type: "array", items: { type: "string" }, description: "Force roles ON regardless of triage (e.g. [\"security\"])" },
|
|
116
|
-
skipRole: { type: "array", items: { type: "string" }, description: "Force roles OFF regardless of triage (e.g. [\"tester\"])" }
|
|
116
|
+
skipRole: { type: "array", items: { type: "string" }, description: "Force roles OFF regardless of triage (e.g. [\"tester\"])" },
|
|
117
|
+
specReviewMode: { type: "string", enum: ["auto", "skip"], description: "Pre-pipeline spec-reviewer behaviour (KJC-PCS-0048). `auto` (default): the role runs, findings stream to the client via progress events; the pipeline continues regardless. `skip`: bypass the reviewer entirely (mirrors the --skip-spec-review CLI flag)." }
|
|
117
118
|
}
|
|
118
119
|
}
|
|
119
120
|
},
|
|
@@ -245,7 +246,8 @@ export const tools = [
|
|
|
245
246
|
coder: { type: "string", description: "Legacy alias for planner" },
|
|
246
247
|
coderModel: { type: "string", description: "Legacy alias for plannerModel" },
|
|
247
248
|
projectDir: { type: "string", description: "Absolute path to the project directory" },
|
|
248
|
-
kjHome: { type: "string" }
|
|
249
|
+
kjHome: { type: "string" },
|
|
250
|
+
specReviewMode: { type: "string", enum: ["auto", "skip"], description: "Pre-planner spec-reviewer behaviour (KJC-PCS-0048). `auto` (default): runs the reviewer, findings stream as progress, planner continues. `skip`: bypass entirely." }
|
|
249
251
|
}
|
|
250
252
|
}
|
|
251
253
|
},
|
package/src/plan/plan-store.js
CHANGED
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
|
|
7
7
|
import fs from "node:fs/promises";
|
|
8
8
|
import path from "node:path";
|
|
9
|
-
import os from "node:os";
|
|
10
9
|
import { writeJsonAtomic } from "../utils/atomic-write.js";
|
|
10
|
+
import { resolveHome } from "../utils/paths.js";
|
|
11
11
|
import { generatePlanId, normaliseAlias } from "./plan-id.js";
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -38,33 +38,13 @@ async function handleCorruptPlanFile(filePath, error) {
|
|
|
38
38
|
}
|
|
39
39
|
import { isPlanV2, migratePlanV1toV2 } from "./plan-schema.js";
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
let _vitestKjHome = null;
|
|
47
|
-
function vitestTmpKjHome() {
|
|
48
|
-
if (_vitestKjHome) return _vitestKjHome;
|
|
49
|
-
_vitestKjHome = path.join(
|
|
50
|
-
os.tmpdir(),
|
|
51
|
-
`kj-vitest-${process.pid}-${Math.random().toString(36).slice(2, 10)}`,
|
|
52
|
-
".kj"
|
|
53
|
-
);
|
|
54
|
-
return _vitestKjHome;
|
|
55
|
-
}
|
|
56
|
-
|
|
41
|
+
// Delegates to the unified resolver in src/utils/paths.js. PR 3 of
|
|
42
|
+
// KJC-PCS-0047 flipped the default from `.kj` to `.karajan` — plans
|
|
43
|
+
// now live under `~/.karajan/plans/`. The migrator (src/utils/
|
|
44
|
+
// home-migration.js) auto-moves legacy `~/.kj/plans/` content on the
|
|
45
|
+
// next `kj` invocation, so existing users are not stranded.
|
|
57
46
|
export function getKjHome() {
|
|
58
|
-
|
|
59
|
-
// VITEST guard — without this, a test that calls savePlan() drops
|
|
60
|
-
// fixture files into the developer's real `~/.kj/plans/` and the
|
|
61
|
-
// running HU Board's chokidar watcher ingests them as "real" plans
|
|
62
|
-
// (observed: 49 fake "Add auth" plans contaminating the user's
|
|
63
|
-
// board). Auto-isolate to a per-process tmp dir so accidental
|
|
64
|
-
// writes can't leak. Tests that NEED to assert against a known
|
|
65
|
-
// path should still set KJ_HOME explicitly.
|
|
66
|
-
if (process.env.VITEST) return vitestTmpKjHome();
|
|
67
|
-
return path.join(os.homedir(), ".kj");
|
|
47
|
+
return resolveHome({ defaultSegment: ".karajan" });
|
|
68
48
|
}
|
|
69
49
|
|
|
70
50
|
/**
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Prompt builder for the `spec-reviewer` role (KJC-PCS-0048 PR 1).
|
|
2
|
+
// Mirrors src/prompts/triage.js — project-level override at
|
|
3
|
+
// `<projectDir>/.karajan/roles/spec-reviewer.md` is injected via `instructions`.
|
|
4
|
+
|
|
5
|
+
const SUBAGENT_PREAMBLE = [
|
|
6
|
+
"IMPORTANT: You are running as a Karajan sub-agent.",
|
|
7
|
+
"Do NOT execute or plan the task. Do NOT mention Karajan.",
|
|
8
|
+
"Do NOT use any MCP tools. Your only job is to audit the user's spec.",
|
|
9
|
+
].join(" ");
|
|
10
|
+
|
|
11
|
+
const VALID_CATEGORIES = [
|
|
12
|
+
"ambiguity", "missing_scope", "missing_ac", "contradiction",
|
|
13
|
+
"stack", "assumptions", "out_of_scope",
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const SCHEMA_HINT = JSON.stringify({
|
|
17
|
+
severity: "ok|info|warn|fail",
|
|
18
|
+
findings: [{
|
|
19
|
+
id: "F-001",
|
|
20
|
+
severity: "info|warn|fail",
|
|
21
|
+
category: VALID_CATEGORIES.join("|"),
|
|
22
|
+
message: "concrete description",
|
|
23
|
+
suggestion: "concrete sentence the user can paste",
|
|
24
|
+
}],
|
|
25
|
+
reasoning: "one or two sentences",
|
|
26
|
+
}, null, 2);
|
|
27
|
+
|
|
28
|
+
export function buildSpecReviewerPrompt({ spec, instructions } = {}) {
|
|
29
|
+
const sections = [SUBAGENT_PREAMBLE];
|
|
30
|
+
if (instructions) sections.push(instructions);
|
|
31
|
+
sections.push(
|
|
32
|
+
"You are the **Spec Reviewer** for Karajan Code.",
|
|
33
|
+
`Categories (pick one per finding): ${VALID_CATEGORIES.join(", ")}.`,
|
|
34
|
+
"Severity: `info` (nuance), `warn` (real gap), `fail` (unworkable). Top-level severity is the WORST of any finding (or `ok` if none).",
|
|
35
|
+
"Return JSON only:",
|
|
36
|
+
"```json", SCHEMA_HINT, "```",
|
|
37
|
+
"## Spec to audit", "```", String(spec || "").trim(), "```",
|
|
38
|
+
);
|
|
39
|
+
return sections.join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build the refine prompt — used when the user picks `[r]efine`. The
|
|
44
|
+
* agent must rewrite the spec to fix the listed findings, returning
|
|
45
|
+
* ONLY the rewritten spec (no JSON, no preamble, no fence). The refine
|
|
46
|
+
* loop will persist this verbatim as `spec-v2.md`.
|
|
47
|
+
*/
|
|
48
|
+
export function buildSpecRefinerPrompt({ originalSpec, findings, instructions } = {}) {
|
|
49
|
+
const sections = [SUBAGENT_PREAMBLE];
|
|
50
|
+
if (instructions) sections.push(instructions);
|
|
51
|
+
const findingsBlock = (findings || []).map((f, i) => {
|
|
52
|
+
const idx = i + 1;
|
|
53
|
+
return `${idx}. [${f.severity}/${f.category}] ${f.message}${f.suggestion ? `\n Fix: ${f.suggestion}` : ""}`;
|
|
54
|
+
}).join("\n");
|
|
55
|
+
sections.push(
|
|
56
|
+
"Your task: produce a rewritten version of the user's spec that addresses every finding listed below.",
|
|
57
|
+
"Output the rewritten spec verbatim — no JSON, no preamble, no markdown fences, no commentary.",
|
|
58
|
+
"Preserve the user's intent; do not invent new requirements or scope beyond the existing spec.",
|
|
59
|
+
"## Original spec", "```", String(originalSpec || "").trim(), "```",
|
|
60
|
+
"## Findings to address", findingsBlock || "(none — pass-through)",
|
|
61
|
+
"## Rewritten spec",
|
|
62
|
+
);
|
|
63
|
+
return sections.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export { VALID_CATEGORIES };
|
package/src/roles/perf-role.js
CHANGED
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
|
|
21
21
|
import fs from "node:fs/promises";
|
|
22
22
|
import path from "node:path";
|
|
23
|
-
import os from "node:os";
|
|
24
23
|
import { BaseRole } from "./base-role.js";
|
|
25
24
|
import { runWebperfScan } from "../webperf/scanner.js";
|
|
26
25
|
|
|
@@ -28,9 +27,10 @@ function projectSlug(projectDir) {
|
|
|
28
27
|
return path.basename(projectDir || process.cwd()).replaceAll(/[^a-zA-Z0-9_-]/g, "_");
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
}
|
|
30
|
+
// Delegated to src/utils/paths.js (KJC-TSK-0420) — respects
|
|
31
|
+
// KARAJAN_HOME / KJ_HOME / VITEST tmp / default.
|
|
32
|
+
import { getWebperfDir } from "../utils/paths.js";
|
|
33
|
+
function defaultPersistDir() { return getWebperfDir(); }
|
|
34
34
|
|
|
35
35
|
async function persistResult(result, projectDir) {
|
|
36
36
|
if (!result?.ok) return null;
|