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,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
|
+
}
|