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.
Files changed (57) hide show
  1. package/README.md +53 -34
  2. package/docs/README.es.md +13 -2
  3. package/package.json +9 -1
  4. package/packages/hu-board/README.md +69 -0
  5. package/packages/hu-board/package.json +23 -0
  6. package/packages/hu-board/public/app.js +4038 -0
  7. package/packages/hu-board/public/index.html +55 -0
  8. package/packages/hu-board/public/pipeline.css +132 -0
  9. package/packages/hu-board/public/pipeline.html +48 -0
  10. package/packages/hu-board/public/pipeline.js +200 -0
  11. package/packages/hu-board/public/styles.css +918 -0
  12. package/packages/hu-board/src/auth.js +92 -0
  13. package/packages/hu-board/src/boot-info.js +16 -0
  14. package/packages/hu-board/src/cleanup-zombies.js +142 -0
  15. package/packages/hu-board/src/command-runner.js +134 -0
  16. package/packages/hu-board/src/config-yaml.js +285 -0
  17. package/packages/hu-board/src/db.js +739 -0
  18. package/packages/hu-board/src/ephemeral-cleaner.js +198 -0
  19. package/packages/hu-board/src/event-bus.js +58 -0
  20. package/packages/hu-board/src/format.js +83 -0
  21. package/packages/hu-board/src/hu-zombie-reaper.js +103 -0
  22. package/packages/hu-board/src/journal-parser.js +180 -0
  23. package/packages/hu-board/src/plan-mutations.js +422 -0
  24. package/packages/hu-board/src/preflight.js +285 -0
  25. package/packages/hu-board/src/routes/api.js +1271 -0
  26. package/packages/hu-board/src/routes/pipeline.js +45 -0
  27. package/packages/hu-board/src/run-tracker.js +121 -0
  28. package/packages/hu-board/src/server.js +435 -0
  29. package/packages/hu-board/src/sync.js +626 -0
  30. package/packages/hu-board/src/token-store.js +57 -0
  31. package/packages/hu-board/src/zombie-reaper.js +207 -0
  32. package/src/audit/webperf-input.js +4 -7
  33. package/src/brain/standby-store.js +6 -4
  34. package/src/checks/dir-setup.js +39 -3
  35. package/src/cli/register-pipeline.js +1 -0
  36. package/src/cli/register-plan.js +1 -0
  37. package/src/cli.js +13 -0
  38. package/src/commands/plan/generate.js +15 -0
  39. package/src/commands/run.js +19 -1
  40. package/src/commands/webperf.js +4 -6
  41. package/src/config/role-resolver.js +3 -1
  42. package/src/mcp/handlers/direct-handlers.js +3 -0
  43. package/src/mcp/handlers/run-handler.js +5 -0
  44. package/src/mcp/tools.js +4 -2
  45. package/src/plan/plan-store.js +7 -27
  46. package/src/prompts/spec-reviewer.js +66 -0
  47. package/src/roles/perf-role.js +4 -4
  48. package/src/roles/spec-reviewer-role.js +106 -0
  49. package/src/spec-review/refine-loop.js +58 -0
  50. package/src/spec-review/run-spec-review.js +70 -0
  51. package/src/utils/board-prompt-bridge.js +4 -5
  52. package/src/utils/display/spec-findings.js +44 -0
  53. package/src/utils/garbage-collector.js +8 -6
  54. package/src/utils/home-migration.js +87 -0
  55. package/src/utils/paths.js +85 -17
  56. package/src/utils/run-registry.js +4 -4
  57. package/templates/roles/spec-reviewer.md +38 -0
