karajan-code 2.19.0 → 2.19.2
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 +4 -0
- 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/checks/dir-setup.js +5 -3
- package/src/cli/register-pipeline.js +1 -0
- package/src/cli/register-plan.js +1 -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/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/sonar/api.js +17 -3
- package/src/sonar/token-recovery.js +67 -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/paths.js +15 -0
- package/src/utils/run-registry.js +4 -4
- package/templates/roles/spec-reviewer.md +38 -0
|
@@ -0,0 +1,626 @@
|
|
|
1
|
+
import { watch } from 'chokidar';
|
|
2
|
+
import { readFileSync, readdirSync, existsSync, statSync, rmSync } from 'node:fs';
|
|
3
|
+
import { dirname, join, basename } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import {
|
|
6
|
+
getKjHome,
|
|
7
|
+
upsertProject,
|
|
8
|
+
upsertStory,
|
|
9
|
+
upsertSession,
|
|
10
|
+
insertContextRequest,
|
|
11
|
+
getDb,
|
|
12
|
+
isTombstoned,
|
|
13
|
+
getTombstone,
|
|
14
|
+
removeTombstone,
|
|
15
|
+
} from './db.js';
|
|
16
|
+
import { publish as publishEvent } from './event-bus.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Skip + best-effort fs cleanup when the resource is tombstoned. Returns
|
|
20
|
+
* true if the caller should bail out (resource is dead).
|
|
21
|
+
*
|
|
22
|
+
* KJC-TSK-0380: without this, every fullScan/chokidar event resurrects
|
|
23
|
+
* resources the user already deleted via the API.
|
|
24
|
+
*/
|
|
25
|
+
function skipIfTombstoned(resourceType, resourceId, fsPathToRemove) {
|
|
26
|
+
if (!isTombstoned(resourceType, resourceId)) return false;
|
|
27
|
+
if (fsPathToRemove) {
|
|
28
|
+
try { rmSync(fsPathToRemove, { recursive: true, force: true }); } catch { /* best effort */ }
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Derive a human-readable project name from batch data.
|
|
35
|
+
* Priority: explicit data.project_name → extracted from story text → sessionId.
|
|
36
|
+
*/
|
|
37
|
+
function deriveProjectName(data, fallbackId) {
|
|
38
|
+
if (data.project_name) return data.project_name;
|
|
39
|
+
// Try to find "Part of: <originalTask>" in any story's certified text
|
|
40
|
+
const stories = data.stories || [];
|
|
41
|
+
for (const story of stories) {
|
|
42
|
+
const text = story.certified?.text || '';
|
|
43
|
+
const match = text.match(/Part of:\s*([^\n]+)/);
|
|
44
|
+
if (match) {
|
|
45
|
+
return slugToTitle(match[1].trim());
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Try first story's original text as a fallback
|
|
49
|
+
if (stories[0]?.original?.text) {
|
|
50
|
+
return slugToTitle(stories[0].original.text);
|
|
51
|
+
}
|
|
52
|
+
return fallbackId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Turn a free-text goal into a short title-cased name (max 60 chars).
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Derive a stable `project_id` from a plan's absolute `projectDir`.
|
|
60
|
+
* Used so every plan under the same source tree groups under one project
|
|
61
|
+
* on the board (instead of 1 project per plan — the 2 101-stories bug).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} projectDir
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function deriveProjectIdFromDir(projectDir) {
|
|
67
|
+
if (!projectDir || typeof projectDir !== 'string') return 'unknown';
|
|
68
|
+
// Same slug rule the plan-store uses, so identifiers match if we ever
|
|
69
|
+
// cross-reference `~/.kj/plans/<slug>/` with this project_id.
|
|
70
|
+
return projectDir
|
|
71
|
+
.replace(/^\//, '')
|
|
72
|
+
.replace(/[/\\]/g, '_')
|
|
73
|
+
.replace(/[^a-zA-Z0-9_.-]/g, '')
|
|
74
|
+
.slice(0, 120);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Turn a directory basename into a human-friendly Title Case string,
|
|
79
|
+
* preserving every meaningful word. Unlike `slugToTitle`, this one does
|
|
80
|
+
* NOT apply stopword filtering — directory names are concise on purpose
|
|
81
|
+
* (e.g. "linux-assistant-orchestrator" → "Linux Assistant Orchestrator",
|
|
82
|
+
* "my-dev-tool" → "My Dev Tool").
|
|
83
|
+
*
|
|
84
|
+
* @param {string} name
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function titleCaseBasename(name) {
|
|
88
|
+
const words = String(name || '')
|
|
89
|
+
.replace(/[_\s]+/g, '-')
|
|
90
|
+
.split('-')
|
|
91
|
+
.map((w) => w.replace(/[^\p{L}\p{N}]/gu, ''))
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
if (words.length === 0) return String(name || '');
|
|
94
|
+
return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(' ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function slugToTitle(text) {
|
|
98
|
+
const STOPWORDS = new Set([
|
|
99
|
+
'a', 'an', 'the', 'and', 'or', 'with', 'for', 'to', 'of', 'in', 'on',
|
|
100
|
+
'is', 'it', 'its', 'this', 'that', 'these', 'those', 'be', 'been', 'being',
|
|
101
|
+
'build', 'create', 'implement', 'make', 'develop', 'add', 'set', 'up',
|
|
102
|
+
'setup', 'write', 'code', 'new', 'complete', 'from', 'scratch',
|
|
103
|
+
'application', 'app', 'tool', 'system', 'project', 'using', 'use',
|
|
104
|
+
'full', 'full-stack', 'fullstack', 'stack', 'based', 'simple', 'basic',
|
|
105
|
+
]);
|
|
106
|
+
const words = String(text || '')
|
|
107
|
+
.toLowerCase()
|
|
108
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, ' ')
|
|
109
|
+
.split(/\s+/)
|
|
110
|
+
.filter((w) => w && !STOPWORDS.has(w));
|
|
111
|
+
const meaningful = words.slice(0, 6);
|
|
112
|
+
if (meaningful.length === 0) return String(text).slice(0, 60).trim();
|
|
113
|
+
const titled = meaningful.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
114
|
+
return titled.length > 60 ? titled.slice(0, 57) + '...' : titled;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parses a batch.json HU stories file and syncs to SQLite.
|
|
119
|
+
* @param {string} filePath - Absolute path to the batch.json file
|
|
120
|
+
*/
|
|
121
|
+
function syncStoryFile(filePath) {
|
|
122
|
+
try {
|
|
123
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
124
|
+
const data = JSON.parse(raw);
|
|
125
|
+
const sessionId = data.session_id || basename(join(filePath, '..'));
|
|
126
|
+
const projectId = data.project_id || sessionId;
|
|
127
|
+
|
|
128
|
+
// Tombstone gate: if the project or its batch is buried, drop the
|
|
129
|
+
// file from disk and skip. Without this, fullScan resurrects them.
|
|
130
|
+
if (skipIfTombstoned('project', projectId, dirname(filePath))) return;
|
|
131
|
+
if (skipIfTombstoned('story', sessionId, dirname(filePath))) return;
|
|
132
|
+
|
|
133
|
+
// Skip if this is a non-auto batch and an auto- version already exists.
|
|
134
|
+
// Prevents duplicate projects when both auto-s_xxx/ and s_xxx/ exist.
|
|
135
|
+
if (!projectId.startsWith('auto-')) {
|
|
136
|
+
const db = getDb();
|
|
137
|
+
const autoExists = db.prepare('SELECT id FROM projects WHERE id = ?').get(`auto-${projectId}`);
|
|
138
|
+
if (autoExists) {
|
|
139
|
+
console.log(`[sync] Skipping ${projectId} — auto-${projectId} already covers this session`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
upsertProject({
|
|
145
|
+
id: projectId,
|
|
146
|
+
name: deriveProjectName(data, projectId),
|
|
147
|
+
last_activity: data.created_at || new Date().toISOString(),
|
|
148
|
+
total_stories: (data.stories || []).length,
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
for (const story of data.stories || []) {
|
|
152
|
+
const certified = story.certified || {};
|
|
153
|
+
const quality = story.quality || {};
|
|
154
|
+
const dimensions = quality.dimensions || {};
|
|
155
|
+
|
|
156
|
+
// acceptance_criteria: support both array (auto-generator) and object (hu-reviewer)
|
|
157
|
+
let acText = null;
|
|
158
|
+
if (Array.isArray(story.acceptance_criteria)) {
|
|
159
|
+
acText = JSON.stringify(story.acceptance_criteria);
|
|
160
|
+
} else if (story.acceptance_criteria?.criteria) {
|
|
161
|
+
acText = JSON.stringify(story.acceptance_criteria.criteria);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
upsertStory({
|
|
165
|
+
id: story.id,
|
|
166
|
+
project_id: projectId,
|
|
167
|
+
session_id: sessionId,
|
|
168
|
+
status: story.status || 'pending',
|
|
169
|
+
title: extractTitle(story),
|
|
170
|
+
original_text: story.original?.text || certified.text || null,
|
|
171
|
+
certified_as: certified.as || null,
|
|
172
|
+
certified_want: certified.want || (certified.text ? certified.text.slice(0, 500) : null),
|
|
173
|
+
certified_so_that: certified.so_that || null,
|
|
174
|
+
quality_total: quality.total ?? null,
|
|
175
|
+
quality_d1: dimensions.d1 ?? null,
|
|
176
|
+
quality_d2: dimensions.d2 ?? null,
|
|
177
|
+
quality_d3: dimensions.d3 ?? null,
|
|
178
|
+
quality_d4: dimensions.d4 ?? null,
|
|
179
|
+
quality_d5: dimensions.d5 ?? null,
|
|
180
|
+
quality_d6: dimensions.d6 ?? null,
|
|
181
|
+
antipatterns: quality.antipatterns ? JSON.stringify(quality.antipatterns) : null,
|
|
182
|
+
ac_format: story.acceptance_criteria?.format || (Array.isArray(story.acceptance_criteria) ? 'list' : null),
|
|
183
|
+
acceptance_criteria: acText,
|
|
184
|
+
created_at: story.created_at,
|
|
185
|
+
updated_at: story.updated_at,
|
|
186
|
+
certified_at: story.certified_at || null,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Sync context requests
|
|
190
|
+
for (const ctx of story.context_requests || []) {
|
|
191
|
+
insertContextRequest({
|
|
192
|
+
story_id: story.id,
|
|
193
|
+
fields_needed: ctx.fields_needed ? JSON.stringify(ctx.fields_needed) : null,
|
|
194
|
+
question: ctx.question || null,
|
|
195
|
+
answer: ctx.answer || null,
|
|
196
|
+
requested_at: ctx.requested_at || null,
|
|
197
|
+
answered_at: ctx.answered_at || null,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
console.log(`[sync] Synced stories from ${basename(join(filePath, '..'))}: ${(data.stories || []).length} stories`);
|
|
203
|
+
publishEvent({ type: 'batch', projectId, path: filePath });
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error(`[sync] Error syncing story file ${filePath}:`, err.message);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Extracts a short title from a story.
|
|
211
|
+
* @param {object} story
|
|
212
|
+
* @returns {string}
|
|
213
|
+
*/
|
|
214
|
+
function extractTitle(story) {
|
|
215
|
+
// Auto-generated HUs have story.title directly
|
|
216
|
+
if (story.title && story.title !== story.id) return story.title.slice(0, 120);
|
|
217
|
+
// HU-reviewer format: certified.want
|
|
218
|
+
if (story.certified?.want) return story.certified.want.slice(0, 120);
|
|
219
|
+
// Fallback: original text or id
|
|
220
|
+
const text = story.original?.text || '';
|
|
221
|
+
return text.slice(0, 120) || story.id;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parses a session.json file and syncs to SQLite.
|
|
226
|
+
* @param {string} filePath - Absolute path to session.json
|
|
227
|
+
*/
|
|
228
|
+
function syncSessionFile(filePath) {
|
|
229
|
+
try {
|
|
230
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
231
|
+
const data = JSON.parse(raw);
|
|
232
|
+
const sessionId = data.id || basename(join(filePath, '..'));
|
|
233
|
+
|
|
234
|
+
if (skipIfTombstoned('session', sessionId, dirname(filePath))) return;
|
|
235
|
+
|
|
236
|
+
// Resolve which project this session belongs to:
|
|
237
|
+
// 1. If a plan / HU batch already created `auto-<sessionId>`, reuse it
|
|
238
|
+
// so plan-based runs group with their HU batch.
|
|
239
|
+
// 2. Otherwise fall back to the session's declared `project_id`.
|
|
240
|
+
// 3. Otherwise → REJECT. Orphan sessions (no project_id and no batch)
|
|
241
|
+
// USED to be promoted to a fake "Orphan sessions" project, which
|
|
242
|
+
// violated the user's invariant: "Si se crea proyecto y se crean
|
|
243
|
+
// hus se asocian a proyecto. ES IMPOSIBLE QUE [no exista esa
|
|
244
|
+
// asociación]". Now we skip them entirely. The fix for the source
|
|
245
|
+
// (kj run always stamps project_id derived from cwd) lives in
|
|
246
|
+
// PR-F; this is the safety net so a leftover orphan can never
|
|
247
|
+
// again appear as a phantom project on the board.
|
|
248
|
+
const autoProjectId = `auto-${sessionId}`;
|
|
249
|
+
const db = getDb();
|
|
250
|
+
const existingBatch = db.prepare('SELECT id FROM projects WHERE id = ?').get(autoProjectId);
|
|
251
|
+
let projectId;
|
|
252
|
+
if (existingBatch) {
|
|
253
|
+
projectId = autoProjectId;
|
|
254
|
+
} else if (data.project_id) {
|
|
255
|
+
projectId = data.project_id;
|
|
256
|
+
} else {
|
|
257
|
+
// Orphan: log + skip. Never promote to "default".
|
|
258
|
+
console.warn(`[sync] Session ${sessionId} has no project_id and no matching batch — skipping (orphan). File: ${filePath}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Ensure the project row exists. Skip when an auto-batch owns it already
|
|
263
|
+
// (that row was written by syncStoryFile with richer metadata and we do
|
|
264
|
+
// not want to overwrite name/total_stories).
|
|
265
|
+
if (!existingBatch) {
|
|
266
|
+
upsertProject({
|
|
267
|
+
id: projectId,
|
|
268
|
+
name: projectId,
|
|
269
|
+
last_activity: data.updated_at || data.created_at || new Date().toISOString(),
|
|
270
|
+
total_stories: 0,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Calculate iterations from checkpoints
|
|
275
|
+
const checkpoints = data.checkpoints || [];
|
|
276
|
+
const maxIteration = checkpoints.reduce((max, cp) => Math.max(max, cp.iteration || 0), 0);
|
|
277
|
+
|
|
278
|
+
// Calculate duration from first and last checkpoint
|
|
279
|
+
let durationMs = null;
|
|
280
|
+
if (data.created_at && data.updated_at) {
|
|
281
|
+
durationMs = new Date(data.updated_at).getTime() - new Date(data.created_at).getTime();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Collect completed stages
|
|
285
|
+
const stagesSet = new Set();
|
|
286
|
+
for (const cp of checkpoints) {
|
|
287
|
+
if (cp.stage && !cp.stage.includes('attempt') && !cp.stage.includes('checkpoint')) {
|
|
288
|
+
stagesSet.add(cp.stage);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Project row was created above (unless an auto-batch owns it); the
|
|
293
|
+
// session always has a project to attach to now.
|
|
294
|
+
|
|
295
|
+
upsertSession({
|
|
296
|
+
id: sessionId,
|
|
297
|
+
project_id: projectId,
|
|
298
|
+
task: data.task || null,
|
|
299
|
+
status: data.status || 'unknown',
|
|
300
|
+
created_at: data.created_at,
|
|
301
|
+
updated_at: data.updated_at,
|
|
302
|
+
iterations: maxIteration,
|
|
303
|
+
duration_ms: durationMs,
|
|
304
|
+
approved: data.status === 'approved' ? 1 : 0,
|
|
305
|
+
commits: data.commits ? JSON.stringify(data.commits) : null,
|
|
306
|
+
stages_completed: JSON.stringify([...stagesSet]),
|
|
307
|
+
checkpoints: JSON.stringify(checkpoints),
|
|
308
|
+
llm_calls: data.llm_calls ? JSON.stringify(data.llm_calls) : null,
|
|
309
|
+
config_snapshot: data.config_snapshot ? JSON.stringify(data.config_snapshot) : null,
|
|
310
|
+
budget: data.budget ? JSON.stringify(data.budget) : null,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
console.log(`[sync] Synced session ${sessionId}: status=${data.status}, iterations=${maxIteration}`);
|
|
314
|
+
publishEvent({ type: 'session', projectId, sessionId });
|
|
315
|
+
} catch (err) {
|
|
316
|
+
console.error(`[sync] Error syncing session file ${filePath}:`, err.message);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Performs a full scan of all existing JSON files.
|
|
322
|
+
*/
|
|
323
|
+
/**
|
|
324
|
+
* Sync a v2 plan file (with embedded HUs) to SQLite.
|
|
325
|
+
* Plans are the authoritative source — replaces hu-stories for plan-based runs.
|
|
326
|
+
*/
|
|
327
|
+
export function syncPlanFile(filePath) {
|
|
328
|
+
try {
|
|
329
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
330
|
+
const data = JSON.parse(raw);
|
|
331
|
+
if (data.version !== 2 || !Array.isArray(data.hus) || data.hus.length === 0) return;
|
|
332
|
+
|
|
333
|
+
// Bug fix: pre-patch, `projectId = data.planId` — every plan was a separate
|
|
334
|
+
// "project" on the board, so 2 464 plans across the same repo produced
|
|
335
|
+
// 2 464 boxes in the UI instead of one. Now we derive the project from
|
|
336
|
+
// the stamped `projectDir` so every plan for the same source tree groups
|
|
337
|
+
// under a single board entry. Legacy plans without `projectDir` still
|
|
338
|
+
// fall back to `planId` (conservative — they at least continue to show
|
|
339
|
+
// up, just each on its own card).
|
|
340
|
+
const projectId = data.projectDir
|
|
341
|
+
? deriveProjectIdFromDir(data.projectDir)
|
|
342
|
+
: data.planId;
|
|
343
|
+
|
|
344
|
+
if (skipIfTombstoned('plan', data.planId, filePath)) return;
|
|
345
|
+
// KJC-BUG-0055 (supersedes KJC-BUG-0050): gating por timestamp.
|
|
346
|
+
// El "fix" anterior quitaba el tombstone del proyecto en CUALQUIER
|
|
347
|
+
// llegada de plan — eso resucitaba proyectos borrados cuando un plan
|
|
348
|
+
// anterior reaparecía (DB nueva + plan-files viejos en disco) o
|
|
349
|
+
// cuando un fullScan posterior los re-encontraba. Comportamiento:
|
|
350
|
+
// • plan.updatedAt/createdAt > tombstone.deleted_at → el usuario
|
|
351
|
+
// ha generado el plan DESPUÉS de borrar el proyecto → revivir.
|
|
352
|
+
// • plan.updatedAt/createdAt <= tombstone.deleted_at → plan stale
|
|
353
|
+
// pre-existente al borrado → respetar el tombstone y borrar el
|
|
354
|
+
// fichero en disco para que no vuelva a re-importarse.
|
|
355
|
+
const projectTomb = getTombstone('project', projectId);
|
|
356
|
+
if (projectTomb) {
|
|
357
|
+
const planTime = data.updatedAt || data.createdAt || null;
|
|
358
|
+
const planTs = planTime ? Date.parse(planTime) : NaN;
|
|
359
|
+
const tombTs = Date.parse(projectTomb.deleted_at);
|
|
360
|
+
if (Number.isFinite(planTs) && planTs > tombTs) {
|
|
361
|
+
console.log(`[sync] Project ${projectId} resucitado: plan ${data.planId} es más reciente (${planTime}) que el tombstone (${projectTomb.deleted_at}).`);
|
|
362
|
+
removeTombstone('project', projectId);
|
|
363
|
+
} else {
|
|
364
|
+
console.log(`[sync] Plan ${data.planId} ignorado: proyecto ${projectId} tombstoned (${projectTomb.deleted_at}); borrando ${filePath}.`);
|
|
365
|
+
try { rmSync(filePath, { force: true }); } catch { /* best effort */ }
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Human-friendly project name resolution order (most-specific first):
|
|
370
|
+
// 1. plan.name explicitly set by the user / planner ("Linux Assistant
|
|
371
|
+
// Orchestrator" — preserved across renames done from the board).
|
|
372
|
+
// 2. titleCaseBasename(projectDir) — derived from the cwd of the
|
|
373
|
+
// original kj plan invocation. NOTE: this is the SUBCARPETA of the
|
|
374
|
+
// projectDir, NOT necessarily where the work happens. If the user
|
|
375
|
+
// ran `kj plan` from inside `…/karajan-code/packages/hu-board/`
|
|
376
|
+
// with a SPEC about "Linux Assistant", the basename here would
|
|
377
|
+
// misleadingly say "Hu Board" — that's why plan.name wins.
|
|
378
|
+
// 3. slugToTitle of the task / projectId as a last resort.
|
|
379
|
+
const projectName = data.name
|
|
380
|
+
|| (data.projectDir ? titleCaseBasename(basename(data.projectDir)) : null)
|
|
381
|
+
|| slugToTitle(data.task || '')
|
|
382
|
+
|| projectId;
|
|
383
|
+
|
|
384
|
+
upsertProject({
|
|
385
|
+
id: projectId,
|
|
386
|
+
name: projectName,
|
|
387
|
+
last_activity: data.updatedAt || data.createdAt || new Date().toISOString(),
|
|
388
|
+
total_stories: data.hus.length,
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
for (let i = 0; i < data.hus.length; i += 1) {
|
|
392
|
+
const hu = data.hus[i];
|
|
393
|
+
const acList = Array.isArray(hu.acceptance_criteria) ? hu.acceptance_criteria : [];
|
|
394
|
+
// Only count tests that carry real content. `kj plan` stamps a
|
|
395
|
+
// placeholder like `["npx vitest run ..."]` on every HU — those
|
|
396
|
+
// DO count as 1 test each. But empty strings / null entries
|
|
397
|
+
// (which show up in half-baked plans) must not inflate the
|
|
398
|
+
// badge to "🧪 1 test" on a HU that effectively has none.
|
|
399
|
+
// Drop empty entries but accept BOTH legacy shapes and the
|
|
400
|
+
// v2.7.5 structured form. The original anti-junk filter only
|
|
401
|
+
// knew the legacy shape (`name|title|description|given|gherkin|
|
|
402
|
+
// scope`) and silently nuked every test produced by the
|
|
403
|
+
// tests-synthesizer (which emits `{type, content, file?}`),
|
|
404
|
+
// leaving every HU flagged "missing test contract" on the
|
|
405
|
+
// board even though the plan JSON had 4 tests apiece.
|
|
406
|
+
const testList = (Array.isArray(hu.acceptance_tests) ? hu.acceptance_tests : [])
|
|
407
|
+
.filter((t) => {
|
|
408
|
+
if (!t) return false;
|
|
409
|
+
if (typeof t === 'string') return t.trim().length > 0;
|
|
410
|
+
if (typeof t !== 'object') return false;
|
|
411
|
+
// v2.7.5 structured form — primary shape going forward.
|
|
412
|
+
if (typeof t.content === 'string' && t.content.trim().length > 0) return true;
|
|
413
|
+
// Legacy shapes kept for back-compat with hand-edited plans.
|
|
414
|
+
return Boolean(t.name || t.title || t.description || t.given || t.gherkin || t.scope);
|
|
415
|
+
});
|
|
416
|
+
const blockedByList = Array.isArray(hu.blocked_by) ? hu.blocked_by : [];
|
|
417
|
+
const acText = acList.length > 0 ? JSON.stringify(acList) : null;
|
|
418
|
+
|
|
419
|
+
upsertStory({
|
|
420
|
+
id: `${projectId}::${hu.id}`,
|
|
421
|
+
project_id: projectId,
|
|
422
|
+
session_id: projectId,
|
|
423
|
+
status: hu.status || 'pending',
|
|
424
|
+
title: hu.title || hu.id,
|
|
425
|
+
original_text: hu.scope || null,
|
|
426
|
+
certified_as: null,
|
|
427
|
+
certified_want: hu.scope || null,
|
|
428
|
+
certified_so_that: null,
|
|
429
|
+
quality_total: null,
|
|
430
|
+
quality_d1: null, quality_d2: null, quality_d3: null,
|
|
431
|
+
quality_d4: null, quality_d5: null, quality_d6: null,
|
|
432
|
+
antipatterns: null,
|
|
433
|
+
ac_format: acText ? 'list' : null,
|
|
434
|
+
acceptance_criteria: acText,
|
|
435
|
+
created_at: hu.createdAt,
|
|
436
|
+
updated_at: hu.updatedAt,
|
|
437
|
+
certified_at: null,
|
|
438
|
+
// Stamp plan_id so the PATCH / mark-ready endpoints can locate
|
|
439
|
+
// the source plan JSON without walking the plans dir.
|
|
440
|
+
plan_id: data.planId,
|
|
441
|
+
// Denormalised counters + deps so the card renders "3 ACs ·
|
|
442
|
+
// 2 tests · waits for lao-001" without every client parsing JSON.
|
|
443
|
+
ac_count: acList.length,
|
|
444
|
+
test_count: testList.length,
|
|
445
|
+
blocked_by: blockedByList.length > 0 ? JSON.stringify(blockedByList) : null,
|
|
446
|
+
// Full test list goes to SQLite too so the modal can show
|
|
447
|
+
// "🧪 2 tests" next to the list. Pre-patch the card showed
|
|
448
|
+
// test_count but the modal had nothing to render.
|
|
449
|
+
acceptance_tests: testList.length > 0 ? JSON.stringify(testList) : null,
|
|
450
|
+
// Index in plan.hus[] — used by getStoriesByProject to order
|
|
451
|
+
// cards topologically (roots first, dependents last).
|
|
452
|
+
plan_order: i,
|
|
453
|
+
// Spec mapping (PR C): "implements §5.3" trace pointer.
|
|
454
|
+
spec_section: typeof hu.spec_section === 'string' && hu.spec_section.trim() ? hu.spec_section.trim() : null,
|
|
455
|
+
// PR3: per-HU outcome (iterations, duration, commits,
|
|
456
|
+
// blockers, summary). Stored as a JSON string in SQLite —
|
|
457
|
+
// the front parses on read. Null until hu-sub-pipeline
|
|
458
|
+
// stamps it at the end of each HU.
|
|
459
|
+
outcome: hu.outcome ? JSON.stringify(hu.outcome) : null,
|
|
460
|
+
// KJC-TSK-0394: result (pass|fail|partial|null) ortogonal al status.
|
|
461
|
+
// Distinto de `outcome` (blob JSON) — esto es el enum corto para
|
|
462
|
+
// badge ✓/✗ en la card.
|
|
463
|
+
result: hu.result || null,
|
|
464
|
+
// KJC-TSK-0406: coder_model y reviewer_model por HU. Reflejan
|
|
465
|
+
// qué modelo se va a usar (o se usó) en este HU. El modal del
|
|
466
|
+
// board los muestra y permite cambiarlos antes de run.
|
|
467
|
+
coder_model: hu.coder_model || null,
|
|
468
|
+
reviewer_model: hu.reviewer_model || null,
|
|
469
|
+
coder_provider: hu.coder_provider || null,
|
|
470
|
+
reviewer_provider: hu.reviewer_provider || null,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
console.log(`[sync] Plan ${projectId}: ${data.hus.length} HUs (${projectName})`);
|
|
475
|
+
publishEvent({ type: 'plan', projectId, planId: data.planId });
|
|
476
|
+
} catch (err) {
|
|
477
|
+
console.error(`[sync] Failed to sync plan ${filePath}: ${err.message}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function fullScan() {
|
|
482
|
+
const kjHome = getKjHome();
|
|
483
|
+
const storiesDir = join(kjHome, 'hu-stories');
|
|
484
|
+
const sessionsDir = join(kjHome, 'sessions');
|
|
485
|
+
|
|
486
|
+
// Clear existing data for a clean rebuild
|
|
487
|
+
const db = getDb();
|
|
488
|
+
db.exec('DELETE FROM context_requests');
|
|
489
|
+
db.exec('DELETE FROM stories');
|
|
490
|
+
db.exec('DELETE FROM sessions');
|
|
491
|
+
db.exec('DELETE FROM projects');
|
|
492
|
+
|
|
493
|
+
// KJC-BUG-0055: garbage-collect on-disk story/session artefacts whose
|
|
494
|
+
// ids carry an active tombstone. Without this, a tombstone is useless
|
|
495
|
+
// against a stray ~/.karajan/hu-stories/<id>/ that survived a manual
|
|
496
|
+
// DB wipe — the next pass would re-import it as a ghost project.
|
|
497
|
+
// (Plan dirs are NOT pre-purged here: a tombstoned project may hold a
|
|
498
|
+
// newer plan from a legit projectDir re-use; `syncPlanFile`'s
|
|
499
|
+
// timestamp gate decides per-file.)
|
|
500
|
+
for (const [type, root] of [['story', storiesDir], ['session', sessionsDir]]) {
|
|
501
|
+
if (!existsSync(root)) continue;
|
|
502
|
+
for (const name of readdirSync(root)) {
|
|
503
|
+
if (!isTombstoned(type, name)) continue;
|
|
504
|
+
try { rmSync(join(root, name), { recursive: true, force: true }); } catch { /* best effort */ }
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Scan stories
|
|
509
|
+
if (existsSync(storiesDir)) {
|
|
510
|
+
const dirs = readdirSync(storiesDir);
|
|
511
|
+
for (const dir of dirs) {
|
|
512
|
+
const batchPath = join(storiesDir, dir, 'batch.json');
|
|
513
|
+
if (existsSync(batchPath)) {
|
|
514
|
+
syncStoryFile(batchPath);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// Scan sessions
|
|
520
|
+
if (existsSync(sessionsDir)) {
|
|
521
|
+
const dirs = readdirSync(sessionsDir);
|
|
522
|
+
for (const dir of dirs) {
|
|
523
|
+
const sessionPath = join(sessionsDir, dir, 'session.json');
|
|
524
|
+
if (existsSync(sessionPath)) {
|
|
525
|
+
syncSessionFile(sessionPath);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Scan v2 plans (`kj plan` writes to `~/.karajan/plans/<slug>/` after
|
|
531
|
+
// KJC-PCS-0047 PR 3). `KJ_PLANS_DIR` keeps overriding for test
|
|
532
|
+
// isolation. A second pass picks up the legacy `~/.kj/plans/`
|
|
533
|
+
// location for users whose CLI hasn't yet run the migrator (e.g.
|
|
534
|
+
// the board was started before any `kj` command on a fresh upgrade).
|
|
535
|
+
const kjDir = process.env.KJ_PLANS_DIR || join(homedir(), '.karajan', 'plans');
|
|
536
|
+
const legacyKjDir = process.env.KJ_PLANS_DIR ? null : join(homedir(), '.kj', 'plans');
|
|
537
|
+
for (const root of [kjDir, legacyKjDir]) {
|
|
538
|
+
if (!root || !existsSync(root)) continue;
|
|
539
|
+
const projectDirs = readdirSync(root);
|
|
540
|
+
for (const projDir of projectDirs) {
|
|
541
|
+
const projPath = join(root, projDir);
|
|
542
|
+
try {
|
|
543
|
+
const files = readdirSync(projPath);
|
|
544
|
+
for (const file of files) {
|
|
545
|
+
if (file.startsWith('plan-') && file.endsWith('.json')) {
|
|
546
|
+
syncPlanFile(join(projPath, file));
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
} catch { /* skip */ }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
console.log('[sync] Full scan completed');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Starts the file watcher for live sync.
|
|
558
|
+
* @returns {import('chokidar').FSWatcher}
|
|
559
|
+
*/
|
|
560
|
+
export function startWatcher() {
|
|
561
|
+
const kjHome = getKjHome();
|
|
562
|
+
const storiesGlob = join(kjHome, 'hu-stories', '*', 'batch.json');
|
|
563
|
+
const sessionsGlob = join(kjHome, 'sessions', '*', 'session.json');
|
|
564
|
+
const promptsGlob = join(kjHome, 'prompts', '*.json');
|
|
565
|
+
// Plans live under KJ_PLANS_DIR or `~/.karajan/plans/` after
|
|
566
|
+
// KJC-PCS-0047 PR 3 consolidated the home directories. Without this
|
|
567
|
+
// glob, HU statuses flipped to coding/done by `kj run --plan` never
|
|
568
|
+
// reach the board until the user clicks 🔄 manually. The legacy
|
|
569
|
+
// `~/.kj/plans/` root is also watched so users running the board
|
|
570
|
+
// before their first post-upgrade `kj` command (the trigger of the
|
|
571
|
+
// auto-migrator) keep seeing live updates.
|
|
572
|
+
const plansRoot = process.env.KJ_PLANS_DIR || join(homedir(), '.karajan', 'plans');
|
|
573
|
+
const legacyPlansRoot = process.env.KJ_PLANS_DIR ? null : join(homedir(), '.kj', 'plans');
|
|
574
|
+
const plansGlob = join(plansRoot, '*', 'plan-*.json');
|
|
575
|
+
const legacyPlansGlob = legacyPlansRoot ? join(legacyPlansRoot, '*', 'plan-*.json') : null;
|
|
576
|
+
|
|
577
|
+
const watchTargets = [storiesGlob, sessionsGlob, plansGlob, promptsGlob];
|
|
578
|
+
if (legacyPlansGlob) watchTargets.push(legacyPlansGlob);
|
|
579
|
+
const watcher = watch(watchTargets, {
|
|
580
|
+
ignoreInitial: true,
|
|
581
|
+
awaitWriteFinish: { stabilityThreshold: 500, pollInterval: 100 },
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const syncByPath = (p, eventName) => {
|
|
585
|
+
if (p.includes('hu-stories')) syncStoryFile(p);
|
|
586
|
+
else if (p.includes('sessions')) syncSessionFile(p);
|
|
587
|
+
else if (p.includes(`${plansRoot}${plansRoot.endsWith('/') ? '' : '/'}`) || p.includes('/plans/')) {
|
|
588
|
+
syncPlanFile(p);
|
|
589
|
+
} else if (p.includes('/prompts/')) {
|
|
590
|
+
// Prompt files are RPC packets the runner writes when it needs
|
|
591
|
+
// an answer (Solomon escalation, etc.) — see board-prompt-bridge.js.
|
|
592
|
+
// The board's API exposes a /api/prompts list; here we just emit
|
|
593
|
+
// a bus event so connected SSE clients pop the modal immediately
|
|
594
|
+
// instead of waiting on the next render cycle.
|
|
595
|
+
if (p.endsWith('.answer.json')) return; // ignore our own writes
|
|
596
|
+
try {
|
|
597
|
+
const raw = readFileSync(p, 'utf-8');
|
|
598
|
+
const data = JSON.parse(raw);
|
|
599
|
+
publishEvent({ type: 'prompt', event: eventName, promptId: data.promptId, sessionId: data.sessionId, question: data.question });
|
|
600
|
+
} catch { /* malformed prompt file — ignore */ }
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
watcher.on('add', (p) => {
|
|
605
|
+
console.log(`[watcher] New file: ${p}`);
|
|
606
|
+
syncByPath(p, 'add');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
watcher.on('change', (p) => {
|
|
610
|
+
console.log(`[watcher] Changed: ${p}`);
|
|
611
|
+
syncByPath(p, 'change');
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
// The runner deletes prompt files once the answer arrives. Surface
|
|
615
|
+
// those deletions as `prompt:resolved` events so already-open modals
|
|
616
|
+
// can close themselves without polling.
|
|
617
|
+
watcher.on('unlink', (p) => {
|
|
618
|
+
if (p.includes('/prompts/') && p.endsWith('.json') && !p.endsWith('.answer.json')) {
|
|
619
|
+
const m = /([^/]+)\.json$/.exec(p);
|
|
620
|
+
if (m) publishEvent({ type: 'prompt-resolved', promptId: m[1] });
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
console.log(`[watcher] Watching ${kjHome} + ${plansRoot} for changes`);
|
|
625
|
+
return watcher;
|
|
626
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token persistence for HU Board (KJC-TSK-0355).
|
|
3
|
+
*
|
|
4
|
+
* Stored at `~/.karajan/hu-board/token` (or `$KJ_HOME/hu-board/token`)
|
|
5
|
+
* with mode 0600 — single-line, hex-encoded, 32 bytes of randomness.
|
|
6
|
+
* Idempotent: read existing token if file exists, otherwise generate
|
|
7
|
+
* + persist a new one.
|
|
8
|
+
*
|
|
9
|
+
* The token is ONLY enforced when the server binds to a non-loopback
|
|
10
|
+
* interface. See `auth.js` for the full threat model.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const TOKEN_BYTES = 32;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Compute the on-disk token path. Delegates to db.js::getKjHome which
|
|
22
|
+
* (post-KJC-TSK-0420) honours KARAJAN_HOME > KJ_HOME > VITEST > default.
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
import { getKjHome } from "./db.js";
|
|
26
|
+
export function getTokenPath() {
|
|
27
|
+
return join(getKjHome(), "hu-board", "token");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Read the persisted token if it exists; otherwise generate a fresh
|
|
32
|
+
* one, write it (mode 0600), and return it. Returns `null` only if
|
|
33
|
+
* the filesystem operation itself fails (caller decides whether that
|
|
34
|
+
* disables auth or aborts startup).
|
|
35
|
+
*
|
|
36
|
+
* @returns {string|null}
|
|
37
|
+
*/
|
|
38
|
+
export function getOrCreateToken() {
|
|
39
|
+
const tokenPath = getTokenPath();
|
|
40
|
+
try {
|
|
41
|
+
if (existsSync(tokenPath)) {
|
|
42
|
+
const raw = readFileSync(tokenPath, "utf8").trim();
|
|
43
|
+
if (raw.length >= 16) return raw;
|
|
44
|
+
// Corrupt or empty file — overwrite below.
|
|
45
|
+
}
|
|
46
|
+
const token = randomBytes(TOKEN_BYTES).toString("hex");
|
|
47
|
+
mkdirSync(dirname(tokenPath), { recursive: true });
|
|
48
|
+
writeFileSync(tokenPath, token, { mode: 0o600 });
|
|
49
|
+
try { chmodSync(tokenPath, 0o600); } catch { /* best-effort on Windows */ }
|
|
50
|
+
return token;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
// Don't crash the board over this — just disable auth and let the
|
|
53
|
+
// caller log a warning.
|
|
54
|
+
console.warn(`[token-store] failed to manage token file at ${tokenPath}: ${err.message}`);
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|