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,198 @@
1
+ /**
2
+ * Ephemeral project cleaner for the HU Board (board-polish #3,
3
+ * KJC-TSK-0371 — dogfooding 2026-05-07).
4
+ *
5
+ * Why this exists
6
+ * ===============
7
+ *
8
+ * Every `kj run` against a throwaway directory (`/tmp/kj-test-1`,
9
+ * `/tmp/demo-foo`, ad-hoc scratch projects under `tmp_*`) produces
10
+ * a board project that hangs around forever, even after the user
11
+ * has long since rm-rf'd the source tree. After 12 days of
12
+ * dogfooding the board ended up with 30+ "kj-test-N" projects
13
+ * the user couldn't tell apart from real ones — exactly the
14
+ * "noise on the demo" risk the user flagged on 2026-05-07.
15
+ *
16
+ * Heuristic (per the user's spec on the same session, "solución
17
+ * c — heurística + flag visible"):
18
+ *
19
+ * 1. Project is ephemeral when:
20
+ * a) `is_test = 1` (user explicitly marked it test), OR
21
+ * b) project id matches /^(tmp_|test_|demo_|kj-test-)/i
22
+ * AND `is_test IS NULL` (no override).
23
+ * 2. AND last_activity is older than `ttlHours` (default 24h).
24
+ *
25
+ * 3. Override: when `is_test = 0` (user clicked "keep it"), the
26
+ * project is NEVER deleted, even if the heuristic would match.
27
+ *
28
+ * What "cleanup" means: DELETE the project row + cascade-delete its
29
+ * stories and sessions, ADD a project tombstone, and rm-rf the
30
+ * on-disk dirs (`hu-stories/<sid>/`, `sessions/<sid>/`,
31
+ * `~/.kj/plans/<projectId>/`). Without the tombstone + fs cleanup,
32
+ * the very next chokidar `add` event or `fullScan` would re-import
33
+ * those files as a brand-new project (KJC-BUG-0055).
34
+ */
35
+
36
+ import fs from "node:fs";
37
+ import path from "node:path";
38
+ import { homedir } from "node:os";
39
+ import { addTombstone, getKjHome } from "./db.js";
40
+
41
+ const DEFAULT_TTL_HOURS = 24;
42
+ const EPHEMERAL_ID_PATTERNS = [
43
+ /^tmp_/i,
44
+ /^test_/i,
45
+ /^demo_/i,
46
+ /^kj-test-/i,
47
+ /^auto-tmp_/i, // also covers auto-batch projects derived from /tmp/
48
+ /^auto-test_/i,
49
+ /^auto-demo_/i,
50
+ // KJC-TSK-0377: stray session/plan-prefixed projects accumulate during
51
+ // dogfooding when a kj run is invoked without a proper projectDir.
52
+ // The HU Board's sync_*File handlers create a placeholder project row
53
+ // with `id = session_id` or `id = plan_id`, which after 24h of
54
+ // inactivity is the same kind of zombi the other patterns target.
55
+ /^s_/i,
56
+ /^plan-/i,
57
+ ];
58
+
59
+ /**
60
+ * Decide whether a single project row qualifies as an ephemeral
61
+ * candidate ready for deletion at `now`. Pure function — no I/O.
62
+ *
63
+ * The matrix:
64
+ * is_test = 1 → ephemeral (user marked it)
65
+ * is_test = 0 → not ephemeral, EVER (user override wins)
66
+ * is_test = NULL/und. → fall back to id-pattern heuristic
67
+ * too recent → not ephemeral (still in use)
68
+ *
69
+ * @param {{ id: string, last_activity?: string|null, first_seen?: string|null,
70
+ * is_test?: number|null }} project
71
+ * @param {{ now: number, ttlHours: number }} ctx
72
+ * @returns {{ ephemeral: boolean, reason?: string }}
73
+ */
74
+ export function classifyEphemeral(project, ctx) {
75
+ if (!project || !project.id) return { ephemeral: false };
76
+ // Explicit user override always wins. is_test=0 means "keep it".
77
+ if (project.is_test === 0) return { ephemeral: false };
78
+
79
+ const idMatch = EPHEMERAL_ID_PATTERNS.some((re) => re.test(project.id));
80
+ const explicitTest = project.is_test === 1;
81
+ if (!idMatch && !explicitTest) return { ephemeral: false };
82
+
83
+ const refIso = project.last_activity || project.first_seen;
84
+ if (!refIso) return { ephemeral: false };
85
+ const refTs = new Date(refIso).getTime();
86
+ if (Number.isNaN(refTs)) return { ephemeral: false };
87
+ const ageHours = (ctx.now - refTs) / 3_600_000;
88
+ if (ageHours <= 0) return { ephemeral: false };
89
+ if (ageHours < ctx.ttlHours) return { ephemeral: false };
90
+
91
+ const why = explicitTest ? "is_test=1" : "id heuristic";
92
+ return {
93
+ ephemeral: true,
94
+ reason: `${why}, ${ageHours.toFixed(1)}h inactive (>= ${ctx.ttlHours}h)`,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Filter a list of project rows down to the ephemeral ones. Pure.
100
+ *
101
+ * @param {Array<object>} projects
102
+ * @param {{ now?: number, ttlHours?: number }} [opts]
103
+ * @returns {Array<{ project: object, reason: string }>}
104
+ */
105
+ export function findEphemeralProjects(projects, opts = {}) {
106
+ const ctx = {
107
+ now: opts.now ?? Date.now(),
108
+ ttlHours: opts.ttlHours ?? DEFAULT_TTL_HOURS,
109
+ };
110
+ const out = [];
111
+ for (const project of projects || []) {
112
+ const verdict = classifyEphemeral(project, ctx);
113
+ if (verdict.ephemeral) out.push({ project, reason: verdict.reason });
114
+ }
115
+ return out;
116
+ }
117
+
118
+ /**
119
+ * Cascade-delete every ephemeral project at `now`. Side-effecting.
120
+ * Returns the list of cleaned projects so the server can log them.
121
+ *
122
+ * Idempotent: cleanup deletes the rows outright, so running twice
123
+ * has no effect on the second pass.
124
+ *
125
+ * @param {object} deps
126
+ * @param {object} deps.db better-sqlite3 Database handle
127
+ * @param {object} [deps.opts] threshold overrides
128
+ * @param {Function} [deps.now] () => number, for tests
129
+ * @returns {Array<{ id: string, reason: string, stories_deleted: number, sessions_deleted: number }>}
130
+ */
131
+ export function cleanupEphemeralProjects({
132
+ db,
133
+ opts = {},
134
+ now = () => Date.now(),
135
+ // Deps are injectable so the legacy in-memory tests (which never
136
+ // call `initDb()`) can pass no-op stubs and still verify the
137
+ // cascade-delete logic. Production callers omit them and get the
138
+ // real persistence + fs side-effects.
139
+ tombstone = (type, id, o) => {
140
+ try { addTombstone(type, id, o); } catch { /* DB not initialised (test) */ }
141
+ },
142
+ kjHome = () => {
143
+ try { return getKjHome(); } catch { return null; }
144
+ },
145
+ } = {}) {
146
+ if (!db) return [];
147
+ const allProjects = db
148
+ .prepare("SELECT id, last_activity, first_seen, is_test FROM projects")
149
+ .all();
150
+ const candidates = findEphemeralProjects(allProjects, { ...opts, now: now() });
151
+ if (candidates.length === 0) return [];
152
+
153
+ const countStories = db.prepare("SELECT COUNT(*) AS n FROM stories WHERE project_id = ?");
154
+ const countSessions = db.prepare("SELECT COUNT(*) AS n FROM sessions WHERE project_id = ?");
155
+ const delStories = db.prepare("DELETE FROM stories WHERE project_id = ?");
156
+ const delSessions = db.prepare("DELETE FROM sessions WHERE project_id = ?");
157
+ const delProject = db.prepare("DELETE FROM projects WHERE id = ?");
158
+
159
+ const selStories = db.prepare("SELECT id FROM stories WHERE project_id = ?");
160
+ const selSessions = db.prepare("SELECT id FROM sessions WHERE project_id = ?");
161
+ const plansRoot = process.env.KJ_PLANS_DIR
162
+ || path.join(homedir(), ".kj", "plans");
163
+ const home = kjHome();
164
+ const cleaned = [];
165
+ for (const { project, reason } of candidates) {
166
+ const storyIds = selStories.all(project.id).map((r) => r.id);
167
+ const sessionIds = selSessions.all(project.id).map((r) => r.id);
168
+ const fsPaths = home
169
+ ? [
170
+ ...storyIds.map((sid) => path.join(home, "hu-stories", sid)),
171
+ ...sessionIds.map((sid) => path.join(home, "sessions", sid)),
172
+ path.join(plansRoot, project.id),
173
+ ]
174
+ : [];
175
+ db.transaction(() => {
176
+ delStories.run(project.id);
177
+ delSessions.run(project.id);
178
+ delProject.run(project.id);
179
+ })();
180
+ // Tombstone the lot + best-effort rm-rf. If the fs op fails (perm
181
+ // error, file in use) we still keep the tombstone — that alone is
182
+ // enough to stop sync.js from re-importing on the next chokidar
183
+ // event, and `fullScan` will retry the rm-rf at next boot.
184
+ tombstone("project", project.id, { source: "ephemeral-cleaner", fsPaths });
185
+ for (const sid of storyIds) tombstone("story", sid, { source: "ephemeral-cleaner" });
186
+ for (const sid of sessionIds) tombstone("session", sid, { source: "ephemeral-cleaner" });
187
+ for (const p of fsPaths) {
188
+ try { fs.rmSync(p, { recursive: true, force: true }); } catch { /* best effort */ }
189
+ }
190
+ cleaned.push({
191
+ id: project.id,
192
+ reason,
193
+ stories_deleted: storyIds.length,
194
+ sessions_deleted: sessionIds.length,
195
+ });
196
+ }
197
+ return cleaned;
198
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tiny pub/sub used to push sync-related events to connected SSE
3
+ * clients. The chokidar watcher calls `publish(event)` whenever a plan
4
+ * / session / batch file changes; the SSE endpoint subscribes each
5
+ * connected response and writes the JSON-encoded event as a message.
6
+ *
7
+ * No external dependency on purpose — `events.EventEmitter` would work
8
+ * but would make testing the endpoint awkward (we need deterministic
9
+ * subscribe/publish semantics for the integration tests in
10
+ * sse.test.js). The contract is:
11
+ *
12
+ * subscribe(fn) → returns an unsubscribe function
13
+ * publish(event) → calls every current subscriber once
14
+ * subscriberCount → how many listeners are live (tests only)
15
+ *
16
+ * Events are plain JSON objects. Callers pick the shape:
17
+ *
18
+ * { type: 'plan', planId, projectId } — a plan JSON was synced
19
+ * { type: 'session', sessionId, projectId }— a session JSON was synced
20
+ * { type: 'story', storyId, projectId } — a single HU row mutated
21
+ *
22
+ * The board's client treats these as hints: the usual response is to
23
+ * re-fetch the affected project's stories and patch the kanban in
24
+ * place, so no full page reload is ever required.
25
+ */
26
+
27
+ const subscribers = new Set();
28
+
29
+ /**
30
+ * @param {(event: object) => void} fn
31
+ * @returns {() => void} unsubscribe
32
+ */
33
+ export function subscribe(fn) {
34
+ subscribers.add(fn);
35
+ return () => { subscribers.delete(fn); };
36
+ }
37
+
38
+ /**
39
+ * @param {object} event
40
+ */
41
+ export function publish(event) {
42
+ for (const fn of subscribers) {
43
+ try { fn(event); } catch (err) {
44
+ // A broken subscriber must not take the bus down. Log and move on.
45
+ console.error('[event-bus] subscriber threw:', err.message);
46
+ }
47
+ }
48
+ }
49
+
50
+ /** @returns {number} */
51
+ export function subscriberCount() {
52
+ return subscribers.size;
53
+ }
54
+
55
+ /** Test helper — drops every subscriber. */
56
+ export function _resetForTests() {
57
+ subscribers.clear();
58
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Pure formatters used by the board UI to turn database rows into
3
+ * human-friendly labels. No DOM, no node APIs — easy to unit-test.
4
+ */
5
+
6
+ const TIME_RE_FALLBACK = "—";
7
+ const MAX_TASK_CHARS = 60;
8
+ const MAX_PROJECT_CHARS = 40;
9
+
10
+ /**
11
+ * Format an ISO 8601 timestamp as `HH:MM` (24h, local time of the
12
+ * server). Falls back to `—` when the input is missing or unparseable
13
+ * so the UI never renders `Invalid Date` or `null`.
14
+ *
15
+ * @param {string|null|undefined} iso
16
+ * @returns {string}
17
+ */
18
+ export function formatHHMM(iso) {
19
+ if (!iso) return TIME_RE_FALLBACK;
20
+ const d = new Date(iso);
21
+ if (Number.isNaN(d.getTime())) return TIME_RE_FALLBACK;
22
+ const hh = String(d.getHours()).padStart(2, "0");
23
+ const mm = String(d.getMinutes()).padStart(2, "0");
24
+ return `${hh}:${mm}`;
25
+ }
26
+
27
+ /**
28
+ * Truncate a task description to a max of N chars, collapsing internal
29
+ * whitespace, suffixed with "…" when truncated. Returns "" when the
30
+ * input is empty so the caller can decide whether to render the slot.
31
+ *
32
+ * @param {string|null|undefined} text
33
+ * @param {number} [max=MAX_TASK_CHARS]
34
+ * @returns {string}
35
+ */
36
+ export function shortTask(text, max = MAX_TASK_CHARS) {
37
+ if (!text) return "";
38
+ const collapsed = String(text).replace(/\s+/g, " ").trim();
39
+ if (collapsed.length <= max) return collapsed;
40
+ return collapsed.slice(0, max - 1).trimEnd() + "…";
41
+ }
42
+
43
+ /**
44
+ * Build the label tuple used by `renderSessionCard` and
45
+ * `showSessionDetail`. The board used to print the session.id verbatim
46
+ * (e.g. `s_2026-05-07T08-35-58-010Z`) which is technically correct but
47
+ * impossible to scan at a glance — the user explicitly flagged this
48
+ * during pre-talk testing on 2026-05-07.
49
+ *
50
+ * Returned shape:
51
+ * - `title`: bold-line title for the card (project + time).
52
+ * Always non-empty; falls back to the session id when no project
53
+ * is known so the card is never blank.
54
+ * - `subtitle`: short task summary (≤ 60 chars). Empty when the
55
+ * session has no task field — caller hides the slot.
56
+ * - `idChip`: the cryptic session id, kept available for the user
57
+ * who needs it for `kj resume <id>` and as a tooltip on the title.
58
+ *
59
+ * @param {{ id: string, project_name?: string|null, task?: string|null,
60
+ * created_at?: string|null }} session
61
+ * @returns {{ title: string, subtitle: string, idChip: string }}
62
+ */
63
+ export function formatSessionLabel(session) {
64
+ if (!session || !session.id) {
65
+ return { title: "(no session)", subtitle: "", idChip: "" };
66
+ }
67
+ const projectName = (session.project_name || "").trim();
68
+ const time = formatHHMM(session.created_at);
69
+ const projectPart = projectName
70
+ ? projectName.length > MAX_PROJECT_CHARS
71
+ ? projectName.slice(0, MAX_PROJECT_CHARS - 1) + "…"
72
+ : projectName
73
+ : session.id; // fall back so a card without a joined project never
74
+ // ends up blank — the cryptic id is better than empty
75
+ const title = time !== TIME_RE_FALLBACK
76
+ ? `${projectPart} · ${time}`
77
+ : projectPart;
78
+ return {
79
+ title,
80
+ subtitle: shortTask(session.task, MAX_TASK_CHARS),
81
+ idChip: session.id,
82
+ };
83
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * HU-level zombie reaper (KJC-TSK-0394 step 3).
3
+ *
4
+ * Counterpart to zombie-reaper.js (que opera sobre sesiones). Detecta
5
+ * HUs que el orquestador dejó stuck en `coding`/`reviewing` —síntoma
6
+ * de crash mid-iteration— y las resetea a `pending`.
7
+ *
8
+ * Threshold separado de las sesiones: una sesión legítima puede
9
+ * tardar horas (loop largo), una HU individual rara vez >30 min. Por
10
+ * eso este reaper trabaja en minutos, no en horas.
11
+ *
12
+ * Semántica del reap: status → `pending` (NO `failed`: el orquestador
13
+ * perdió contacto, no podemos afirmar el outcome). El campo `result`
14
+ * NO se toca — se conserva como historial. La persistencia pasa por
15
+ * plan-mutations::setHuStatus (escribe el plan JSON + resync DB). Si
16
+ * el JSON write falla cae al fallback DB-only.
17
+ */
18
+
19
+ const DEFAULT_RUNNING_MINUTES = 30;
20
+ // `coding` + `reviewing` son los nombres legacy; `running` es el
21
+ // canónico tras step 5. Aceptamos los tres durante la transición.
22
+ const RUNNING_STATUSES = new Set(["coding", "reviewing", "running"]);
23
+
24
+ /**
25
+ * Pure. Decide si un HU row es zombi a `now`.
26
+ */
27
+ export function classifyHuZombie(hu, ctx) {
28
+ if (!hu || !hu.status) return { zombie: false };
29
+ const status = String(hu.status).toLowerCase();
30
+ if (!RUNNING_STATUSES.has(status)) return { zombie: false };
31
+ const refIso = hu.updated_at;
32
+ if (!refIso) return { zombie: false };
33
+ const refTs = new Date(refIso).getTime();
34
+ if (Number.isNaN(refTs)) return { zombie: false };
35
+ const ageMinutes = (ctx.now - refTs) / 60_000;
36
+ if (ageMinutes < ctx.runningMinutes) return { zombie: false };
37
+ return {
38
+ zombie: true,
39
+ reason: `${status} ${ageMinutes.toFixed(0)}min inactive (>= ${ctx.runningMinutes}min)`,
40
+ };
41
+ }
42
+
43
+ /** Pure. Filtra solo los zombis. */
44
+ export function findZombieHus(hus, opts = {}) {
45
+ const ctx = {
46
+ now: opts.now ?? Date.now(),
47
+ runningMinutes: opts.runningMinutes ?? DEFAULT_RUNNING_MINUTES,
48
+ };
49
+ const out = [];
50
+ for (const hu of hus || []) {
51
+ const verdict = classifyHuZombie(hu, ctx);
52
+ if (verdict.zombie) out.push({ hu, reason: verdict.reason });
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Resetea a `pending` cada HU zombi en la DB Y en el fichero del
59
+ * plan. `setHuStatus` se inyecta para que los tests no necesiten un
60
+ * plan-file real. Devuelve la lista de reapeados para el log.
61
+ */
62
+ export function reapZombieHus({ db, setHuStatus, setHuFailResult, opts = {}, now = () => Date.now() }) {
63
+ if (!db || typeof setHuStatus !== "function") return [];
64
+ const rows = db.prepare(
65
+ "SELECT id, project_id, plan_id, status, updated_at FROM stories WHERE status IN ('coding', 'reviewing', 'running')",
66
+ ).all();
67
+ const zombies = findZombieHus(rows, { ...opts, now: now() });
68
+ if (zombies.length === 0) return [];
69
+ const reaped = [];
70
+ for (const { hu, reason } of zombies) {
71
+ // El id compuesto del board es `${projectId}::${huId}`; setHuStatus
72
+ // quiere el huId local.
73
+ const localHuId = hu.id.includes("::") ? hu.id.split("::").slice(1).join("::") : hu.id;
74
+ let planPersisted = false;
75
+ if (hu.plan_id) {
76
+ try {
77
+ const result = setHuStatus({ planId: hu.plan_id, huId: localHuId, status: "pending", projectId: hu.project_id });
78
+ planPersisted = !!(result && result.ok);
79
+ } catch { /* swallow — never block startup */ }
80
+ // KJC-TSK-0404: además de resetear status=pending, marcar la HU con
81
+ // result=fail + blocker. El siguiente `kj run` ve el blocker y se
82
+ // lo inyecta al coder como "## PREVIOUS ATTEMPT FAILED" para que
83
+ // no repita el mismo error. No-op si la fn no se inyectó.
84
+ if (planPersisted && typeof setHuFailResult === "function") {
85
+ try {
86
+ setHuFailResult({
87
+ planId: hu.plan_id, huId: localHuId, projectId: hu.project_id,
88
+ blocker: `timeout: ${reason}`,
89
+ });
90
+ } catch { /* swallow — el status reset ya está hecho */ }
91
+ }
92
+ }
93
+ // Fallback DB-only para legacy rows (plan_id null) o si el write al
94
+ // plan falló. setHuStatus ya resincroniza en el caso happy.
95
+ if (!planPersisted) {
96
+ try {
97
+ db.prepare("UPDATE stories SET status = 'pending', updated_at = ? WHERE id = ?").run(new Date(now()).toISOString(), hu.id);
98
+ } catch { /* swallow */ }
99
+ }
100
+ reaped.push({ id: hu.id, status_before: hu.status, reason, plan_persisted: planPersisted });
101
+ }
102
+ return reaped;
103
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Journal parser — reads `.reviews/<sessionId>/` artefacts produced by Karajan's
3
+ * session journal writers (decisions.md, iterations.md, summary.md, tree.txt)
4
+ * and exposes them as structured JSON for the /pipeline dashboard.
5
+ *
6
+ * Scope: zero new runtime dependencies. Uses only node:fs + node:path.
7
+ * Poll-friendly: every call re-reads from disk so the UI can refresh by
8
+ * hitting the API endpoint on an interval (1-2 s). No WebSocket, no SSE —
9
+ * keeps the surface minimal (TSK-0325).
10
+ *
11
+ * Input layout (as written by src/session/journal/*):
12
+ *
13
+ * <projectDir>/.reviews/<sessionId>/
14
+ * ├─ decisions.md — Brain / Solomon / standby / checkpoint decisions
15
+ * ├─ iterations.md — per-iteration coder/reviewer/sonar/solomon detail
16
+ * ├─ summary.md — executive view: stages, budget, links
17
+ * └─ tree.txt — directory-grouped file status
18
+ */
19
+
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+
23
+ /**
24
+ * Resolve the directory where session journals live. Honours KJ_PROJECT_DIR
25
+ * for the common "hu-board runs in a different process from the pipeline"
26
+ * case; otherwise falls back to the current working directory.
27
+ */
28
+ function getProjectDir() {
29
+ return process.env.KJ_PROJECT_DIR || process.cwd();
30
+ }
31
+
32
+ /**
33
+ * Resolve the `.reviews/` directory under the active project dir.
34
+ */
35
+ function getReviewsDir() {
36
+ return path.join(getProjectDir(), ".reviews");
37
+ }
38
+
39
+ /**
40
+ * List available sessions. Each entry points at a `.reviews/<sessionId>/`
41
+ * directory that has been produced by Karajan's journal writers.
42
+ *
43
+ * @returns {Array<{id: string, journalPath: string, mtimeMs: number}>}
44
+ */
45
+ export function listSessions() {
46
+ const reviewsDir = getReviewsDir();
47
+ if (!fs.existsSync(reviewsDir)) return [];
48
+ const entries = fs.readdirSync(reviewsDir, { withFileTypes: true });
49
+ const sessions = [];
50
+ for (const entry of entries) {
51
+ if (!entry.isDirectory()) continue;
52
+ const dir = path.join(reviewsDir, entry.name);
53
+ let mtimeMs = 0;
54
+ try {
55
+ mtimeMs = fs.statSync(dir).mtimeMs;
56
+ } catch { /* stat failed — skip this entry */ }
57
+ sessions.push({ id: entry.name, journalPath: dir, mtimeMs });
58
+ }
59
+ // Most recent first — what the UI wants by default.
60
+ sessions.sort((a, b) => b.mtimeMs - a.mtimeMs);
61
+ return sessions;
62
+ }
63
+
64
+ /**
65
+ * Split a markdown doc into `## Heading` blocks. Keeps the heading name
66
+ * and the body under it.
67
+ * @param {string} md
68
+ * @returns {Array<{heading: string, body: string}>}
69
+ */
70
+ export function splitByHeading(md, level = 2) {
71
+ if (!md) return [];
72
+ const prefix = "#".repeat(level) + " ";
73
+ const lines = md.split(/\r?\n/);
74
+ const blocks = [];
75
+ let current = null;
76
+ for (const line of lines) {
77
+ if (line.startsWith(prefix) && !line.startsWith(prefix + "# ")) {
78
+ if (current) blocks.push(current);
79
+ current = { heading: line.slice(prefix.length).trim(), body: "" };
80
+ } else if (current) {
81
+ current.body += (current.body ? "\n" : "") + line;
82
+ }
83
+ }
84
+ if (current) blocks.push(current);
85
+ return blocks;
86
+ }
87
+
88
+ /**
89
+ * Parse iterations.md into a list of iteration objects. The journal writer
90
+ * (src/session/journal/iteration-logger.js) produces `## Iteration N`
91
+ * sections, each with `### Stage` sub-blocks (Coder, Reviewer, Sonar, …).
92
+ *
93
+ * @param {string} md
94
+ * @returns {Array<{number: number, title: string, duration?: string, stages: Array<{name: string, body: string}>}>}
95
+ */
96
+ export function parseIterations(md) {
97
+ return splitByHeading(md, 2).map((block) => {
98
+ const match = block.heading.match(/Iteration\s+(\d+)/i);
99
+ const number = match ? Number(match[1]) : 0;
100
+ const durationMatch = block.body.match(/\*\*Duration\*\*:\s*([^\n]+)/i);
101
+ const duration = durationMatch ? durationMatch[1].trim() : null;
102
+ const stages = splitByHeading(block.body, 3).map((s) => ({
103
+ name: s.heading,
104
+ body: s.body.trim(),
105
+ }));
106
+ return { number, title: block.heading, duration, stages };
107
+ });
108
+ }
109
+
110
+ /**
111
+ * Parse decisions.md into structured records. The writer uses
112
+ * `## <timestamp> — <decisionType>` headings; fall back to a flat list
113
+ * when the format drifts.
114
+ *
115
+ * @param {string} md
116
+ * @returns {Array<{heading: string, body: string}>}
117
+ */
118
+ export function parseDecisions(md) {
119
+ return splitByHeading(md, 2).map((b) => ({
120
+ heading: b.heading,
121
+ body: b.body.trim(),
122
+ }));
123
+ }
124
+
125
+ /**
126
+ * Parse summary.md into { preamble, sections }. The preamble is anything
127
+ * before the first `## Heading`.
128
+ *
129
+ * @param {string} md
130
+ * @returns {{preamble: string, sections: Array<{heading: string, body: string}>}}
131
+ */
132
+ export function parseSummary(md) {
133
+ const sections = splitByHeading(md, 2);
134
+ const firstHeadingIdx = md.indexOf("## ");
135
+ const preamble = firstHeadingIdx === -1 ? md.trim() : md.slice(0, firstHeadingIdx).trim();
136
+ return { preamble, sections };
137
+ }
138
+
139
+ /**
140
+ * Read a file defensively — returns null when missing, the content when
141
+ * present. Lets `readJournal` return a consistent shape even when the
142
+ * pipeline wrote some artefacts but not others.
143
+ */
144
+ function readIfExists(file) {
145
+ try {
146
+ return fs.readFileSync(file, "utf8");
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read + parse the four journal artefacts for a given session. Always
154
+ * returns an object with the same keys so the client has predictable
155
+ * defaults.
156
+ *
157
+ * @param {string} sessionId
158
+ */
159
+ export function readJournal(sessionId) {
160
+ const dir = path.join(getReviewsDir(), sessionId);
161
+ const iterationsRaw = readIfExists(path.join(dir, "iterations.md"));
162
+ const decisionsRaw = readIfExists(path.join(dir, "decisions.md"));
163
+ const summaryRaw = readIfExists(path.join(dir, "summary.md"));
164
+ const treeRaw = readIfExists(path.join(dir, "tree.txt"));
165
+ return {
166
+ id: sessionId,
167
+ journalPath: dir,
168
+ exists: fs.existsSync(dir),
169
+ iterations: iterationsRaw ? parseIterations(iterationsRaw) : [],
170
+ decisions: decisionsRaw ? parseDecisions(decisionsRaw) : [],
171
+ summary: summaryRaw ? parseSummary(summaryRaw) : { preamble: "", sections: [] },
172
+ tree: treeRaw || "",
173
+ raw: {
174
+ iterations: iterationsRaw,
175
+ decisions: decisionsRaw,
176
+ summary: summaryRaw,
177
+ tree: treeRaw,
178
+ },
179
+ };
180
+ }