@@ -0,0 +1,422 @@
1
+ /**
2
+ * Server-side mutations for plan HUs triggered from the board UI.
3
+ *
4
+ * The source of truth is the plan JSON at `~/.kj/plans/<slug>/<planId>.json`.
5
+ * Every mutation in this module follows the same protocol:
6
+ *
7
+ * 1. Load the plan JSON from disk.
8
+ * 2. Mutate it in-memory using the helpers from `src/plan/plan-hu-ops.js`
9
+ * (so CLI `kj plan ready` and board "Mark ready" stay in lock-step).
10
+ * 3. Write the JSON back.
11
+ * 4. Re-sync the plan into SQLite via `syncPlanFile` so the board
12
+ * reflects the change on the next render without a manual refresh.
13
+ *
14
+ * Steps 2-4 must share a single on-disk write: we never ack the mutation to
15
+ * the UI without persisting it, otherwise a reload would silently revert.
16
+ */
17
+ import { readFileSync, existsSync, readdirSync, mkdirSync, openSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+ import { writeJsonAtomicSync } from '../../../src/utils/atomic-write.js';
21
+ import { spawn } from 'node:child_process';
22
+ import { trackRun, untrack } from './run-tracker.js';
23
+ import { getHuBoardRunsDir } from './db.js';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { updateHuStatus, certifyAllHus, updateHu } from '../../../src/plan/plan-hu-ops.js';
26
+ import { validateBlockedByChange } from '../../../src/plan/plan-validation.js';
27
+ import { syncPlanFile } from './sync.js';
28
+ import { getDb } from './db.js';
29
+
30
+ // Repo root → so we can spawn `node <repo>/src/cli.js run ...` without
31
+ // depending on `kj` being in $PATH of the board process.
32
+ const REPO_ROOT = (() => {
33
+ const here = dirname(fileURLToPath(import.meta.url)); // packages/hu-board/src
34
+ return join(here, '..', '..', '..'); // → repo root
35
+ })();
36
+ const KJ_CLI = join(REPO_ROOT, 'src', 'cli.js');
37
+
38
+ /**
39
+ * Root directory where `kj plan` writes `<slug>/<planId>.json`. Honours
40
+ * `KJ_PLANS_DIR` (tests) then `KJ_HOME/plans` (power users) then
41
+ * `~/.kj/plans/` (default) — same precedence as `sync.js::fullScan`.
42
+ */
43
+ function plansRoot() {
44
+ if (process.env.KJ_PLANS_DIR) return process.env.KJ_PLANS_DIR;
45
+ if (process.env.KJ_HOME) return join(process.env.KJ_HOME, 'plans');
46
+ return join(homedir(), '.kj', 'plans');
47
+ }
48
+
49
+ /**
50
+ * Locate `<planId>.json` on disk. If `projectId` is known we go straight
51
+ * to that subdir; otherwise we fall back to scanning every project dir
52
+ * (costly, only used when the HU row pre-dates the `plan_id` migration
53
+ * and we have nothing else to go on).
54
+ *
55
+ * @param {string} planId
56
+ * @param {string|undefined} projectId
57
+ * @returns {string|null}
58
+ */
59
+ function findPlanFilePath(planId, projectId) {
60
+ const root = plansRoot();
61
+ if (!existsSync(root)) return null;
62
+ const fileName = `${planId}.json`;
63
+ if (projectId) {
64
+ const direct = join(root, projectId, fileName);
65
+ if (existsSync(direct)) return direct;
66
+ }
67
+ // Fall back to a scan so legacy rows without plan_id still work.
68
+ let dirs;
69
+ try { dirs = readdirSync(root); } catch { return null; }
70
+ for (const d of dirs) {
71
+ const candidate = join(root, d, fileName);
72
+ if (existsSync(candidate)) return candidate;
73
+ }
74
+ return null;
75
+ }
76
+
77
+ /**
78
+ * Load + validate a plan file in one step. Callers pass the on-disk path
79
+ * returned by `findPlanFilePath`.
80
+ * @param {string} filePath
81
+ */
82
+ function readPlan(filePath) {
83
+ const raw = readFileSync(filePath, 'utf-8');
84
+ return JSON.parse(raw);
85
+ }
86
+
87
+ function writePlan(filePath, plan) {
88
+ writeJsonAtomicSync(filePath, plan);
89
+ }
90
+
91
+ /**
92
+ * Change a single HU's status inside its plan JSON and re-sync into
93
+ * SQLite. Returns the new status, so callers can 200-reply with the
94
+ * committed value (not a shadow copy that might differ from disk).
95
+ *
96
+ * Valid statuses mirror the CLI: pending, certified, done. Anything else
97
+ * is rejected up the stack — we don't silently coerce.
98
+ *
99
+ * @param {object} args
100
+ * @param {string} args.planId
101
+ * @param {string} args.huId
102
+ * @param {string} args.status - new status
103
+ * @param {string} [args.projectId] - optional hint to avoid the scan
104
+ * @returns {{ ok: true, status: string, planStatus: string } | { ok: false, error: string }}
105
+ */
106
+ export function setHuStatus({ planId, huId, status, projectId }) {
107
+ const filePath = findPlanFilePath(planId, projectId);
108
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
109
+
110
+ const plan = readPlan(filePath);
111
+ if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
112
+
113
+ const existed = updateHuStatus(plan, huId, status);
114
+ if (!existed) return { ok: false, error: `hu not found: ${huId}` };
115
+
116
+ // When every remaining HU is certified, auto-advance the plan status
117
+ // to "ready" so the CLI hint `kj run --plan <id>` matches reality.
118
+ // Conversely, downgrading the last certified HU back to pending must
119
+ // roll the plan back to "draft" — otherwise `kj run` would refuse to
120
+ // reload a supposedly-ready plan whose HUs disagree.
121
+ if (plan.hus.every((h) => h.status === 'certified' || h.status === 'done')) {
122
+ plan.status = 'ready';
123
+ } else if (plan.hus.some((h) => h.status === 'pending')) {
124
+ plan.status = 'draft';
125
+ }
126
+
127
+ writePlan(filePath, plan);
128
+ syncPlanFile(filePath);
129
+ return { ok: true, status, planStatus: plan.status };
130
+ }
131
+
132
+ /**
133
+ * Edit one HU's user-facing fields in place. Whitelist mirrors
134
+ * `updateHu` in plan-hu-ops so the CLI and the board stay in lock-step:
135
+ * title, task_type, scope, acceptance_criteria, acceptance_tests,
136
+ * blocked_by. Unknown keys are silently ignored.
137
+ *
138
+ * The caller passes a `patch` object with whichever subset of fields
139
+ * the user actually changed — pushing the whole HU every time would
140
+ * make optimistic concurrency harder to add later.
141
+ *
142
+ * @param {object} args
143
+ * @param {string} args.planId
144
+ * @param {string} args.huId
145
+ * @param {object} args.patch
146
+ * @param {string} [args.projectId]
147
+ * @returns {{ ok: true, hu: object } | { ok: false, error: string }}
148
+ */
149
+ export function setHuFields({ planId, huId, patch, projectId }) {
150
+ const filePath = findPlanFilePath(planId, projectId);
151
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
152
+
153
+ const plan = readPlan(filePath);
154
+ if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
155
+
156
+ // KJC-TSK-0401: si el patch toca blocked_by, validar antes de
157
+ // persistir. Rechazar refs huérfanas, auto-deps y ciclos.
158
+ if (patch && Object.prototype.hasOwnProperty.call(patch, 'blocked_by')) {
159
+ const result = validateBlockedByChange(plan, huId, patch.blocked_by);
160
+ if (!result.ok) {
161
+ return { ok: false, error: result.error, code: 'INVALID_BLOCKED_BY', cycle: result.cycle };
162
+ }
163
+ }
164
+
165
+ const updated = updateHu(plan, huId, patch || {});
166
+ if (!updated) return { ok: false, error: `hu not found: ${huId}` };
167
+
168
+ writePlan(filePath, plan);
169
+ syncPlanFile(filePath);
170
+ return { ok: true, hu: updated };
171
+ }
172
+
173
+ /**
174
+ * KJC-TSK-0404: marcar una HU como fallida con blocker y timestamp
175
+ * (sin tocar status). Usado por el zombie-reaper al detectar timeout
176
+ * para que el siguiente `kj run` sepa el motivo del último fallo.
177
+ *
178
+ * @param {object} args
179
+ * @param {string} args.planId
180
+ * @param {string} args.huId
181
+ * @param {string} args.blocker - razón del fallo
182
+ * @param {string} [args.projectId]
183
+ * @returns {{ ok: boolean, hu?: object, error?: string }}
184
+ */
185
+ export function setHuFailResult({ planId, huId, blocker, projectId }) {
186
+ const filePath = findPlanFilePath(planId, projectId);
187
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
188
+ const plan = readPlan(filePath);
189
+ if (!Array.isArray(plan.hus)) return { ok: false, error: 'plan has no hus[]' };
190
+ const hu = plan.hus.find(h => h.id === huId);
191
+ if (!hu) return { ok: false, error: `hu not found: ${huId}` };
192
+
193
+ hu.result = 'fail';
194
+ hu.updatedAt = new Date().toISOString();
195
+ const existing = (hu.outcome && Array.isArray(hu.outcome.blockers)) ? hu.outcome.blockers : [];
196
+ hu.outcome = {
197
+ ...(hu.outcome || {}),
198
+ blockers: blocker ? [...existing, blocker] : existing,
199
+ failedAt: new Date().toISOString(),
200
+ };
201
+ writePlan(filePath, plan);
202
+ syncPlanFile(filePath);
203
+ return { ok: true, hu };
204
+ }
205
+
206
+ /**
207
+ * KJC-TSK-0408 step 2/3: deshace los cambios de una HU restaurando
208
+ * el snapshot git tomado pre-run. Marca status=pending, result=null
209
+ * y outcome.reverted=true para que la UI muestre "deshecha".
210
+ *
211
+ * @param {object} args
212
+ * @param {string} args.planId
213
+ * @param {string} args.huId
214
+ * @param {string} [args.projectId]
215
+ * @returns {Promise<{ ok: boolean, sha?: string, error?: string }>}
216
+ */
217
+ export async function revertHuFromSnapshot({ planId, huId, projectId }) {
218
+ const filePath = findPlanFilePath(planId, projectId);
219
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
220
+ const plan = readPlan(filePath);
221
+ const hu = plan.hus?.find(h => h.id === huId);
222
+ if (!hu) return { ok: false, error: `hu not found: ${huId}` };
223
+ const snapshotSha = hu.outcome?.snapshot_sha;
224
+ if (!snapshotSha) {
225
+ return { ok: false, error: 'esta HU no tiene snapshot — solo HUs ejecutadas con KJC-TSK-0408 pueden deshacerse' };
226
+ }
227
+ const projectDir = plan.projectDir;
228
+ if (!projectDir) return { ok: false, error: 'plan sin projectDir' };
229
+
230
+ const { restoreHuSnapshot } = await import('../../../src/git/hu-snapshot.js');
231
+ const restore = await restoreHuSnapshot({ projectDir, huId });
232
+ if (!restore.ok) return { ok: false, error: `restore falló: ${restore.error}` };
233
+
234
+ hu.status = 'pending';
235
+ hu.result = null;
236
+ hu.outcome = { ...(hu.outcome || {}), reverted: true, revertedAt: new Date().toISOString() };
237
+ hu.updatedAt = new Date().toISOString();
238
+ writePlan(filePath, plan);
239
+ syncPlanFile(filePath);
240
+ return { ok: true, sha: restore.sha };
241
+ }
242
+
243
+ /**
244
+ * Bulk certify: equivalent to `kj plan ready <planId>` over HTTP. Flips
245
+ * every pending HU to certified and sets `plan.status = "ready"`. No-op
246
+ * on already-ready plans (idempotent, returns count=0).
247
+ *
248
+ * @param {object} args
249
+ * @param {string} args.planId
250
+ * @param {string} [args.projectId]
251
+ * @returns {{ ok: true, count: number, planStatus: string } | { ok: false, error: string }}
252
+ */
253
+ /**
254
+ * PR-G: rename a project from the board.
255
+ *
256
+ * "Project" on the board is just a row in the projects table that
257
+ * groups stories. The source of truth for the human-readable name
258
+ * is `plan.name` inside each plan JSON for that project (PR-B made
259
+ * sync.js prefer it over the cwd basename). So a rename has to:
260
+ *
261
+ * 1. Update plan.name on every plan JSON belonging to this project,
262
+ * so the next sync doesn't clobber the rename back to the
263
+ * basename derivation.
264
+ * 2. Update the projects.name column directly so the UI updates
265
+ * without waiting for a full re-scan.
266
+ *
267
+ * Validates name length (1–120) and rejects empty/whitespace-only.
268
+ *
269
+ * @returns {{ ok: true, name, plansUpdated } | { ok: false, error }}
270
+ */
271
+ export function renameProject({ projectId, name }) {
272
+ const trimmed = String(name || '').trim();
273
+ if (!trimmed) return { ok: false, error: 'El nombre no puede estar vacío.' };
274
+ if (trimmed.length > 120) return { ok: false, error: 'El nombre no puede superar los 120 caracteres.' };
275
+
276
+ const root = plansRoot();
277
+ let plansUpdated = 0;
278
+ if (existsSync(root)) {
279
+ const projDir = join(root, projectId);
280
+ if (existsSync(projDir)) {
281
+ let files;
282
+ try { files = readdirSync(projDir); } catch { files = []; }
283
+ for (const f of files) {
284
+ if (!f.endsWith('.json')) continue;
285
+ const filePath = join(projDir, f);
286
+ try {
287
+ const plan = readPlan(filePath);
288
+ plan.name = trimmed;
289
+ plan.updatedAt = new Date().toISOString();
290
+ writePlan(filePath, plan);
291
+ plansUpdated += 1;
292
+ } catch { /* skip unreadable plan */ }
293
+ }
294
+ }
295
+ }
296
+
297
+ // Update the project row directly so the UI reflects the new name
298
+ // without waiting for the chokidar watcher to fire on the plan
299
+ // edits above.
300
+ try {
301
+ const stmt = getDb().prepare('UPDATE projects SET name = ? WHERE id = ?');
302
+ const res = stmt.run(trimmed, projectId);
303
+ if (res.changes === 0 && plansUpdated === 0) {
304
+ return { ok: false, error: `Proyecto no encontrado: ${projectId}` };
305
+ }
306
+ } catch (err) {
307
+ return { ok: false, error: `Error actualizando la base de datos: ${err.message}` };
308
+ }
309
+ return { ok: true, name: trimmed, plansUpdated };
310
+ }
311
+
312
+ export function markPlanReady({ planId, projectId }) {
313
+ const filePath = findPlanFilePath(planId, projectId);
314
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
315
+
316
+ const plan = readPlan(filePath);
317
+ if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
318
+ return { ok: false, error: 'plan has no hus[]' };
319
+ }
320
+
321
+ const count = certifyAllHus(plan);
322
+ writePlan(filePath, plan);
323
+ syncPlanFile(filePath);
324
+ return { ok: true, count, planStatus: plan.status };
325
+ }
326
+
327
+ /**
328
+ * Launch `kj run --plan <planId>` as a detached child. This is the
329
+ * "Run plan" button's server-side handler: the board no longer asks the
330
+ * user to drop to the terminal to kick off execution.
331
+ *
332
+ * We spawn against the repo's own CLI (`node <repo>/src/cli.js`) instead
333
+ * of a `kj` binary on $PATH — the board is always shipped with the repo
334
+ * that hosts it, so that entry point is guaranteed to exist. Tests set
335
+ * `KJ_RUN_SPAWN_MODE=echo` to replace the spawn with a no-op that just
336
+ * returns a fake pid, so we can assert payload shape without booting the
337
+ * whole orchestrator inside vitest.
338
+ *
339
+ * Output is redirected to `~/.karajan/hu-board-runs/<planId>.log` so the
340
+ * board can tail it later if we wire up a live log viewer — for now it's
341
+ * just a durable trail. stdin is `ignore` (coder subprocess requirement
342
+ * documented in CLAUDE.md).
343
+ *
344
+ * @param {object} args
345
+ * @param {string} args.planId
346
+ * @param {string} [args.projectId]
347
+ * @param {string} [args.taskOverride] - optional custom task string
348
+ * @returns {{ ok: true, pid: number, logPath: string } | { ok: false, error: string }}
349
+ */
350
+ export function runPlan({ planId, projectId, taskOverride, huIds = null } = {}) {
351
+ const filePath = findPlanFilePath(planId, projectId);
352
+ if (!filePath) return { ok: false, error: `plan not found: ${planId}` };
353
+
354
+ const plan = readPlan(filePath);
355
+ if (!Array.isArray(plan.hus) || plan.hus.length === 0) {
356
+ return { ok: false, error: 'plan has no hus[]' };
357
+ }
358
+ const projectDir = plan.projectDir;
359
+ if (!projectDir) {
360
+ return {
361
+ ok: false,
362
+ error:
363
+ 'plan has no projectDir stamped — re-run `kj plan` to regenerate it, '
364
+ + 'then try again (plans pre-v2.7.4 did not persist the dir).',
365
+ };
366
+ }
367
+
368
+ // PR4: when huIds is set, validate they exist before spawning so
369
+ // we surface "unknown HU" as an HTTP 400 instead of letting the
370
+ // CLI fail mid-startup with a less obvious message.
371
+ let huFlag = null;
372
+ if (Array.isArray(huIds) && huIds.length > 0) {
373
+ const validIds = new Set(plan.hus.map((h) => h.id));
374
+ const unknown = huIds.filter((id) => !validIds.has(id));
375
+ if (unknown.length > 0) {
376
+ return { ok: false, error: `unknown HU id(s) in plan ${planId}: ${unknown.join(', ')}` };
377
+ }
378
+ huFlag = huIds.join(',');
379
+ }
380
+
381
+ const runsDir = getHuBoardRunsDir(); // KJC-TSK-0421 — centralised in db.js
382
+ mkdirSync(runsDir, { recursive: true });
383
+ // PR4: when running a single HU, suffix the log with the HU id so
384
+ // a subsequent full-plan run doesn't overwrite the per-HU log.
385
+ const logName = huFlag
386
+ ? `${planId}--hu-${huIds.join('-').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 60)}.log`
387
+ : `${planId}.log`;
388
+ const logPath = join(runsDir, logName);
389
+
390
+ const task = taskOverride || plan.task || `run plan ${planId}`;
391
+ const args = [KJ_CLI, 'run', '--plan', planId];
392
+ if (huFlag) args.push('--hu', huFlag);
393
+ args.push(task);
394
+
395
+ // Escape hatch for tests — hard-kill the spawn so vitest doesn't boot
396
+ // the full orchestrator. The shape of the response is what we assert.
397
+ if (process.env.KJ_RUN_SPAWN_MODE === 'echo') {
398
+ return { ok: true, pid: 0, logPath, argv: [process.execPath, ...args], projectDir, huIds: huIds || null };
399
+ }
400
+
401
+ const out = openSync(logPath, 'a');
402
+ const err = openSync(logPath, 'a');
403
+ const child = spawn(process.execPath, args, {
404
+ cwd: projectDir,
405
+ detached: true,
406
+ stdio: ['ignore', out, err],
407
+ // Strip CLAUDECODE so the Claude agent subprocess trick in
408
+ // claude-agent.js doesn't misbehave (documented in CLAUDE.md).
409
+ env: { ...process.env, CLAUDECODE: undefined },
410
+ });
411
+ child.unref();
412
+ // KJC-TSK-0396: registrar el PID para que el botón ⏹ Stop del board
413
+ // sepa qué procesos matar. trackRun usa Set, así que llamadas
414
+ // duplicadas no inflan el registro. Cuando el proceso muera, el
415
+ // event 'exit' lo des-registra (defensa adicional: reaper periódico
416
+ // que limpia PIDs muertos cada 30s).
417
+ if (child.pid) {
418
+ trackRun(planId, { pid: child.pid, huIds: huIds || null });
419
+ child.on('exit', () => { untrack(planId, child.pid); });
420
+ }
421
+ return { ok: true, pid: child.pid ?? 0, logPath };
422
+ }