@papi-ai/adapter-md 0.1.0-alpha
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 +29 -0
- package/dist/index.d.ts +904 -0
- package/dist/index.js +2398 -0
- package/package.json +45 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2398 @@
|
|
|
1
|
+
// src/adapter.ts
|
|
2
|
+
import { readFile, writeFile, access } from "fs/promises";
|
|
3
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
// src/types.ts
|
|
7
|
+
import {
|
|
8
|
+
VALID_TRANSITIONS as _VALID_TRANSITIONS,
|
|
9
|
+
isValidTransition as _isValidTransition,
|
|
10
|
+
validateTransition as _validateTransition,
|
|
11
|
+
isValidStatus as _isValidStatus
|
|
12
|
+
} from "@papi-ai/shared";
|
|
13
|
+
var VALID_TRANSITIONS = _VALID_TRANSITIONS;
|
|
14
|
+
var isValidTransition = _isValidTransition;
|
|
15
|
+
var validateTransition = _validateTransition;
|
|
16
|
+
var isValidStatus = _isValidStatus;
|
|
17
|
+
var TASK_TYPE_TIERS = {
|
|
18
|
+
bug: 1,
|
|
19
|
+
task: 1,
|
|
20
|
+
research: 2,
|
|
21
|
+
idea: 3,
|
|
22
|
+
feedback: 3
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/parsers/planning-log.ts
|
|
26
|
+
import { randomUUID } from "crypto";
|
|
27
|
+
function extractSection(content, heading) {
|
|
28
|
+
const headingPattern = new RegExp(`^## ${heading}\\s*$`, "m");
|
|
29
|
+
const start = content.search(headingPattern);
|
|
30
|
+
if (start === -1) return "";
|
|
31
|
+
const afterHeading = content.slice(start);
|
|
32
|
+
const nextSection = afterHeading.slice(1).search(/^## /m);
|
|
33
|
+
return nextSection === -1 ? afterHeading : afterHeading.slice(0, nextSection + 1);
|
|
34
|
+
}
|
|
35
|
+
function extractSectionCompat(content, newHeading, legacyHeading) {
|
|
36
|
+
const section = extractSection(content, newHeading);
|
|
37
|
+
return section || extractSection(content, legacyHeading);
|
|
38
|
+
}
|
|
39
|
+
function parseSprintHealth(content) {
|
|
40
|
+
const section = extractSectionCompat(content, "Sprint Health", "Session Health");
|
|
41
|
+
const rows = /* @__PURE__ */ new Map();
|
|
42
|
+
for (const line of section.split("\n")) {
|
|
43
|
+
const match = line.match(/^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/);
|
|
44
|
+
if (!match) continue;
|
|
45
|
+
const key = match[1].trim().toLowerCase();
|
|
46
|
+
const value = match[2].trim();
|
|
47
|
+
if (key !== "metric") rows.set(key, value);
|
|
48
|
+
}
|
|
49
|
+
const get = (key) => rows.get(key) ?? "";
|
|
50
|
+
return {
|
|
51
|
+
totalSprints: parseInt(get("total sprints") || get("total sessions"), 10) || 0,
|
|
52
|
+
sprintsSinceLastStrategyReview: parseInt(get("sprints since last strategy review") || get("sessions since last strategy review"), 10) || 0,
|
|
53
|
+
strategyReviewDue: get("strategy review due"),
|
|
54
|
+
boardHealth: get("board health"),
|
|
55
|
+
strategicDirection: get("strategic direction"),
|
|
56
|
+
lastFullMode: parseInt(get("last full mode"), 10) || 0
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function serializeSprintHealth(health, content) {
|
|
60
|
+
const section = extractSectionCompat(content, "Sprint Health", "Session Health");
|
|
61
|
+
const fieldMap = {
|
|
62
|
+
"Total sprints": String(health.totalSprints),
|
|
63
|
+
"Sprints since last Strategy Review": String(health.sprintsSinceLastStrategyReview),
|
|
64
|
+
"Strategy Review due": health.strategyReviewDue,
|
|
65
|
+
"Board health": health.boardHealth,
|
|
66
|
+
"Strategic direction": health.strategicDirection,
|
|
67
|
+
"Last Full Mode": String(health.lastFullMode)
|
|
68
|
+
};
|
|
69
|
+
const legacyFieldMap = {
|
|
70
|
+
"Total sessions": String(health.totalSprints),
|
|
71
|
+
"Sessions since last Strategy Review": String(health.sprintsSinceLastStrategyReview)
|
|
72
|
+
};
|
|
73
|
+
let updatedSection = section;
|
|
74
|
+
for (const [metric, value] of Object.entries({ ...fieldMap, ...legacyFieldMap })) {
|
|
75
|
+
const pattern = new RegExp(`(\\|\\s*${metric}\\s*\\|\\s*)(.+?)(\\s*\\|)`, "i");
|
|
76
|
+
updatedSection = updatedSection.replace(pattern, `$1${value}$3`);
|
|
77
|
+
}
|
|
78
|
+
return content.replace(section, updatedSection);
|
|
79
|
+
}
|
|
80
|
+
function parseActiveDecisions(content) {
|
|
81
|
+
const section = extractSection(content, "Active Decisions");
|
|
82
|
+
const chunks = section.split(/^(?=### AD-\d+:)/m).map((c) => c.trim()).filter((c) => c.startsWith("### AD-"));
|
|
83
|
+
return chunks.map((block) => {
|
|
84
|
+
const headingMatch = block.match(
|
|
85
|
+
/^### (AD-\d+):\s*(.+?)(?:\s*\[Confidence:\s*(HIGH|MEDIUM|LOW)\])?(?:\s*\[SUPERSEDED by (AD-\d+)\])?\s*$/m
|
|
86
|
+
);
|
|
87
|
+
if (!headingMatch) return null;
|
|
88
|
+
const metaMatch = block.match(/<!-- papi:(?:created_sprint=(\d+))?\s*(?:modified_sprint=(\d+)\s*)*(?:uuid=(\S+))? -->/);
|
|
89
|
+
const createdSprint = metaMatch?.[1] ? parseInt(metaMatch[1], 10) : void 0;
|
|
90
|
+
const modifiedSprint = metaMatch?.[2] ? parseInt(metaMatch[2], 10) : void 0;
|
|
91
|
+
const uuid = metaMatch?.[3] ?? randomUUID();
|
|
92
|
+
return {
|
|
93
|
+
uuid,
|
|
94
|
+
id: headingMatch[1],
|
|
95
|
+
displayId: headingMatch[1],
|
|
96
|
+
title: headingMatch[2].trim(),
|
|
97
|
+
confidence: headingMatch[3] ?? "HIGH",
|
|
98
|
+
superseded: !!headingMatch[4],
|
|
99
|
+
supersededBy: headingMatch[4],
|
|
100
|
+
createdSprint,
|
|
101
|
+
modifiedSprint,
|
|
102
|
+
body: block
|
|
103
|
+
};
|
|
104
|
+
}).filter((d) => d !== null);
|
|
105
|
+
}
|
|
106
|
+
function stripTemporalMeta(body) {
|
|
107
|
+
return body.replace(/\n?<!-- papi:(?:created_sprint=\d+)?\s*(?:modified_sprint=\d+\s*)*(?:uuid=\S+)? -->/g, "");
|
|
108
|
+
}
|
|
109
|
+
function buildTemporalMeta(createdSprint, modifiedSprint, uuid) {
|
|
110
|
+
const parts = [];
|
|
111
|
+
if (createdSprint != null) parts.push(`created_sprint=${createdSprint}`);
|
|
112
|
+
if (modifiedSprint != null) parts.push(`modified_sprint=${modifiedSprint}`);
|
|
113
|
+
if (uuid) parts.push(`uuid=${uuid}`);
|
|
114
|
+
if (parts.length === 0) return "";
|
|
115
|
+
return `
|
|
116
|
+
<!-- papi:${parts.join(" ")} -->`;
|
|
117
|
+
}
|
|
118
|
+
function extractCreatedSprint(block) {
|
|
119
|
+
const m = block.match(/<!-- papi:(?:created_sprint=(\d+))/);
|
|
120
|
+
return m?.[1] ? parseInt(m[1], 10) : void 0;
|
|
121
|
+
}
|
|
122
|
+
function extractUuid(block) {
|
|
123
|
+
const m = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
124
|
+
return m?.[1];
|
|
125
|
+
}
|
|
126
|
+
function updateActiveDecisionInContent(id, newBody, content, sprintNumber) {
|
|
127
|
+
if (!newBody) return content;
|
|
128
|
+
const escapedId = id.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
|
129
|
+
const pattern = new RegExp(`(### ${escapedId}:.*?)(?=^### AD-\\d+:|^## |$(?![\\s\\S]))`, "ms");
|
|
130
|
+
const cleanBody = stripTemporalMeta(newBody);
|
|
131
|
+
if (pattern.test(content)) {
|
|
132
|
+
const existingMatch = content.match(pattern);
|
|
133
|
+
const existingCreated = existingMatch ? extractCreatedSprint(existingMatch[0]) : void 0;
|
|
134
|
+
const existingUuid = existingMatch ? extractUuid(existingMatch[0]) : void 0;
|
|
135
|
+
const meta2 = sprintNumber != null ? buildTemporalMeta(existingCreated, sprintNumber, existingUuid) : existingUuid ? buildTemporalMeta(existingCreated, void 0, existingUuid) : "";
|
|
136
|
+
return content.replace(pattern, cleanBody.trimEnd() + meta2 + "\n\n");
|
|
137
|
+
}
|
|
138
|
+
const meta = sprintNumber != null ? buildTemporalMeta(sprintNumber) : "";
|
|
139
|
+
const sectionPattern = /^(#{1,2} Active Decisions\n)([\s\S]*?)(?=^#{1,2} |$(?![\s\S]))/m;
|
|
140
|
+
const sectionMatch = content.match(sectionPattern);
|
|
141
|
+
if (sectionMatch) {
|
|
142
|
+
const sectionHeader = sectionMatch[1];
|
|
143
|
+
const sectionBody = sectionMatch[2];
|
|
144
|
+
const newSection = sectionHeader + sectionBody.trimEnd() + "\n\n" + cleanBody.trimEnd() + meta + "\n\n";
|
|
145
|
+
return content.replace(sectionPattern, newSection);
|
|
146
|
+
}
|
|
147
|
+
return content;
|
|
148
|
+
}
|
|
149
|
+
function parseSprintLog(content, limit) {
|
|
150
|
+
const section = extractSectionCompat(content, "Sprint Log", "Session Log");
|
|
151
|
+
const chunks = section.split(/^(?=### (?:Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Sprint|Session) \d+ —/));
|
|
152
|
+
const entries = chunks.map((block) => {
|
|
153
|
+
const headingMatch = block.match(/^### (?:Sprint|Session) (\d+) — (.+?)$/m);
|
|
154
|
+
const sprintNumber = headingMatch ? parseInt(headingMatch[1], 10) : 0;
|
|
155
|
+
const title = headingMatch ? headingMatch[2].trim() : block.split("\n")[0].replace(/^### /, "");
|
|
156
|
+
const carryForwardMatch = block.match(/^- \*\*CARRY FORWARD:\*\*\s*(.+)$/m);
|
|
157
|
+
const uuidMatch = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
158
|
+
const uuid = uuidMatch?.[1];
|
|
159
|
+
const blockClean = block.replace(/\n?<!-- papi:.*?-->/g, "");
|
|
160
|
+
const notesMatch = blockClean.match(/\*\*Sprint Notes:\*\*\s*([\s\S]*?)$/);
|
|
161
|
+
const notes = notesMatch ? notesMatch[1].trim() : void 0;
|
|
162
|
+
return {
|
|
163
|
+
uuid: uuid ?? randomUUID(),
|
|
164
|
+
sprintNumber,
|
|
165
|
+
title,
|
|
166
|
+
content: block,
|
|
167
|
+
carryForward: carryForwardMatch ? carryForwardMatch[1].trim() : void 0,
|
|
168
|
+
notes
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
return limit ? entries.slice(0, limit) : entries;
|
|
172
|
+
}
|
|
173
|
+
function prependSprintLogEntry(entry, content) {
|
|
174
|
+
const headingPattern = /^## (?:Sprint|Session) Log\s*$/m;
|
|
175
|
+
const headingMatch = content.match(headingPattern);
|
|
176
|
+
if (!headingMatch || headingMatch.index === void 0) {
|
|
177
|
+
throw new Error("Sprint Log section not found in Planning Log");
|
|
178
|
+
}
|
|
179
|
+
const insertPos = headingMatch.index + headingMatch[0].length;
|
|
180
|
+
const before = content.slice(0, insertPos);
|
|
181
|
+
const after = content.slice(insertPos);
|
|
182
|
+
let entryContent = entry.content;
|
|
183
|
+
if (entry.notes) {
|
|
184
|
+
entryContent = `${entryContent}
|
|
185
|
+
|
|
186
|
+
**Sprint Notes:** ${entry.notes}`;
|
|
187
|
+
}
|
|
188
|
+
if (entry.uuid) {
|
|
189
|
+
entryContent = `${entryContent}
|
|
190
|
+
<!-- papi:uuid=${entry.uuid} -->`;
|
|
191
|
+
}
|
|
192
|
+
return `${before}
|
|
193
|
+
|
|
194
|
+
${entryContent}
|
|
195
|
+
${after}`;
|
|
196
|
+
}
|
|
197
|
+
function parseNorthStar(content) {
|
|
198
|
+
return extractSection(content, "North Star").replace(/^## North Star\s*/m, "").trim();
|
|
199
|
+
}
|
|
200
|
+
function parseDeferred(content) {
|
|
201
|
+
const section = extractSection(content, "Deferred / Parking Lot");
|
|
202
|
+
return section.split("\n").filter((line) => line.match(/^-\s+/)).map((line) => line.replace(/^-\s+/, "").trim());
|
|
203
|
+
}
|
|
204
|
+
function compressSprintLogInContent(content, threshold, summary) {
|
|
205
|
+
const section = extractSectionCompat(content, "Sprint Log", "Session Log");
|
|
206
|
+
const chunks = section.split(/^(?=### (?:Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Sprint|Session) \d+ —/));
|
|
207
|
+
const keep = [];
|
|
208
|
+
let hasOld = false;
|
|
209
|
+
for (const block of chunks) {
|
|
210
|
+
const match = block.match(/^### (?:Sprint|Session) (\d+) —/);
|
|
211
|
+
const num = match ? parseInt(match[1], 10) : 0;
|
|
212
|
+
if (num >= threshold) {
|
|
213
|
+
keep.push(block);
|
|
214
|
+
} else {
|
|
215
|
+
hasOld = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!hasOld) return content;
|
|
219
|
+
const summaryBlock = `### Sprints 1\u2013${threshold - 1} \u2014 Compressed Summary
|
|
220
|
+
|
|
221
|
+
${summary}`;
|
|
222
|
+
const newEntries = [...keep, summaryBlock].join("\n\n");
|
|
223
|
+
const newSection = `## Sprint Log
|
|
224
|
+
|
|
225
|
+
${newEntries}
|
|
226
|
+
`;
|
|
227
|
+
return content.replace(section, newSection);
|
|
228
|
+
}
|
|
229
|
+
function parsePlanningLog(content, activeDecisionsContent, sprintLogContent) {
|
|
230
|
+
return {
|
|
231
|
+
sprintHealth: parseSprintHealth(content),
|
|
232
|
+
northStar: parseNorthStar(content),
|
|
233
|
+
activeDecisions: parseActiveDecisions(activeDecisionsContent ?? content),
|
|
234
|
+
deferred: parseDeferred(content),
|
|
235
|
+
sprintLog: sprintLogContent ? parseSprintLog(sprintLogContent) : []
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// src/parsers/sprint-board.ts
|
|
240
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
241
|
+
import yaml from "js-yaml";
|
|
242
|
+
|
|
243
|
+
// src/parsers/build-handoff.ts
|
|
244
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
245
|
+
var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
246
|
+
var SECTION_HEADERS = [
|
|
247
|
+
"SCOPE (DO THIS)",
|
|
248
|
+
"SCOPE BOUNDARY (DO NOT DO THIS)",
|
|
249
|
+
"ACCEPTANCE CRITERIA",
|
|
250
|
+
"SECURITY CONSIDERATIONS",
|
|
251
|
+
"FILES LIKELY TOUCHED",
|
|
252
|
+
"EFFORT"
|
|
253
|
+
];
|
|
254
|
+
function splitSections(text) {
|
|
255
|
+
const sections = /* @__PURE__ */ new Map();
|
|
256
|
+
const lines = text.split("\n");
|
|
257
|
+
let currentSection = null;
|
|
258
|
+
const sectionLines = [];
|
|
259
|
+
const flush = () => {
|
|
260
|
+
if (currentSection !== null) {
|
|
261
|
+
sections.set(currentSection, sectionLines.join("\n").trim());
|
|
262
|
+
sectionLines.length = 0;
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
for (const line of lines) {
|
|
266
|
+
const trimmed = line.trim();
|
|
267
|
+
const matched = SECTION_HEADERS.find((h) => trimmed === h);
|
|
268
|
+
if (matched) {
|
|
269
|
+
flush();
|
|
270
|
+
currentSection = matched;
|
|
271
|
+
} else if (currentSection !== null) {
|
|
272
|
+
sectionLines.push(line);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
flush();
|
|
276
|
+
return sections;
|
|
277
|
+
}
|
|
278
|
+
function parseBulletList(text) {
|
|
279
|
+
return text.split("\n").map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
|
|
280
|
+
}
|
|
281
|
+
function parseChecklist(text) {
|
|
282
|
+
return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
|
|
283
|
+
}
|
|
284
|
+
function parseBuildHandoff(markdown) {
|
|
285
|
+
if (!markdown.includes("BUILD HANDOFF")) return null;
|
|
286
|
+
const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
|
|
287
|
+
const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
|
|
288
|
+
const sprintMatch = markdown.match(/^Sprint:\s*(\d+)$/m);
|
|
289
|
+
const whyNowMatch = markdown.match(/^Why now:\s*([\s\S]*?)(?=\n\n|\nSCOPE)/m);
|
|
290
|
+
const taskId = taskIdMatch?.[1] ?? "";
|
|
291
|
+
const taskTitle = taskTitleMatch?.[1]?.trim() ?? "";
|
|
292
|
+
const sprint = sprintMatch ? parseInt(sprintMatch[1], 10) : 0;
|
|
293
|
+
const whyNow = whyNowMatch?.[1]?.replace(/\s+/g, " ").trim() ?? "";
|
|
294
|
+
const uuidMatch = markdown.match(/^UUID:\s*(\S+)$/m);
|
|
295
|
+
const uuid = uuidMatch?.[1];
|
|
296
|
+
const displayIdMatch = markdown.match(/^Display ID:\s*(\S+)$/m);
|
|
297
|
+
const displayId = displayIdMatch?.[1];
|
|
298
|
+
const createdAtMatch = markdown.match(/^Created:\s*(.+)$/m);
|
|
299
|
+
const createdAt = createdAtMatch?.[1]?.trim();
|
|
300
|
+
const sections = splitSections(markdown);
|
|
301
|
+
const effortText = (sections.get("EFFORT") ?? "").trim().toUpperCase();
|
|
302
|
+
const effort = VALID_EFFORT_SIZES.has(effortText) ? effortText : "M";
|
|
303
|
+
return {
|
|
304
|
+
uuid: uuid ?? randomUUID2(),
|
|
305
|
+
...displayId ? { displayId } : {},
|
|
306
|
+
...createdAt ? { createdAt } : {},
|
|
307
|
+
taskId,
|
|
308
|
+
taskTitle,
|
|
309
|
+
sprint,
|
|
310
|
+
whyNow,
|
|
311
|
+
scope: parseBulletList(sections.get("SCOPE (DO THIS)") ?? ""),
|
|
312
|
+
scopeBoundary: parseBulletList(sections.get("SCOPE BOUNDARY (DO NOT DO THIS)") ?? ""),
|
|
313
|
+
acceptanceCriteria: parseChecklist(sections.get("ACCEPTANCE CRITERIA") ?? ""),
|
|
314
|
+
securityConsiderations: (sections.get("SECURITY CONSIDERATIONS") ?? "").trim(),
|
|
315
|
+
filesLikelyTouched: parseBulletList(sections.get("FILES LIKELY TOUCHED") ?? ""),
|
|
316
|
+
effort
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
function serializeBuildHandoff(handoff) {
|
|
320
|
+
const lines = [];
|
|
321
|
+
lines.push(`BUILD HANDOFF \u2014 ${handoff.taskId}`);
|
|
322
|
+
if (handoff.uuid) lines.push(`UUID: ${handoff.uuid}`);
|
|
323
|
+
if (handoff.displayId) lines.push(`Display ID: ${handoff.displayId}`);
|
|
324
|
+
if (handoff.createdAt) lines.push(`Created: ${handoff.createdAt}`);
|
|
325
|
+
lines.push(`Task: ${handoff.taskTitle}`);
|
|
326
|
+
lines.push(`Sprint: ${handoff.sprint}`);
|
|
327
|
+
lines.push(`Why now: ${handoff.whyNow}`);
|
|
328
|
+
lines.push("");
|
|
329
|
+
lines.push("SCOPE (DO THIS)");
|
|
330
|
+
for (const item of handoff.scope) {
|
|
331
|
+
lines.push(`- ${item}`);
|
|
332
|
+
}
|
|
333
|
+
lines.push("");
|
|
334
|
+
lines.push("SCOPE BOUNDARY (DO NOT DO THIS)");
|
|
335
|
+
for (const item of handoff.scopeBoundary) {
|
|
336
|
+
lines.push(`- ${item}`);
|
|
337
|
+
}
|
|
338
|
+
lines.push("");
|
|
339
|
+
lines.push("ACCEPTANCE CRITERIA");
|
|
340
|
+
for (const item of handoff.acceptanceCriteria) {
|
|
341
|
+
lines.push(`[ ] ${item}`);
|
|
342
|
+
}
|
|
343
|
+
lines.push("");
|
|
344
|
+
lines.push("SECURITY CONSIDERATIONS");
|
|
345
|
+
lines.push(handoff.securityConsiderations);
|
|
346
|
+
lines.push("");
|
|
347
|
+
lines.push("FILES LIKELY TOUCHED");
|
|
348
|
+
for (const item of handoff.filesLikelyTouched) {
|
|
349
|
+
lines.push(`- ${item}`);
|
|
350
|
+
}
|
|
351
|
+
lines.push("");
|
|
352
|
+
lines.push("EFFORT");
|
|
353
|
+
lines.push(handoff.effort);
|
|
354
|
+
return lines.join("\n");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// src/parsers/sprint-board.ts
|
|
358
|
+
var YAML_MARKER = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
359
|
+
var YAML_START = "<!-- PAPI-YAML-START -->";
|
|
360
|
+
var YAML_END = "<!-- PAPI-YAML-END -->";
|
|
361
|
+
function toSprintTask(raw) {
|
|
362
|
+
return {
|
|
363
|
+
uuid: raw.uuid || randomUUID3(),
|
|
364
|
+
id: raw.id,
|
|
365
|
+
displayId: raw.id,
|
|
366
|
+
title: raw.title,
|
|
367
|
+
status: raw.status,
|
|
368
|
+
priority: raw.priority,
|
|
369
|
+
complexity: raw.complexity,
|
|
370
|
+
module: raw.module,
|
|
371
|
+
epic: raw.epic,
|
|
372
|
+
phase: raw.phase,
|
|
373
|
+
owner: raw.owner,
|
|
374
|
+
reviewed: raw.reviewed ?? false,
|
|
375
|
+
sprint: raw.sprint != null ? raw.sprint : void 0,
|
|
376
|
+
createdSprint: raw.created_sprint != null ? raw.created_sprint : void 0,
|
|
377
|
+
createdAt: raw.created_at || void 0,
|
|
378
|
+
why: raw.why || void 0,
|
|
379
|
+
dependsOn: raw.depends_on || void 0,
|
|
380
|
+
notes: raw.notes || void 0,
|
|
381
|
+
stateHistory: raw.state_history?.length ? raw.state_history.map((e) => ({ status: e.status, timestamp: e.timestamp })) : void 0,
|
|
382
|
+
closureReason: raw.closure_reason || void 0,
|
|
383
|
+
buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
|
|
384
|
+
buildReport: raw.build_report || void 0
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
function sanitizeDelimiters(value) {
|
|
388
|
+
return value.replaceAll(YAML_END, "<!-- PAPI-YAML-END (sanitized) -->");
|
|
389
|
+
}
|
|
390
|
+
function fromSprintTask(task) {
|
|
391
|
+
const raw = {
|
|
392
|
+
uuid: task.uuid,
|
|
393
|
+
id: task.id,
|
|
394
|
+
title: task.title,
|
|
395
|
+
status: task.status,
|
|
396
|
+
priority: task.priority,
|
|
397
|
+
complexity: task.complexity,
|
|
398
|
+
module: task.module,
|
|
399
|
+
epic: task.epic,
|
|
400
|
+
phase: task.phase,
|
|
401
|
+
owner: task.owner,
|
|
402
|
+
reviewed: task.reviewed,
|
|
403
|
+
depends_on: task.dependsOn ?? "",
|
|
404
|
+
notes: task.notes ? sanitizeDelimiters(task.notes) : ""
|
|
405
|
+
};
|
|
406
|
+
if (task.sprint != null) raw.sprint = task.sprint;
|
|
407
|
+
if (task.createdSprint != null) raw.created_sprint = task.createdSprint;
|
|
408
|
+
if (task.createdAt) raw.created_at = task.createdAt;
|
|
409
|
+
if (task.why) raw.why = task.why;
|
|
410
|
+
if (task.stateHistory?.length) {
|
|
411
|
+
raw.state_history = task.stateHistory.map((e) => ({ status: e.status, timestamp: e.timestamp }));
|
|
412
|
+
}
|
|
413
|
+
if (task.closureReason) raw.closure_reason = task.closureReason;
|
|
414
|
+
if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
|
|
415
|
+
if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
|
|
416
|
+
return raw;
|
|
417
|
+
}
|
|
418
|
+
function mergeConflictHint(content) {
|
|
419
|
+
if (/^[<=>]{7}/m.test(content)) {
|
|
420
|
+
return " The file contains merge conflict markers (<<<<<<, ======, >>>>>>) \u2014 resolve them first.";
|
|
421
|
+
}
|
|
422
|
+
return "";
|
|
423
|
+
}
|
|
424
|
+
function extractYamlBlock(content) {
|
|
425
|
+
const markerIdx = content.indexOf(YAML_MARKER);
|
|
426
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in SPRINT_BOARD.md");
|
|
427
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER.length);
|
|
428
|
+
const startIdx = afterMarker.indexOf(YAML_START);
|
|
429
|
+
if (startIdx !== -1) {
|
|
430
|
+
const yamlStart = startIdx + YAML_START.length;
|
|
431
|
+
const endIdx = afterMarker.indexOf(YAML_END, yamlStart);
|
|
432
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in SPRINT_BOARD.md");
|
|
433
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
434
|
+
}
|
|
435
|
+
const blockMatch = afterMarker.match(/```yaml\n([\s\S]*?)```/);
|
|
436
|
+
if (!blockMatch) throw new Error("YAML block not found in SPRINT_BOARD.md");
|
|
437
|
+
return blockMatch[1];
|
|
438
|
+
}
|
|
439
|
+
function parseBoard(content) {
|
|
440
|
+
const yamlText = extractYamlBlock(content);
|
|
441
|
+
let data;
|
|
442
|
+
try {
|
|
443
|
+
data = yaml.load(yamlText);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
const yamlErr = err;
|
|
446
|
+
const lineInfo = yamlErr.mark?.line != null ? ` (near line ${yamlErr.mark.line + 1} of YAML block)` : "";
|
|
447
|
+
const hint = mergeConflictHint(yamlText);
|
|
448
|
+
throw new Error(
|
|
449
|
+
`YAML parse error in SPRINT_BOARD.md${lineInfo}. Check for syntax errors \u2014 unquoted special characters, bad indentation, or missing colons.${hint}`
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
return (data.tasks ?? []).map(toSprintTask);
|
|
453
|
+
}
|
|
454
|
+
function serializeBoard(tasks, content) {
|
|
455
|
+
const raw = tasks.map(fromSprintTask);
|
|
456
|
+
const yamlStr = yaml.dump({ tasks: raw }, { lineWidth: 120, quotingType: '"' });
|
|
457
|
+
const markerIdx = content.indexOf(YAML_MARKER);
|
|
458
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in SPRINT_BOARD.md");
|
|
459
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER.length);
|
|
460
|
+
const htmlStartIdx = afterMarker.indexOf(YAML_START);
|
|
461
|
+
if (htmlStartIdx !== -1) {
|
|
462
|
+
const absStart = markerIdx + YAML_MARKER.length + htmlStartIdx;
|
|
463
|
+
const endIdx = afterMarker.indexOf(YAML_END, htmlStartIdx);
|
|
464
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in SPRINT_BOARD.md");
|
|
465
|
+
const absEnd = markerIdx + YAML_MARKER.length + endIdx + YAML_END.length;
|
|
466
|
+
return content.slice(0, absStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(absEnd);
|
|
467
|
+
}
|
|
468
|
+
const blockMatch = afterMarker.match(/```yaml\n[\s\S]*?```/);
|
|
469
|
+
if (!blockMatch) throw new Error("YAML block not found in SPRINT_BOARD.md");
|
|
470
|
+
const blockStart = markerIdx + YAML_MARKER.length + afterMarker.indexOf(blockMatch[0]);
|
|
471
|
+
const blockEnd = blockStart + blockMatch[0].length;
|
|
472
|
+
return content.slice(0, blockStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(blockEnd);
|
|
473
|
+
}
|
|
474
|
+
function filterTasks(tasks, options) {
|
|
475
|
+
return tasks.filter((task) => {
|
|
476
|
+
if (options.status && !options.status.includes(task.status)) return false;
|
|
477
|
+
if (options.priority && !options.priority.includes(task.priority)) return false;
|
|
478
|
+
if (options.phase && !task.phase.toLowerCase().includes(options.phase.toLowerCase())) return false;
|
|
479
|
+
if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
|
|
480
|
+
if (options.module && task.module !== options.module) return false;
|
|
481
|
+
if (options.epic && task.epic !== options.epic) return false;
|
|
482
|
+
return true;
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
function nextTaskId(tasks) {
|
|
486
|
+
const maxN = tasks.reduce((max, t) => {
|
|
487
|
+
const match = t.id.match(/^task-(\d+)$/);
|
|
488
|
+
return match ? Math.max(max, parseInt(match[1], 10)) : max;
|
|
489
|
+
}, 0);
|
|
490
|
+
return `task-${String(maxN + 1).padStart(3, "0")}`;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// src/parsers/build-reports.ts
|
|
494
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
495
|
+
var VALID_EFFORT_SIZES2 = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
496
|
+
function parseEffortSize(value) {
|
|
497
|
+
const normalized = value.trim().toUpperCase();
|
|
498
|
+
return VALID_EFFORT_SIZES2.has(normalized) ? normalized : void 0;
|
|
499
|
+
}
|
|
500
|
+
var HEADER_SENTINEL = "*After each build";
|
|
501
|
+
function parseField(block, field) {
|
|
502
|
+
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
503
|
+
const match = block.match(pattern);
|
|
504
|
+
return match ? match[1].trim() : "";
|
|
505
|
+
}
|
|
506
|
+
function parseEffort(effortLine) {
|
|
507
|
+
const match = effortLine.match(/^(\S+)\s+vs\s+estimated\s+(\S+)$/i);
|
|
508
|
+
return match ? { actual: match[1], estimated: match[2] } : { actual: effortLine, estimated: "" };
|
|
509
|
+
}
|
|
510
|
+
function parseBuildReports(content) {
|
|
511
|
+
const chunks = content.split(/^(?=### .+ — .+ — (?:Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Sprint|Session) \d+/));
|
|
512
|
+
return chunks.map((block) => {
|
|
513
|
+
const headingMatch = block.match(/^### (.+?) — (.+?) — (?:Sprint|Session) (\d+)/);
|
|
514
|
+
if (!headingMatch) return null;
|
|
515
|
+
const effortLine = parseField(block, "Actual Effort");
|
|
516
|
+
const { actual, estimated } = parseEffort(effortLine);
|
|
517
|
+
const completedRaw = parseField(block, "Completed");
|
|
518
|
+
const taskId = parseField(block, "Task ID") || "unknown";
|
|
519
|
+
const uuidRaw = parseField(block, "UUID");
|
|
520
|
+
const displayIdRaw = parseField(block, "Display ID");
|
|
521
|
+
const scopeAccuracyRaw = parseField(block, "Scope Accuracy");
|
|
522
|
+
const validScopeValues = /* @__PURE__ */ new Set(["accurate", "over-scoped", "under-scoped", "missed-context"]);
|
|
523
|
+
const scopeAccuracy = scopeAccuracyRaw && validScopeValues.has(scopeAccuracyRaw) ? scopeAccuracyRaw : "accurate";
|
|
524
|
+
const actualEffort = parseEffortSize(actual) ?? "M";
|
|
525
|
+
const estimatedEffort = parseEffortSize(estimated) ?? "M";
|
|
526
|
+
const createdAtRaw = parseField(block, "Created");
|
|
527
|
+
const commitShaRaw = parseField(block, "Commit SHA");
|
|
528
|
+
const filesChangedRaw = parseField(block, "Files Changed");
|
|
529
|
+
const report = {
|
|
530
|
+
uuid: uuidRaw ?? randomUUID4(),
|
|
531
|
+
...displayIdRaw ? { displayId: displayIdRaw } : {},
|
|
532
|
+
...createdAtRaw ? { createdAt: createdAtRaw } : {},
|
|
533
|
+
taskId,
|
|
534
|
+
taskName: headingMatch[1].trim(),
|
|
535
|
+
date: headingMatch[2].trim(),
|
|
536
|
+
sprint: parseInt(headingMatch[3], 10),
|
|
537
|
+
completed: completedRaw.startsWith("Yes") ? "Yes" : completedRaw.startsWith("No") ? "No" : "Partial",
|
|
538
|
+
actualEffort,
|
|
539
|
+
estimatedEffort,
|
|
540
|
+
surprises: parseField(block, "Surprises"),
|
|
541
|
+
discoveredIssues: parseField(block, "Discovered Issues"),
|
|
542
|
+
architectureNotes: parseField(block, "Architecture Notes"),
|
|
543
|
+
scopeAccuracy
|
|
544
|
+
};
|
|
545
|
+
if (commitShaRaw) report.commitSha = commitShaRaw;
|
|
546
|
+
if (filesChangedRaw) report.filesChanged = filesChangedRaw.split(",").map((f) => f.trim()).filter(Boolean);
|
|
547
|
+
return report;
|
|
548
|
+
}).filter((r) => r !== null);
|
|
549
|
+
}
|
|
550
|
+
function serializeBuildReport(report) {
|
|
551
|
+
const lines = [
|
|
552
|
+
`### ${report.taskName} \u2014 ${report.date} \u2014 Sprint ${report.sprint}`
|
|
553
|
+
];
|
|
554
|
+
if (report.uuid) lines.push(`- **UUID:** ${report.uuid}`);
|
|
555
|
+
if (report.displayId) lines.push(`- **Display ID:** ${report.displayId}`);
|
|
556
|
+
if (report.createdAt) lines.push(`- **Created:** ${report.createdAt}`);
|
|
557
|
+
lines.push(
|
|
558
|
+
`- **Task ID:** ${report.taskId}`,
|
|
559
|
+
`- **Completed:** ${report.completed}`,
|
|
560
|
+
`- **Actual Effort:** ${report.actualEffort} vs estimated ${report.estimatedEffort}`,
|
|
561
|
+
`- **Surprises:** ${report.surprises || "None"}`,
|
|
562
|
+
`- **Discovered Issues:** ${report.discoveredIssues || "None"}`,
|
|
563
|
+
`- **Architecture Notes:** ${report.architectureNotes || "None"}`,
|
|
564
|
+
`- **Scope Accuracy:** ${report.scopeAccuracy}`
|
|
565
|
+
);
|
|
566
|
+
if (report.commitSha) lines.push(`- **Commit SHA:** ${report.commitSha}`);
|
|
567
|
+
if (report.filesChanged && report.filesChanged.length > 0) {
|
|
568
|
+
lines.push(`- **Files Changed:** ${report.filesChanged.join(", ")}`);
|
|
569
|
+
}
|
|
570
|
+
return lines.join("\n");
|
|
571
|
+
}
|
|
572
|
+
function formatCompressedSummary(reports, sprintRange, aiSummary) {
|
|
573
|
+
const dates = reports.map((r) => r.date).filter(Boolean);
|
|
574
|
+
const dateRange = dates.length > 0 ? `${dates[dates.length - 1]} \u2013 ${dates[0]}` : "unknown";
|
|
575
|
+
const completed = reports.filter((r) => r.completed === "Yes");
|
|
576
|
+
const partial = reports.filter((r) => r.completed === "Partial");
|
|
577
|
+
const failed = reports.filter((r) => r.completed === "No");
|
|
578
|
+
const formatTaskList = (list) => list.map((r) => r.taskId !== "unknown" ? `${r.taskId} (${r.taskName})` : r.taskName).join(", ");
|
|
579
|
+
const lines = [`### ${sprintRange} \u2014 Compressed Summary`];
|
|
580
|
+
lines.push(`**Date range:** ${dateRange}`);
|
|
581
|
+
lines.push(`**Reports:** ${reports.length}`);
|
|
582
|
+
if (completed.length > 0) {
|
|
583
|
+
lines.push(`**Completed:** ${formatTaskList(completed)}`);
|
|
584
|
+
}
|
|
585
|
+
if (partial.length > 0) {
|
|
586
|
+
lines.push(`**Partial:** ${formatTaskList(partial)}`);
|
|
587
|
+
}
|
|
588
|
+
if (failed.length > 0) {
|
|
589
|
+
lines.push(`**Failed:** ${formatTaskList(failed)}`);
|
|
590
|
+
}
|
|
591
|
+
const surprises = reports.map((r) => r.surprises).filter((s) => s && s !== "None" && s !== "None.");
|
|
592
|
+
if (surprises.length > 0) {
|
|
593
|
+
lines.push(`**Surprises:** ${surprises.join("; ")}`);
|
|
594
|
+
}
|
|
595
|
+
const issues = reports.map((r) => r.discoveredIssues).filter((s) => s && s !== "None" && s !== "None.");
|
|
596
|
+
if (issues.length > 0) {
|
|
597
|
+
lines.push(`**Discovered issues:** ${issues.join("; ")}`);
|
|
598
|
+
}
|
|
599
|
+
if (aiSummary) {
|
|
600
|
+
lines.push(`**Key outcomes:** ${aiSummary}`);
|
|
601
|
+
}
|
|
602
|
+
return lines.join("\n");
|
|
603
|
+
}
|
|
604
|
+
function compressBuildReportsInContent(content, threshold, summary) {
|
|
605
|
+
const chunks = content.split(/^(?=### .+ — .+ — (?:Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Sprint|Session) \d+/));
|
|
606
|
+
const keep = [];
|
|
607
|
+
const oldChunks = [];
|
|
608
|
+
for (const block of chunks) {
|
|
609
|
+
const match = block.match(/— (?:Sprint|Session) (\d+)/);
|
|
610
|
+
const num = match ? parseInt(match[1], 10) : 0;
|
|
611
|
+
if (num >= threshold) {
|
|
612
|
+
keep.push(block);
|
|
613
|
+
} else {
|
|
614
|
+
oldChunks.push(block);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
if (oldChunks.length === 0) return content;
|
|
618
|
+
const oldReports = parseBuildReports(oldChunks.join("\n\n---\n\n"));
|
|
619
|
+
const sprintRange = `Sprints 1\u2013${threshold - 1}`;
|
|
620
|
+
const summaryBlock = formatCompressedSummary(oldReports, sprintRange, summary);
|
|
621
|
+
const firstReportIdx = content.search(/^### /m);
|
|
622
|
+
const header = firstReportIdx === -1 ? content : content.slice(0, firstReportIdx);
|
|
623
|
+
const entries = [...keep, summaryBlock].join("\n\n---\n\n");
|
|
624
|
+
return header + entries + "\n";
|
|
625
|
+
}
|
|
626
|
+
function mergeTextField(existing, incoming) {
|
|
627
|
+
if (!existing || existing === "None" || existing === "None.") return incoming;
|
|
628
|
+
if (!incoming || incoming === "None" || incoming === "None.") return existing;
|
|
629
|
+
if (existing === incoming) return existing;
|
|
630
|
+
return `${existing} | ${incoming}`;
|
|
631
|
+
}
|
|
632
|
+
function mergeBuildReports(existing, incoming) {
|
|
633
|
+
const uuid = incoming.uuid ?? existing.uuid;
|
|
634
|
+
const displayId = incoming.displayId ?? existing.displayId;
|
|
635
|
+
const merged = {
|
|
636
|
+
uuid,
|
|
637
|
+
...displayId ? { displayId } : {},
|
|
638
|
+
taskId: incoming.taskId,
|
|
639
|
+
taskName: incoming.taskName,
|
|
640
|
+
date: incoming.date,
|
|
641
|
+
sprint: incoming.sprint,
|
|
642
|
+
completed: incoming.completed,
|
|
643
|
+
actualEffort: incoming.actualEffort,
|
|
644
|
+
estimatedEffort: incoming.estimatedEffort,
|
|
645
|
+
surprises: mergeTextField(existing.surprises, incoming.surprises),
|
|
646
|
+
discoveredIssues: mergeTextField(existing.discoveredIssues, incoming.discoveredIssues),
|
|
647
|
+
architectureNotes: incoming.architectureNotes,
|
|
648
|
+
scopeAccuracy: incoming.scopeAccuracy
|
|
649
|
+
};
|
|
650
|
+
if (incoming.createdAt ?? existing.createdAt) merged.createdAt = incoming.createdAt ?? existing.createdAt;
|
|
651
|
+
if (incoming.commitSha ?? existing.commitSha) merged.commitSha = incoming.commitSha ?? existing.commitSha;
|
|
652
|
+
if (incoming.filesChanged ?? existing.filesChanged) merged.filesChanged = incoming.filesChanged ?? existing.filesChanged;
|
|
653
|
+
return merged;
|
|
654
|
+
}
|
|
655
|
+
function replaceBuildReport(existing, replacement, content) {
|
|
656
|
+
const oldSerialized = serializeBuildReport(existing);
|
|
657
|
+
const newSerialized = serializeBuildReport(replacement);
|
|
658
|
+
const idx = content.indexOf(oldSerialized);
|
|
659
|
+
if (idx !== -1) {
|
|
660
|
+
return content.slice(0, idx) + newSerialized + content.slice(idx + oldSerialized.length);
|
|
661
|
+
}
|
|
662
|
+
const headingPattern = `### ${existing.taskName} \u2014 ${existing.date} \u2014 Sprint ${existing.sprint}`;
|
|
663
|
+
const headingIdx = content.indexOf(headingPattern);
|
|
664
|
+
if (headingIdx === -1) throw new Error(`Could not find existing build report for ${existing.taskId}`);
|
|
665
|
+
const afterHeading = content.slice(headingIdx);
|
|
666
|
+
const nextSeparator = afterHeading.indexOf("\n\n---\n");
|
|
667
|
+
const blockEnd = nextSeparator === -1 ? content.length : headingIdx + nextSeparator;
|
|
668
|
+
return content.slice(0, headingIdx) + newSerialized + content.slice(blockEnd);
|
|
669
|
+
}
|
|
670
|
+
function prependBuildReport(report, content) {
|
|
671
|
+
if (report.taskId !== "unknown") {
|
|
672
|
+
const existingReports = parseBuildReports(content);
|
|
673
|
+
const existingReport = existingReports.find((r) => r.taskId === report.taskId);
|
|
674
|
+
if (existingReport) {
|
|
675
|
+
const merged = mergeBuildReports(existingReport, report);
|
|
676
|
+
return replaceBuildReport(existingReport, merged, content);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
const sentinelIdx = content.indexOf(HEADER_SENTINEL);
|
|
680
|
+
if (sentinelIdx === -1) throw new Error("Build Reports header sentinel not found");
|
|
681
|
+
const afterSentinel = content.slice(sentinelIdx);
|
|
682
|
+
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
683
|
+
if (separatorIdx === -1) throw new Error("Separator after Build Reports header not found");
|
|
684
|
+
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
685
|
+
const serialized = serializeBuildReport(report);
|
|
686
|
+
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// src/parsers/metrics.ts
|
|
690
|
+
var TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Sprint | Context |";
|
|
691
|
+
var TABLE_SEPARATOR = "|-----------|------|---------------|--------------|---------------|----------|-------|--------|---------|";
|
|
692
|
+
var PREV_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Sprint |";
|
|
693
|
+
var LEGACY_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model |";
|
|
694
|
+
var SECTION_HEADING = "## Tool Call Metrics";
|
|
695
|
+
var FILE_TEMPLATE = `# PAPI Metrics
|
|
696
|
+
|
|
697
|
+
${SECTION_HEADING}
|
|
698
|
+
|
|
699
|
+
${TABLE_HEADER}
|
|
700
|
+
${TABLE_SEPARATOR}
|
|
701
|
+
`;
|
|
702
|
+
function parseToolMetrics(content) {
|
|
703
|
+
const lines = content.split("\n");
|
|
704
|
+
const metrics = [];
|
|
705
|
+
let inSection = false;
|
|
706
|
+
let inTable = false;
|
|
707
|
+
for (const line of lines) {
|
|
708
|
+
if (line.startsWith(SECTION_HEADING)) {
|
|
709
|
+
inSection = true;
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
if (inSection && line.startsWith(COST_SECTION_HEADING)) {
|
|
713
|
+
break;
|
|
714
|
+
}
|
|
715
|
+
if (!inSection) continue;
|
|
716
|
+
if (line.startsWith(TABLE_SEPARATOR) || line.startsWith("|---")) {
|
|
717
|
+
inTable = true;
|
|
718
|
+
continue;
|
|
719
|
+
}
|
|
720
|
+
if (!inTable) continue;
|
|
721
|
+
if (!line.startsWith("|")) {
|
|
722
|
+
inTable = false;
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
726
|
+
if (cells.length < 7) continue;
|
|
727
|
+
const inputTokens = cells[3] !== "-" ? parseInt(cells[3].replace(/,/g, ""), 10) : void 0;
|
|
728
|
+
const outputTokens = cells[4] !== "-" ? parseInt(cells[4].replace(/,/g, ""), 10) : void 0;
|
|
729
|
+
const cost = cells[5] !== "-" ? parseFloat(cells[5]) : void 0;
|
|
730
|
+
const model = cells[6] !== "-" ? cells[6] : void 0;
|
|
731
|
+
const sprintRaw = cells.length >= 8 && cells[7] !== "-" ? parseInt(cells[7], 10) : void 0;
|
|
732
|
+
const sprintNumber = sprintRaw !== void 0 && !isNaN(sprintRaw) ? sprintRaw : void 0;
|
|
733
|
+
const contextRaw = cells.length >= 9 && cells[8] !== "-" ? parseInt(cells[8].replace(/,/g, ""), 10) : void 0;
|
|
734
|
+
const contextBytes = contextRaw !== void 0 && !isNaN(contextRaw) ? contextRaw : void 0;
|
|
735
|
+
const utilisationRaw = cells.length >= 10 && cells[9] !== "-" ? parseFloat(cells[9]) : void 0;
|
|
736
|
+
const contextUtilisation = utilisationRaw !== void 0 && !isNaN(utilisationRaw) ? utilisationRaw : void 0;
|
|
737
|
+
metrics.push({
|
|
738
|
+
timestamp: cells[0],
|
|
739
|
+
tool: cells[1],
|
|
740
|
+
durationMs: parseInt(cells[2].replace(/,/g, ""), 10),
|
|
741
|
+
...inputTokens !== void 0 && !isNaN(inputTokens) ? { inputTokens } : {},
|
|
742
|
+
...outputTokens !== void 0 && !isNaN(outputTokens) ? { outputTokens } : {},
|
|
743
|
+
...cost !== void 0 && !isNaN(cost) ? { estimatedCostUsd: cost } : {},
|
|
744
|
+
...model ? { model } : {},
|
|
745
|
+
...sprintNumber !== void 0 ? { sprintNumber } : {},
|
|
746
|
+
...contextBytes !== void 0 ? { contextBytes } : {},
|
|
747
|
+
...contextUtilisation !== void 0 ? { contextUtilisation } : {}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
return metrics;
|
|
751
|
+
}
|
|
752
|
+
function formatNumber(n) {
|
|
753
|
+
return n.toLocaleString("en-US");
|
|
754
|
+
}
|
|
755
|
+
function serializeToolMetric(metric) {
|
|
756
|
+
const inputTokens = metric.inputTokens !== void 0 ? formatNumber(metric.inputTokens) : "-";
|
|
757
|
+
const outputTokens = metric.outputTokens !== void 0 ? formatNumber(metric.outputTokens) : "-";
|
|
758
|
+
const cost = metric.estimatedCostUsd !== void 0 ? metric.estimatedCostUsd.toFixed(4) : "-";
|
|
759
|
+
const model = metric.model ?? "-";
|
|
760
|
+
const sprint = metric.sprintNumber !== void 0 ? String(metric.sprintNumber) : "-";
|
|
761
|
+
const context = metric.contextBytes !== void 0 ? formatNumber(metric.contextBytes) : "-";
|
|
762
|
+
const utilisation = metric.contextUtilisation !== void 0 ? metric.contextUtilisation.toFixed(2) : "-";
|
|
763
|
+
return `| ${metric.timestamp} | ${metric.tool} | ${formatNumber(metric.durationMs)} | ${inputTokens} | ${outputTokens} | ${cost} | ${model} | ${sprint} | ${context} | ${utilisation} |`;
|
|
764
|
+
}
|
|
765
|
+
function appendToolMetricToContent(metric, content) {
|
|
766
|
+
if (!content.trim()) {
|
|
767
|
+
return FILE_TEMPLATE + serializeToolMetric(metric) + "\n";
|
|
768
|
+
}
|
|
769
|
+
if (content.includes(LEGACY_TABLE_HEADER) && !content.includes(TABLE_HEADER)) {
|
|
770
|
+
content = content.replace(LEGACY_TABLE_HEADER, TABLE_HEADER);
|
|
771
|
+
content = content.replace(
|
|
772
|
+
/\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|/,
|
|
773
|
+
TABLE_SEPARATOR
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (content.includes(PREV_TABLE_HEADER) && !content.includes(TABLE_HEADER)) {
|
|
777
|
+
content = content.replace(PREV_TABLE_HEADER, TABLE_HEADER);
|
|
778
|
+
content = content.replace(
|
|
779
|
+
/\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|/,
|
|
780
|
+
TABLE_SEPARATOR
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
if (!content.includes(SECTION_HEADING)) {
|
|
784
|
+
return content.trimEnd() + "\n\n" + SECTION_HEADING + "\n\n" + TABLE_HEADER + "\n" + TABLE_SEPARATOR + "\n" + serializeToolMetric(metric) + "\n";
|
|
785
|
+
}
|
|
786
|
+
const costIdx = content.indexOf(COST_SECTION_HEADING);
|
|
787
|
+
if (costIdx === -1) {
|
|
788
|
+
return content.trimEnd() + "\n" + serializeToolMetric(metric) + "\n";
|
|
789
|
+
}
|
|
790
|
+
const before = content.slice(0, costIdx).trimEnd();
|
|
791
|
+
const after = content.slice(costIdx);
|
|
792
|
+
return before + "\n" + serializeToolMetric(metric) + "\n\n" + after;
|
|
793
|
+
}
|
|
794
|
+
function aggregateCostSummary(metrics, sprintNumber) {
|
|
795
|
+
const filtered = sprintNumber !== void 0 ? metrics.filter((m) => m.sprintNumber === sprintNumber) : metrics;
|
|
796
|
+
let totalCostUsd = 0;
|
|
797
|
+
let totalInputTokens = 0;
|
|
798
|
+
let totalOutputTokens = 0;
|
|
799
|
+
const byCommand = /* @__PURE__ */ new Map();
|
|
800
|
+
for (const m of filtered) {
|
|
801
|
+
totalCostUsd += m.estimatedCostUsd ?? 0;
|
|
802
|
+
totalInputTokens += m.inputTokens ?? 0;
|
|
803
|
+
totalOutputTokens += m.outputTokens ?? 0;
|
|
804
|
+
const entry = byCommand.get(m.tool) ?? { cost: 0, calls: 0 };
|
|
805
|
+
entry.cost += m.estimatedCostUsd ?? 0;
|
|
806
|
+
entry.calls += 1;
|
|
807
|
+
byCommand.set(m.tool, entry);
|
|
808
|
+
}
|
|
809
|
+
const costByCommand = Array.from(byCommand.entries()).map(([command, { cost, calls }]) => ({
|
|
810
|
+
command,
|
|
811
|
+
totalCostUsd: cost,
|
|
812
|
+
calls,
|
|
813
|
+
avgCostUsd: calls > 0 ? cost / calls : 0
|
|
814
|
+
})).sort((a, b) => b.totalCostUsd - a.totalCostUsd);
|
|
815
|
+
const mostExpensiveCommand = costByCommand.length > 0 ? costByCommand[0].command : null;
|
|
816
|
+
return {
|
|
817
|
+
totalCostUsd,
|
|
818
|
+
totalInputTokens,
|
|
819
|
+
totalOutputTokens,
|
|
820
|
+
totalCalls: filtered.length,
|
|
821
|
+
costByCommand,
|
|
822
|
+
mostExpensiveCommand,
|
|
823
|
+
avgCostPerCall: filtered.length > 0 ? totalCostUsd / filtered.length : 0
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
var COST_SECTION_HEADING = "## Cost Summary";
|
|
827
|
+
var COST_TABLE_HEADER = "| Sprint | Date | Total Cost ($) | Input Tokens | Output Tokens | Calls |";
|
|
828
|
+
var COST_TABLE_SEPARATOR = "|--------|------|----------------|--------------|---------------|-------|";
|
|
829
|
+
function parseCostSnapshots(content) {
|
|
830
|
+
const lines = content.split("\n");
|
|
831
|
+
const snapshots = [];
|
|
832
|
+
let inTable = false;
|
|
833
|
+
for (const line of lines) {
|
|
834
|
+
if (line.startsWith(COST_TABLE_SEPARATOR)) {
|
|
835
|
+
inTable = true;
|
|
836
|
+
continue;
|
|
837
|
+
}
|
|
838
|
+
if (!inTable) continue;
|
|
839
|
+
if (!line.startsWith("|")) {
|
|
840
|
+
inTable = false;
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
844
|
+
if (cells.length < 6) continue;
|
|
845
|
+
snapshots.push({
|
|
846
|
+
sprint: parseInt(cells[0], 10),
|
|
847
|
+
date: cells[1],
|
|
848
|
+
totalCostUsd: parseFloat(cells[2]),
|
|
849
|
+
totalInputTokens: parseInt(cells[3].replace(/,/g, ""), 10),
|
|
850
|
+
totalOutputTokens: parseInt(cells[4].replace(/,/g, ""), 10),
|
|
851
|
+
totalCalls: parseInt(cells[5].replace(/,/g, ""), 10)
|
|
852
|
+
});
|
|
853
|
+
}
|
|
854
|
+
return snapshots;
|
|
855
|
+
}
|
|
856
|
+
function serializeCostSnapshot(snapshot) {
|
|
857
|
+
return `| ${snapshot.sprint} | ${snapshot.date} | ${snapshot.totalCostUsd.toFixed(4)} | ${formatNumber(snapshot.totalInputTokens)} | ${formatNumber(snapshot.totalOutputTokens)} | ${formatNumber(snapshot.totalCalls)} |`;
|
|
858
|
+
}
|
|
859
|
+
function writeCostSnapshotToContent(snapshot, content) {
|
|
860
|
+
if (!content.includes(COST_SECTION_HEADING)) {
|
|
861
|
+
return content.trimEnd() + "\n\n" + COST_SECTION_HEADING + "\n\n" + COST_TABLE_HEADER + "\n" + COST_TABLE_SEPARATOR + "\n" + serializeCostSnapshot(snapshot) + "\n";
|
|
862
|
+
}
|
|
863
|
+
const lines = content.split("\n");
|
|
864
|
+
const sprintPrefix = `| ${snapshot.sprint} |`;
|
|
865
|
+
let replaced = false;
|
|
866
|
+
for (let i = 0; i < lines.length; i++) {
|
|
867
|
+
if (lines[i].startsWith(sprintPrefix)) {
|
|
868
|
+
lines[i] = serializeCostSnapshot(snapshot);
|
|
869
|
+
replaced = true;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
if (replaced) {
|
|
874
|
+
return lines.join("\n");
|
|
875
|
+
}
|
|
876
|
+
return content.trimEnd() + "\n" + serializeCostSnapshot(snapshot) + "\n";
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// src/parsers/sprint-metrics.ts
|
|
880
|
+
var FILE_HEADING = "# Sprint Methodology Metrics";
|
|
881
|
+
var ACCURACY_HEADER = "| Sprint | Reports | Match Rate | MAE | Bias |";
|
|
882
|
+
var ACCURACY_SEPARATOR = "|--------|---------|------------|-----|------|";
|
|
883
|
+
var VELOCITY_HEADER = "| Sprint | Completed | Partial | Failed | Effort Points |";
|
|
884
|
+
var VELOCITY_SEPARATOR = "|--------|-----------|---------|--------|---------------|";
|
|
885
|
+
var EFFORT_SCALE = {
|
|
886
|
+
XS: 1,
|
|
887
|
+
S: 2,
|
|
888
|
+
M: 3,
|
|
889
|
+
L: 4,
|
|
890
|
+
XL: 5
|
|
891
|
+
};
|
|
892
|
+
function effortOrdinal(effort) {
|
|
893
|
+
const normalized = effort.trim().toUpperCase();
|
|
894
|
+
return EFFORT_SCALE[normalized];
|
|
895
|
+
}
|
|
896
|
+
function calculateSprintMetrics(reports, currentSprint, window = 5) {
|
|
897
|
+
const recentReports = reports.filter(
|
|
898
|
+
(r) => r.sprint > currentSprint - window && r.sprint <= currentSprint
|
|
899
|
+
);
|
|
900
|
+
const perSprint = /* @__PURE__ */ new Map();
|
|
901
|
+
for (const r of recentReports) {
|
|
902
|
+
const group = perSprint.get(r.sprint) ?? [];
|
|
903
|
+
group.push(r);
|
|
904
|
+
perSprint.set(r.sprint, group);
|
|
905
|
+
}
|
|
906
|
+
const accuracy = [];
|
|
907
|
+
const velocity = [];
|
|
908
|
+
const sortedSprints = [...perSprint.keys()].sort((a, b) => a - b);
|
|
909
|
+
for (const sprint of sortedSprints) {
|
|
910
|
+
const reps = perSprint.get(sprint);
|
|
911
|
+
const deltas = [];
|
|
912
|
+
for (const r of reps) {
|
|
913
|
+
const actual = effortOrdinal(r.actualEffort);
|
|
914
|
+
const estimated = effortOrdinal(r.estimatedEffort);
|
|
915
|
+
if (actual !== void 0 && estimated !== void 0) {
|
|
916
|
+
deltas.push(actual - estimated);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
if (deltas.length > 0) {
|
|
920
|
+
accuracy.push({
|
|
921
|
+
sprint,
|
|
922
|
+
reports: deltas.length,
|
|
923
|
+
matchRate: Math.round(deltas.filter((d) => d === 0).length / deltas.length * 100),
|
|
924
|
+
mae: Math.round(deltas.reduce((s, d) => s + Math.abs(d), 0) / deltas.length * 10) / 10,
|
|
925
|
+
bias: Math.round(deltas.reduce((s, d) => s + d, 0) / deltas.length * 10) / 10
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
velocity.push({
|
|
929
|
+
sprint,
|
|
930
|
+
completed: reps.filter((r) => r.completed === "Yes").length,
|
|
931
|
+
partial: reps.filter((r) => r.completed === "Partial").length,
|
|
932
|
+
failed: reps.filter((r) => r.completed === "No").length,
|
|
933
|
+
effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return { accuracy, velocity };
|
|
937
|
+
}
|
|
938
|
+
function serializeAccuracyRow(a) {
|
|
939
|
+
return `| ${a.sprint} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
|
|
940
|
+
}
|
|
941
|
+
function serializeVelocityRow(v) {
|
|
942
|
+
return `| ${v.sprint} | ${v.completed} | ${v.partial} | ${v.failed} | ${v.effortPoints} |`;
|
|
943
|
+
}
|
|
944
|
+
function serializeSnapshot(snapshot) {
|
|
945
|
+
const lines = [];
|
|
946
|
+
lines.push(`## Sprint ${snapshot.sprint} Snapshot \u2014 ${snapshot.date}`);
|
|
947
|
+
lines.push("");
|
|
948
|
+
lines.push("### Estimation Accuracy (last 5 sprints)");
|
|
949
|
+
lines.push(ACCURACY_HEADER);
|
|
950
|
+
lines.push(ACCURACY_SEPARATOR);
|
|
951
|
+
for (const a of snapshot.accuracy) {
|
|
952
|
+
lines.push(serializeAccuracyRow(a));
|
|
953
|
+
}
|
|
954
|
+
lines.push("");
|
|
955
|
+
lines.push("### Sprint Velocity");
|
|
956
|
+
lines.push(VELOCITY_HEADER);
|
|
957
|
+
lines.push(VELOCITY_SEPARATOR);
|
|
958
|
+
for (const v of snapshot.velocity) {
|
|
959
|
+
lines.push(serializeVelocityRow(v));
|
|
960
|
+
}
|
|
961
|
+
return lines.join("\n");
|
|
962
|
+
}
|
|
963
|
+
function appendSnapshotToContent(snapshot, content) {
|
|
964
|
+
const block = serializeSnapshot(snapshot);
|
|
965
|
+
if (!content.trim()) {
|
|
966
|
+
return FILE_HEADING + "\n\n" + block + "\n";
|
|
967
|
+
}
|
|
968
|
+
const marker = `## Sprint ${snapshot.sprint} Snapshot`;
|
|
969
|
+
const markerIdx = content.indexOf(marker);
|
|
970
|
+
if (markerIdx !== -1) {
|
|
971
|
+
const nextSnapshotIdx = content.indexOf("\n## Sprint ", markerIdx + marker.length);
|
|
972
|
+
const before = content.slice(0, markerIdx).trimEnd();
|
|
973
|
+
const after = nextSnapshotIdx !== -1 ? content.slice(nextSnapshotIdx) : "";
|
|
974
|
+
return before + "\n\n" + block + (after ? after : "\n");
|
|
975
|
+
}
|
|
976
|
+
return content.trimEnd() + "\n\n" + block + "\n";
|
|
977
|
+
}
|
|
978
|
+
function parseSnapshots(content) {
|
|
979
|
+
if (!content.trim()) return [];
|
|
980
|
+
const snapshots = [];
|
|
981
|
+
const headerRegex = /^## Sprint (\d+) Snapshot — (\S+)/gm;
|
|
982
|
+
let match;
|
|
983
|
+
const headers = [];
|
|
984
|
+
while ((match = headerRegex.exec(content)) !== null) {
|
|
985
|
+
headers.push({
|
|
986
|
+
sprint: parseInt(match[1], 10),
|
|
987
|
+
date: match[2],
|
|
988
|
+
index: match.index
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
for (let i = 0; i < headers.length; i++) {
|
|
992
|
+
const start = headers[i].index;
|
|
993
|
+
const end = i + 1 < headers.length ? headers[i + 1].index : content.length;
|
|
994
|
+
const block = content.slice(start, end);
|
|
995
|
+
const accuracy = parseAccuracyTable(block);
|
|
996
|
+
const velocity = parseVelocityTable(block);
|
|
997
|
+
snapshots.push({
|
|
998
|
+
sprint: headers[i].sprint,
|
|
999
|
+
date: headers[i].date,
|
|
1000
|
+
accuracy,
|
|
1001
|
+
velocity
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
return snapshots;
|
|
1005
|
+
}
|
|
1006
|
+
var NONE_PATTERN = /^none\b/i;
|
|
1007
|
+
function normalizeText(text) {
|
|
1008
|
+
return text.trim().toLowerCase().replace(/[.,;:!]+$/, "").replace(/\s+/g, " ");
|
|
1009
|
+
}
|
|
1010
|
+
function isRealFinding(text) {
|
|
1011
|
+
const trimmed = text.trim();
|
|
1012
|
+
return trimmed.length > 0 && !NONE_PATTERN.test(trimmed);
|
|
1013
|
+
}
|
|
1014
|
+
async function detectBuildPatterns(reports, currentSprint, window = 5, clusterer) {
|
|
1015
|
+
const recentReports = reports.filter(
|
|
1016
|
+
(r) => r.sprint > currentSprint - window && r.sprint <= currentSprint
|
|
1017
|
+
);
|
|
1018
|
+
let recurringSurprises;
|
|
1019
|
+
const realSurprises = recentReports.filter((r) => isRealFinding(r.surprises));
|
|
1020
|
+
if (clusterer && realSurprises.length >= 2) {
|
|
1021
|
+
const entries = realSurprises.map((r) => ({
|
|
1022
|
+
text: r.surprises.trim(),
|
|
1023
|
+
source: String(r.sprint)
|
|
1024
|
+
}));
|
|
1025
|
+
const clusters = await clusterer(entries);
|
|
1026
|
+
recurringSurprises = clusters.filter((c) => c.entries.length >= 2).map((c) => ({
|
|
1027
|
+
text: c.theme,
|
|
1028
|
+
count: c.entries.length,
|
|
1029
|
+
sprints: [...new Set(c.entries.map((e) => parseInt(e.source, 10)))].sort((a, b) => a - b)
|
|
1030
|
+
}));
|
|
1031
|
+
recurringSurprises.sort((a, b) => b.count - a.count);
|
|
1032
|
+
} else {
|
|
1033
|
+
const surpriseMap = /* @__PURE__ */ new Map();
|
|
1034
|
+
for (const r of realSurprises) {
|
|
1035
|
+
const key = normalizeText(r.surprises);
|
|
1036
|
+
const existing = surpriseMap.get(key);
|
|
1037
|
+
if (existing) {
|
|
1038
|
+
existing.count += 1;
|
|
1039
|
+
existing.sprints.add(r.sprint);
|
|
1040
|
+
} else {
|
|
1041
|
+
surpriseMap.set(key, { original: r.surprises.trim(), count: 1, sprints: /* @__PURE__ */ new Set([r.sprint]) });
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
recurringSurprises = [];
|
|
1045
|
+
for (const entry of surpriseMap.values()) {
|
|
1046
|
+
if (entry.count >= 2) {
|
|
1047
|
+
recurringSurprises.push({
|
|
1048
|
+
text: entry.original,
|
|
1049
|
+
count: entry.count,
|
|
1050
|
+
sprints: [...entry.sprints].sort((a, b) => a - b)
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
recurringSurprises.sort((a, b) => b.count - a.count);
|
|
1055
|
+
}
|
|
1056
|
+
let overCount = 0;
|
|
1057
|
+
let underCount = 0;
|
|
1058
|
+
let totalWithEffort = 0;
|
|
1059
|
+
for (const r of recentReports) {
|
|
1060
|
+
const actual = effortOrdinal(r.actualEffort);
|
|
1061
|
+
const estimated = effortOrdinal(r.estimatedEffort);
|
|
1062
|
+
if (actual === void 0 || estimated === void 0) continue;
|
|
1063
|
+
totalWithEffort += 1;
|
|
1064
|
+
if (actual > estimated) underCount += 1;
|
|
1065
|
+
if (actual < estimated) overCount += 1;
|
|
1066
|
+
}
|
|
1067
|
+
let estimationBias = "none";
|
|
1068
|
+
let estimationBiasRate = 0;
|
|
1069
|
+
if (totalWithEffort > 0) {
|
|
1070
|
+
const underRate = underCount / totalWithEffort;
|
|
1071
|
+
const overRate = overCount / totalWithEffort;
|
|
1072
|
+
if (underRate >= 0.5) {
|
|
1073
|
+
estimationBias = "under";
|
|
1074
|
+
estimationBiasRate = Math.round(underRate * 100);
|
|
1075
|
+
} else if (overRate >= 0.5) {
|
|
1076
|
+
estimationBias = "over";
|
|
1077
|
+
estimationBiasRate = Math.round(overRate * 100);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
const scopeAccuracyBreakdown = {};
|
|
1081
|
+
for (const r of recentReports) {
|
|
1082
|
+
if (r.scopeAccuracy) {
|
|
1083
|
+
scopeAccuracyBreakdown[r.scopeAccuracy] = (scopeAccuracyBreakdown[r.scopeAccuracy] ?? 0) + 1;
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
const untriagedIssues = [];
|
|
1087
|
+
for (const r of recentReports) {
|
|
1088
|
+
if (isRealFinding(r.discoveredIssues)) {
|
|
1089
|
+
untriagedIssues.push(r.discoveredIssues.trim());
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return {
|
|
1093
|
+
recurringSurprises,
|
|
1094
|
+
estimationBias,
|
|
1095
|
+
estimationBiasRate,
|
|
1096
|
+
scopeAccuracyBreakdown,
|
|
1097
|
+
untriagedIssues
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
function hasBuildPatterns(patterns) {
|
|
1101
|
+
return patterns.recurringSurprises.length > 0 || patterns.estimationBias !== "none" || patterns.untriagedIssues.length > 0;
|
|
1102
|
+
}
|
|
1103
|
+
function parseAccuracyTable(block) {
|
|
1104
|
+
const rows = [];
|
|
1105
|
+
const lines = block.split("\n");
|
|
1106
|
+
let inTable = false;
|
|
1107
|
+
for (const line of lines) {
|
|
1108
|
+
if (line.startsWith(ACCURACY_SEPARATOR)) {
|
|
1109
|
+
inTable = true;
|
|
1110
|
+
continue;
|
|
1111
|
+
}
|
|
1112
|
+
if (!inTable) continue;
|
|
1113
|
+
if (!line.startsWith("|")) {
|
|
1114
|
+
inTable = false;
|
|
1115
|
+
continue;
|
|
1116
|
+
}
|
|
1117
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
1118
|
+
if (cells.length < 5) continue;
|
|
1119
|
+
rows.push({
|
|
1120
|
+
sprint: parseInt(cells[0], 10),
|
|
1121
|
+
reports: parseInt(cells[1], 10),
|
|
1122
|
+
matchRate: parseInt(cells[2].replace("%", ""), 10),
|
|
1123
|
+
mae: parseFloat(cells[3]),
|
|
1124
|
+
bias: parseFloat(cells[4])
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
return rows;
|
|
1128
|
+
}
|
|
1129
|
+
function parseVelocityTable(block) {
|
|
1130
|
+
const rows = [];
|
|
1131
|
+
const lines = block.split("\n");
|
|
1132
|
+
let inTable = false;
|
|
1133
|
+
for (const line of lines) {
|
|
1134
|
+
if (line.startsWith(VELOCITY_SEPARATOR)) {
|
|
1135
|
+
inTable = true;
|
|
1136
|
+
continue;
|
|
1137
|
+
}
|
|
1138
|
+
if (!inTable) continue;
|
|
1139
|
+
if (!line.startsWith("|")) {
|
|
1140
|
+
inTable = false;
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
1144
|
+
if (cells.length < 5) continue;
|
|
1145
|
+
rows.push({
|
|
1146
|
+
sprint: parseInt(cells[0], 10),
|
|
1147
|
+
completed: parseInt(cells[1], 10),
|
|
1148
|
+
partial: parseInt(cells[2], 10),
|
|
1149
|
+
failed: parseInt(cells[3], 10),
|
|
1150
|
+
effortPoints: parseInt(cells[4], 10)
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
return rows;
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// src/parsers/reviews.ts
|
|
1157
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1158
|
+
var HEADER_SENTINEL2 = "*Reviews are stored newest-first.";
|
|
1159
|
+
var VALID_STAGES = /* @__PURE__ */ new Set(["handoff-review", "build-acceptance"]);
|
|
1160
|
+
var VALID_VERDICTS = /* @__PURE__ */ new Set(["approve", "accept", "request-changes", "reject"]);
|
|
1161
|
+
function parseField2(block, field) {
|
|
1162
|
+
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
1163
|
+
const match = block.match(pattern);
|
|
1164
|
+
return match ? match[1].trim() : "";
|
|
1165
|
+
}
|
|
1166
|
+
function parseReviews(content) {
|
|
1167
|
+
if (!content.trim()) return [];
|
|
1168
|
+
const chunks = content.split(/^(?=### task-\S+ — .+ — \d{4}-\d{2}-\d{2})/m).map((c) => c.trim()).filter((c) => c.match(/^### task-\S+ — .+ — \d{4}-\d{2}-\d{2}/));
|
|
1169
|
+
return chunks.map((block) => {
|
|
1170
|
+
const headingMatch = block.match(/^### (task-\S+) — (.+?) — (\d{4}-\d{2}-\d{2}[T\d:.Z]*)/);
|
|
1171
|
+
if (!headingMatch) return null;
|
|
1172
|
+
const taskId = headingMatch[1];
|
|
1173
|
+
const stageRaw = headingMatch[2].trim();
|
|
1174
|
+
const date = headingMatch[3];
|
|
1175
|
+
const stage = stageRaw.toLowerCase().replace(/\s+/g, "-");
|
|
1176
|
+
if (!VALID_STAGES.has(stage)) return null;
|
|
1177
|
+
const reviewer = parseField2(block, "Reviewer");
|
|
1178
|
+
const verdictRaw = parseField2(block, "Verdict");
|
|
1179
|
+
if (!VALID_VERDICTS.has(verdictRaw)) return null;
|
|
1180
|
+
const verdict = verdictRaw;
|
|
1181
|
+
const sprint = parseInt(parseField2(block, "Sprint"), 10);
|
|
1182
|
+
if (isNaN(sprint)) return null;
|
|
1183
|
+
const comments = parseField2(block, "Comments");
|
|
1184
|
+
const uuidRaw = parseField2(block, "UUID");
|
|
1185
|
+
const displayIdRaw = parseField2(block, "Display ID");
|
|
1186
|
+
const review = { uuid: uuidRaw ?? randomUUID5(), ...displayIdRaw ? { displayId: displayIdRaw } : {}, taskId, stage, reviewer, verdict, sprint, date, comments };
|
|
1187
|
+
const handoffRevRaw = parseField2(block, "Handoff Revision");
|
|
1188
|
+
if (handoffRevRaw) {
|
|
1189
|
+
const parsed = parseInt(handoffRevRaw, 10);
|
|
1190
|
+
if (!isNaN(parsed)) review.handoffRevision = parsed;
|
|
1191
|
+
}
|
|
1192
|
+
const buildCommitSha = parseField2(block, "Build Commit SHA");
|
|
1193
|
+
if (buildCommitSha) review.buildCommitSha = buildCommitSha;
|
|
1194
|
+
return review;
|
|
1195
|
+
}).filter((r) => r !== null);
|
|
1196
|
+
}
|
|
1197
|
+
var STAGE_DISPLAY = {
|
|
1198
|
+
"handoff-review": "Handoff Review",
|
|
1199
|
+
"build-acceptance": "Build Acceptance"
|
|
1200
|
+
};
|
|
1201
|
+
function serializeReview(review) {
|
|
1202
|
+
const stageDisplay = STAGE_DISPLAY[review.stage];
|
|
1203
|
+
const lines = [
|
|
1204
|
+
`### ${review.taskId} \u2014 ${stageDisplay} \u2014 ${review.date}`,
|
|
1205
|
+
""
|
|
1206
|
+
];
|
|
1207
|
+
if (review.uuid) lines.push(`- **UUID:** ${review.uuid}`);
|
|
1208
|
+
if (review.displayId) lines.push(`- **Display ID:** ${review.displayId}`);
|
|
1209
|
+
lines.push(
|
|
1210
|
+
`- **Reviewer:** ${review.reviewer}`,
|
|
1211
|
+
`- **Verdict:** ${review.verdict}`,
|
|
1212
|
+
`- **Sprint:** ${review.sprint}`,
|
|
1213
|
+
`- **Comments:** ${review.comments}`
|
|
1214
|
+
);
|
|
1215
|
+
if (review.handoffRevision !== void 0) lines.push(`- **Handoff Revision:** ${review.handoffRevision}`);
|
|
1216
|
+
if (review.buildCommitSha) lines.push(`- **Build Commit SHA:** ${review.buildCommitSha}`);
|
|
1217
|
+
return lines.join("\n");
|
|
1218
|
+
}
|
|
1219
|
+
function prependReview(review, content) {
|
|
1220
|
+
const serialized = serializeReview(review);
|
|
1221
|
+
if (!content.trim()) {
|
|
1222
|
+
return `# Human Reviews
|
|
1223
|
+
|
|
1224
|
+
${HEADER_SENTINEL2} Each review block references a task and stage.*
|
|
1225
|
+
|
|
1226
|
+
---
|
|
1227
|
+
|
|
1228
|
+
${serialized}
|
|
1229
|
+
`;
|
|
1230
|
+
}
|
|
1231
|
+
const sentinelIdx = content.indexOf(HEADER_SENTINEL2);
|
|
1232
|
+
if (sentinelIdx === -1) {
|
|
1233
|
+
const firstSep = content.indexOf("\n---\n");
|
|
1234
|
+
if (firstSep === -1) return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
1235
|
+
const insertAt2 = firstSep + "\n---\n".length;
|
|
1236
|
+
return content.slice(0, insertAt2) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt2);
|
|
1237
|
+
}
|
|
1238
|
+
const afterSentinel = content.slice(sentinelIdx);
|
|
1239
|
+
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
1240
|
+
if (separatorIdx === -1) {
|
|
1241
|
+
return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
1242
|
+
}
|
|
1243
|
+
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
1244
|
+
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
// src/parsers/phases.ts
|
|
1248
|
+
var VALID_STATUSES = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
|
|
1249
|
+
var PHASES_START = "<!-- PHASES:START -->";
|
|
1250
|
+
var PHASES_END = "<!-- PHASES:END -->";
|
|
1251
|
+
function parsePhases(content) {
|
|
1252
|
+
const startIdx = content.indexOf(PHASES_START);
|
|
1253
|
+
const endIdx = content.indexOf(PHASES_END);
|
|
1254
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return [];
|
|
1255
|
+
const section = content.slice(startIdx + PHASES_START.length, endIdx);
|
|
1256
|
+
const yamlMatch = section.match(/```yaml\s*\n([\s\S]*?)```/);
|
|
1257
|
+
if (!yamlMatch) return [];
|
|
1258
|
+
const yamlBody = yamlMatch[1];
|
|
1259
|
+
const phases = [];
|
|
1260
|
+
const blocks = yamlBody.split(/^(?=\s*- id:)/m).filter((b) => b.trim());
|
|
1261
|
+
for (const block of blocks) {
|
|
1262
|
+
const phase = parsePhaseBlock(block);
|
|
1263
|
+
if (phase) phases.push(phase);
|
|
1264
|
+
}
|
|
1265
|
+
return phases.sort((a, b) => a.order - b.order);
|
|
1266
|
+
}
|
|
1267
|
+
function parseYamlField(block, field) {
|
|
1268
|
+
const pattern = new RegExp(`^\\s*${field}:\\s*(.+)$`, "m");
|
|
1269
|
+
const match = block.match(pattern);
|
|
1270
|
+
if (!match) return "";
|
|
1271
|
+
let value = match[1].trim();
|
|
1272
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1273
|
+
value = value.slice(1, -1);
|
|
1274
|
+
}
|
|
1275
|
+
return value;
|
|
1276
|
+
}
|
|
1277
|
+
function parsePhaseBlock(block) {
|
|
1278
|
+
const idMatch = block.match(/^\s*- id:\s*(\S+)/m);
|
|
1279
|
+
if (!idMatch) return null;
|
|
1280
|
+
const id = idMatch[1];
|
|
1281
|
+
const slug = parseYamlField(block, "slug");
|
|
1282
|
+
const label = parseYamlField(block, "label");
|
|
1283
|
+
const description = parseYamlField(block, "description");
|
|
1284
|
+
const statusRaw = parseYamlField(block, "status");
|
|
1285
|
+
const orderRaw = parseYamlField(block, "order");
|
|
1286
|
+
if (!slug || !label || !statusRaw || !orderRaw) return null;
|
|
1287
|
+
const status = statusRaw;
|
|
1288
|
+
if (!VALID_STATUSES.has(status)) return null;
|
|
1289
|
+
const order = parseInt(orderRaw, 10);
|
|
1290
|
+
if (isNaN(order)) return null;
|
|
1291
|
+
return { id, slug, label, description, status, order };
|
|
1292
|
+
}
|
|
1293
|
+
function serializePhases(phases) {
|
|
1294
|
+
const sorted = [...phases].sort((a, b) => a.order - b.order);
|
|
1295
|
+
const yamlLines = ["phases:"];
|
|
1296
|
+
for (const p of sorted) {
|
|
1297
|
+
yamlLines.push(` - id: ${p.id}`);
|
|
1298
|
+
yamlLines.push(` slug: "${p.slug}"`);
|
|
1299
|
+
yamlLines.push(` label: "${p.label}"`);
|
|
1300
|
+
yamlLines.push(` description: "${p.description}"`);
|
|
1301
|
+
yamlLines.push(` status: "${p.status}"`);
|
|
1302
|
+
yamlLines.push(` order: ${p.order}`);
|
|
1303
|
+
}
|
|
1304
|
+
return yamlLines.join("\n");
|
|
1305
|
+
}
|
|
1306
|
+
function writePhasesToContent(phases, content) {
|
|
1307
|
+
const yaml4 = serializePhases(phases);
|
|
1308
|
+
const newSection = `${PHASES_START}
|
|
1309
|
+
|
|
1310
|
+
\`\`\`yaml
|
|
1311
|
+
${yaml4}
|
|
1312
|
+
\`\`\`
|
|
1313
|
+
|
|
1314
|
+
${PHASES_END}`;
|
|
1315
|
+
const startIdx = content.indexOf(PHASES_START);
|
|
1316
|
+
const endIdx = content.indexOf(PHASES_END);
|
|
1317
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
1318
|
+
return content.slice(0, startIdx) + newSection + content.slice(endIdx + PHASES_END.length);
|
|
1319
|
+
}
|
|
1320
|
+
return content.trimEnd() + "\n\n" + newSection + "\n";
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// src/parsers/features.ts
|
|
1324
|
+
var VALID_STATUSES2 = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
|
|
1325
|
+
var FEATURES_START = "<!-- FEATURES:START -->";
|
|
1326
|
+
var FEATURES_END = "<!-- FEATURES:END -->";
|
|
1327
|
+
function parseFeatures(content) {
|
|
1328
|
+
const startIdx = content.indexOf(FEATURES_START);
|
|
1329
|
+
const endIdx = content.indexOf(FEATURES_END);
|
|
1330
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return [];
|
|
1331
|
+
const section = content.slice(startIdx + FEATURES_START.length, endIdx);
|
|
1332
|
+
const yamlMatch = section.match(/```yaml\s*\n([\s\S]*?)```/);
|
|
1333
|
+
if (!yamlMatch) return [];
|
|
1334
|
+
const yamlBody = yamlMatch[1];
|
|
1335
|
+
const features = [];
|
|
1336
|
+
const blocks = yamlBody.split(/^(?=\s*- id:)/m).filter((b) => b.trim());
|
|
1337
|
+
for (const block of blocks) {
|
|
1338
|
+
const feature = parseFeatureBlock(block);
|
|
1339
|
+
if (feature) features.push(feature);
|
|
1340
|
+
}
|
|
1341
|
+
return features.sort((a, b) => a.roadmapPosition - b.roadmapPosition);
|
|
1342
|
+
}
|
|
1343
|
+
function parseYamlField2(block, field) {
|
|
1344
|
+
const pattern = new RegExp(`^\\s*${field}:\\s*(.+)$`, "m");
|
|
1345
|
+
const match = block.match(pattern);
|
|
1346
|
+
if (!match) return "";
|
|
1347
|
+
let value = match[1].trim();
|
|
1348
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
1349
|
+
value = value.slice(1, -1);
|
|
1350
|
+
}
|
|
1351
|
+
return value;
|
|
1352
|
+
}
|
|
1353
|
+
function parseOptionalYamlField(block, field) {
|
|
1354
|
+
const value = parseYamlField2(block, field);
|
|
1355
|
+
return value || void 0;
|
|
1356
|
+
}
|
|
1357
|
+
function parseFeatureBlock(block) {
|
|
1358
|
+
const idMatch = block.match(/^\s*- id:\s*(.+)$/m);
|
|
1359
|
+
if (!idMatch) return null;
|
|
1360
|
+
let id = idMatch[1].trim();
|
|
1361
|
+
if (id.startsWith('"') && id.endsWith('"') || id.startsWith("'") && id.endsWith("'")) {
|
|
1362
|
+
id = id.slice(1, -1);
|
|
1363
|
+
}
|
|
1364
|
+
const displayId = parseYamlField2(block, "displayId");
|
|
1365
|
+
const slug = parseYamlField2(block, "slug");
|
|
1366
|
+
const name = parseYamlField2(block, "name");
|
|
1367
|
+
const description = parseYamlField2(block, "description");
|
|
1368
|
+
const statusRaw = parseYamlField2(block, "status");
|
|
1369
|
+
const roadmapPositionRaw = parseYamlField2(block, "roadmapPosition");
|
|
1370
|
+
const createdAt = parseYamlField2(block, "createdAt");
|
|
1371
|
+
const createdSprintRaw = parseYamlField2(block, "createdSprint");
|
|
1372
|
+
if (!displayId || !slug || !name || !statusRaw || !roadmapPositionRaw || !createdAt || !createdSprintRaw) return null;
|
|
1373
|
+
const status = statusRaw;
|
|
1374
|
+
if (!VALID_STATUSES2.has(status)) return null;
|
|
1375
|
+
const roadmapPosition = parseInt(roadmapPositionRaw, 10);
|
|
1376
|
+
if (isNaN(roadmapPosition)) return null;
|
|
1377
|
+
const createdSprint = parseInt(createdSprintRaw, 10);
|
|
1378
|
+
if (isNaN(createdSprint)) return null;
|
|
1379
|
+
const completedAt = parseOptionalYamlField(block, "completedAt");
|
|
1380
|
+
const commitDate = parseOptionalYamlField(block, "commitDate");
|
|
1381
|
+
const completedSprintRaw = parseOptionalYamlField(block, "completedSprint");
|
|
1382
|
+
const completedSprint = completedSprintRaw ? parseInt(completedSprintRaw, 10) : void 0;
|
|
1383
|
+
return {
|
|
1384
|
+
id,
|
|
1385
|
+
displayId,
|
|
1386
|
+
slug,
|
|
1387
|
+
name,
|
|
1388
|
+
description,
|
|
1389
|
+
status,
|
|
1390
|
+
roadmapPosition,
|
|
1391
|
+
createdAt,
|
|
1392
|
+
completedAt,
|
|
1393
|
+
commitDate,
|
|
1394
|
+
createdSprint,
|
|
1395
|
+
completedSprint: completedSprint !== void 0 && !isNaN(completedSprint) ? completedSprint : void 0
|
|
1396
|
+
};
|
|
1397
|
+
}
|
|
1398
|
+
function serializeFeatures(features) {
|
|
1399
|
+
const sorted = [...features].sort((a, b) => a.roadmapPosition - b.roadmapPosition);
|
|
1400
|
+
const yamlLines = ["features:"];
|
|
1401
|
+
for (const f of sorted) {
|
|
1402
|
+
yamlLines.push(` - id: "${f.id}"`);
|
|
1403
|
+
yamlLines.push(` displayId: "${f.displayId}"`);
|
|
1404
|
+
yamlLines.push(` slug: "${f.slug}"`);
|
|
1405
|
+
yamlLines.push(` name: "${f.name}"`);
|
|
1406
|
+
yamlLines.push(` description: "${f.description}"`);
|
|
1407
|
+
yamlLines.push(` status: "${f.status}"`);
|
|
1408
|
+
yamlLines.push(` roadmapPosition: ${f.roadmapPosition}`);
|
|
1409
|
+
yamlLines.push(` createdAt: "${f.createdAt}"`);
|
|
1410
|
+
if (f.completedAt) yamlLines.push(` completedAt: "${f.completedAt}"`);
|
|
1411
|
+
if (f.commitDate) yamlLines.push(` commitDate: "${f.commitDate}"`);
|
|
1412
|
+
yamlLines.push(` createdSprint: ${f.createdSprint}`);
|
|
1413
|
+
if (f.completedSprint !== void 0) yamlLines.push(` completedSprint: ${f.completedSprint}`);
|
|
1414
|
+
}
|
|
1415
|
+
return yamlLines.join("\n");
|
|
1416
|
+
}
|
|
1417
|
+
function writeFeaturesToContent(features, content) {
|
|
1418
|
+
const yaml4 = serializeFeatures(features);
|
|
1419
|
+
const newSection = `${FEATURES_START}
|
|
1420
|
+
|
|
1421
|
+
\`\`\`yaml
|
|
1422
|
+
${yaml4}
|
|
1423
|
+
\`\`\`
|
|
1424
|
+
|
|
1425
|
+
${FEATURES_END}`;
|
|
1426
|
+
const startIdx = content.indexOf(FEATURES_START);
|
|
1427
|
+
const endIdx = content.indexOf(FEATURES_END);
|
|
1428
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
1429
|
+
return content.slice(0, startIdx) + newSection + content.slice(endIdx + FEATURES_END.length);
|
|
1430
|
+
}
|
|
1431
|
+
return content.trimEnd() + "\n\n" + newSection + "\n";
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// src/parsers/sprints.ts
|
|
1435
|
+
import yaml2 from "js-yaml";
|
|
1436
|
+
var YAML_MARKER2 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
1437
|
+
var YAML_START2 = "<!-- PAPI-YAML-START -->";
|
|
1438
|
+
var YAML_END2 = "<!-- PAPI-YAML-END -->";
|
|
1439
|
+
var VALID_STATUSES3 = /* @__PURE__ */ new Set(["planning", "active", "complete"]);
|
|
1440
|
+
function toSprint(raw) {
|
|
1441
|
+
if (!VALID_STATUSES3.has(raw.status)) return null;
|
|
1442
|
+
const sprint = {
|
|
1443
|
+
id: raw.id,
|
|
1444
|
+
number: raw.number,
|
|
1445
|
+
status: raw.status,
|
|
1446
|
+
startDate: raw.start_date,
|
|
1447
|
+
goals: raw.goals ?? [],
|
|
1448
|
+
boardHealth: raw.board_health ?? "",
|
|
1449
|
+
taskIds: raw.task_ids ?? []
|
|
1450
|
+
};
|
|
1451
|
+
if (raw.end_date) sprint.endDate = raw.end_date;
|
|
1452
|
+
return sprint;
|
|
1453
|
+
}
|
|
1454
|
+
function fromSprint(sprint) {
|
|
1455
|
+
const raw = {
|
|
1456
|
+
id: sprint.id,
|
|
1457
|
+
number: sprint.number,
|
|
1458
|
+
status: sprint.status,
|
|
1459
|
+
start_date: sprint.startDate,
|
|
1460
|
+
goals: sprint.goals,
|
|
1461
|
+
board_health: sprint.boardHealth,
|
|
1462
|
+
task_ids: sprint.taskIds
|
|
1463
|
+
};
|
|
1464
|
+
if (sprint.endDate) raw.end_date = sprint.endDate;
|
|
1465
|
+
return raw;
|
|
1466
|
+
}
|
|
1467
|
+
function extractYamlBlock2(content) {
|
|
1468
|
+
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
1469
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in SPRINTS.md");
|
|
1470
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
1471
|
+
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
1472
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in SPRINTS.md");
|
|
1473
|
+
const yamlStart = startIdx + YAML_START2.length;
|
|
1474
|
+
const endIdx = afterMarker.indexOf(YAML_END2, yamlStart);
|
|
1475
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in SPRINTS.md");
|
|
1476
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
1477
|
+
}
|
|
1478
|
+
function parseSprints(content) {
|
|
1479
|
+
if (!content.trim()) return [];
|
|
1480
|
+
const yamlText = extractYamlBlock2(content);
|
|
1481
|
+
const data = yaml2.load(yamlText);
|
|
1482
|
+
return (data.sprints ?? []).map(toSprint).filter((s) => s !== null);
|
|
1483
|
+
}
|
|
1484
|
+
function serializeSprints(sprints, content) {
|
|
1485
|
+
const raw = sprints.map(fromSprint);
|
|
1486
|
+
const yamlStr = yaml2.dump({ sprints: raw }, { lineWidth: 120, quotingType: '"' });
|
|
1487
|
+
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
1488
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in SPRINTS.md");
|
|
1489
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
1490
|
+
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
1491
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in SPRINTS.md");
|
|
1492
|
+
const absStart = markerIdx + YAML_MARKER2.length + startIdx;
|
|
1493
|
+
const endIdx = afterMarker.indexOf(YAML_END2, startIdx);
|
|
1494
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in SPRINTS.md");
|
|
1495
|
+
const absEnd = markerIdx + YAML_MARKER2.length + endIdx + YAML_END2.length;
|
|
1496
|
+
return content.slice(0, absStart) + YAML_START2 + "\n" + yamlStr + YAML_END2 + content.slice(absEnd);
|
|
1497
|
+
}
|
|
1498
|
+
function serializeSprint(sprint) {
|
|
1499
|
+
const raw = fromSprint(sprint);
|
|
1500
|
+
return yaml2.dump(raw, { lineWidth: 120, quotingType: '"' }).trim();
|
|
1501
|
+
}
|
|
1502
|
+
function prependSprint(sprint, content) {
|
|
1503
|
+
if (!content.trim()) {
|
|
1504
|
+
const header = `# Sprints
|
|
1505
|
+
|
|
1506
|
+
<!-- PAPI-ADAPTER: parse the yaml block below -->
|
|
1507
|
+
|
|
1508
|
+
`;
|
|
1509
|
+
const raw = fromSprint(sprint);
|
|
1510
|
+
const yamlStr = yaml2.dump({ sprints: [raw] }, { lineWidth: 120, quotingType: '"' });
|
|
1511
|
+
return header + YAML_START2 + "\n" + yamlStr + YAML_END2 + "\n";
|
|
1512
|
+
}
|
|
1513
|
+
const existing = parseSprints(content);
|
|
1514
|
+
const merged = [sprint, ...existing];
|
|
1515
|
+
return serializeSprints(merged, content);
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// src/parsers/registries.ts
|
|
1519
|
+
import yaml3 from "js-yaml";
|
|
1520
|
+
var YAML_MARKER3 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
1521
|
+
var YAML_START3 = "<!-- PAPI-YAML-START -->";
|
|
1522
|
+
var YAML_END3 = "<!-- PAPI-YAML-END -->";
|
|
1523
|
+
function extractYamlBlock3(content) {
|
|
1524
|
+
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
1525
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
1526
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
1527
|
+
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
1528
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
1529
|
+
const yamlStart = startIdx + YAML_START3.length;
|
|
1530
|
+
const endIdx = afterMarker.indexOf(YAML_END3, yamlStart);
|
|
1531
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
1532
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
1533
|
+
}
|
|
1534
|
+
function parseRegistries(content) {
|
|
1535
|
+
if (!content.trim()) return { modules: [], epics: [] };
|
|
1536
|
+
const yamlText = extractYamlBlock3(content);
|
|
1537
|
+
const data = yaml3.load(yamlText);
|
|
1538
|
+
return {
|
|
1539
|
+
modules: data.modules ?? [],
|
|
1540
|
+
epics: data.epics ?? []
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
function serializeRegistries(registries, content) {
|
|
1544
|
+
const yamlStr = yaml3.dump(
|
|
1545
|
+
{ modules: registries.modules, epics: registries.epics },
|
|
1546
|
+
{ lineWidth: 120, quotingType: '"' }
|
|
1547
|
+
);
|
|
1548
|
+
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
1549
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
1550
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
1551
|
+
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
1552
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
1553
|
+
const absStart = markerIdx + YAML_MARKER3.length + startIdx;
|
|
1554
|
+
const endIdx = afterMarker.indexOf(YAML_END3, startIdx);
|
|
1555
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
1556
|
+
const absEnd = markerIdx + YAML_MARKER3.length + endIdx + YAML_END3.length;
|
|
1557
|
+
return content.slice(0, absStart) + YAML_START3 + "\n" + yamlStr + YAML_END3 + content.slice(absEnd);
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// src/adapter.ts
|
|
1561
|
+
function displayIdNumber(displayId, prefix) {
|
|
1562
|
+
if (!displayId) return 0;
|
|
1563
|
+
const match = displayId.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
1564
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
1565
|
+
}
|
|
1566
|
+
var MdFileAdapter = class {
|
|
1567
|
+
dir;
|
|
1568
|
+
constructor(projectDir) {
|
|
1569
|
+
this.dir = projectDir;
|
|
1570
|
+
}
|
|
1571
|
+
/** Resolve a filename to an absolute path within the .papi/ directory. */
|
|
1572
|
+
path(file) {
|
|
1573
|
+
return join(this.dir, file);
|
|
1574
|
+
}
|
|
1575
|
+
/** Read a .papi/ file as UTF-8 text. Throws a clear error if the file is missing. */
|
|
1576
|
+
async read(file) {
|
|
1577
|
+
try {
|
|
1578
|
+
return await readFile(this.path(file), "utf-8");
|
|
1579
|
+
} catch (err) {
|
|
1580
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
1581
|
+
throw new Error(`.papi/${file} not found. Run the setup tool to initialise your project.`);
|
|
1582
|
+
}
|
|
1583
|
+
throw err;
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/** Write UTF-8 text to a .papi/ file. */
|
|
1587
|
+
async write(file, content) {
|
|
1588
|
+
await writeFile(this.path(file), content, "utf-8");
|
|
1589
|
+
}
|
|
1590
|
+
// --- Planning Log ---
|
|
1591
|
+
/** Parse the full planning context into structured sections (reads from PLANNING_LOG.md + ACTIVE_DECISIONS.md + SPRINT_LOG.md). */
|
|
1592
|
+
async readPlanningLog() {
|
|
1593
|
+
const [planningContent, activeDecisionsContent, sprintLogContent] = await Promise.all([
|
|
1594
|
+
this.read("PLANNING_LOG.md"),
|
|
1595
|
+
this.readOptional("ACTIVE_DECISIONS.md"),
|
|
1596
|
+
this.readOptional("SPRINT_LOG.md")
|
|
1597
|
+
]);
|
|
1598
|
+
return parsePlanningLog(planningContent, activeDecisionsContent, sprintLogContent);
|
|
1599
|
+
}
|
|
1600
|
+
/** Read the Sprint Health table from PLANNING_LOG.md. */
|
|
1601
|
+
async getSprintHealth() {
|
|
1602
|
+
return parseSprintHealth(await this.read("PLANNING_LOG.md"));
|
|
1603
|
+
}
|
|
1604
|
+
/** Read all Active Decisions from ACTIVE_DECISIONS.md. */
|
|
1605
|
+
async getActiveDecisions() {
|
|
1606
|
+
const content = await this.readOptional("ACTIVE_DECISIONS.md");
|
|
1607
|
+
if (!content) return [];
|
|
1608
|
+
return parseActiveDecisions(content);
|
|
1609
|
+
}
|
|
1610
|
+
/** Read sprint log entries (newest first), optionally limited to {@link limit} entries. */
|
|
1611
|
+
async getSprintLog(limit) {
|
|
1612
|
+
return parseSprintLog(await this.read("SPRINT_LOG.md"), limit);
|
|
1613
|
+
}
|
|
1614
|
+
async getSprintLogSince(sprintNumber) {
|
|
1615
|
+
const log = await this.getSprintLog();
|
|
1616
|
+
return log.filter((entry) => entry.sprintNumber >= sprintNumber);
|
|
1617
|
+
}
|
|
1618
|
+
/** Merge partial updates into the Sprint Health table and write back. */
|
|
1619
|
+
async setSprintHealth(updates) {
|
|
1620
|
+
const content = await this.read("PLANNING_LOG.md");
|
|
1621
|
+
const current = parseSprintHealth(content);
|
|
1622
|
+
const updated = { ...current, ...updates };
|
|
1623
|
+
await this.write("PLANNING_LOG.md", serializeSprintHealth(updated, content));
|
|
1624
|
+
}
|
|
1625
|
+
/** Prepend a new sprint log entry at the top of the Sprint Log section. */
|
|
1626
|
+
async writeSprintLogEntry(entry) {
|
|
1627
|
+
if (!entry.uuid) {
|
|
1628
|
+
entry = { ...entry, uuid: randomUUID6() };
|
|
1629
|
+
}
|
|
1630
|
+
const content = await this.read("SPRINT_LOG.md");
|
|
1631
|
+
await this.write("SPRINT_LOG.md", prependSprintLogEntry(entry, content));
|
|
1632
|
+
}
|
|
1633
|
+
/** Write a strategy review — for md adapter, delegates to sprint log. */
|
|
1634
|
+
async writeStrategyReview(review) {
|
|
1635
|
+
await this.writeSprintLogEntry({
|
|
1636
|
+
uuid: randomUUID6(),
|
|
1637
|
+
sprintNumber: review.sprintNumber,
|
|
1638
|
+
title: review.title,
|
|
1639
|
+
content: review.content,
|
|
1640
|
+
notes: review.notes
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
/** Get the sprint number of the last strategy review. */
|
|
1644
|
+
async getLastStrategyReviewSprint() {
|
|
1645
|
+
const log = await this.getSprintLog();
|
|
1646
|
+
const entry = log.find(
|
|
1647
|
+
(e) => /strategy.*review|strategic.*shift/i.test(e.title)
|
|
1648
|
+
);
|
|
1649
|
+
return entry?.sprintNumber ?? 0;
|
|
1650
|
+
}
|
|
1651
|
+
/** Get strategy reviews — md adapter returns empty (reviews live in sprint log). */
|
|
1652
|
+
async getStrategyReviews(_limit) {
|
|
1653
|
+
return [];
|
|
1654
|
+
}
|
|
1655
|
+
/** Update or insert an Active Decision block by ID. */
|
|
1656
|
+
async updateActiveDecision(id, body, sprintNumber) {
|
|
1657
|
+
const content = await this.readOptional("ACTIVE_DECISIONS.md") || "## Active Decisions\n\n";
|
|
1658
|
+
await this.write("ACTIVE_DECISIONS.md", updateActiveDecisionInContent(id, body, content, sprintNumber));
|
|
1659
|
+
}
|
|
1660
|
+
// --- Sprint Board ---
|
|
1661
|
+
/** Query the sprint board, optionally filtering by status/priority/phase/etc. */
|
|
1662
|
+
async queryBoard(options) {
|
|
1663
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
1664
|
+
return options ? filterTasks(tasks, options) : tasks;
|
|
1665
|
+
}
|
|
1666
|
+
/** Look up a single task by ID, returning null if not found. */
|
|
1667
|
+
async getTask(id) {
|
|
1668
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
1669
|
+
const found = tasks.find((t) => t.id === id);
|
|
1670
|
+
if (found) return found;
|
|
1671
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
1672
|
+
if (!archiveContent) return null;
|
|
1673
|
+
return parseBoard(archiveContent).find((t) => t.id === id) ?? null;
|
|
1674
|
+
}
|
|
1675
|
+
/** Look up multiple tasks by ID in a single board read. */
|
|
1676
|
+
async getTasks(ids) {
|
|
1677
|
+
const idSet = new Set(ids);
|
|
1678
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
1679
|
+
return tasks.filter((t) => idSet.has(t.id));
|
|
1680
|
+
}
|
|
1681
|
+
/** Warn if a phase name doesn't match any known phase label from PRODUCT_BRIEF.md. */
|
|
1682
|
+
async warnInvalidPhase(phase) {
|
|
1683
|
+
const phases = await this.readPhases();
|
|
1684
|
+
if (phases.length === 0) return;
|
|
1685
|
+
const knownLabels = new Set(phases.map((p) => p.label));
|
|
1686
|
+
if (!knownLabels.has(phase)) {
|
|
1687
|
+
console.warn(`[papi] Warning: phase "${phase}" does not match any known phase. Valid phases: ${[...knownLabels].join(", ")}`);
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
/** Warn if a module name doesn't match any registered module in REGISTRIES.md. */
|
|
1691
|
+
async warnInvalidModule(module) {
|
|
1692
|
+
const registries = await this.readRegistries();
|
|
1693
|
+
if (registries.modules.length === 0) return;
|
|
1694
|
+
const known = new Set(registries.modules);
|
|
1695
|
+
if (!known.has(module)) {
|
|
1696
|
+
console.warn(`[papi] Warning: module "${module}" is not a registered module. Registered modules: ${registries.modules.join(", ")}`);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
/** Warn if an epic name doesn't match any registered epic in REGISTRIES.md. */
|
|
1700
|
+
async warnInvalidEpic(epic) {
|
|
1701
|
+
const registries = await this.readRegistries();
|
|
1702
|
+
if (registries.epics.length === 0) return;
|
|
1703
|
+
const known = new Set(registries.epics);
|
|
1704
|
+
if (!known.has(epic)) {
|
|
1705
|
+
console.warn(`[papi] Warning: epic "${epic}" is not a registered epic. Registered epics: ${registries.epics.join(", ")}`);
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
/** Create a new task on the board with an auto-generated sequential ID. */
|
|
1709
|
+
async createTask(task) {
|
|
1710
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
1711
|
+
const tasks = parseBoard(content);
|
|
1712
|
+
const createdAt = task.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
1713
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
1714
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
1715
|
+
await this.warnInvalidPhase(task.phase);
|
|
1716
|
+
await this.warnInvalidModule(task.module);
|
|
1717
|
+
await this.warnInvalidEpic(task.epic);
|
|
1718
|
+
if (task.dependsOn) {
|
|
1719
|
+
const allTaskIds = new Set([...tasks, ...archivedTasks].map((t) => t.id));
|
|
1720
|
+
const depIds = task.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1721
|
+
for (const depId of depIds) {
|
|
1722
|
+
if (!allTaskIds.has(depId)) {
|
|
1723
|
+
console.warn(`[papi] Warning: dependsOn references non-existent task "${depId}"`);
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
const uuid = task.uuid ?? randomUUID6();
|
|
1728
|
+
const id = nextTaskId([...tasks, ...archivedTasks]);
|
|
1729
|
+
const newTask = { ...task, uuid, createdAt, id, displayId: id };
|
|
1730
|
+
if (newTask.buildHandoff) {
|
|
1731
|
+
if (!newTask.buildHandoff.uuid) {
|
|
1732
|
+
newTask.buildHandoff = { ...newTask.buildHandoff, uuid: randomUUID6() };
|
|
1733
|
+
}
|
|
1734
|
+
if (!newTask.buildHandoff.displayId) {
|
|
1735
|
+
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
1736
|
+
newTask.buildHandoff = { ...newTask.buildHandoff, displayId: `ho-${maxNum + 1}` };
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
tasks.push(newTask);
|
|
1740
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
1741
|
+
return newTask;
|
|
1742
|
+
}
|
|
1743
|
+
/** Update one or more fields on an existing task. Throws if the task ID is not found. */
|
|
1744
|
+
async updateTask(id, updates, options) {
|
|
1745
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
1746
|
+
const tasks = parseBoard(content);
|
|
1747
|
+
const idx = tasks.findIndex((t) => t.id === id);
|
|
1748
|
+
if (idx === -1) throw new Error(`Task ${id} not found`);
|
|
1749
|
+
if (updates.phase && updates.phase !== tasks[idx].phase) {
|
|
1750
|
+
await this.warnInvalidPhase(updates.phase);
|
|
1751
|
+
}
|
|
1752
|
+
if (updates.module && updates.module !== tasks[idx].module) {
|
|
1753
|
+
await this.warnInvalidModule(updates.module);
|
|
1754
|
+
}
|
|
1755
|
+
if (updates.epic && updates.epic !== tasks[idx].epic) {
|
|
1756
|
+
await this.warnInvalidEpic(updates.epic);
|
|
1757
|
+
}
|
|
1758
|
+
if (updates.status && updates.status !== tasks[idx].status && !options?.force) {
|
|
1759
|
+
const from = tasks[idx].status;
|
|
1760
|
+
const allowed = VALID_TRANSITIONS[from];
|
|
1761
|
+
if (!allowed.includes(updates.status)) {
|
|
1762
|
+
console.warn(`[papi] Warning: invalid status transition "${from}" \u2192 "${updates.status}" for task ${id}. Allowed from "${from}": ${allowed.length > 0 ? allowed.join(", ") : "none"}`);
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
if (updates.status && updates.status !== tasks[idx].status) {
|
|
1766
|
+
const history = tasks[idx].stateHistory ?? [];
|
|
1767
|
+
history.push({ status: updates.status, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
1768
|
+
updates = { ...updates, stateHistory: history };
|
|
1769
|
+
}
|
|
1770
|
+
if (updates.buildHandoff) {
|
|
1771
|
+
let handoff = { ...updates.buildHandoff };
|
|
1772
|
+
if (!handoff.uuid) handoff = { ...handoff, uuid: randomUUID6() };
|
|
1773
|
+
if (!handoff.displayId) {
|
|
1774
|
+
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
1775
|
+
handoff = { ...handoff, displayId: `ho-${maxNum + 1}` };
|
|
1776
|
+
}
|
|
1777
|
+
updates = { ...updates, buildHandoff: handoff };
|
|
1778
|
+
}
|
|
1779
|
+
tasks[idx] = { ...tasks[idx], ...updates };
|
|
1780
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
1781
|
+
}
|
|
1782
|
+
/** Shorthand to update only the status field of a task. */
|
|
1783
|
+
async updateTaskStatus(id, status) {
|
|
1784
|
+
return this.updateTask(id, { status });
|
|
1785
|
+
}
|
|
1786
|
+
// --- Build Reports ---
|
|
1787
|
+
/** Insert a new build report at the top of BUILD_REPORTS.md. */
|
|
1788
|
+
async appendBuildReport(report) {
|
|
1789
|
+
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
1790
|
+
if (!boardTasks.some((t) => t.id === report.taskId)) {
|
|
1791
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
1792
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
1793
|
+
if (!archivedTasks.some((t) => t.id === report.taskId)) {
|
|
1794
|
+
console.warn(`[papi] Warning: BuildReport.taskId references non-existent task "${report.taskId}"`);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
const content = await this.read("BUILD_REPORTS.md");
|
|
1798
|
+
if (!report.uuid) {
|
|
1799
|
+
report = { ...report, uuid: randomUUID6() };
|
|
1800
|
+
}
|
|
1801
|
+
if (!report.displayId) {
|
|
1802
|
+
const existing = parseBuildReports(content);
|
|
1803
|
+
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "br")), 0);
|
|
1804
|
+
report = { ...report, displayId: `br-${maxNum + 1}` };
|
|
1805
|
+
}
|
|
1806
|
+
await this.write("BUILD_REPORTS.md", prependBuildReport(report, content));
|
|
1807
|
+
}
|
|
1808
|
+
/** Return the most recent {@link count} build reports. */
|
|
1809
|
+
async getRecentBuildReports(count) {
|
|
1810
|
+
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
1811
|
+
return reports.slice(0, count);
|
|
1812
|
+
}
|
|
1813
|
+
/** Return all build reports from sprints >= {@link sprintNumber}. */
|
|
1814
|
+
async getBuildReportsSince(sprintNumber) {
|
|
1815
|
+
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
1816
|
+
return reports.filter((r) => r.sprint >= sprintNumber);
|
|
1817
|
+
}
|
|
1818
|
+
// --- Human Reviews ---
|
|
1819
|
+
/** Return recent human reviews from REVIEWS.md (newest first), optionally limited to {@link count}. */
|
|
1820
|
+
async getRecentReviews(count) {
|
|
1821
|
+
const content = await this.readOptional("REVIEWS.md");
|
|
1822
|
+
if (!content) return [];
|
|
1823
|
+
const reviews = parseReviews(content);
|
|
1824
|
+
return count ? reviews.slice(0, count) : reviews;
|
|
1825
|
+
}
|
|
1826
|
+
/** Write a new human review to REVIEWS.md. */
|
|
1827
|
+
async writeReview(review) {
|
|
1828
|
+
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
1829
|
+
if (!boardTasks.some((t) => t.id === review.taskId)) {
|
|
1830
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
1831
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
1832
|
+
if (!archivedTasks.some((t) => t.id === review.taskId)) {
|
|
1833
|
+
console.warn(`[papi] Warning: HumanReview.taskId references non-existent task "${review.taskId}"`);
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
const content = await this.readOptional("REVIEWS.md");
|
|
1837
|
+
if (!review.uuid) {
|
|
1838
|
+
review = { ...review, uuid: randomUUID6() };
|
|
1839
|
+
}
|
|
1840
|
+
if (!review.displayId) {
|
|
1841
|
+
const existing = content ? parseReviews(content) : [];
|
|
1842
|
+
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "rv")), 0);
|
|
1843
|
+
review = { ...review, displayId: `rv-${maxNum + 1}` };
|
|
1844
|
+
}
|
|
1845
|
+
await this.write("REVIEWS.md", prependReview(review, content));
|
|
1846
|
+
}
|
|
1847
|
+
// --- Compression ---
|
|
1848
|
+
/** Compress old sprint log entries below {@link threshold} into a summary block. */
|
|
1849
|
+
async compressSprintLog(threshold, summary) {
|
|
1850
|
+
const content = await this.read("SPRINT_LOG.md");
|
|
1851
|
+
await this.write("SPRINT_LOG.md", compressSprintLogInContent(content, threshold, summary));
|
|
1852
|
+
}
|
|
1853
|
+
/** Compress old build reports below {@link threshold} into a summary block. */
|
|
1854
|
+
async compressBuildReports(threshold, summary) {
|
|
1855
|
+
const content = await this.read("BUILD_REPORTS.md");
|
|
1856
|
+
await this.write("BUILD_REPORTS.md", compressBuildReportsInContent(content, threshold, summary));
|
|
1857
|
+
}
|
|
1858
|
+
// --- Archival ---
|
|
1859
|
+
/** Read a .papi/ file, returning empty string if it doesn't exist. */
|
|
1860
|
+
async readOptional(file) {
|
|
1861
|
+
try {
|
|
1862
|
+
await access(this.path(file));
|
|
1863
|
+
return readFile(this.path(file), "utf-8");
|
|
1864
|
+
} catch (_err) {
|
|
1865
|
+
return "";
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
/** Strip build_handoff and build_report from a task before archiving — these are already in BUILD_REPORTS.md. */
|
|
1869
|
+
stripHeavyFields(task) {
|
|
1870
|
+
const { buildHandoff, buildReport, ...rest } = task;
|
|
1871
|
+
return rest;
|
|
1872
|
+
}
|
|
1873
|
+
/** Append tasks to ARCHIVE_SPRINT_BOARD.md, stripping heavy fields and deduplicating by ID. */
|
|
1874
|
+
async appendToArchive(tasks) {
|
|
1875
|
+
const existing = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
1876
|
+
const archiveContent = existing || "# PAPI Sprint Board \u2014 Archive\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\ntasks: []\n<!-- PAPI-YAML-END -->\n";
|
|
1877
|
+
const existingArchived = parseBoard(archiveContent);
|
|
1878
|
+
const existingIds = new Set(existingArchived.map((t) => t.id));
|
|
1879
|
+
const newArchive = tasks.filter((t) => !existingIds.has(t.id)).map((t) => this.stripHeavyFields(t));
|
|
1880
|
+
const merged = [...existingArchived, ...newArchive];
|
|
1881
|
+
await this.write("ARCHIVE_SPRINT_BOARD.md", serializeBoard(merged, archiveContent));
|
|
1882
|
+
}
|
|
1883
|
+
/** Archive tasks matching phases and/or statuses to ARCHIVE_SPRINT_BOARD.md and remove them from active board. */
|
|
1884
|
+
async archiveTasks(phases, statuses) {
|
|
1885
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
1886
|
+
const tasks = parseBoard(content);
|
|
1887
|
+
const phaseSet = new Set(phases.map((p) => p.toLowerCase()));
|
|
1888
|
+
const statusSet = statuses ? new Set(statuses.map((s) => s.toLowerCase())) : null;
|
|
1889
|
+
const keep = [];
|
|
1890
|
+
const archive = [];
|
|
1891
|
+
const hasPhaseFilter = phaseSet.size > 0;
|
|
1892
|
+
const hasStatusFilter = statusSet !== null;
|
|
1893
|
+
for (const task of tasks) {
|
|
1894
|
+
const matchesPhase = hasPhaseFilter && phaseSet.has(task.phase.toLowerCase());
|
|
1895
|
+
const matchesStatus = hasStatusFilter && statusSet.has(task.status.toLowerCase());
|
|
1896
|
+
const shouldArchive = hasPhaseFilter && hasStatusFilter ? matchesPhase && matchesStatus : matchesPhase || matchesStatus;
|
|
1897
|
+
if (shouldArchive) {
|
|
1898
|
+
archive.push(task);
|
|
1899
|
+
} else {
|
|
1900
|
+
keep.push(task);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
if (archive.length === 0) {
|
|
1904
|
+
return { archivedCount: 0, taskIds: [] };
|
|
1905
|
+
}
|
|
1906
|
+
await this.appendToArchive(archive);
|
|
1907
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(keep, content));
|
|
1908
|
+
return { archivedCount: archive.length, taskIds: archive.map((t) => t.id) };
|
|
1909
|
+
}
|
|
1910
|
+
// --- Product Brief ---
|
|
1911
|
+
/** Read the raw PRODUCT_BRIEF.md content. */
|
|
1912
|
+
async readProductBrief() {
|
|
1913
|
+
return this.read("PRODUCT_BRIEF.md");
|
|
1914
|
+
}
|
|
1915
|
+
/** Overwrite PRODUCT_BRIEF.md with new content. */
|
|
1916
|
+
async updateProductBrief(content) {
|
|
1917
|
+
await this.write("PRODUCT_BRIEF.md", content);
|
|
1918
|
+
}
|
|
1919
|
+
/** Read all phases from PHASES.md (falls back to PRODUCT_BRIEF.md for migration). */
|
|
1920
|
+
async readPhases() {
|
|
1921
|
+
const phasesContent = await this.readOptional("PHASES.md");
|
|
1922
|
+
if (phasesContent) return parsePhases(phasesContent);
|
|
1923
|
+
const briefContent = await this.readOptional("PRODUCT_BRIEF.md");
|
|
1924
|
+
return briefContent ? parsePhases(briefContent) : [];
|
|
1925
|
+
}
|
|
1926
|
+
/** Write phases to PHASES.md. */
|
|
1927
|
+
async writePhases(phases) {
|
|
1928
|
+
const content = await this.readOptional("PHASES.md");
|
|
1929
|
+
const existing = content || "";
|
|
1930
|
+
const yaml4 = serializePhases(phases);
|
|
1931
|
+
const PHASES_START2 = "<!-- PHASES:START -->";
|
|
1932
|
+
const PHASES_END2 = "<!-- PHASES:END -->";
|
|
1933
|
+
const newSection = `${PHASES_START2}
|
|
1934
|
+
|
|
1935
|
+
\`\`\`yaml
|
|
1936
|
+
${yaml4}
|
|
1937
|
+
\`\`\`
|
|
1938
|
+
|
|
1939
|
+
${PHASES_END2}`;
|
|
1940
|
+
const startIdx = existing.indexOf(PHASES_START2);
|
|
1941
|
+
const endIdx = existing.indexOf(PHASES_END2);
|
|
1942
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
1943
|
+
await this.write("PHASES.md", existing.slice(0, startIdx) + newSection + existing.slice(endIdx + PHASES_END2.length));
|
|
1944
|
+
} else {
|
|
1945
|
+
await this.write("PHASES.md", `# Phases
|
|
1946
|
+
|
|
1947
|
+
${newSection}
|
|
1948
|
+
`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
// --- Features ---
|
|
1952
|
+
/** Read all features from FEATURES.md. */
|
|
1953
|
+
async readFeatures() {
|
|
1954
|
+
const content = await this.readOptional("FEATURES.md");
|
|
1955
|
+
return content ? parseFeatures(content) : [];
|
|
1956
|
+
}
|
|
1957
|
+
/** Get a single feature by UUID. */
|
|
1958
|
+
async getFeature(id) {
|
|
1959
|
+
const features = await this.readFeatures();
|
|
1960
|
+
return features.find((f) => f.id === id) ?? null;
|
|
1961
|
+
}
|
|
1962
|
+
/** Create a new feature and append it to FEATURES.md. */
|
|
1963
|
+
async createFeature(feature) {
|
|
1964
|
+
const features = await this.readFeatures();
|
|
1965
|
+
const id = randomUUID6();
|
|
1966
|
+
const newFeature = { id, ...feature };
|
|
1967
|
+
features.push(newFeature);
|
|
1968
|
+
await this.writeFeatures(features);
|
|
1969
|
+
return newFeature;
|
|
1970
|
+
}
|
|
1971
|
+
/** Update an existing feature by UUID. */
|
|
1972
|
+
async updateFeature(id, updates) {
|
|
1973
|
+
const features = await this.readFeatures();
|
|
1974
|
+
const idx = features.findIndex((f) => f.id === id);
|
|
1975
|
+
if (idx === -1) throw new Error(`Feature not found: ${id}`);
|
|
1976
|
+
features[idx] = { ...features[idx], ...updates };
|
|
1977
|
+
await this.writeFeatures(features);
|
|
1978
|
+
}
|
|
1979
|
+
/** Write all features to FEATURES.md. */
|
|
1980
|
+
async writeFeatures(features) {
|
|
1981
|
+
const content = await this.readOptional("FEATURES.md");
|
|
1982
|
+
const existing = content || "";
|
|
1983
|
+
const yaml4 = serializeFeatures(features);
|
|
1984
|
+
const FEATURES_START2 = "<!-- FEATURES:START -->";
|
|
1985
|
+
const FEATURES_END2 = "<!-- FEATURES:END -->";
|
|
1986
|
+
const newSection = `${FEATURES_START2}
|
|
1987
|
+
|
|
1988
|
+
\`\`\`yaml
|
|
1989
|
+
${yaml4}
|
|
1990
|
+
\`\`\`
|
|
1991
|
+
|
|
1992
|
+
${FEATURES_END2}`;
|
|
1993
|
+
const startIdx = existing.indexOf(FEATURES_START2);
|
|
1994
|
+
const endIdx = existing.indexOf(FEATURES_END2);
|
|
1995
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
1996
|
+
await this.write("FEATURES.md", existing.slice(0, startIdx) + newSection + existing.slice(endIdx + FEATURES_END2.length));
|
|
1997
|
+
} else {
|
|
1998
|
+
await this.write("FEATURES.md", `# Features
|
|
1999
|
+
|
|
2000
|
+
${newSection}
|
|
2001
|
+
`);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
// --- Tool Call Metrics ---
|
|
2005
|
+
/** Append a tool call metric entry to METRICS.md. */
|
|
2006
|
+
async appendToolMetric(metric) {
|
|
2007
|
+
const content = await this.readOptional("METRICS.md");
|
|
2008
|
+
await this.write("METRICS.md", appendToolMetricToContent(metric, content));
|
|
2009
|
+
}
|
|
2010
|
+
/** Read all tool call metrics from METRICS.md. */
|
|
2011
|
+
async readToolMetrics() {
|
|
2012
|
+
const content = await this.readOptional("METRICS.md");
|
|
2013
|
+
if (!content) return [];
|
|
2014
|
+
return parseToolMetrics(content);
|
|
2015
|
+
}
|
|
2016
|
+
/** Aggregate tool call metrics into a cost summary, optionally filtered by sprint. */
|
|
2017
|
+
async getCostSummary(sprintNumber) {
|
|
2018
|
+
const metrics = await this.readToolMetrics();
|
|
2019
|
+
return aggregateCostSummary(metrics, sprintNumber);
|
|
2020
|
+
}
|
|
2021
|
+
/** Write a cost snapshot to the Cost Summary section of METRICS.md. */
|
|
2022
|
+
async writeCostSnapshot(snapshot) {
|
|
2023
|
+
const content = await this.readOptional("METRICS.md");
|
|
2024
|
+
await this.write("METRICS.md", writeCostSnapshotToContent(snapshot, content));
|
|
2025
|
+
}
|
|
2026
|
+
/** Read all cost snapshots from the Cost Summary section of METRICS.md. */
|
|
2027
|
+
async getCostSnapshots() {
|
|
2028
|
+
const content = await this.readOptional("METRICS.md");
|
|
2029
|
+
if (!content) return [];
|
|
2030
|
+
return parseCostSnapshots(content);
|
|
2031
|
+
}
|
|
2032
|
+
// --- Sprint Methodology Metrics ---
|
|
2033
|
+
/** Append a sprint metrics snapshot to SPRINT_METRICS.md. */
|
|
2034
|
+
async appendSprintMetrics(snapshot) {
|
|
2035
|
+
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
2036
|
+
await this.write("SPRINT_METRICS.md", appendSnapshotToContent(snapshot, content));
|
|
2037
|
+
}
|
|
2038
|
+
/** Read all sprint metrics snapshots from SPRINT_METRICS.md. */
|
|
2039
|
+
async readSprintMetrics() {
|
|
2040
|
+
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
2041
|
+
if (!content) return [];
|
|
2042
|
+
return parseSnapshots(content);
|
|
2043
|
+
}
|
|
2044
|
+
// --- Sprints ---
|
|
2045
|
+
/** Read all Sprint entities from SPRINTS.md (newest first). */
|
|
2046
|
+
async readSprints() {
|
|
2047
|
+
const content = await this.readOptional("SPRINTS.md");
|
|
2048
|
+
if (!content) return [];
|
|
2049
|
+
return parseSprints(content);
|
|
2050
|
+
}
|
|
2051
|
+
/** Write a new Sprint entity to SPRINTS.md. */
|
|
2052
|
+
async createSprint(sprint) {
|
|
2053
|
+
const content = await this.readOptional("SPRINTS.md");
|
|
2054
|
+
await this.write("SPRINTS.md", prependSprint(sprint, content));
|
|
2055
|
+
}
|
|
2056
|
+
// --- Registries ---
|
|
2057
|
+
/** Read module and epic registries from REGISTRIES.md. */
|
|
2058
|
+
async readRegistries() {
|
|
2059
|
+
const content = await this.readOptional("REGISTRIES.md");
|
|
2060
|
+
if (!content) return { modules: [], epics: [] };
|
|
2061
|
+
return parseRegistries(content);
|
|
2062
|
+
}
|
|
2063
|
+
/** Overwrite REGISTRIES.md with updated registries. */
|
|
2064
|
+
async updateRegistries(registries) {
|
|
2065
|
+
const content = await this.readOptional("REGISTRIES.md") || "# Registries\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\nmodules: []\nepics: []\n<!-- PAPI-YAML-END -->\n";
|
|
2066
|
+
await this.write("REGISTRIES.md", serializeRegistries(registries, content));
|
|
2067
|
+
}
|
|
2068
|
+
// --- Strategy Recommendations ---
|
|
2069
|
+
/**
|
|
2070
|
+
* Write a new strategy recommendation to STRATEGY_RECOMMENDATIONS.md.
|
|
2071
|
+
* File-based implementation stores as YAML entries.
|
|
2072
|
+
*/
|
|
2073
|
+
async writeRecommendation(rec) {
|
|
2074
|
+
const id = randomUUID6();
|
|
2075
|
+
const full = { id, ...rec };
|
|
2076
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
2077
|
+
const header = "# Strategy Recommendations\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\nrecommendations:\n";
|
|
2078
|
+
const footer = "<!-- PAPI-YAML-END -->\n";
|
|
2079
|
+
const entry = [
|
|
2080
|
+
` - id: ${full.id}`,
|
|
2081
|
+
` type: ${full.type}`,
|
|
2082
|
+
` status: ${full.status}`,
|
|
2083
|
+
` content: ${JSON.stringify(full.content)}`,
|
|
2084
|
+
` created_sprint: ${full.createdSprint}`,
|
|
2085
|
+
full.actionedSprint != null ? ` actioned_sprint: ${full.actionedSprint}` : null
|
|
2086
|
+
].filter(Boolean).join("\n");
|
|
2087
|
+
if (!content) {
|
|
2088
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
2089
|
+
${footer}`);
|
|
2090
|
+
} else {
|
|
2091
|
+
const insertPoint = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
2092
|
+
if (insertPoint === -1) {
|
|
2093
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
2094
|
+
${footer}`);
|
|
2095
|
+
} else {
|
|
2096
|
+
const updated = content.slice(0, insertPoint) + entry + "\n" + content.slice(insertPoint);
|
|
2097
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
2098
|
+
}
|
|
2099
|
+
}
|
|
2100
|
+
return full;
|
|
2101
|
+
}
|
|
2102
|
+
/** Read all pending (unactioned) strategy recommendations. */
|
|
2103
|
+
async getPendingRecommendations() {
|
|
2104
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
2105
|
+
if (!content) return [];
|
|
2106
|
+
const yamlStart = content.indexOf("<!-- PAPI-YAML-START -->");
|
|
2107
|
+
const yamlEnd = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
2108
|
+
if (yamlStart === -1 || yamlEnd === -1) return [];
|
|
2109
|
+
const yamlBlock = content.slice(yamlStart + "<!-- PAPI-YAML-START -->".length, yamlEnd).trim();
|
|
2110
|
+
const entries = yamlBlock.split(/(?=\s+-\s+id:)/);
|
|
2111
|
+
const recs = [];
|
|
2112
|
+
for (const block of entries) {
|
|
2113
|
+
const idMatch = block.match(/id:\s+(.+)/);
|
|
2114
|
+
const typeMatch = block.match(/type:\s+(.+)/);
|
|
2115
|
+
const statusMatch = block.match(/status:\s+(.+)/);
|
|
2116
|
+
const contentMatch = block.match(/content:\s+(.+)/);
|
|
2117
|
+
const createdMatch = block.match(/created_sprint:\s+(\d+)/);
|
|
2118
|
+
const actionedMatch = block.match(/actioned_sprint:\s+(\d+)/);
|
|
2119
|
+
if (!idMatch || !typeMatch || !statusMatch || !contentMatch || !createdMatch) continue;
|
|
2120
|
+
const status = statusMatch[1].trim();
|
|
2121
|
+
if (status !== "pending") continue;
|
|
2122
|
+
let parsedContent = contentMatch[1].trim();
|
|
2123
|
+
if (parsedContent.startsWith('"') && parsedContent.endsWith('"')) {
|
|
2124
|
+
try {
|
|
2125
|
+
parsedContent = JSON.parse(parsedContent);
|
|
2126
|
+
} catch {
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
recs.push({
|
|
2130
|
+
id: idMatch[1].trim(),
|
|
2131
|
+
type: typeMatch[1].trim(),
|
|
2132
|
+
status: "pending",
|
|
2133
|
+
content: parsedContent,
|
|
2134
|
+
createdSprint: parseInt(createdMatch[1], 10),
|
|
2135
|
+
actionedSprint: actionedMatch ? parseInt(actionedMatch[1], 10) : void 0
|
|
2136
|
+
});
|
|
2137
|
+
}
|
|
2138
|
+
return recs;
|
|
2139
|
+
}
|
|
2140
|
+
/** Mark a recommendation as actioned. */
|
|
2141
|
+
async actionRecommendation(id, sprintNumber) {
|
|
2142
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
2143
|
+
if (!content) return;
|
|
2144
|
+
const idPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(\\s+status:\\s+)pending`);
|
|
2145
|
+
let updated = content.replace(idPattern, `$1$2actioned`);
|
|
2146
|
+
const entryPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(?=\\s+-\\s+id:|<!-- PAPI-YAML-END -->)`);
|
|
2147
|
+
const entryMatch = updated.match(entryPattern);
|
|
2148
|
+
if (entryMatch && !entryMatch[0].includes("actioned_sprint:")) {
|
|
2149
|
+
const sprintPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+created_sprint:\\s+\\d+)\\n`);
|
|
2150
|
+
updated = updated.replace(sprintPattern, `$1
|
|
2151
|
+
actioned_sprint: ${sprintNumber}
|
|
2152
|
+
`);
|
|
2153
|
+
}
|
|
2154
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
2155
|
+
}
|
|
2156
|
+
// -------------------------------------------------------------------------
|
|
2157
|
+
// Decision Events & Scores (markdown persistence)
|
|
2158
|
+
// -------------------------------------------------------------------------
|
|
2159
|
+
async appendDecisionEvent(event) {
|
|
2160
|
+
const id = randomUUID6();
|
|
2161
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2162
|
+
const full = { ...event, id, createdAt };
|
|
2163
|
+
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
2164
|
+
const header = "# Decision Events\n\n";
|
|
2165
|
+
const entry = `## ${full.decisionId} | ${full.eventType} | Sprint ${full.sprint}
|
|
2166
|
+
- **id:** ${full.id}
|
|
2167
|
+
- **source:** ${full.source}
|
|
2168
|
+
` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
|
|
2169
|
+
` : "") + (full.detail ? `- **detail:** ${full.detail}
|
|
2170
|
+
` : "") + `- **createdAt:** ${full.createdAt}
|
|
2171
|
+
|
|
2172
|
+
---
|
|
2173
|
+
|
|
2174
|
+
`;
|
|
2175
|
+
if (!content) {
|
|
2176
|
+
await this.write("DECISION_EVENTS.md", header + entry);
|
|
2177
|
+
} else {
|
|
2178
|
+
await this.write("DECISION_EVENTS.md", content + entry);
|
|
2179
|
+
}
|
|
2180
|
+
return full;
|
|
2181
|
+
}
|
|
2182
|
+
async getDecisionEvents(decisionId, limit) {
|
|
2183
|
+
const all = await this.parseDecisionEvents();
|
|
2184
|
+
const filtered = all.filter((e) => e.decisionId === decisionId);
|
|
2185
|
+
return limit ? filtered.slice(0, limit) : filtered;
|
|
2186
|
+
}
|
|
2187
|
+
async getDecisionEventsSince(sprint) {
|
|
2188
|
+
const all = await this.parseDecisionEvents();
|
|
2189
|
+
return all.filter((e) => e.sprint >= sprint);
|
|
2190
|
+
}
|
|
2191
|
+
async parseDecisionEvents() {
|
|
2192
|
+
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
2193
|
+
if (!content) return [];
|
|
2194
|
+
const events = [];
|
|
2195
|
+
const blocks = content.split("---").filter((b) => b.trim());
|
|
2196
|
+
for (const block of blocks) {
|
|
2197
|
+
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+(\S+)\s+\|\s+Sprint\s+(\d+)/m);
|
|
2198
|
+
if (!headingMatch) continue;
|
|
2199
|
+
const idMatch = block.match(/\*\*id:\*\*\s+(.+)/);
|
|
2200
|
+
const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
|
|
2201
|
+
const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
|
|
2202
|
+
const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
|
|
2203
|
+
const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
|
|
2204
|
+
if (!idMatch || !sourceMatch || !createdAtMatch) continue;
|
|
2205
|
+
events.push({
|
|
2206
|
+
id: idMatch[1].trim(),
|
|
2207
|
+
decisionId: headingMatch[1],
|
|
2208
|
+
eventType: headingMatch[2],
|
|
2209
|
+
sprint: parseInt(headingMatch[3], 10),
|
|
2210
|
+
source: sourceMatch[1].trim(),
|
|
2211
|
+
sourceRef: sourceRefMatch?.[1]?.trim(),
|
|
2212
|
+
detail: detailMatch?.[1]?.trim(),
|
|
2213
|
+
createdAt: createdAtMatch[1].trim()
|
|
2214
|
+
});
|
|
2215
|
+
}
|
|
2216
|
+
return events;
|
|
2217
|
+
}
|
|
2218
|
+
async writeDecisionScore(score) {
|
|
2219
|
+
const id = randomUUID6();
|
|
2220
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2221
|
+
const totalScore = score.effort + score.risk + score.reversibility + score.scaleCost + score.lockIn;
|
|
2222
|
+
const full = { ...score, id, totalScore, createdAt };
|
|
2223
|
+
const content = await this.readOptional("DECISION_SCORES.md");
|
|
2224
|
+
const header = "# Decision Scores\n\n";
|
|
2225
|
+
const entry = `## ${full.decisionId} | Sprint ${full.sprint}
|
|
2226
|
+
- **id:** ${full.id}
|
|
2227
|
+
- **effort:** ${full.effort}
|
|
2228
|
+
- **risk:** ${full.risk}
|
|
2229
|
+
- **reversibility:** ${full.reversibility}
|
|
2230
|
+
- **scaleCost:** ${full.scaleCost}
|
|
2231
|
+
- **lockIn:** ${full.lockIn}
|
|
2232
|
+
- **totalScore:** ${full.totalScore}
|
|
2233
|
+
` + (full.rationale ? `- **rationale:** ${full.rationale}
|
|
2234
|
+
` : "") + `- **createdAt:** ${full.createdAt}
|
|
2235
|
+
|
|
2236
|
+
---
|
|
2237
|
+
|
|
2238
|
+
`;
|
|
2239
|
+
if (!content) {
|
|
2240
|
+
await this.write("DECISION_SCORES.md", header + entry);
|
|
2241
|
+
} else {
|
|
2242
|
+
await this.write("DECISION_SCORES.md", content + entry);
|
|
2243
|
+
}
|
|
2244
|
+
return full;
|
|
2245
|
+
}
|
|
2246
|
+
async getDecisionScores(decisionId) {
|
|
2247
|
+
const all = await this.parseDecisionScores();
|
|
2248
|
+
return all.filter((s) => s.decisionId === decisionId);
|
|
2249
|
+
}
|
|
2250
|
+
async getLatestDecisionScores() {
|
|
2251
|
+
const all = await this.parseDecisionScores();
|
|
2252
|
+
const latest = /* @__PURE__ */ new Map();
|
|
2253
|
+
for (const s of all) {
|
|
2254
|
+
const existing = latest.get(s.decisionId);
|
|
2255
|
+
if (!existing || s.sprint > existing.sprint) {
|
|
2256
|
+
latest.set(s.decisionId, s);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
return Array.from(latest.values());
|
|
2260
|
+
}
|
|
2261
|
+
async parseDecisionScores() {
|
|
2262
|
+
const content = await this.readOptional("DECISION_SCORES.md");
|
|
2263
|
+
if (!content) return [];
|
|
2264
|
+
const scores = [];
|
|
2265
|
+
const blocks = content.split("---").filter((b) => b.trim());
|
|
2266
|
+
for (const block of blocks) {
|
|
2267
|
+
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+Sprint\s+(\d+)/m);
|
|
2268
|
+
if (!headingMatch) continue;
|
|
2269
|
+
const field = (name) => block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s+(.+)`))?.[1]?.trim();
|
|
2270
|
+
const id = field("id");
|
|
2271
|
+
const createdAt = field("createdAt");
|
|
2272
|
+
if (!id || !createdAt) continue;
|
|
2273
|
+
scores.push({
|
|
2274
|
+
id,
|
|
2275
|
+
decisionId: headingMatch[1],
|
|
2276
|
+
sprint: parseInt(headingMatch[2], 10),
|
|
2277
|
+
effort: parseInt(field("effort") ?? "0", 10),
|
|
2278
|
+
risk: parseInt(field("risk") ?? "0", 10),
|
|
2279
|
+
reversibility: parseInt(field("reversibility") ?? "0", 10),
|
|
2280
|
+
scaleCost: parseInt(field("scaleCost") ?? "0", 10),
|
|
2281
|
+
lockIn: parseInt(field("lockIn") ?? "0", 10),
|
|
2282
|
+
totalScore: parseInt(field("totalScore") ?? "0", 10),
|
|
2283
|
+
rationale: field("rationale"),
|
|
2284
|
+
createdAt
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
return scores;
|
|
2288
|
+
}
|
|
2289
|
+
// Entity reference tracking — no-op for md adapter (DB-only feature)
|
|
2290
|
+
async logEntityReferences(_refs) {
|
|
2291
|
+
}
|
|
2292
|
+
async getDecisionUsage(_currentSprint) {
|
|
2293
|
+
return [];
|
|
2294
|
+
}
|
|
2295
|
+
};
|
|
2296
|
+
|
|
2297
|
+
// src/parsers/review-patterns.ts
|
|
2298
|
+
var NONE_PATTERN2 = /^none\b/i;
|
|
2299
|
+
function normalizeText2(text) {
|
|
2300
|
+
return text.trim().toLowerCase().replace(/[.,;:!]+$/, "").replace(/\s+/g, " ");
|
|
2301
|
+
}
|
|
2302
|
+
function isRealComment(text) {
|
|
2303
|
+
const trimmed = text.trim();
|
|
2304
|
+
return trimmed.length > 0 && !NONE_PATTERN2.test(trimmed);
|
|
2305
|
+
}
|
|
2306
|
+
async function detectReviewPatterns(reviews, currentSprint, window = 5, clusterer) {
|
|
2307
|
+
const recentReviews = reviews.filter(
|
|
2308
|
+
(r) => r.sprint > currentSprint - window && r.sprint <= currentSprint
|
|
2309
|
+
);
|
|
2310
|
+
let recurringFeedback;
|
|
2311
|
+
const realComments = recentReviews.filter((r) => isRealComment(r.comments));
|
|
2312
|
+
if (clusterer && realComments.length >= 2) {
|
|
2313
|
+
const entries = realComments.map((r) => ({
|
|
2314
|
+
text: r.comments.trim(),
|
|
2315
|
+
source: r.taskId
|
|
2316
|
+
}));
|
|
2317
|
+
const clusters = await clusterer(entries);
|
|
2318
|
+
recurringFeedback = clusters.filter((c) => c.entries.length >= 2).map((c) => ({
|
|
2319
|
+
text: c.theme,
|
|
2320
|
+
count: c.entries.length,
|
|
2321
|
+
taskIds: [...new Set(c.entries.map((e) => e.source))].sort()
|
|
2322
|
+
}));
|
|
2323
|
+
recurringFeedback.sort((a, b) => b.count - a.count);
|
|
2324
|
+
} else {
|
|
2325
|
+
const feedbackMap = /* @__PURE__ */ new Map();
|
|
2326
|
+
for (const r of realComments) {
|
|
2327
|
+
const key = normalizeText2(r.comments);
|
|
2328
|
+
const existing = feedbackMap.get(key);
|
|
2329
|
+
if (existing) {
|
|
2330
|
+
existing.count += 1;
|
|
2331
|
+
existing.taskIds.add(r.taskId);
|
|
2332
|
+
} else {
|
|
2333
|
+
feedbackMap.set(key, { original: r.comments.trim(), count: 1, taskIds: /* @__PURE__ */ new Set([r.taskId]) });
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
2336
|
+
recurringFeedback = [];
|
|
2337
|
+
for (const entry of feedbackMap.values()) {
|
|
2338
|
+
if (entry.count >= 2) {
|
|
2339
|
+
recurringFeedback.push({
|
|
2340
|
+
text: entry.original,
|
|
2341
|
+
count: entry.count,
|
|
2342
|
+
taskIds: [...entry.taskIds].sort()
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
recurringFeedback.sort((a, b) => b.count - a.count);
|
|
2347
|
+
}
|
|
2348
|
+
const verdictBreakdown = {};
|
|
2349
|
+
for (const r of recentReviews) {
|
|
2350
|
+
verdictBreakdown[r.verdict] = (verdictBreakdown[r.verdict] ?? 0) + 1;
|
|
2351
|
+
}
|
|
2352
|
+
const totalReviews = recentReviews.length;
|
|
2353
|
+
const requestChangesCount = recentReviews.filter((r) => r.verdict === "request-changes").length;
|
|
2354
|
+
const requestChangesRate = totalReviews > 0 ? Math.round(requestChangesCount / totalReviews * 100) : 0;
|
|
2355
|
+
return {
|
|
2356
|
+
recurringFeedback,
|
|
2357
|
+
verdictBreakdown,
|
|
2358
|
+
requestChangesRate
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
function hasReviewPatterns(patterns) {
|
|
2362
|
+
return patterns.recurringFeedback.length > 0 || patterns.requestChangesRate >= 50;
|
|
2363
|
+
}
|
|
2364
|
+
export {
|
|
2365
|
+
MdFileAdapter,
|
|
2366
|
+
TASK_TYPE_TIERS,
|
|
2367
|
+
VALID_TRANSITIONS,
|
|
2368
|
+
aggregateCostSummary,
|
|
2369
|
+
appendToolMetricToContent,
|
|
2370
|
+
calculateSprintMetrics,
|
|
2371
|
+
detectBuildPatterns,
|
|
2372
|
+
detectReviewPatterns,
|
|
2373
|
+
hasBuildPatterns,
|
|
2374
|
+
hasReviewPatterns,
|
|
2375
|
+
isValidStatus,
|
|
2376
|
+
isValidTransition,
|
|
2377
|
+
parseBuildHandoff,
|
|
2378
|
+
parseEffortSize,
|
|
2379
|
+
parseFeatures,
|
|
2380
|
+
parsePhases,
|
|
2381
|
+
parseRegistries,
|
|
2382
|
+
parseReviews,
|
|
2383
|
+
parseSprints,
|
|
2384
|
+
parseToolMetrics,
|
|
2385
|
+
prependReview,
|
|
2386
|
+
prependSprint,
|
|
2387
|
+
serializeBuildHandoff,
|
|
2388
|
+
serializeFeatures,
|
|
2389
|
+
serializePhases,
|
|
2390
|
+
serializeRegistries,
|
|
2391
|
+
serializeReview,
|
|
2392
|
+
serializeSprint,
|
|
2393
|
+
serializeSprints,
|
|
2394
|
+
serializeToolMetric,
|
|
2395
|
+
validateTransition,
|
|
2396
|
+
writeFeaturesToContent,
|
|
2397
|
+
writePhasesToContent
|
|
2398
|
+
};
|