sdtk-wiki-kit 0.2.1 → 0.3.0
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 +8 -0
- package/assets/atlas/doc_atlas_viewer_template.html +519 -58
- package/bin/sdtk-wiki.js +0 -0
- package/package.json +1 -1
- package/src/commands/ask.js +2 -0
- package/src/commands/help.js +1 -0
- package/src/commands/init.js +1 -1
- package/src/commands/kaban.js +85 -0
- package/src/index.js +4 -0
- package/src/lib/wiki-ask.js +254 -19
- package/src/lib/wiki-config.js +3 -1
- package/src/lib/wiki-kaban-parse.js +529 -0
- package/src/lib/wiki-runner.js +46 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// BK-272: Agent Kaban — pure parser (no I/O). Caller passes file text.
|
|
4
|
+
// Spec §D3: view model; §D4: column derivation rules; §D5: liveness.
|
|
5
|
+
|
|
6
|
+
const KABAN_LIVENESS_MIN = 15;
|
|
7
|
+
const COLUMNS = ["todo", "in_progress", "pending", "done"];
|
|
8
|
+
const AGENT_ORDER = ["PM", "BA", "ARCH", "DEV", "QA"];
|
|
9
|
+
const DONE_STATUSES = new Set(["done", "complete", "example complete"]);
|
|
10
|
+
|
|
11
|
+
// Normalize agent identifier to canonical form (PM/BA/ARCH/DEV/QA)
|
|
12
|
+
function normalizeAgent(raw) {
|
|
13
|
+
if (!raw) return null;
|
|
14
|
+
const s = raw.replace(/[`*@]/g, "").trim().toLowerCase();
|
|
15
|
+
if (!s) return null;
|
|
16
|
+
if (s.startsWith("pm")) return "PM";
|
|
17
|
+
if (s.startsWith("ba")) return "BA";
|
|
18
|
+
if (s.startsWith("arch")) return "ARCH";
|
|
19
|
+
if (s.startsWith("dev")) return "DEV";
|
|
20
|
+
if (s.startsWith("qa")) return "QA";
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Extract a header field matching **FieldName:** `value` or FieldName: value
|
|
25
|
+
function getHeaderField(text) {
|
|
26
|
+
for (let i = 1; i < arguments.length; i++) {
|
|
27
|
+
const fieldName = arguments[i];
|
|
28
|
+
const patterns = [
|
|
29
|
+
new RegExp("\\*\\*" + fieldName + "[:\\s]*\\*\\*\\s*[:`]?\\s*`?([^`\\n]+)`?", "i"),
|
|
30
|
+
new RegExp("^\\s*\\*\\*" + fieldName + ":\\*\\*\\s+`?([^`\\n]+)`?", "im"),
|
|
31
|
+
new RegExp("^\\s*" + fieldName + ":\\s+([^\\n]+)", "im"),
|
|
32
|
+
];
|
|
33
|
+
for (const re of patterns) {
|
|
34
|
+
const m = text.match(re);
|
|
35
|
+
if (m) return m[1].trim().replace(/`/g, "").replace(/\*\*/g, "");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Parse a markdown table block from an array of pipe-delimited lines.
|
|
42
|
+
// Returns { headerCols: string[], rows: string[][] }
|
|
43
|
+
function parseMarkdownTable(tableLines) {
|
|
44
|
+
const rows = [];
|
|
45
|
+
let headerCols = null;
|
|
46
|
+
for (const line of tableLines) {
|
|
47
|
+
const t = line.trim();
|
|
48
|
+
if (!t.startsWith("|")) continue;
|
|
49
|
+
const cells = t.slice(1, t.endsWith("|") ? -1 : undefined)
|
|
50
|
+
.split("|")
|
|
51
|
+
.map((c) => c.trim());
|
|
52
|
+
if (headerCols === null) {
|
|
53
|
+
headerCols = cells.map((c) =>
|
|
54
|
+
c.replace(/\*\*/g, "").replace(/`/g, "").toLowerCase().trim()
|
|
55
|
+
);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (cells.every((c) => /^[-: ]+$/.test(c) || c === "")) continue;
|
|
59
|
+
rows.push(cells);
|
|
60
|
+
}
|
|
61
|
+
return { headerCols: headerCols || [], rows };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Find the first table in text whose header columns match all required patterns.
|
|
65
|
+
function findTable(text, requiredColPatterns) {
|
|
66
|
+
const allLines = text.split("\n");
|
|
67
|
+
let i = 0;
|
|
68
|
+
while (i < allLines.length) {
|
|
69
|
+
const line = allLines[i].trim();
|
|
70
|
+
if (!line.startsWith("|")) {
|
|
71
|
+
i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const tableLines = [];
|
|
75
|
+
while (i < allLines.length && allLines[i].trim().startsWith("|")) {
|
|
76
|
+
tableLines.push(allLines[i]);
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
if (tableLines.length < 2) continue;
|
|
80
|
+
const parsed = parseMarkdownTable(tableLines);
|
|
81
|
+
const matches = requiredColPatterns.every((p) =>
|
|
82
|
+
parsed.headerCols.some((h) => p.test(h))
|
|
83
|
+
);
|
|
84
|
+
if (matches) return parsed;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Parse planning file header (featureKey, featureName, lastUpdated)
|
|
90
|
+
function parsePlanningHeader(text) {
|
|
91
|
+
return {
|
|
92
|
+
featureKey: getHeaderField(text, "Current Feature", "Feature key"),
|
|
93
|
+
featureName: getHeaderField(text, "Feature Name"),
|
|
94
|
+
lastUpdated: getHeaderField(text, "Last Updated"),
|
|
95
|
+
pipelineStatus: getHeaderField(text, "Pipeline Status", "Status"),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Parse the pipeline status table into phase objects.
|
|
100
|
+
// Handles both canonical shape (8 cols) and simplified demo shape (5 cols).
|
|
101
|
+
function parsePipelinePhases(text) {
|
|
102
|
+
const result = findTable(text, [/phase/, /status/]);
|
|
103
|
+
if (!result) return [];
|
|
104
|
+
const { headerCols, rows } = result;
|
|
105
|
+
|
|
106
|
+
const phaseIdx = headerCols.findIndex((h) => h.includes("phase"));
|
|
107
|
+
const statusIdx = headerCols.findIndex((h) => h.includes("status"));
|
|
108
|
+
const ownerIdx = headerCols.findIndex((h) => h.includes("owner"));
|
|
109
|
+
const artifactIdx = headerCols.findIndex(
|
|
110
|
+
(h) => h.includes("artifact") || h.includes("output")
|
|
111
|
+
);
|
|
112
|
+
const notesIdx = headerCols.findIndex(
|
|
113
|
+
(h) => h.includes("notes") || h.includes("blockers")
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
if (phaseIdx < 0 || statusIdx < 0) return [];
|
|
117
|
+
|
|
118
|
+
const phases = [];
|
|
119
|
+
for (const cells of rows) {
|
|
120
|
+
const phase = (cells[phaseIdx] || "")
|
|
121
|
+
.replace(/\*\*/g, "")
|
|
122
|
+
.replace(/`/g, "")
|
|
123
|
+
.trim();
|
|
124
|
+
const rawStatus = (cells[statusIdx] || "")
|
|
125
|
+
.replace(/\*\*/g, "")
|
|
126
|
+
.replace(/`/g, "")
|
|
127
|
+
.trim();
|
|
128
|
+
const owner =
|
|
129
|
+
ownerIdx >= 0 ? (cells[ownerIdx] || "").trim() : "";
|
|
130
|
+
const artifact =
|
|
131
|
+
artifactIdx >= 0
|
|
132
|
+
? (cells[artifactIdx] || "").replace(/`/g, "").trim()
|
|
133
|
+
: "";
|
|
134
|
+
const notes =
|
|
135
|
+
notesIdx >= 0
|
|
136
|
+
? (cells[notesIdx] || "").replace(/\*\*/g, "").replace(/`/g, "").trim()
|
|
137
|
+
: "";
|
|
138
|
+
|
|
139
|
+
if (!phase || !rawStatus) continue;
|
|
140
|
+
|
|
141
|
+
phases.push({
|
|
142
|
+
phase,
|
|
143
|
+
rawStatus,
|
|
144
|
+
owner,
|
|
145
|
+
artifact,
|
|
146
|
+
notes,
|
|
147
|
+
agent: normalizeAgent(owner),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return phases;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Extract blocker strings; returns [] if sentinel NO BLOCKERS or section absent.
|
|
154
|
+
function parseBlockers(text) {
|
|
155
|
+
const m = text.match(
|
|
156
|
+
/##\s+CURRENT BLOCKERS[^\n]*([\s\S]*?)(?=\n##\s+|$)/i
|
|
157
|
+
);
|
|
158
|
+
if (!m) return [];
|
|
159
|
+
const section = m[1];
|
|
160
|
+
if (section.includes("NO BLOCKERS")) return [];
|
|
161
|
+
return section
|
|
162
|
+
.split("\n")
|
|
163
|
+
.map((l) => l.trim())
|
|
164
|
+
.filter((l) => l && l !== "```" && !l.startsWith("#"));
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Extract OPEN open questions from the OQ table.
|
|
168
|
+
function parseOpenQuestions(text) {
|
|
169
|
+
const result = findTable(text, [/q-id|^id$/, /status/]);
|
|
170
|
+
if (!result) return [];
|
|
171
|
+
const { headerCols, rows } = result;
|
|
172
|
+
|
|
173
|
+
const idIdx = headerCols.findIndex((h) => h === "q-id" || h === "id");
|
|
174
|
+
const statusIdx = headerCols.findIndex((h) => h.includes("status"));
|
|
175
|
+
const ownerIdx = headerCols.findIndex((h) => h.includes("owner"));
|
|
176
|
+
const summaryIdx = headerCols.findIndex((h) => h.includes("summary"));
|
|
177
|
+
|
|
178
|
+
return rows
|
|
179
|
+
.filter((cells) => {
|
|
180
|
+
const st = (statusIdx >= 0 ? cells[statusIdx] || "" : "").trim().toUpperCase();
|
|
181
|
+
return st.includes("OPEN");
|
|
182
|
+
})
|
|
183
|
+
.map((cells) => ({
|
|
184
|
+
id: idIdx >= 0 ? (cells[idIdx] || "").trim() : "",
|
|
185
|
+
status: (statusIdx >= 0 ? cells[statusIdx] || "" : "").trim(),
|
|
186
|
+
owner: ownerIdx >= 0 ? (cells[ownerIdx] || "").trim() : "",
|
|
187
|
+
summary: summaryIdx >= 0 ? (cells[summaryIdx] || "").trim() : "",
|
|
188
|
+
}));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Parse activity log lines for timestamps and @handoff hints.
|
|
192
|
+
function parseActivityEntries(text) {
|
|
193
|
+
const re = /(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2})[^|\n]*\|[^|\n]*\|[^|\n]*\|[^|\n]*?(\@\w+)?(?=\n|$)/g;
|
|
194
|
+
const entries = [];
|
|
195
|
+
let m;
|
|
196
|
+
while ((m = re.exec(text)) !== null) {
|
|
197
|
+
entries.push({ ts: m[1].trim(), handoffNext: m[2] ? m[2].trim() : null });
|
|
198
|
+
}
|
|
199
|
+
return entries;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Derive pipeline card column per spec §D4.
|
|
203
|
+
function derivePipelineColumn(rawStatus, hasOpenOQ, hasBlocker, trustMismatch) {
|
|
204
|
+
const s = rawStatus.toLowerCase();
|
|
205
|
+
const isDone = DONE_STATUSES.has(s);
|
|
206
|
+
if (isDone && !trustMismatch && !hasOpenOQ && !hasBlocker) {
|
|
207
|
+
return { column: "done", attention: false };
|
|
208
|
+
}
|
|
209
|
+
if (hasOpenOQ || hasBlocker || trustMismatch) {
|
|
210
|
+
return { column: "pending", attention: true };
|
|
211
|
+
}
|
|
212
|
+
if (s === "in_progress" || s === "in progress") {
|
|
213
|
+
return { column: "in_progress", attention: false };
|
|
214
|
+
}
|
|
215
|
+
return { column: "todo", attention: false };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Derive quality card column per spec §D4.
|
|
219
|
+
function deriveQualityColumn(checked, phaseHasBlocker, phaseHasOpenOQ, hasIssue, phaseStatus) {
|
|
220
|
+
if (checked) return { column: "done", attention: false };
|
|
221
|
+
if (phaseHasBlocker || phaseHasOpenOQ || hasIssue) {
|
|
222
|
+
return { column: "pending", attention: true };
|
|
223
|
+
}
|
|
224
|
+
const s = (phaseStatus || "").toLowerCase();
|
|
225
|
+
if (s === "in_progress" || s === "in progress") {
|
|
226
|
+
return { column: "in_progress", attention: false };
|
|
227
|
+
}
|
|
228
|
+
return { column: "todo", attention: false };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Derive agent from QUALITY_CHECKLIST.md phase section heading.
|
|
232
|
+
function deriveAgentFromPhaseName(name) {
|
|
233
|
+
const u = name.toUpperCase();
|
|
234
|
+
if (u.includes("PM") || u.includes("CLOSURE")) return "PM";
|
|
235
|
+
if (u.includes("BA") || u.includes("BUSINESS ANALYSIS")) return "BA";
|
|
236
|
+
if (u.includes("ARCH") || u.includes("ARCHITECTURE")) return "ARCH";
|
|
237
|
+
if (u.includes("DEV") || u.includes("DEVELOPMENT") || u.includes("IMPLEMENTATION")) return "DEV";
|
|
238
|
+
if (u.includes("QA") || u.includes("QUALITY")) return "QA";
|
|
239
|
+
return "PM";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Parse QUALITY_CHECKLIST.md into phase objects with criteria + gate rows.
|
|
243
|
+
function parseQualityCriteria(text) {
|
|
244
|
+
const phases = [];
|
|
245
|
+
const lines = text.split("\n");
|
|
246
|
+
let currentPhase = null;
|
|
247
|
+
|
|
248
|
+
for (const line of lines) {
|
|
249
|
+
const phaseMatch = line.match(
|
|
250
|
+
/^#{1,3}\s+PHASE\s+(\d+[+]?)[\s:]+(.+?)\s*(?:CHECKLIST)?\s*$/i
|
|
251
|
+
);
|
|
252
|
+
if (phaseMatch) {
|
|
253
|
+
currentPhase = {
|
|
254
|
+
phaseNum: phaseMatch[1],
|
|
255
|
+
phaseName: phaseMatch[2].replace(/\(.*?\)/g, "").trim(),
|
|
256
|
+
agent: deriveAgentFromPhaseName(phaseMatch[2]),
|
|
257
|
+
criteria: [],
|
|
258
|
+
gate: null,
|
|
259
|
+
};
|
|
260
|
+
phases.push(currentPhase);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (!currentPhase) continue;
|
|
264
|
+
|
|
265
|
+
const t = line.trim();
|
|
266
|
+
if (!t.startsWith("|")) continue;
|
|
267
|
+
if (t.replace(/[\-:|]/g, "").trim() === "") continue;
|
|
268
|
+
|
|
269
|
+
const cells = t
|
|
270
|
+
.slice(1, t.endsWith("|") ? -1 : undefined)
|
|
271
|
+
.split("|")
|
|
272
|
+
.map((c) => c.trim());
|
|
273
|
+
if (cells.length < 3) continue;
|
|
274
|
+
|
|
275
|
+
const numRaw = cells[0].replace(/\*\*/g, "").trim();
|
|
276
|
+
if (!numRaw || numRaw === "#" || numRaw === "num") continue;
|
|
277
|
+
|
|
278
|
+
const criteriaRaw = cells[1] || "";
|
|
279
|
+
const statusRaw = cells[2] || "";
|
|
280
|
+
const verifiedByRaw = cells[3] || "";
|
|
281
|
+
const notesRaw = cells[4] || "";
|
|
282
|
+
|
|
283
|
+
const criteria = criteriaRaw.replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
284
|
+
if (!criteria) continue;
|
|
285
|
+
|
|
286
|
+
const verifiedByClean = verifiedByRaw.replace(/\*\*/g, "").trim();
|
|
287
|
+
const isGate = verifiedByClean.toLowerCase().includes("gate");
|
|
288
|
+
|
|
289
|
+
const checked =
|
|
290
|
+
statusRaw.includes("[x]") ||
|
|
291
|
+
statusRaw.toLowerCase() === "done" ||
|
|
292
|
+
statusRaw.toLowerCase() === "complete";
|
|
293
|
+
|
|
294
|
+
const issue = notesRaw.replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
295
|
+
const agent = normalizeAgent(verifiedByClean) || currentPhase.agent;
|
|
296
|
+
|
|
297
|
+
const item = {
|
|
298
|
+
criteria,
|
|
299
|
+
checked,
|
|
300
|
+
isGate,
|
|
301
|
+
issue,
|
|
302
|
+
agent,
|
|
303
|
+
phaseNum: currentPhase.phaseNum,
|
|
304
|
+
phaseName: currentPhase.phaseName,
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
if (isGate) {
|
|
308
|
+
currentPhase.gate = item;
|
|
309
|
+
} else {
|
|
310
|
+
currentPhase.criteria.push(item);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return phases;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Fuzzy-match a phase name in a map to find the closest entry.
|
|
317
|
+
function findMatchingEntry(map, targetName) {
|
|
318
|
+
const tn = targetName.toLowerCase().replace(/^\d+[+]?\.\s*/, "").replace(/\s*checklist\s*$/i, "").trim();
|
|
319
|
+
for (const [key, val] of Object.entries(map)) {
|
|
320
|
+
const k = key.replace(/^\d+[+]?\.\s*/, "").trim();
|
|
321
|
+
const firstWord = tn.split(/\s+/)[0];
|
|
322
|
+
if (k.includes(tn) || tn.includes(k) || k.startsWith(firstWord)) {
|
|
323
|
+
return val;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Main parser — pure, no I/O.
|
|
330
|
+
// Input: { planningText: string|null, qualityText: string|null, now: Date }
|
|
331
|
+
// Output: { meta, agents, pipeline: { cards }, quality: { cards } }
|
|
332
|
+
function parseKaban({ planningText, qualityText, now }) {
|
|
333
|
+
const nowDate = now instanceof Date ? now : new Date();
|
|
334
|
+
const errors = [];
|
|
335
|
+
|
|
336
|
+
const meta = {
|
|
337
|
+
featureKey: null,
|
|
338
|
+
featureName: null,
|
|
339
|
+
lastUpdated: null,
|
|
340
|
+
generatedAt: nowDate.toISOString(),
|
|
341
|
+
pipelinePresent: !!planningText,
|
|
342
|
+
qualityPresent: !!qualityText,
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
const pipeline = { cards: [] };
|
|
346
|
+
const quality = { cards: [] };
|
|
347
|
+
|
|
348
|
+
let phases = [];
|
|
349
|
+
let blockers = [];
|
|
350
|
+
let openQuestions = [];
|
|
351
|
+
let activityEntries = [];
|
|
352
|
+
|
|
353
|
+
// --- Parse SHARED_PLANNING.md ---
|
|
354
|
+
if (planningText) {
|
|
355
|
+
try {
|
|
356
|
+
const header = parsePlanningHeader(planningText);
|
|
357
|
+
meta.featureKey = header.featureKey;
|
|
358
|
+
meta.featureName = header.featureName;
|
|
359
|
+
meta.lastUpdated = header.lastUpdated;
|
|
360
|
+
phases = parsePipelinePhases(planningText);
|
|
361
|
+
blockers = parseBlockers(planningText);
|
|
362
|
+
openQuestions = parseOpenQuestions(planningText);
|
|
363
|
+
activityEntries = parseActivityEntries(planningText);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
errors.push("planning parse error: " + e.message);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// --- Parse QUALITY_CHECKLIST.md ---
|
|
370
|
+
let qualityPhases = [];
|
|
371
|
+
const qualityGateByPhaseName = {};
|
|
372
|
+
|
|
373
|
+
if (qualityText) {
|
|
374
|
+
try {
|
|
375
|
+
const qHeader = parsePlanningHeader(qualityText);
|
|
376
|
+
if (!meta.featureKey) meta.featureKey = qHeader.featureKey;
|
|
377
|
+
if (!meta.lastUpdated) meta.lastUpdated = qHeader.lastUpdated;
|
|
378
|
+
qualityPhases = parseQualityCriteria(qualityText);
|
|
379
|
+
for (const qp of qualityPhases) {
|
|
380
|
+
qualityGateByPhaseName[qp.phaseName.toLowerCase()] = qp.gate;
|
|
381
|
+
}
|
|
382
|
+
} catch (e) {
|
|
383
|
+
errors.push("quality parse error: " + e.message);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- Enrich pipeline phases with OQ/blocker flags ---
|
|
388
|
+
const enrichedPhases = phases.map((phase) => {
|
|
389
|
+
const phaseKey = phase.phase.toLowerCase();
|
|
390
|
+
const hasOpenOQ = openQuestions.some((oq) => {
|
|
391
|
+
const oqAgent = normalizeAgent(oq.owner);
|
|
392
|
+
const firstWord = phaseKey.split(/[\s.]+/)[0];
|
|
393
|
+
return (
|
|
394
|
+
oqAgent === phase.agent ||
|
|
395
|
+
(oq.id || "").toLowerCase().includes(firstWord) ||
|
|
396
|
+
(oq.summary || "").toLowerCase().includes(firstWord)
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
const hasBlocker =
|
|
400
|
+
blockers.length > 0 &&
|
|
401
|
+
blockers.some((b) => {
|
|
402
|
+
const bl = b.toLowerCase();
|
|
403
|
+
const firstWord = phaseKey.split(/[\s.]+/)[0];
|
|
404
|
+
return (
|
|
405
|
+
bl.includes(firstWord) ||
|
|
406
|
+
(phase.agent && bl.includes(phase.agent.toLowerCase()))
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
return { ...phase, hasOpenOQ, hasBlocker };
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
// --- Build pipeline cards ---
|
|
413
|
+
for (const phase of enrichedPhases) {
|
|
414
|
+
const phaseKey = phase.phase.toLowerCase();
|
|
415
|
+
|
|
416
|
+
const matchedGate = findMatchingEntry(qualityGateByPhaseName, phaseKey);
|
|
417
|
+
const statusLower = phase.rawStatus.toLowerCase();
|
|
418
|
+
const isDone = DONE_STATUSES.has(statusLower);
|
|
419
|
+
const trustMismatch =
|
|
420
|
+
matchedGate !== null && isDone && matchedGate !== null && !matchedGate.checked;
|
|
421
|
+
|
|
422
|
+
const { column, attention } = derivePipelineColumn(
|
|
423
|
+
phase.rawStatus,
|
|
424
|
+
phase.hasOpenOQ,
|
|
425
|
+
phase.hasBlocker,
|
|
426
|
+
trustMismatch
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
const agentLower = (phase.agent || "").toLowerCase();
|
|
430
|
+
const relevantActivity = activityEntries.filter(
|
|
431
|
+
(e) => e.handoffNext && e.handoffNext.toLowerCase().includes(agentLower)
|
|
432
|
+
);
|
|
433
|
+
const lastActivity =
|
|
434
|
+
relevantActivity.length > 0
|
|
435
|
+
? relevantActivity[relevantActivity.length - 1]
|
|
436
|
+
: activityEntries.length > 0
|
|
437
|
+
? activityEntries[activityEntries.length - 1]
|
|
438
|
+
: null;
|
|
439
|
+
|
|
440
|
+
pipeline.cards.push({
|
|
441
|
+
id: "phase-" + phaseKey.replace(/[^a-z0-9]/g, "-"),
|
|
442
|
+
title: phase.phase
|
|
443
|
+
.replace(/^\*{0,2}\d+[+]?\.\s*\*{0,2}/, "")
|
|
444
|
+
.replace(/\*\*/g, "")
|
|
445
|
+
.trim(),
|
|
446
|
+
agent: phase.agent || "OTHER",
|
|
447
|
+
column,
|
|
448
|
+
rawStatus: phase.rawStatus,
|
|
449
|
+
owner: phase.owner,
|
|
450
|
+
artifact: phase.artifact,
|
|
451
|
+
notes: phase.notes,
|
|
452
|
+
attention,
|
|
453
|
+
lastUpdate: lastActivity ? lastActivity.ts : null,
|
|
454
|
+
handoffNext: lastActivity ? lastActivity.handoffNext : null,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// --- Build quality cards ---
|
|
459
|
+
const phaseInfoByName = {};
|
|
460
|
+
for (const p of enrichedPhases) {
|
|
461
|
+
phaseInfoByName[p.phase.toLowerCase()] = {
|
|
462
|
+
rawStatus: p.rawStatus,
|
|
463
|
+
hasBlocker: p.hasBlocker,
|
|
464
|
+
hasOpenOQ: p.hasOpenOQ,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
for (const qPhase of qualityPhases) {
|
|
469
|
+
const phaseInfo = findMatchingEntry(phaseInfoByName, qPhase.phaseName);
|
|
470
|
+
const phaseStatus = phaseInfo ? phaseInfo.rawStatus : "";
|
|
471
|
+
const phaseHasBlocker = phaseInfo ? phaseInfo.hasBlocker : false;
|
|
472
|
+
const phaseHasOpenOQ = phaseInfo ? phaseInfo.hasOpenOQ : false;
|
|
473
|
+
|
|
474
|
+
const allItems = [
|
|
475
|
+
...qPhase.criteria,
|
|
476
|
+
...(qPhase.gate ? [qPhase.gate] : []),
|
|
477
|
+
];
|
|
478
|
+
|
|
479
|
+
for (let i = 0; i < allItems.length; i++) {
|
|
480
|
+
const item = allItems[i];
|
|
481
|
+
// Issue is meaningful if it's not just an @-mention (those are handoff hints)
|
|
482
|
+
const hasIssue =
|
|
483
|
+
item.issue &&
|
|
484
|
+
item.issue.length > 0 &&
|
|
485
|
+
!item.issue.trim().startsWith("@");
|
|
486
|
+
|
|
487
|
+
const { column, attention } = deriveQualityColumn(
|
|
488
|
+
item.checked,
|
|
489
|
+
phaseHasBlocker,
|
|
490
|
+
phaseHasOpenOQ,
|
|
491
|
+
hasIssue,
|
|
492
|
+
phaseStatus
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
quality.cards.push({
|
|
496
|
+
id: "q-" + qPhase.phaseNum + "-" + i,
|
|
497
|
+
title: item.criteria,
|
|
498
|
+
agent: item.agent || qPhase.agent || "OTHER",
|
|
499
|
+
phase: qPhase.phaseName,
|
|
500
|
+
column,
|
|
501
|
+
checked: item.checked,
|
|
502
|
+
isGate: item.isGate,
|
|
503
|
+
issue: item.issue,
|
|
504
|
+
attention,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// --- Derive agents list (canonical order first, extras appended) ---
|
|
510
|
+
const seenAgents = new Set();
|
|
511
|
+
for (const c of [...pipeline.cards, ...quality.cards]) {
|
|
512
|
+
if (c.agent && c.agent !== "OTHER") seenAgents.add(c.agent);
|
|
513
|
+
}
|
|
514
|
+
const agents = AGENT_ORDER.filter((a) => seenAgents.has(a));
|
|
515
|
+
for (const a of seenAgents) {
|
|
516
|
+
if (!AGENT_ORDER.includes(a)) agents.push(a);
|
|
517
|
+
}
|
|
518
|
+
if (agents.length === 0) agents.push(...AGENT_ORDER);
|
|
519
|
+
|
|
520
|
+
meta.errors = errors;
|
|
521
|
+
return { meta, agents, pipeline, quality };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
module.exports = {
|
|
525
|
+
parseKaban,
|
|
526
|
+
KABAN_LIVENESS_MIN,
|
|
527
|
+
COLUMNS,
|
|
528
|
+
AGENT_ORDER,
|
|
529
|
+
};
|
package/src/lib/wiki-runner.js
CHANGED
|
@@ -9,6 +9,7 @@ const { spawn, execFile } = require("child_process");
|
|
|
9
9
|
const { CliError, DependencyError } = require("./errors");
|
|
10
10
|
const { resolveBuilderPath } = require("./wiki-config");
|
|
11
11
|
const { openBrowser } = require("./browser-open");
|
|
12
|
+
const { parseKaban } = require("./wiki-kaban-parse");
|
|
12
13
|
|
|
13
14
|
const HEALTH_CHECK_RETRIES = 20;
|
|
14
15
|
const HEALTH_CHECK_INTERVAL_MS = 300;
|
|
@@ -248,6 +249,51 @@ function startWikiServer(host, port, outputDir, projectPath) {
|
|
|
248
249
|
return;
|
|
249
250
|
}
|
|
250
251
|
|
|
252
|
+
if (url === "/api/kaban" || url === "/api/kaban/") {
|
|
253
|
+
const projectRoot = projectPath || outputDir;
|
|
254
|
+
let planningText = null;
|
|
255
|
+
let qualityText = null;
|
|
256
|
+
|
|
257
|
+
const planningFile = path.resolve(projectRoot, "SHARED_PLANNING.md");
|
|
258
|
+
const qualityFile = path.resolve(projectRoot, "QUALITY_CHECKLIST.md");
|
|
259
|
+
const inRoot = (f) =>
|
|
260
|
+
f.startsWith(projectRoot + path.sep) || f === projectRoot;
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
if (inRoot(planningFile) && fs.existsSync(planningFile)) {
|
|
264
|
+
planningText = fs.readFileSync(planningFile, "utf-8");
|
|
265
|
+
}
|
|
266
|
+
} catch (_) {}
|
|
267
|
+
try {
|
|
268
|
+
if (inRoot(qualityFile) && fs.existsSync(qualityFile)) {
|
|
269
|
+
qualityText = fs.readFileSync(qualityFile, "utf-8");
|
|
270
|
+
}
|
|
271
|
+
} catch (_) {}
|
|
272
|
+
|
|
273
|
+
let viewModel;
|
|
274
|
+
try {
|
|
275
|
+
viewModel = parseKaban({ planningText, qualityText, now: new Date() });
|
|
276
|
+
} catch (e) {
|
|
277
|
+
viewModel = {
|
|
278
|
+
meta: {
|
|
279
|
+
errors: ["Server error: " + e.message],
|
|
280
|
+
pipelinePresent: false,
|
|
281
|
+
qualityPresent: false,
|
|
282
|
+
},
|
|
283
|
+
agents: [],
|
|
284
|
+
pipeline: { cards: [] },
|
|
285
|
+
quality: { cards: [] },
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
res.writeHead(200, {
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
"Cache-Control": "no-cache",
|
|
292
|
+
});
|
|
293
|
+
res.end(JSON.stringify(viewModel));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
251
297
|
if (url.startsWith("/api/note")) {
|
|
252
298
|
const qs = new URL(url, `http://${host}:${port}`).searchParams;
|
|
253
299
|
const notePath = qs.get("path");
|