@papi-ai/server 0.7.78 → 0.7.80
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/dist/backfill-cycle-metrics.js +200 -2387
- package/dist/index.js +1858 -3326
- package/dist/prompts.js +33 -3
- package/package.json +3 -3
- package/skills/papi-cycle/papi-strategy/SKILL.md +1 -0
|
@@ -1116,16 +1116,16 @@ var init_git = __esm({
|
|
|
1116
1116
|
});
|
|
1117
1117
|
|
|
1118
1118
|
// src/lib/install-id.ts
|
|
1119
|
-
import { randomUUID
|
|
1119
|
+
import { randomUUID } from "crypto";
|
|
1120
1120
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync } from "fs";
|
|
1121
1121
|
import { homedir } from "os";
|
|
1122
|
-
import { join
|
|
1122
|
+
import { join } from "path";
|
|
1123
1123
|
var PAPI_HOME_DIR, INSTALL_ID_FILE;
|
|
1124
1124
|
var init_install_id = __esm({
|
|
1125
1125
|
"src/lib/install-id.ts"() {
|
|
1126
1126
|
"use strict";
|
|
1127
|
-
PAPI_HOME_DIR =
|
|
1128
|
-
INSTALL_ID_FILE =
|
|
1127
|
+
PAPI_HOME_DIR = join(homedir(), ".papi");
|
|
1128
|
+
INSTALL_ID_FILE = join(PAPI_HOME_DIR, "install-id.json");
|
|
1129
1129
|
}
|
|
1130
1130
|
});
|
|
1131
1131
|
|
|
@@ -1226,6 +1226,10 @@ var init_proxy_adapter = __esm({
|
|
|
1226
1226
|
"stateHistory",
|
|
1227
1227
|
"handoffAccuracy",
|
|
1228
1228
|
"briefImplications",
|
|
1229
|
+
// task-3223: acceptance_results carries host-authored strings (a check's
|
|
1230
|
+
// `run`/`pattern`). Its internals are already camelCase and must not be
|
|
1231
|
+
// key-transformed — same reason as handoffAccuracy above.
|
|
1232
|
+
"acceptanceResults",
|
|
1229
1233
|
"autoReview",
|
|
1230
1234
|
"structuredData",
|
|
1231
1235
|
"data"
|
|
@@ -2068,2402 +2072,110 @@ import { pathToFileURL } from "url";
|
|
|
2068
2072
|
import path2 from "path";
|
|
2069
2073
|
import { execSync } from "child_process";
|
|
2070
2074
|
|
|
2071
|
-
//
|
|
2072
|
-
import
|
|
2073
|
-
import
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
isValidStatus as _isValidStatus,
|
|
2080
|
-
isLiveDecision as _isLiveDecision,
|
|
2081
|
-
RETIRED_DECISION_OUTCOMES as _RETIRED_DECISION_OUTCOMES
|
|
2082
|
-
} from "@papi-ai/shared";
|
|
2083
|
-
import { randomUUID } from "crypto";
|
|
2084
|
-
import { randomUUID as randomUUID3 } from "crypto";
|
|
2085
|
-
import yaml from "js-yaml";
|
|
2086
|
-
import { randomUUID as randomUUID2 } from "crypto";
|
|
2087
|
-
import { randomUUID as randomUUID4 } from "crypto";
|
|
2088
|
-
import { randomUUID as randomUUID5 } from "crypto";
|
|
2089
|
-
import yaml2 from "js-yaml";
|
|
2090
|
-
import yaml3 from "js-yaml";
|
|
2091
|
-
var VALID_TRANSITIONS = _VALID_TRANSITIONS;
|
|
2092
|
-
var isLiveDecision = _isLiveDecision;
|
|
2093
|
-
function extractSection(content, heading) {
|
|
2094
|
-
const headingPattern = new RegExp(`^## ${heading}\\s*$`, "m");
|
|
2095
|
-
const start = content.search(headingPattern);
|
|
2096
|
-
if (start === -1) return "";
|
|
2097
|
-
const afterHeading = content.slice(start);
|
|
2098
|
-
const nextSection = afterHeading.slice(1).search(/^## /m);
|
|
2099
|
-
return nextSection === -1 ? afterHeading : afterHeading.slice(0, nextSection + 1);
|
|
2100
|
-
}
|
|
2101
|
-
function extractSectionCompat(content, newHeading, legacyHeading) {
|
|
2102
|
-
const section = extractSection(content, newHeading);
|
|
2103
|
-
return section || extractSection(content, legacyHeading);
|
|
2104
|
-
}
|
|
2105
|
-
function parseCycleHealth(content) {
|
|
2106
|
-
const section = extractSectionCompat(content, "Cycle Health", "Sprint Health") || extractSection(content, "Session Health");
|
|
2107
|
-
const rows = /* @__PURE__ */ new Map();
|
|
2108
|
-
for (const line of section.split("\n")) {
|
|
2109
|
-
const match = line.match(/^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/);
|
|
2110
|
-
if (!match) continue;
|
|
2111
|
-
const key = match[1].trim().toLowerCase();
|
|
2112
|
-
const value = match[2].trim();
|
|
2113
|
-
if (key !== "metric") rows.set(key, value);
|
|
2114
|
-
}
|
|
2115
|
-
const get = (key) => rows.get(key) ?? "";
|
|
2116
|
-
return {
|
|
2117
|
-
totalCycles: parseInt(get("total cycles") || get("total sprints") || get("total sessions"), 10) || 0,
|
|
2118
|
-
cyclesSinceLastStrategyReview: parseInt(get("cycles since last strategy review") || get("sprints since last strategy review") || get("sessions since last strategy review"), 10) || 0,
|
|
2119
|
-
strategyReviewDue: get("strategy review due"),
|
|
2120
|
-
boardHealth: get("board health"),
|
|
2121
|
-
strategicDirection: get("strategic direction"),
|
|
2122
|
-
lastFullMode: parseInt(get("last full mode"), 10) || 0
|
|
2123
|
-
};
|
|
2124
|
-
}
|
|
2125
|
-
function serializeCycleHealth(health, content) {
|
|
2126
|
-
const section = extractSectionCompat(content, "Cycle Health", "Sprint Health") || extractSection(content, "Session Health");
|
|
2127
|
-
const fieldMap = {
|
|
2128
|
-
"Total cycles": String(health.totalCycles),
|
|
2129
|
-
"Cycles since last Strategy Review": String(health.cyclesSinceLastStrategyReview),
|
|
2130
|
-
"Strategy Review due": health.strategyReviewDue,
|
|
2131
|
-
"Board health": health.boardHealth,
|
|
2132
|
-
"Strategic direction": health.strategicDirection,
|
|
2133
|
-
"Last Full Mode": String(health.lastFullMode)
|
|
2134
|
-
};
|
|
2135
|
-
const legacyFieldMap = {
|
|
2136
|
-
"Total sprints": String(health.totalCycles),
|
|
2137
|
-
"Sprints since last Strategy Review": String(health.cyclesSinceLastStrategyReview),
|
|
2138
|
-
"Total sessions": String(health.totalCycles),
|
|
2139
|
-
"Sessions since last Strategy Review": String(health.cyclesSinceLastStrategyReview)
|
|
2140
|
-
};
|
|
2141
|
-
let updatedSection = section;
|
|
2142
|
-
for (const [metric, value] of Object.entries({ ...fieldMap, ...legacyFieldMap })) {
|
|
2143
|
-
const pattern = new RegExp(`(\\|\\s*${metric}\\s*\\|\\s*)(.+?)(\\s*\\|)`, "i");
|
|
2144
|
-
updatedSection = updatedSection.replace(pattern, `$1${value}$3`);
|
|
2145
|
-
}
|
|
2146
|
-
return content.replace(section, updatedSection);
|
|
2147
|
-
}
|
|
2148
|
-
function parseActiveDecisions(content) {
|
|
2149
|
-
const section = extractSection(content, "Active Decisions");
|
|
2150
|
-
const chunks = section.split(/^(?=### AD-\d+:)/m).map((c) => c.trim()).filter((c) => c.startsWith("### AD-"));
|
|
2151
|
-
return chunks.map((block) => {
|
|
2152
|
-
const headingMatch = block.match(
|
|
2153
|
-
/^### (AD-\d+):\s*(.+?)(?:\s*\[Confidence:\s*(HIGH|MEDIUM|LOW)\])?(?:\s*\[SUPERSEDED by (AD-\d+)\])?\s*$/m
|
|
2154
|
-
);
|
|
2155
|
-
if (!headingMatch) return null;
|
|
2156
|
-
const metaMatch = block.match(/<!-- papi:(?:created_sprint=(\d+))?\s*(?:modified_sprint=(\d+)\s*)*(?:uuid=(\S+))? -->/);
|
|
2157
|
-
const createdCycle = metaMatch?.[1] ? parseInt(metaMatch[1], 10) : void 0;
|
|
2158
|
-
const modifiedCycle = metaMatch?.[2] ? parseInt(metaMatch[2], 10) : void 0;
|
|
2159
|
-
const uuid = metaMatch?.[3] ?? randomUUID();
|
|
2160
|
-
return {
|
|
2161
|
-
uuid,
|
|
2162
|
-
id: headingMatch[1],
|
|
2163
|
-
displayId: headingMatch[1],
|
|
2164
|
-
title: headingMatch[2].trim(),
|
|
2165
|
-
confidence: headingMatch[3] ?? "HIGH",
|
|
2166
|
-
superseded: !!headingMatch[4],
|
|
2167
|
-
supersededBy: headingMatch[4],
|
|
2168
|
-
createdCycle,
|
|
2169
|
-
modifiedCycle,
|
|
2170
|
-
body: block
|
|
2171
|
-
};
|
|
2172
|
-
}).filter((d) => d !== null);
|
|
2173
|
-
}
|
|
2174
|
-
function stripTemporalMeta(body) {
|
|
2175
|
-
return body.replace(/\n?<!-- papi:(?:created_sprint=\d+)?\s*(?:modified_sprint=\d+\s*)*(?:uuid=\S+)? -->/g, "");
|
|
2176
|
-
}
|
|
2177
|
-
function buildTemporalMeta(createdCycle, modifiedCycle, uuid) {
|
|
2178
|
-
const parts = [];
|
|
2179
|
-
if (createdCycle != null) parts.push(`created_sprint=${createdCycle}`);
|
|
2180
|
-
if (modifiedCycle != null) parts.push(`modified_sprint=${modifiedCycle}`);
|
|
2181
|
-
if (uuid) parts.push(`uuid=${uuid}`);
|
|
2182
|
-
if (parts.length === 0) return "";
|
|
2183
|
-
return `
|
|
2184
|
-
<!-- papi:${parts.join(" ")} -->`;
|
|
2185
|
-
}
|
|
2186
|
-
function extractCreatedCycle(block) {
|
|
2187
|
-
const m = block.match(/<!-- papi:(?:created_sprint=(\d+))/);
|
|
2188
|
-
return m?.[1] ? parseInt(m[1], 10) : void 0;
|
|
2189
|
-
}
|
|
2190
|
-
function extractUuid(block) {
|
|
2191
|
-
const m = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
2192
|
-
return m?.[1];
|
|
2193
|
-
}
|
|
2194
|
-
function updateActiveDecisionInContent(id, newBody, content, cycleNumber) {
|
|
2195
|
-
if (!newBody) return content;
|
|
2196
|
-
const escapedId = id.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
|
2197
|
-
const pattern = new RegExp(`(### ${escapedId}:.*?)(?=^### AD-\\d+:|^## |$(?![\\s\\S]))`, "ms");
|
|
2198
|
-
const cleanBody = stripTemporalMeta(newBody);
|
|
2199
|
-
if (pattern.test(content)) {
|
|
2200
|
-
const existingMatch = content.match(pattern);
|
|
2201
|
-
const existingCreated = existingMatch ? extractCreatedCycle(existingMatch[0]) : void 0;
|
|
2202
|
-
const existingUuid = existingMatch ? extractUuid(existingMatch[0]) : void 0;
|
|
2203
|
-
const meta2 = cycleNumber != null ? buildTemporalMeta(existingCreated, cycleNumber, existingUuid) : existingUuid ? buildTemporalMeta(existingCreated, void 0, existingUuid) : "";
|
|
2204
|
-
return content.replace(pattern, cleanBody.trimEnd() + meta2 + "\n\n");
|
|
2205
|
-
}
|
|
2206
|
-
const meta = cycleNumber != null ? buildTemporalMeta(cycleNumber) : "";
|
|
2207
|
-
const sectionPattern = /^(#{1,2} Active Decisions\n)([\s\S]*?)(?=^#{1,2} |$(?![\s\S]))/m;
|
|
2208
|
-
const sectionMatch = content.match(sectionPattern);
|
|
2209
|
-
if (sectionMatch) {
|
|
2210
|
-
const sectionHeader = sectionMatch[1];
|
|
2211
|
-
const sectionBody = sectionMatch[2];
|
|
2212
|
-
const newSection = sectionHeader + sectionBody.trimEnd() + "\n\n" + cleanBody.trimEnd() + meta + "\n\n";
|
|
2213
|
-
return content.replace(sectionPattern, newSection);
|
|
2214
|
-
}
|
|
2215
|
-
return content;
|
|
2216
|
-
}
|
|
2217
|
-
function parseCycleLog(content, limit) {
|
|
2218
|
-
const section = extractSectionCompat(content, "Cycle Log", "Sprint Log");
|
|
2219
|
-
const chunks = section.split(/^(?=### (?:Cycle|Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Cycle|Sprint|Session) \d+ —/));
|
|
2220
|
-
const entries = chunks.map((block) => {
|
|
2221
|
-
const headingMatch = block.match(/^### (?:Cycle|Sprint|Session) (\d+) — (.+?)$/m);
|
|
2222
|
-
const cycleNumber = headingMatch ? parseInt(headingMatch[1], 10) : 0;
|
|
2223
|
-
const title = headingMatch ? headingMatch[2].trim() : block.split("\n")[0].replace(/^### /, "");
|
|
2224
|
-
const carryForwardMatch = block.match(/^- \*\*CARRY FORWARD:\*\*\s*(.+)$/m);
|
|
2225
|
-
const uuidMatch = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
2226
|
-
const uuid = uuidMatch?.[1];
|
|
2227
|
-
const blockClean = block.replace(/\n?<!-- papi:.*?-->/g, "");
|
|
2228
|
-
const notesMatch = blockClean.match(/\*\*Cycle Notes:\*\*\s*([\s\S]*?)$/);
|
|
2229
|
-
const notes = notesMatch ? notesMatch[1].trim() : void 0;
|
|
2230
|
-
return {
|
|
2231
|
-
uuid: uuid ?? randomUUID(),
|
|
2232
|
-
cycleNumber,
|
|
2233
|
-
title,
|
|
2234
|
-
content: block,
|
|
2235
|
-
carryForward: carryForwardMatch ? carryForwardMatch[1].trim() : void 0,
|
|
2236
|
-
notes
|
|
2237
|
-
};
|
|
2238
|
-
});
|
|
2239
|
-
return limit ? entries.slice(0, limit) : entries;
|
|
2240
|
-
}
|
|
2241
|
-
function prependCycleLogEntry(entry, content) {
|
|
2242
|
-
const headingPattern = /^## (?:Cycle|Sprint|Session) Log\s*$/m;
|
|
2243
|
-
const headingMatch = content.match(headingPattern);
|
|
2244
|
-
if (!headingMatch || headingMatch.index === void 0) {
|
|
2245
|
-
throw new Error("Cycle Log section not found in Planning Log");
|
|
2246
|
-
}
|
|
2247
|
-
const insertPos = headingMatch.index + headingMatch[0].length;
|
|
2248
|
-
const before = content.slice(0, insertPos);
|
|
2249
|
-
const after = content.slice(insertPos);
|
|
2250
|
-
let entryContent = entry.content;
|
|
2251
|
-
if (entry.notes) {
|
|
2252
|
-
entryContent = `${entryContent}
|
|
2253
|
-
|
|
2254
|
-
**Cycle Notes:** ${entry.notes}`;
|
|
2255
|
-
}
|
|
2256
|
-
if (entry.uuid) {
|
|
2257
|
-
entryContent = `${entryContent}
|
|
2258
|
-
<!-- papi:uuid=${entry.uuid} -->`;
|
|
2259
|
-
}
|
|
2260
|
-
return `${before}
|
|
2261
|
-
|
|
2262
|
-
${entryContent}
|
|
2263
|
-
${after}`;
|
|
2264
|
-
}
|
|
2265
|
-
function parseNorthStar(content) {
|
|
2266
|
-
return extractSection(content, "North Star").replace(/^## North Star\s*/m, "").trim();
|
|
2267
|
-
}
|
|
2268
|
-
function upsertNorthStarInContent(content, statement) {
|
|
2269
|
-
const headingPattern = /^## North Star\s*$/m;
|
|
2270
|
-
const start = content.search(headingPattern);
|
|
2271
|
-
if (start === -1) {
|
|
2272
|
-
const cycleLogIdx = content.search(/^## (?:Cycle Log|Sprint Log)/m);
|
|
2273
|
-
const newSection = `## North Star
|
|
2274
|
-
|
|
2275
|
-
${statement}
|
|
2276
|
-
|
|
2277
|
-
`;
|
|
2278
|
-
if (cycleLogIdx === -1) {
|
|
2279
|
-
return content.trimEnd() + "\n\n" + newSection;
|
|
2280
|
-
}
|
|
2281
|
-
return content.slice(0, cycleLogIdx) + newSection + content.slice(cycleLogIdx);
|
|
2282
|
-
}
|
|
2283
|
-
const afterHeading = content.slice(start);
|
|
2284
|
-
const nextSection = afterHeading.slice(1).search(/^## /m);
|
|
2285
|
-
const sectionEnd = nextSection === -1 ? content.length : start + nextSection + 1;
|
|
2286
|
-
return content.slice(0, start) + `## North Star
|
|
2287
|
-
|
|
2288
|
-
${statement}
|
|
2289
|
-
|
|
2290
|
-
` + content.slice(sectionEnd);
|
|
2291
|
-
}
|
|
2292
|
-
function parseDeferred(content) {
|
|
2293
|
-
const section = extractSection(content, "Deferred / Parking Lot");
|
|
2294
|
-
return section.split("\n").filter((line) => line.match(/^-\s+/)).map((line) => line.replace(/^-\s+/, "").trim());
|
|
2295
|
-
}
|
|
2296
|
-
function compressCycleLogInContent(content, threshold, summary) {
|
|
2297
|
-
const section = extractSectionCompat(content, "Cycle Log", "Sprint Log");
|
|
2298
|
-
const chunks = section.split(/^(?=### (?:Cycle|Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Cycle|Sprint|Session) \d+ —/));
|
|
2299
|
-
const keep = [];
|
|
2300
|
-
let hasOld = false;
|
|
2301
|
-
for (const block of chunks) {
|
|
2302
|
-
const match = block.match(/^### (?:Cycle|Sprint|Session) (\d+) —/);
|
|
2303
|
-
const num = match ? parseInt(match[1], 10) : 0;
|
|
2304
|
-
if (num >= threshold) {
|
|
2305
|
-
keep.push(block);
|
|
2306
|
-
} else {
|
|
2307
|
-
hasOld = true;
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2310
|
-
if (!hasOld) return content;
|
|
2311
|
-
const summaryBlock = `### Cycles 1\u2013${threshold - 1} \u2014 Compressed Summary
|
|
2312
|
-
|
|
2313
|
-
${summary}`;
|
|
2314
|
-
const newEntries = [...keep, summaryBlock].join("\n\n");
|
|
2315
|
-
const newSection = `## Cycle Log
|
|
2316
|
-
|
|
2317
|
-
${newEntries}
|
|
2318
|
-
`;
|
|
2319
|
-
return content.replace(section, newSection);
|
|
2320
|
-
}
|
|
2321
|
-
function parsePlanningLog(content, activeDecisionsContent, cycleLogContent) {
|
|
2322
|
-
return {
|
|
2323
|
-
cycleHealth: parseCycleHealth(content),
|
|
2324
|
-
northStar: parseNorthStar(content),
|
|
2325
|
-
activeDecisions: parseActiveDecisions(activeDecisionsContent ?? content),
|
|
2326
|
-
deferred: parseDeferred(content),
|
|
2327
|
-
cycleLog: cycleLogContent ? parseCycleLog(cycleLogContent) : []
|
|
2328
|
-
};
|
|
2329
|
-
}
|
|
2330
|
-
var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
2331
|
-
var SECTION_HEADERS = [
|
|
2332
|
-
"SCOPE (DO THIS)",
|
|
2333
|
-
"WHY NOT SIMPLER",
|
|
2334
|
-
"SCOPE BOUNDARY (DO NOT DO THIS)",
|
|
2335
|
-
"ACCEPTANCE CRITERIA",
|
|
2336
|
-
"PRE-MORTEM",
|
|
2337
|
-
"SECURITY CONSIDERATIONS",
|
|
2338
|
-
"DEPLOY VERIFICATION",
|
|
2339
|
-
"PRE-BUILD VERIFICATION",
|
|
2340
|
-
"FILES LIKELY TOUCHED",
|
|
2341
|
-
"EFFORT"
|
|
2342
|
-
];
|
|
2343
|
-
function normaliseHeaderLine(line) {
|
|
2344
|
-
return line.trim().replace(/^#{1,6}\s*/, "").replace(/^-\s+/, "").replace(/^\*\*(.*?)\*\*$/, "$1").replace(/:\s*$/, "").trim();
|
|
2345
|
-
}
|
|
2346
|
-
function splitSections(text) {
|
|
2347
|
-
const sections = /* @__PURE__ */ new Map();
|
|
2348
|
-
const lines = text.split("\n");
|
|
2349
|
-
let currentSection = null;
|
|
2350
|
-
const sectionLines = [];
|
|
2351
|
-
const flush = () => {
|
|
2352
|
-
if (currentSection !== null) {
|
|
2353
|
-
sections.set(currentSection, sectionLines.join("\n").trim());
|
|
2354
|
-
sectionLines.length = 0;
|
|
2355
|
-
}
|
|
2356
|
-
};
|
|
2357
|
-
for (const line of lines) {
|
|
2358
|
-
const normalised = normaliseHeaderLine(line);
|
|
2359
|
-
const matched = SECTION_HEADERS.find((h) => normalised === h);
|
|
2360
|
-
if (matched) {
|
|
2361
|
-
flush();
|
|
2362
|
-
currentSection = matched;
|
|
2363
|
-
} else if (currentSection !== null) {
|
|
2364
|
-
sectionLines.push(line);
|
|
2365
|
-
}
|
|
2366
|
-
}
|
|
2367
|
-
flush();
|
|
2368
|
-
return sections;
|
|
2369
|
-
}
|
|
2370
|
-
function parseBulletList(text) {
|
|
2371
|
-
return text.split("\n").map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
|
|
2372
|
-
}
|
|
2373
|
-
function parseBulletsOnly(text) {
|
|
2374
|
-
return text.split("\n").filter((l) => /^\s*-\s/.test(l)).map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
|
|
2375
|
-
}
|
|
2376
|
-
function parseChecklist(text) {
|
|
2377
|
-
return text.split("\n").map((l) => l.replace(/^\s*(?:[-*+]\s*)?(?:\[[ xX]\]\s*)?/, "").trim()).filter((l) => l.length > 0);
|
|
2378
|
-
}
|
|
2379
|
-
function parseBuildHandoff(markdown) {
|
|
2380
|
-
if (typeof markdown !== "string" || !markdown.trim()) return null;
|
|
2381
|
-
if (!markdown.includes("BUILD HANDOFF") && splitSections(markdown).size === 0) {
|
|
2382
|
-
return null;
|
|
2075
|
+
// src/lib/path-identity.ts
|
|
2076
|
+
import fs from "fs";
|
|
2077
|
+
import path from "path";
|
|
2078
|
+
function realpathOrSelf(p) {
|
|
2079
|
+
try {
|
|
2080
|
+
return fs.realpathSync(p);
|
|
2081
|
+
} catch {
|
|
2082
|
+
return p;
|
|
2383
2083
|
}
|
|
2384
|
-
const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
|
|
2385
|
-
const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
|
|
2386
|
-
const cycleMatch = markdown.match(/^Cycle:\s*(\d+)$/m);
|
|
2387
|
-
const whyNowMatch = markdown.match(/^Why now:\s*([\s\S]*?)(?=\n\n|\nSCOPE)/m);
|
|
2388
|
-
const taskId = taskIdMatch?.[1] ?? "";
|
|
2389
|
-
const taskTitle = taskTitleMatch?.[1]?.trim() ?? "";
|
|
2390
|
-
const cycle = cycleMatch ? parseInt(cycleMatch[1], 10) : 0;
|
|
2391
|
-
const whyNow = whyNowMatch?.[1]?.replace(/\s+/g, " ").trim() ?? "";
|
|
2392
|
-
const uuidMatch = markdown.match(/^UUID:\s*(\S+)$/m);
|
|
2393
|
-
const uuid = uuidMatch?.[1];
|
|
2394
|
-
const displayIdMatch = markdown.match(/^Display ID:\s*(\S+)$/m);
|
|
2395
|
-
const displayId = displayIdMatch?.[1];
|
|
2396
|
-
const createdAtMatch = markdown.match(/^Created:\s*(.+)$/m);
|
|
2397
|
-
const createdAt = createdAtMatch?.[1]?.trim();
|
|
2398
|
-
const sections = splitSections(markdown);
|
|
2399
|
-
const effortText = (sections.get("EFFORT") ?? "").trim().toUpperCase();
|
|
2400
|
-
const effort = VALID_EFFORT_SIZES.has(effortText) ? effortText : "M";
|
|
2401
|
-
return {
|
|
2402
|
-
uuid: uuid ?? randomUUID2(),
|
|
2403
|
-
...displayId ? { displayId } : {},
|
|
2404
|
-
...createdAt ? { createdAt } : {},
|
|
2405
|
-
taskId,
|
|
2406
|
-
taskTitle,
|
|
2407
|
-
cycle,
|
|
2408
|
-
whyNow,
|
|
2409
|
-
scope: parseBulletList(sections.get("SCOPE (DO THIS)") ?? ""),
|
|
2410
|
-
scopeBoundary: parseBulletList(sections.get("SCOPE BOUNDARY (DO NOT DO THIS)") ?? ""),
|
|
2411
|
-
acceptanceCriteria: parseChecklist(sections.get("ACCEPTANCE CRITERIA") ?? ""),
|
|
2412
|
-
securityConsiderations: (sections.get("SECURITY CONSIDERATIONS") ?? "").trim(),
|
|
2413
|
-
verificationFiles: parseBulletsOnly(sections.get("PRE-BUILD VERIFICATION") ?? ""),
|
|
2414
|
-
filesLikelyTouched: parseBulletList(sections.get("FILES LIKELY TOUCHED") ?? ""),
|
|
2415
|
-
effort
|
|
2416
|
-
};
|
|
2417
2084
|
}
|
|
2418
|
-
function
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
}
|
|
2426
|
-
return
|
|
2085
|
+
function checkProjectPathIdentity(opts) {
|
|
2086
|
+
const { storedPapiDir, projectName, cwd } = opts;
|
|
2087
|
+
const log = opts.log ?? ((msg) => console.error(msg));
|
|
2088
|
+
const allowMigrate = opts.allowMigrate ?? (process.env.PAPI_ALLOW_PATH_MIGRATE === "1" || process.env.PAPI_ALLOW_PATH_MIGRATE === "true");
|
|
2089
|
+
const realCwd = realpathOrSelf(cwd);
|
|
2090
|
+
const expectedPapiDir = path.join(realCwd, ".papi");
|
|
2091
|
+
if (!storedPapiDir || storedPapiDir.trim() === "" || storedPapiDir.trim() === ".") {
|
|
2092
|
+
log(`[papi] Backfilling project root for '${projectName}' from current cwd: ${realCwd}`);
|
|
2093
|
+
return { action: "backfill", newPapiDir: expectedPapiDir };
|
|
2427
2094
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
} catch {
|
|
2436
|
-
return raw;
|
|
2437
|
-
}
|
|
2095
|
+
const trimmed = storedPapiDir.trim();
|
|
2096
|
+
const endsInPapi = trimmed.endsWith("/.papi") || trimmed.endsWith("\\.papi");
|
|
2097
|
+
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(trimmed);
|
|
2098
|
+
const realStored = realpathOrSelf(trimmed);
|
|
2099
|
+
let storedProjectRoot;
|
|
2100
|
+
if (endsInPapi) {
|
|
2101
|
+
storedProjectRoot = isWindowsPath ? path.win32.dirname(realStored) : path.dirname(realStored);
|
|
2438
2102
|
} else {
|
|
2439
|
-
|
|
2440
|
-
}
|
|
2441
|
-
const lines = [];
|
|
2442
|
-
lines.push(`BUILD HANDOFF \u2014 ${handoff.taskId}`);
|
|
2443
|
-
if (handoff.uuid) lines.push(`UUID: ${handoff.uuid}`);
|
|
2444
|
-
if (handoff.displayId) lines.push(`Display ID: ${handoff.displayId}`);
|
|
2445
|
-
if (handoff.createdAt) lines.push(`Created: ${handoff.createdAt}`);
|
|
2446
|
-
lines.push(`Task: ${handoff.taskTitle}`);
|
|
2447
|
-
lines.push(`Cycle: ${handoff.cycle}`);
|
|
2448
|
-
lines.push(`Why now: ${handoff.whyNow}`);
|
|
2449
|
-
lines.push("");
|
|
2450
|
-
lines.push("SCOPE (DO THIS)");
|
|
2451
|
-
for (const item of ensureArray(handoff.scope)) {
|
|
2452
|
-
lines.push(`- ${item}`);
|
|
2453
|
-
}
|
|
2454
|
-
lines.push("");
|
|
2455
|
-
lines.push("SCOPE BOUNDARY (DO NOT DO THIS)");
|
|
2456
|
-
for (const item of ensureArray(handoff.scopeBoundary)) {
|
|
2457
|
-
lines.push(`- ${item}`);
|
|
2458
|
-
}
|
|
2459
|
-
lines.push("");
|
|
2460
|
-
lines.push("ACCEPTANCE CRITERIA");
|
|
2461
|
-
for (const item of ensureArray(handoff.acceptanceCriteria)) {
|
|
2462
|
-
lines.push(`[ ] ${item}`);
|
|
2103
|
+
storedProjectRoot = realStored;
|
|
2463
2104
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
if (verificationFiles.length > 0) {
|
|
2469
|
-
lines.push("");
|
|
2470
|
-
lines.push("PRE-BUILD VERIFICATION");
|
|
2471
|
-
lines.push("Before implementing, read these files and check if the functionality already exists:");
|
|
2472
|
-
for (const item of verificationFiles) {
|
|
2473
|
-
lines.push(`- ${item}`);
|
|
2105
|
+
if (storedProjectRoot === realCwd) {
|
|
2106
|
+
if (!endsInPapi) {
|
|
2107
|
+
log(`[papi] Normalising legacy papi_dir for '${projectName}': ${trimmed} \u2192 ${expectedPapiDir}`);
|
|
2108
|
+
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
2474
2109
|
}
|
|
2475
|
-
|
|
2476
|
-
}
|
|
2477
|
-
lines.push("");
|
|
2478
|
-
lines.push("FILES LIKELY TOUCHED");
|
|
2479
|
-
for (const item of ensureArray(handoff.filesLikelyTouched)) {
|
|
2480
|
-
lines.push(`- ${item}`);
|
|
2481
|
-
}
|
|
2482
|
-
lines.push("");
|
|
2483
|
-
lines.push("EFFORT");
|
|
2484
|
-
lines.push(handoff.effort ?? "M");
|
|
2485
|
-
return lines.join("\n");
|
|
2486
|
-
}
|
|
2487
|
-
var YAML_MARKER = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
2488
|
-
var YAML_START = "<!-- PAPI-YAML-START -->";
|
|
2489
|
-
var YAML_END = "<!-- PAPI-YAML-END -->";
|
|
2490
|
-
function toCycleTask(raw) {
|
|
2491
|
-
return {
|
|
2492
|
-
uuid: raw.uuid || randomUUID3(),
|
|
2493
|
-
id: raw.id,
|
|
2494
|
-
displayId: raw.id,
|
|
2495
|
-
title: raw.title,
|
|
2496
|
-
status: raw.status,
|
|
2497
|
-
priority: raw.priority,
|
|
2498
|
-
complexity: raw.complexity,
|
|
2499
|
-
module: raw.module,
|
|
2500
|
-
epic: raw.epic,
|
|
2501
|
-
phase: raw.phase,
|
|
2502
|
-
owner: raw.owner,
|
|
2503
|
-
reviewed: raw.reviewed ?? false,
|
|
2504
|
-
cycle: raw.cycle != null ? raw.cycle : void 0,
|
|
2505
|
-
createdCycle: raw.created_sprint != null ? raw.created_sprint : void 0,
|
|
2506
|
-
createdAt: raw.created_at || void 0,
|
|
2507
|
-
why: raw.why || void 0,
|
|
2508
|
-
dependsOn: raw.depends_on || void 0,
|
|
2509
|
-
notes: raw.notes || void 0,
|
|
2510
|
-
stateHistory: raw.state_history?.length ? raw.state_history.map((e) => ({ status: e.status, timestamp: e.timestamp })) : void 0,
|
|
2511
|
-
closureReason: raw.closure_reason || void 0,
|
|
2512
|
-
buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
|
|
2513
|
-
buildReport: raw.build_report || void 0,
|
|
2514
|
-
scopeClass: raw.scope_class === "brief" ? "brief" : "task",
|
|
2515
|
-
assigneeId: raw.assignee_id || void 0,
|
|
2516
|
-
claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
|
|
2517
|
-
reviewerId: raw.reviewer_id || void 0
|
|
2518
|
-
};
|
|
2519
|
-
}
|
|
2520
|
-
function sanitizeDelimiters(value) {
|
|
2521
|
-
return value.replaceAll(YAML_END, "<!-- PAPI-YAML-END (sanitized) -->");
|
|
2522
|
-
}
|
|
2523
|
-
function fromCycleTask(task) {
|
|
2524
|
-
const raw = {
|
|
2525
|
-
uuid: task.uuid,
|
|
2526
|
-
id: task.id,
|
|
2527
|
-
title: task.title,
|
|
2528
|
-
status: task.status,
|
|
2529
|
-
priority: task.priority,
|
|
2530
|
-
complexity: task.complexity,
|
|
2531
|
-
module: task.module,
|
|
2532
|
-
epic: task.epic,
|
|
2533
|
-
phase: task.phase,
|
|
2534
|
-
owner: task.owner,
|
|
2535
|
-
reviewed: task.reviewed,
|
|
2536
|
-
depends_on: task.dependsOn ?? "",
|
|
2537
|
-
notes: task.notes ? sanitizeDelimiters(task.notes) : ""
|
|
2538
|
-
};
|
|
2539
|
-
if (task.cycle != null) raw.cycle = task.cycle;
|
|
2540
|
-
if (task.createdCycle != null) raw.created_sprint = task.createdCycle;
|
|
2541
|
-
if (task.createdAt) raw.created_at = task.createdAt;
|
|
2542
|
-
if (task.why) raw.why = task.why;
|
|
2543
|
-
if (task.stateHistory?.length) {
|
|
2544
|
-
raw.state_history = task.stateHistory.map((e) => ({ status: e.status, timestamp: e.timestamp }));
|
|
2545
|
-
}
|
|
2546
|
-
if (task.closureReason) raw.closure_reason = task.closureReason;
|
|
2547
|
-
if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
|
|
2548
|
-
if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
|
|
2549
|
-
if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
|
|
2550
|
-
if (task.assigneeId) raw.assignee_id = task.assigneeId;
|
|
2551
|
-
if (task.claimSource) raw.claim_source = task.claimSource;
|
|
2552
|
-
if (task.reviewerId) raw.reviewer_id = task.reviewerId;
|
|
2553
|
-
return raw;
|
|
2554
|
-
}
|
|
2555
|
-
function mergeConflictHint(content) {
|
|
2556
|
-
if (/^[<=>]{7}/m.test(content)) {
|
|
2557
|
-
return " The file contains merge conflict markers (<<<<<<, ======, >>>>>>) \u2014 resolve them first.";
|
|
2558
|
-
}
|
|
2559
|
-
return "";
|
|
2560
|
-
}
|
|
2561
|
-
function extractYamlBlock(content) {
|
|
2562
|
-
const markerIdx = content.indexOf(YAML_MARKER);
|
|
2563
|
-
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLE_BOARD.md");
|
|
2564
|
-
const afterMarker = content.slice(markerIdx + YAML_MARKER.length);
|
|
2565
|
-
const startIdx = afterMarker.indexOf(YAML_START);
|
|
2566
|
-
if (startIdx !== -1) {
|
|
2567
|
-
const yamlStart = startIdx + YAML_START.length;
|
|
2568
|
-
const endIdx = afterMarker.indexOf(YAML_END, yamlStart);
|
|
2569
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLE_BOARD.md");
|
|
2570
|
-
return afterMarker.slice(yamlStart, endIdx);
|
|
2110
|
+
return { action: "ok" };
|
|
2571
2111
|
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
}
|
|
2576
|
-
function parseBoard(content) {
|
|
2577
|
-
const yamlText = extractYamlBlock(content);
|
|
2578
|
-
let data;
|
|
2579
|
-
try {
|
|
2580
|
-
data = yaml.load(yamlText);
|
|
2581
|
-
} catch (err) {
|
|
2582
|
-
const yamlErr = err;
|
|
2583
|
-
const lineInfo = yamlErr.mark?.line != null ? ` (near line ${yamlErr.mark.line + 1} of YAML block)` : "";
|
|
2584
|
-
const hint = mergeConflictHint(yamlText);
|
|
2585
|
-
throw new Error(
|
|
2586
|
-
`YAML parse error in CYCLE_BOARD.md${lineInfo}. Check for syntax errors \u2014 unquoted special characters, bad indentation, or missing colons.${hint}`
|
|
2112
|
+
if (allowMigrate) {
|
|
2113
|
+
log(
|
|
2114
|
+
`[papi] PAPI_ALLOW_PATH_MIGRATE set \u2014 updating project '${projectName}' root: ${storedProjectRoot} \u2192 ${realCwd}`
|
|
2587
2115
|
);
|
|
2116
|
+
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
2588
2117
|
}
|
|
2589
|
-
|
|
2590
|
-
}
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
if (htmlStartIdx !== -1) {
|
|
2599
|
-
const absStart = markerIdx + YAML_MARKER.length + htmlStartIdx;
|
|
2600
|
-
const endIdx = afterMarker.indexOf(YAML_END, htmlStartIdx);
|
|
2601
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLE_BOARD.md");
|
|
2602
|
-
const absEnd = markerIdx + YAML_MARKER.length + endIdx + YAML_END.length;
|
|
2603
|
-
return content.slice(0, absStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(absEnd);
|
|
2604
|
-
}
|
|
2605
|
-
const blockMatch = afterMarker.match(/```yaml\n[\s\S]*?```/);
|
|
2606
|
-
if (!blockMatch) throw new Error("YAML block not found in CYCLE_BOARD.md");
|
|
2607
|
-
const blockStart = markerIdx + YAML_MARKER.length + afterMarker.indexOf(blockMatch[0]);
|
|
2608
|
-
const blockEnd = blockStart + blockMatch[0].length;
|
|
2609
|
-
return content.slice(0, blockStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(blockEnd);
|
|
2610
|
-
}
|
|
2611
|
-
function filterTasks(tasks, options) {
|
|
2612
|
-
return tasks.filter((task) => {
|
|
2613
|
-
if (options.status && !options.status.includes(task.status)) return false;
|
|
2614
|
-
if (options.priority && !options.priority.includes(task.priority)) return false;
|
|
2615
|
-
if (options.phase && !task.phase.toLowerCase().includes(options.phase.toLowerCase())) return false;
|
|
2616
|
-
if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
|
|
2617
|
-
if (options.module && task.module !== options.module) return false;
|
|
2618
|
-
if (options.epic && task.epic !== options.epic) return false;
|
|
2619
|
-
if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
|
|
2620
|
-
return true;
|
|
2621
|
-
});
|
|
2622
|
-
}
|
|
2623
|
-
function nextTaskId(tasks) {
|
|
2624
|
-
const maxN = tasks.reduce((max, t) => {
|
|
2625
|
-
const match = t.id.match(/^task-(\d+)$/);
|
|
2626
|
-
return match ? Math.max(max, parseInt(match[1], 10)) : max;
|
|
2627
|
-
}, 0);
|
|
2628
|
-
return `task-${String(maxN + 1).padStart(3, "0")}`;
|
|
2629
|
-
}
|
|
2630
|
-
var VALID_EFFORT_SIZES2 = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
2631
|
-
function parseEffortSize(value) {
|
|
2632
|
-
const normalized = value.trim().toUpperCase();
|
|
2633
|
-
return VALID_EFFORT_SIZES2.has(normalized) ? normalized : void 0;
|
|
2634
|
-
}
|
|
2635
|
-
var HEADER_SENTINEL = "*After each build";
|
|
2636
|
-
function parseField(block, field) {
|
|
2637
|
-
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
2638
|
-
const match = block.match(pattern);
|
|
2639
|
-
return match ? match[1].trim() : "";
|
|
2640
|
-
}
|
|
2641
|
-
function parseEffort(effortLine) {
|
|
2642
|
-
const match = effortLine.match(/^(\S+)\s+vs\s+estimated\s+(\S+)$/i);
|
|
2643
|
-
return match ? { actual: match[1], estimated: match[2] } : { actual: effortLine, estimated: "" };
|
|
2644
|
-
}
|
|
2645
|
-
function parseBuildReports(content) {
|
|
2646
|
-
const chunks = content.split(/^(?=### .+ — .+ — (?:Cycle|Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Cycle|Sprint|Session) \d+/));
|
|
2647
|
-
return chunks.map((block) => {
|
|
2648
|
-
const headingMatch = block.match(/^### (.+?) — (.+?) — (?:Cycle|Sprint|Session) (\d+)/);
|
|
2649
|
-
if (!headingMatch) return null;
|
|
2650
|
-
const effortLine = parseField(block, "Actual Effort");
|
|
2651
|
-
const { actual, estimated } = parseEffort(effortLine);
|
|
2652
|
-
const completedRaw = parseField(block, "Completed");
|
|
2653
|
-
const taskId = parseField(block, "Task ID") || "unknown";
|
|
2654
|
-
const uuidRaw = parseField(block, "UUID");
|
|
2655
|
-
const displayIdRaw = parseField(block, "Display ID");
|
|
2656
|
-
const scopeAccuracyRaw = parseField(block, "Scope Accuracy");
|
|
2657
|
-
const validScopeValues = /* @__PURE__ */ new Set(["accurate", "over-scoped", "under-scoped", "missed-context"]);
|
|
2658
|
-
const scopeAccuracy = scopeAccuracyRaw && validScopeValues.has(scopeAccuracyRaw) ? scopeAccuracyRaw : "accurate";
|
|
2659
|
-
const actualEffort = parseEffortSize(actual) ?? "M";
|
|
2660
|
-
const estimatedEffort = parseEffortSize(estimated) ?? "M";
|
|
2661
|
-
const createdAtRaw = parseField(block, "Created");
|
|
2662
|
-
const commitShaRaw = parseField(block, "Commit SHA");
|
|
2663
|
-
const filesChangedRaw = parseField(block, "Files Changed");
|
|
2664
|
-
const report = {
|
|
2665
|
-
uuid: uuidRaw ?? randomUUID4(),
|
|
2666
|
-
...displayIdRaw ? { displayId: displayIdRaw } : {},
|
|
2667
|
-
...createdAtRaw ? { createdAt: createdAtRaw } : {},
|
|
2668
|
-
taskId,
|
|
2669
|
-
taskName: headingMatch[1].trim(),
|
|
2670
|
-
date: headingMatch[2].trim(),
|
|
2671
|
-
cycle: parseInt(headingMatch[3], 10),
|
|
2672
|
-
completed: completedRaw.startsWith("Yes") ? "Yes" : completedRaw.startsWith("No") ? "No" : "Partial",
|
|
2673
|
-
actualEffort,
|
|
2674
|
-
estimatedEffort,
|
|
2675
|
-
surprises: parseField(block, "Surprises"),
|
|
2676
|
-
discoveredIssues: parseField(block, "Discovered Issues"),
|
|
2677
|
-
architectureNotes: parseField(block, "Architecture Notes"),
|
|
2678
|
-
scopeAccuracy
|
|
2679
|
-
};
|
|
2680
|
-
if (commitShaRaw) report.commitSha = commitShaRaw;
|
|
2681
|
-
if (filesChangedRaw) report.filesChanged = filesChangedRaw.split(",").map((f) => f.trim()).filter(Boolean);
|
|
2682
|
-
return report;
|
|
2683
|
-
}).filter((r) => r !== null);
|
|
2684
|
-
}
|
|
2685
|
-
function serializeBuildReport(report) {
|
|
2686
|
-
const lines = [
|
|
2687
|
-
`### ${report.taskName} \u2014 ${report.date} \u2014 Cycle ${report.cycle}`
|
|
2688
|
-
];
|
|
2689
|
-
if (report.uuid) lines.push(`- **UUID:** ${report.uuid}`);
|
|
2690
|
-
if (report.displayId) lines.push(`- **Display ID:** ${report.displayId}`);
|
|
2691
|
-
if (report.createdAt) lines.push(`- **Created:** ${report.createdAt}`);
|
|
2692
|
-
lines.push(
|
|
2693
|
-
`- **Task ID:** ${report.taskId}`,
|
|
2694
|
-
`- **Completed:** ${report.completed}`,
|
|
2695
|
-
`- **Actual Effort:** ${report.actualEffort} vs estimated ${report.estimatedEffort}`,
|
|
2696
|
-
`- **Surprises:** ${report.surprises || "None"}`,
|
|
2697
|
-
`- **Discovered Issues:** ${report.discoveredIssues || "None"}`,
|
|
2698
|
-
`- **Architecture Notes:** ${report.architectureNotes || "None"}`,
|
|
2699
|
-
`- **Scope Accuracy:** ${report.scopeAccuracy}`
|
|
2118
|
+
throw new Error(
|
|
2119
|
+
`PAPI is configured for project '${projectName}' which was set up in ${storedProjectRoot},
|
|
2120
|
+
but you're running in ${realCwd}.
|
|
2121
|
+
|
|
2122
|
+
To fix:
|
|
2123
|
+
- cd to the right project directory, OR
|
|
2124
|
+
- run \`setup\` to attach this directory to a project, OR
|
|
2125
|
+
- update PAPI_PROJECT_ID in .mcp.json if you intentionally moved the project, OR
|
|
2126
|
+
- set PAPI_ALLOW_PATH_MIGRATE=1 to update the stored path on next boot.`
|
|
2700
2127
|
);
|
|
2701
|
-
if (report.commitSha) lines.push(`- **Commit SHA:** ${report.commitSha}`);
|
|
2702
|
-
if (report.filesChanged && report.filesChanged.length > 0) {
|
|
2703
|
-
lines.push(`- **Files Changed:** ${report.filesChanged.join(", ")}`);
|
|
2704
|
-
}
|
|
2705
|
-
return lines.join("\n");
|
|
2706
|
-
}
|
|
2707
|
-
function formatCompressedSummary(reports, cycleRange, aiSummary) {
|
|
2708
|
-
const dates = reports.map((r) => r.date).filter(Boolean);
|
|
2709
|
-
const dateRange = dates.length > 0 ? `${dates[dates.length - 1]} \u2013 ${dates[0]}` : "unknown";
|
|
2710
|
-
const completed = reports.filter((r) => r.completed === "Yes");
|
|
2711
|
-
const partial = reports.filter((r) => r.completed === "Partial");
|
|
2712
|
-
const failed = reports.filter((r) => r.completed === "No");
|
|
2713
|
-
const formatTaskList = (list) => list.map((r) => r.taskId !== "unknown" ? `${r.taskId} (${r.taskName})` : r.taskName).join(", ");
|
|
2714
|
-
const lines = [`### ${cycleRange} \u2014 Compressed Summary`];
|
|
2715
|
-
lines.push(`**Date range:** ${dateRange}`);
|
|
2716
|
-
lines.push(`**Reports:** ${reports.length}`);
|
|
2717
|
-
if (completed.length > 0) {
|
|
2718
|
-
lines.push(`**Completed:** ${formatTaskList(completed)}`);
|
|
2719
|
-
}
|
|
2720
|
-
if (partial.length > 0) {
|
|
2721
|
-
lines.push(`**Partial:** ${formatTaskList(partial)}`);
|
|
2722
|
-
}
|
|
2723
|
-
if (failed.length > 0) {
|
|
2724
|
-
lines.push(`**Failed:** ${formatTaskList(failed)}`);
|
|
2725
|
-
}
|
|
2726
|
-
const surprises = reports.map((r) => r.surprises).filter((s) => s && s !== "None" && s !== "None.");
|
|
2727
|
-
if (surprises.length > 0) {
|
|
2728
|
-
lines.push(`**Surprises:** ${surprises.join("; ")}`);
|
|
2729
|
-
}
|
|
2730
|
-
const issues = reports.map((r) => r.discoveredIssues).filter((s) => s && s !== "None" && s !== "None.");
|
|
2731
|
-
if (issues.length > 0) {
|
|
2732
|
-
lines.push(`**Discovered issues:** ${issues.join("; ")}`);
|
|
2733
|
-
}
|
|
2734
|
-
if (aiSummary) {
|
|
2735
|
-
lines.push(`**Key outcomes:** ${aiSummary}`);
|
|
2736
|
-
}
|
|
2737
|
-
return lines.join("\n");
|
|
2738
|
-
}
|
|
2739
|
-
function compressBuildReportsInContent(content, threshold, summary) {
|
|
2740
|
-
const chunks = content.split(/^(?=### .+ — .+ — (?:Cycle|Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Cycle|Sprint|Session) \d+/));
|
|
2741
|
-
const keep = [];
|
|
2742
|
-
const oldChunks = [];
|
|
2743
|
-
for (const block of chunks) {
|
|
2744
|
-
const match = block.match(/— (?:Cycle|Sprint|Session) (\d+)/);
|
|
2745
|
-
const num = match ? parseInt(match[1], 10) : 0;
|
|
2746
|
-
if (num >= threshold) {
|
|
2747
|
-
keep.push(block);
|
|
2748
|
-
} else {
|
|
2749
|
-
oldChunks.push(block);
|
|
2750
|
-
}
|
|
2751
|
-
}
|
|
2752
|
-
if (oldChunks.length === 0) return content;
|
|
2753
|
-
const oldReports = parseBuildReports(oldChunks.join("\n\n---\n\n"));
|
|
2754
|
-
const cycleRange = `Cycles 1\u2013${threshold - 1}`;
|
|
2755
|
-
const summaryBlock = formatCompressedSummary(oldReports, cycleRange, summary);
|
|
2756
|
-
const firstReportIdx = content.search(/^### /m);
|
|
2757
|
-
const header = firstReportIdx === -1 ? content : content.slice(0, firstReportIdx);
|
|
2758
|
-
const entries = [...keep, summaryBlock].join("\n\n---\n\n");
|
|
2759
|
-
return header + entries + "\n";
|
|
2760
2128
|
}
|
|
2761
|
-
function mergeTextField(existing, incoming) {
|
|
2762
|
-
if (!existing || existing === "None" || existing === "None.") return incoming;
|
|
2763
|
-
if (!incoming || incoming === "None" || incoming === "None.") return existing;
|
|
2764
|
-
if (existing === incoming) return existing;
|
|
2765
|
-
return `${existing} | ${incoming}`;
|
|
2766
|
-
}
|
|
2767
|
-
function mergeBuildReports(existing, incoming) {
|
|
2768
|
-
const uuid = incoming.uuid ?? existing.uuid;
|
|
2769
|
-
const displayId = incoming.displayId ?? existing.displayId;
|
|
2770
|
-
const merged = {
|
|
2771
|
-
uuid,
|
|
2772
|
-
...displayId ? { displayId } : {},
|
|
2773
|
-
taskId: incoming.taskId,
|
|
2774
|
-
taskName: incoming.taskName,
|
|
2775
|
-
date: incoming.date,
|
|
2776
|
-
cycle: incoming.cycle,
|
|
2777
|
-
completed: incoming.completed,
|
|
2778
|
-
actualEffort: incoming.actualEffort,
|
|
2779
|
-
estimatedEffort: incoming.estimatedEffort,
|
|
2780
|
-
surprises: mergeTextField(existing.surprises, incoming.surprises),
|
|
2781
|
-
discoveredIssues: mergeTextField(existing.discoveredIssues, incoming.discoveredIssues),
|
|
2782
|
-
architectureNotes: incoming.architectureNotes,
|
|
2783
|
-
scopeAccuracy: incoming.scopeAccuracy
|
|
2784
|
-
};
|
|
2785
|
-
if (incoming.createdAt ?? existing.createdAt) merged.createdAt = incoming.createdAt ?? existing.createdAt;
|
|
2786
|
-
if (incoming.commitSha ?? existing.commitSha) merged.commitSha = incoming.commitSha ?? existing.commitSha;
|
|
2787
|
-
if (incoming.filesChanged ?? existing.filesChanged) merged.filesChanged = incoming.filesChanged ?? existing.filesChanged;
|
|
2788
|
-
return merged;
|
|
2789
|
-
}
|
|
2790
|
-
function replaceBuildReport(existing, replacement, content) {
|
|
2791
|
-
const oldSerialized = serializeBuildReport(existing);
|
|
2792
|
-
const newSerialized = serializeBuildReport(replacement);
|
|
2793
|
-
const idx = content.indexOf(oldSerialized);
|
|
2794
|
-
if (idx !== -1) {
|
|
2795
|
-
return content.slice(0, idx) + newSerialized + content.slice(idx + oldSerialized.length);
|
|
2796
|
-
}
|
|
2797
|
-
const headingPattern = `### ${existing.taskName} \u2014 ${existing.date} \u2014 Cycle ${existing.cycle}`;
|
|
2798
|
-
let headingIdx = content.indexOf(headingPattern);
|
|
2799
|
-
if (headingIdx === -1) {
|
|
2800
|
-
const legacyPattern = `### ${existing.taskName} \u2014 ${existing.date} \u2014 Sprint ${existing.cycle}`;
|
|
2801
|
-
headingIdx = content.indexOf(legacyPattern);
|
|
2802
|
-
}
|
|
2803
|
-
if (headingIdx === -1) throw new Error(`Could not find existing build report for ${existing.taskId}`);
|
|
2804
|
-
const afterHeading = content.slice(headingIdx);
|
|
2805
|
-
const nextSeparator = afterHeading.indexOf("\n\n---\n");
|
|
2806
|
-
const blockEnd = nextSeparator === -1 ? content.length : headingIdx + nextSeparator;
|
|
2807
|
-
return content.slice(0, headingIdx) + newSerialized + content.slice(blockEnd);
|
|
2808
|
-
}
|
|
2809
|
-
function prependBuildReport(report, content) {
|
|
2810
|
-
if (report.taskId !== "unknown") {
|
|
2811
|
-
const existingReports = parseBuildReports(content);
|
|
2812
|
-
const existingReport = existingReports.find((r) => r.taskId === report.taskId);
|
|
2813
|
-
if (existingReport) {
|
|
2814
|
-
const merged = mergeBuildReports(existingReport, report);
|
|
2815
|
-
return replaceBuildReport(existingReport, merged, content);
|
|
2816
|
-
}
|
|
2817
|
-
}
|
|
2818
|
-
const sentinelIdx = content.indexOf(HEADER_SENTINEL);
|
|
2819
|
-
if (sentinelIdx === -1) throw new Error("Build Reports header sentinel not found");
|
|
2820
|
-
const afterSentinel = content.slice(sentinelIdx);
|
|
2821
|
-
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
2822
|
-
if (separatorIdx === -1) throw new Error("Separator after Build Reports header not found");
|
|
2823
|
-
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
2824
|
-
const serialized = serializeBuildReport(report);
|
|
2825
|
-
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
2826
|
-
}
|
|
2827
|
-
var TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Cycle | Context |";
|
|
2828
|
-
var TABLE_SEPARATOR = "|-----------|------|---------------|--------------|---------------|----------|-------|--------|---------|";
|
|
2829
|
-
var PREV_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Cycle |";
|
|
2830
|
-
var LEGACY_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model |";
|
|
2831
|
-
var SECTION_HEADING = "## Tool Call Metrics";
|
|
2832
|
-
var FILE_TEMPLATE = `# PAPI Metrics
|
|
2833
2129
|
|
|
2834
|
-
|
|
2130
|
+
// src/lib/project-resolution.ts
|
|
2131
|
+
function assertWorkspaceMatch(input) {
|
|
2132
|
+
if (!input.workspacePath) return null;
|
|
2133
|
+
return checkProjectPathIdentity({
|
|
2134
|
+
storedPapiDir: input.storedPapiDir,
|
|
2135
|
+
projectName: input.projectName,
|
|
2136
|
+
cwd: input.workspacePath,
|
|
2137
|
+
allowMigrate: input.allowMigrate,
|
|
2138
|
+
log: input.log
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2835
2141
|
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
let inSection = false;
|
|
2843
|
-
let inTable = false;
|
|
2844
|
-
for (const line of lines) {
|
|
2845
|
-
if (line.startsWith(SECTION_HEADING)) {
|
|
2846
|
-
inSection = true;
|
|
2847
|
-
continue;
|
|
2848
|
-
}
|
|
2849
|
-
if (inSection && line.startsWith(COST_SECTION_HEADING)) {
|
|
2850
|
-
break;
|
|
2851
|
-
}
|
|
2852
|
-
if (!inSection) continue;
|
|
2853
|
-
if (line.startsWith(TABLE_SEPARATOR) || line.startsWith("|---")) {
|
|
2854
|
-
inTable = true;
|
|
2855
|
-
continue;
|
|
2856
|
-
}
|
|
2857
|
-
if (!inTable) continue;
|
|
2858
|
-
if (!line.startsWith("|")) {
|
|
2859
|
-
inTable = false;
|
|
2860
|
-
continue;
|
|
2861
|
-
}
|
|
2862
|
-
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2863
|
-
if (cells.length < 7) continue;
|
|
2864
|
-
const inputTokens = cells[3] !== "-" ? parseInt(cells[3].replace(/,/g, ""), 10) : void 0;
|
|
2865
|
-
const outputTokens = cells[4] !== "-" ? parseInt(cells[4].replace(/,/g, ""), 10) : void 0;
|
|
2866
|
-
const cost = cells[5] !== "-" ? parseFloat(cells[5]) : void 0;
|
|
2867
|
-
const model = cells[6] !== "-" ? cells[6] : void 0;
|
|
2868
|
-
const cycleRaw = cells.length >= 8 && cells[7] !== "-" ? parseInt(cells[7], 10) : void 0;
|
|
2869
|
-
const cycleNumber = cycleRaw !== void 0 && !isNaN(cycleRaw) ? cycleRaw : void 0;
|
|
2870
|
-
const contextRaw = cells.length >= 9 && cells[8] !== "-" ? parseInt(cells[8].replace(/,/g, ""), 10) : void 0;
|
|
2871
|
-
const contextBytes = contextRaw !== void 0 && !isNaN(contextRaw) ? contextRaw : void 0;
|
|
2872
|
-
const utilisationRaw = cells.length >= 10 && cells[9] !== "-" ? parseFloat(cells[9]) : void 0;
|
|
2873
|
-
const contextUtilisation = utilisationRaw !== void 0 && !isNaN(utilisationRaw) ? utilisationRaw : void 0;
|
|
2874
|
-
metrics.push({
|
|
2875
|
-
timestamp: cells[0],
|
|
2876
|
-
tool: cells[1],
|
|
2877
|
-
durationMs: parseInt(cells[2].replace(/,/g, ""), 10),
|
|
2878
|
-
...inputTokens !== void 0 && !isNaN(inputTokens) ? { inputTokens } : {},
|
|
2879
|
-
...outputTokens !== void 0 && !isNaN(outputTokens) ? { outputTokens } : {},
|
|
2880
|
-
...cost !== void 0 && !isNaN(cost) ? { estimatedCostUsd: cost } : {},
|
|
2881
|
-
...model ? { model } : {},
|
|
2882
|
-
...cycleNumber !== void 0 ? { cycleNumber } : {},
|
|
2883
|
-
...contextBytes !== void 0 ? { contextBytes } : {},
|
|
2884
|
-
...contextUtilisation !== void 0 ? { contextUtilisation } : {}
|
|
2885
|
-
});
|
|
2142
|
+
// src/adapter-factory.ts
|
|
2143
|
+
function detectUserId() {
|
|
2144
|
+
try {
|
|
2145
|
+
const email = execSync("git config user.email", { encoding: "utf8", timeout: 5e3 }).trim();
|
|
2146
|
+
if (email) return email;
|
|
2147
|
+
} catch {
|
|
2886
2148
|
}
|
|
2887
|
-
|
|
2888
|
-
}
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
}
|
|
2892
|
-
function serializeToolMetric(metric) {
|
|
2893
|
-
const inputTokens = metric.inputTokens !== void 0 ? formatNumber(metric.inputTokens) : "-";
|
|
2894
|
-
const outputTokens = metric.outputTokens !== void 0 ? formatNumber(metric.outputTokens) : "-";
|
|
2895
|
-
const cost = metric.estimatedCostUsd !== void 0 ? metric.estimatedCostUsd.toFixed(4) : "-";
|
|
2896
|
-
const model = metric.model ?? "-";
|
|
2897
|
-
const cycle = metric.cycleNumber !== void 0 ? String(metric.cycleNumber) : "-";
|
|
2898
|
-
const context = metric.contextBytes !== void 0 ? formatNumber(metric.contextBytes) : "-";
|
|
2899
|
-
const utilisation = metric.contextUtilisation !== void 0 ? metric.contextUtilisation.toFixed(2) : "-";
|
|
2900
|
-
return `| ${metric.timestamp} | ${metric.tool} | ${formatNumber(metric.durationMs)} | ${inputTokens} | ${outputTokens} | ${cost} | ${model} | ${cycle} | ${context} | ${utilisation} |`;
|
|
2901
|
-
}
|
|
2902
|
-
function appendToolMetricToContent(metric, content) {
|
|
2903
|
-
if (!content.trim()) {
|
|
2904
|
-
return FILE_TEMPLATE + serializeToolMetric(metric) + "\n";
|
|
2149
|
+
try {
|
|
2150
|
+
const ghUser = execSync("gh api user --jq .email", { encoding: "utf8", timeout: 1e4 }).trim();
|
|
2151
|
+
if (ghUser && ghUser !== "null") return ghUser;
|
|
2152
|
+
} catch {
|
|
2905
2153
|
}
|
|
2906
|
-
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2154
|
+
return void 0;
|
|
2155
|
+
}
|
|
2156
|
+
var HOSTED_SUPABASE_URL2 = process.env["PAPI_HOSTED_SUPABASE_URL"] ?? "https://guewgygcpcmrcoppihzx.supabase.co";
|
|
2157
|
+
var HOSTED_PROXY_ENDPOINT = `${HOSTED_SUPABASE_URL2}/functions/v1/data-proxy`;
|
|
2158
|
+
var PLACEHOLDER_PATTERNS = [
|
|
2159
|
+
"<YOUR_DATABASE_URL>",
|
|
2160
|
+
"your-database-url",
|
|
2161
|
+
"your_database_url",
|
|
2162
|
+
"placeholder",
|
|
2163
|
+
"example.com",
|
|
2164
|
+
"localhost:5432/dbname",
|
|
2165
|
+
"user:password@host"
|
|
2166
|
+
];
|
|
2167
|
+
function validateDatabaseUrl(connectionString) {
|
|
2168
|
+
const lower = connectionString.toLowerCase().trim();
|
|
2169
|
+
if (PLACEHOLDER_PATTERNS.some((p) => lower.includes(p.toLowerCase()))) {
|
|
2170
|
+
throw new Error(
|
|
2171
|
+
"DATABASE_URL contains a placeholder value and is not configured.\nReplace it with your actual Supabase connection string in .mcp.json.\nIf you don't have one yet, contact the PAPI admin for access."
|
|
2911
2172
|
);
|
|
2912
2173
|
}
|
|
2913
|
-
if (
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
);
|
|
2919
|
-
}
|
|
2920
|
-
if (!content.includes(SECTION_HEADING)) {
|
|
2921
|
-
return content.trimEnd() + "\n\n" + SECTION_HEADING + "\n\n" + TABLE_HEADER + "\n" + TABLE_SEPARATOR + "\n" + serializeToolMetric(metric) + "\n";
|
|
2922
|
-
}
|
|
2923
|
-
const costIdx = content.indexOf(COST_SECTION_HEADING);
|
|
2924
|
-
if (costIdx === -1) {
|
|
2925
|
-
return content.trimEnd() + "\n" + serializeToolMetric(metric) + "\n";
|
|
2926
|
-
}
|
|
2927
|
-
const before = content.slice(0, costIdx).trimEnd();
|
|
2928
|
-
const after = content.slice(costIdx);
|
|
2929
|
-
return before + "\n" + serializeToolMetric(metric) + "\n\n" + after;
|
|
2930
|
-
}
|
|
2931
|
-
function aggregateCostSummary(metrics, cycleNumber) {
|
|
2932
|
-
const filtered = cycleNumber !== void 0 ? metrics.filter((m) => m.cycleNumber === cycleNumber) : metrics;
|
|
2933
|
-
let totalCostUsd = 0;
|
|
2934
|
-
let totalInputTokens = 0;
|
|
2935
|
-
let totalOutputTokens = 0;
|
|
2936
|
-
const byCommand = /* @__PURE__ */ new Map();
|
|
2937
|
-
for (const m of filtered) {
|
|
2938
|
-
totalCostUsd += m.estimatedCostUsd ?? 0;
|
|
2939
|
-
totalInputTokens += m.inputTokens ?? 0;
|
|
2940
|
-
totalOutputTokens += m.outputTokens ?? 0;
|
|
2941
|
-
const entry = byCommand.get(m.tool) ?? { cost: 0, calls: 0 };
|
|
2942
|
-
entry.cost += m.estimatedCostUsd ?? 0;
|
|
2943
|
-
entry.calls += 1;
|
|
2944
|
-
byCommand.set(m.tool, entry);
|
|
2945
|
-
}
|
|
2946
|
-
const costByCommand = Array.from(byCommand.entries()).map(([command, { cost, calls }]) => ({
|
|
2947
|
-
command,
|
|
2948
|
-
totalCostUsd: cost,
|
|
2949
|
-
calls,
|
|
2950
|
-
avgCostUsd: calls > 0 ? cost / calls : 0
|
|
2951
|
-
})).sort((a, b) => b.totalCostUsd - a.totalCostUsd);
|
|
2952
|
-
const mostExpensiveCommand = costByCommand.length > 0 ? costByCommand[0].command : null;
|
|
2953
|
-
return {
|
|
2954
|
-
totalCostUsd,
|
|
2955
|
-
totalInputTokens,
|
|
2956
|
-
totalOutputTokens,
|
|
2957
|
-
totalCalls: filtered.length,
|
|
2958
|
-
costByCommand,
|
|
2959
|
-
mostExpensiveCommand,
|
|
2960
|
-
avgCostPerCall: filtered.length > 0 ? totalCostUsd / filtered.length : 0
|
|
2961
|
-
};
|
|
2962
|
-
}
|
|
2963
|
-
var COST_SECTION_HEADING = "## Cost Summary";
|
|
2964
|
-
var COST_TABLE_SEPARATOR = "|--------|------|----------------|--------------|---------------|-------|";
|
|
2965
|
-
function parseCostSnapshots(content) {
|
|
2966
|
-
const lines = content.split("\n");
|
|
2967
|
-
const snapshots = [];
|
|
2968
|
-
let inTable = false;
|
|
2969
|
-
for (const line of lines) {
|
|
2970
|
-
if (line.startsWith(COST_TABLE_SEPARATOR)) {
|
|
2971
|
-
inTable = true;
|
|
2972
|
-
continue;
|
|
2973
|
-
}
|
|
2974
|
-
if (!inTable) continue;
|
|
2975
|
-
if (!line.startsWith("|")) {
|
|
2976
|
-
inTable = false;
|
|
2977
|
-
continue;
|
|
2978
|
-
}
|
|
2979
|
-
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2980
|
-
if (cells.length < 6) continue;
|
|
2981
|
-
snapshots.push({
|
|
2982
|
-
cycle: parseInt(cells[0], 10),
|
|
2983
|
-
date: cells[1],
|
|
2984
|
-
totalCostUsd: parseFloat(cells[2]),
|
|
2985
|
-
totalInputTokens: parseInt(cells[3].replace(/,/g, ""), 10),
|
|
2986
|
-
totalOutputTokens: parseInt(cells[4].replace(/,/g, ""), 10),
|
|
2987
|
-
totalCalls: parseInt(cells[5].replace(/,/g, ""), 10)
|
|
2988
|
-
});
|
|
2989
|
-
}
|
|
2990
|
-
return snapshots;
|
|
2991
|
-
}
|
|
2992
|
-
var FILE_HEADING = "# Cycle Methodology Metrics";
|
|
2993
|
-
var ACCURACY_HEADER = "| Cycle | Reports | Match Rate | MAE | Bias |";
|
|
2994
|
-
var ACCURACY_SEPARATOR = "|--------|---------|------------|-----|------|";
|
|
2995
|
-
var VELOCITY_HEADER = "| Cycle | Completed | Partial | Failed | Effort Points |";
|
|
2996
|
-
var VELOCITY_SEPARATOR = "|--------|-----------|---------|--------|---------------|";
|
|
2997
|
-
var EFFORT_SCALE = {
|
|
2998
|
-
XS: 1,
|
|
2999
|
-
S: 2,
|
|
3000
|
-
M: 3,
|
|
3001
|
-
L: 4,
|
|
3002
|
-
XL: 5
|
|
3003
|
-
};
|
|
3004
|
-
function effortOrdinal(effort) {
|
|
3005
|
-
if (typeof effort !== "string") return void 0;
|
|
3006
|
-
const normalized = effort.trim().toUpperCase();
|
|
3007
|
-
return EFFORT_SCALE[normalized];
|
|
3008
|
-
}
|
|
3009
|
-
function isUnparsedEffort(effort) {
|
|
3010
|
-
if (typeof effort !== "string" || effort.trim().length === 0) return false;
|
|
3011
|
-
return effortOrdinal(effort) === void 0;
|
|
3012
|
-
}
|
|
3013
|
-
function calculateCycleMetrics(reports, currentCycle, window = 5) {
|
|
3014
|
-
const recentReports = reports.filter(
|
|
3015
|
-
(r) => r.cycle > currentCycle - window && r.cycle <= currentCycle
|
|
3016
|
-
);
|
|
3017
|
-
const unparsedEffortCount = recentReports.filter(
|
|
3018
|
-
(r) => isUnparsedEffort(r.actualEffort) || isUnparsedEffort(r.estimatedEffort)
|
|
3019
|
-
).length;
|
|
3020
|
-
const perCycle = /* @__PURE__ */ new Map();
|
|
3021
|
-
for (const r of recentReports) {
|
|
3022
|
-
const group = perCycle.get(r.cycle) ?? [];
|
|
3023
|
-
group.push(r);
|
|
3024
|
-
perCycle.set(r.cycle, group);
|
|
3025
|
-
}
|
|
3026
|
-
const accuracy = [];
|
|
3027
|
-
const velocity = [];
|
|
3028
|
-
const sortedCycles = [...perCycle.keys()].sort((a, b) => a - b);
|
|
3029
|
-
for (const cycle of sortedCycles) {
|
|
3030
|
-
const reps = perCycle.get(cycle);
|
|
3031
|
-
const deltas = [];
|
|
3032
|
-
for (const r of reps) {
|
|
3033
|
-
const actual = effortOrdinal(r.actualEffort);
|
|
3034
|
-
const estimated = effortOrdinal(r.estimatedEffort);
|
|
3035
|
-
if (actual !== void 0 && estimated !== void 0) {
|
|
3036
|
-
deltas.push(actual - estimated);
|
|
3037
|
-
}
|
|
3038
|
-
}
|
|
3039
|
-
if (deltas.length > 0) {
|
|
3040
|
-
accuracy.push({
|
|
3041
|
-
cycle,
|
|
3042
|
-
reports: deltas.length,
|
|
3043
|
-
matchRate: Math.round(deltas.filter((d) => d === 0).length / deltas.length * 100),
|
|
3044
|
-
mae: Math.round(deltas.reduce((s, d) => s + Math.abs(d), 0) / deltas.length * 10) / 10,
|
|
3045
|
-
bias: Math.round(deltas.reduce((s, d) => s + d, 0) / deltas.length * 10) / 10
|
|
3046
|
-
});
|
|
3047
|
-
}
|
|
3048
|
-
velocity.push({
|
|
3049
|
-
cycle,
|
|
3050
|
-
completed: reps.filter((r) => r.completed === "Yes").length,
|
|
3051
|
-
partial: reps.filter((r) => r.completed === "Partial").length,
|
|
3052
|
-
failed: reps.filter((r) => r.completed === "No").length,
|
|
3053
|
-
effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
|
|
3054
|
-
});
|
|
3055
|
-
}
|
|
3056
|
-
return { accuracy, velocity, unparsedEffortCount };
|
|
3057
|
-
}
|
|
3058
|
-
function serializeAccuracyRow(a) {
|
|
3059
|
-
return `| ${a.cycle} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
|
|
3060
|
-
}
|
|
3061
|
-
function serializeVelocityRow(v) {
|
|
3062
|
-
return `| ${v.cycle} | ${v.completed} | ${v.partial} | ${v.failed} | ${v.effortPoints} |`;
|
|
3063
|
-
}
|
|
3064
|
-
function serializeSnapshot(snapshot) {
|
|
3065
|
-
const lines = [];
|
|
3066
|
-
lines.push(`## Cycle ${snapshot.cycle} Snapshot \u2014 ${snapshot.date}`);
|
|
3067
|
-
lines.push("");
|
|
3068
|
-
lines.push("### Estimation Accuracy (last 5 cycles)");
|
|
3069
|
-
lines.push(ACCURACY_HEADER);
|
|
3070
|
-
lines.push(ACCURACY_SEPARATOR);
|
|
3071
|
-
for (const a of snapshot.accuracy) {
|
|
3072
|
-
lines.push(serializeAccuracyRow(a));
|
|
3073
|
-
}
|
|
3074
|
-
lines.push("");
|
|
3075
|
-
lines.push("### Cycle Velocity");
|
|
3076
|
-
lines.push(VELOCITY_HEADER);
|
|
3077
|
-
lines.push(VELOCITY_SEPARATOR);
|
|
3078
|
-
for (const v of snapshot.velocity) {
|
|
3079
|
-
lines.push(serializeVelocityRow(v));
|
|
3080
|
-
}
|
|
3081
|
-
return lines.join("\n");
|
|
3082
|
-
}
|
|
3083
|
-
function appendSnapshotToContent(snapshot, content) {
|
|
3084
|
-
const block = serializeSnapshot(snapshot);
|
|
3085
|
-
if (!content.trim()) {
|
|
3086
|
-
return FILE_HEADING + "\n\n" + block + "\n";
|
|
3087
|
-
}
|
|
3088
|
-
const marker = `## Cycle ${snapshot.cycle} Snapshot`;
|
|
3089
|
-
const legacyMarker = `## Sprint ${snapshot.cycle} Snapshot`;
|
|
3090
|
-
let markerIdx = content.indexOf(marker);
|
|
3091
|
-
if (markerIdx === -1) markerIdx = content.indexOf(legacyMarker);
|
|
3092
|
-
if (markerIdx !== -1) {
|
|
3093
|
-
let nextSnapshotIdx = content.indexOf("\n## Cycle ", markerIdx + marker.length);
|
|
3094
|
-
if (nextSnapshotIdx === -1) nextSnapshotIdx = content.indexOf("\n## Sprint ", markerIdx + marker.length);
|
|
3095
|
-
const before = content.slice(0, markerIdx).trimEnd();
|
|
3096
|
-
const after = nextSnapshotIdx !== -1 ? content.slice(nextSnapshotIdx) : "";
|
|
3097
|
-
return before + "\n\n" + block + (after ? after : "\n");
|
|
3098
|
-
}
|
|
3099
|
-
return content.trimEnd() + "\n\n" + block + "\n";
|
|
3100
|
-
}
|
|
3101
|
-
function parseSnapshots(content) {
|
|
3102
|
-
if (!content.trim()) return [];
|
|
3103
|
-
const snapshots = [];
|
|
3104
|
-
const headerRegex = /^## (?:Cycle|Sprint) (\d+) Snapshot — (\S+)/gm;
|
|
3105
|
-
let match;
|
|
3106
|
-
const headers = [];
|
|
3107
|
-
while ((match = headerRegex.exec(content)) !== null) {
|
|
3108
|
-
headers.push({
|
|
3109
|
-
cycle: parseInt(match[1], 10),
|
|
3110
|
-
date: match[2],
|
|
3111
|
-
index: match.index
|
|
3112
|
-
});
|
|
3113
|
-
}
|
|
3114
|
-
for (let i = 0; i < headers.length; i++) {
|
|
3115
|
-
const start = headers[i].index;
|
|
3116
|
-
const end = i + 1 < headers.length ? headers[i + 1].index : content.length;
|
|
3117
|
-
const block = content.slice(start, end);
|
|
3118
|
-
const accuracy = parseAccuracyTable(block);
|
|
3119
|
-
const velocity = parseVelocityTable(block);
|
|
3120
|
-
snapshots.push({
|
|
3121
|
-
cycle: headers[i].cycle,
|
|
3122
|
-
date: headers[i].date,
|
|
3123
|
-
accuracy,
|
|
3124
|
-
velocity
|
|
3125
|
-
});
|
|
3126
|
-
}
|
|
3127
|
-
return snapshots;
|
|
3128
|
-
}
|
|
3129
|
-
function parseAccuracyTable(block) {
|
|
3130
|
-
const rows = [];
|
|
3131
|
-
const lines = block.split("\n");
|
|
3132
|
-
let inTable = false;
|
|
3133
|
-
for (const line of lines) {
|
|
3134
|
-
if (line.startsWith(ACCURACY_SEPARATOR)) {
|
|
3135
|
-
inTable = true;
|
|
3136
|
-
continue;
|
|
3137
|
-
}
|
|
3138
|
-
if (!inTable) continue;
|
|
3139
|
-
if (!line.startsWith("|")) {
|
|
3140
|
-
inTable = false;
|
|
3141
|
-
continue;
|
|
3142
|
-
}
|
|
3143
|
-
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
3144
|
-
if (cells.length < 5) continue;
|
|
3145
|
-
rows.push({
|
|
3146
|
-
cycle: parseInt(cells[0], 10),
|
|
3147
|
-
reports: parseInt(cells[1], 10),
|
|
3148
|
-
matchRate: parseInt(cells[2].replace("%", ""), 10),
|
|
3149
|
-
mae: parseFloat(cells[3]),
|
|
3150
|
-
bias: parseFloat(cells[4])
|
|
3151
|
-
});
|
|
3152
|
-
}
|
|
3153
|
-
return rows;
|
|
3154
|
-
}
|
|
3155
|
-
function parseVelocityTable(block) {
|
|
3156
|
-
const rows = [];
|
|
3157
|
-
const lines = block.split("\n");
|
|
3158
|
-
let inTable = false;
|
|
3159
|
-
for (const line of lines) {
|
|
3160
|
-
if (line.startsWith(VELOCITY_SEPARATOR)) {
|
|
3161
|
-
inTable = true;
|
|
3162
|
-
continue;
|
|
3163
|
-
}
|
|
3164
|
-
if (!inTable) continue;
|
|
3165
|
-
if (!line.startsWith("|")) {
|
|
3166
|
-
inTable = false;
|
|
3167
|
-
continue;
|
|
3168
|
-
}
|
|
3169
|
-
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
3170
|
-
if (cells.length < 5) continue;
|
|
3171
|
-
rows.push({
|
|
3172
|
-
cycle: parseInt(cells[0], 10),
|
|
3173
|
-
completed: parseInt(cells[1], 10),
|
|
3174
|
-
partial: parseInt(cells[2], 10),
|
|
3175
|
-
failed: parseInt(cells[3], 10),
|
|
3176
|
-
effortPoints: parseInt(cells[4], 10)
|
|
3177
|
-
});
|
|
3178
|
-
}
|
|
3179
|
-
return rows;
|
|
3180
|
-
}
|
|
3181
|
-
var HEADER_SENTINEL2 = "*Reviews are stored newest-first.";
|
|
3182
|
-
var VALID_STAGES = /* @__PURE__ */ new Set(["handoff-review", "build-acceptance"]);
|
|
3183
|
-
var VALID_VERDICTS = /* @__PURE__ */ new Set(["approve", "accept", "request-changes", "reject"]);
|
|
3184
|
-
function parseField2(block, field) {
|
|
3185
|
-
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
3186
|
-
const match = block.match(pattern);
|
|
3187
|
-
return match ? match[1].trim() : "";
|
|
3188
|
-
}
|
|
3189
|
-
function parseReviews(content) {
|
|
3190
|
-
if (!content.trim()) return [];
|
|
3191
|
-
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}/));
|
|
3192
|
-
return chunks.map((block) => {
|
|
3193
|
-
const headingMatch = block.match(/^### (task-\S+) — (.+?) — (\d{4}-\d{2}-\d{2}[T\d:.Z]*)/);
|
|
3194
|
-
if (!headingMatch) return null;
|
|
3195
|
-
const taskId = headingMatch[1];
|
|
3196
|
-
const stageRaw = headingMatch[2].trim();
|
|
3197
|
-
const date = headingMatch[3];
|
|
3198
|
-
const stage = stageRaw.toLowerCase().replace(/\s+/g, "-");
|
|
3199
|
-
if (!VALID_STAGES.has(stage)) return null;
|
|
3200
|
-
const reviewer = parseField2(block, "Reviewer");
|
|
3201
|
-
const verdictRaw = parseField2(block, "Verdict");
|
|
3202
|
-
if (!VALID_VERDICTS.has(verdictRaw)) return null;
|
|
3203
|
-
const verdict = verdictRaw;
|
|
3204
|
-
const cycle = parseInt(parseField2(block, "Cycle"), 10);
|
|
3205
|
-
if (isNaN(cycle)) return null;
|
|
3206
|
-
const comments = parseField2(block, "Comments");
|
|
3207
|
-
const uuidRaw = parseField2(block, "UUID");
|
|
3208
|
-
const displayIdRaw = parseField2(block, "Display ID");
|
|
3209
|
-
const review = { uuid: uuidRaw ?? randomUUID5(), ...displayIdRaw ? { displayId: displayIdRaw } : {}, taskId, stage, reviewer, verdict, cycle, date, comments };
|
|
3210
|
-
const handoffRevRaw = parseField2(block, "Handoff Revision");
|
|
3211
|
-
if (handoffRevRaw) {
|
|
3212
|
-
const parsed = parseInt(handoffRevRaw, 10);
|
|
3213
|
-
if (!isNaN(parsed)) review.handoffRevision = parsed;
|
|
3214
|
-
}
|
|
3215
|
-
const buildCommitSha = parseField2(block, "Build Commit SHA");
|
|
3216
|
-
if (buildCommitSha) review.buildCommitSha = buildCommitSha;
|
|
3217
|
-
return review;
|
|
3218
|
-
}).filter((r) => r !== null);
|
|
3219
|
-
}
|
|
3220
|
-
var STAGE_DISPLAY = {
|
|
3221
|
-
"handoff-review": "Handoff Review",
|
|
3222
|
-
"build-acceptance": "Build Acceptance"
|
|
3223
|
-
};
|
|
3224
|
-
function serializeReview(review) {
|
|
3225
|
-
const stageDisplay = STAGE_DISPLAY[review.stage];
|
|
3226
|
-
const lines = [
|
|
3227
|
-
`### ${review.taskId} \u2014 ${stageDisplay} \u2014 ${review.date}`,
|
|
3228
|
-
""
|
|
3229
|
-
];
|
|
3230
|
-
if (review.uuid) lines.push(`- **UUID:** ${review.uuid}`);
|
|
3231
|
-
if (review.displayId) lines.push(`- **Display ID:** ${review.displayId}`);
|
|
3232
|
-
lines.push(
|
|
3233
|
-
`- **Reviewer:** ${review.reviewer}`,
|
|
3234
|
-
`- **Verdict:** ${review.verdict}`,
|
|
3235
|
-
`- **Cycle:** ${review.cycle}`,
|
|
3236
|
-
`- **Comments:** ${review.comments}`
|
|
3237
|
-
);
|
|
3238
|
-
if (review.handoffRevision !== void 0) lines.push(`- **Handoff Revision:** ${review.handoffRevision}`);
|
|
3239
|
-
if (review.buildCommitSha) lines.push(`- **Build Commit SHA:** ${review.buildCommitSha}`);
|
|
3240
|
-
if (review.autoReview) {
|
|
3241
|
-
lines.push("", `#### Auto-Review (${review.autoReview.verdict})`);
|
|
3242
|
-
lines.push(`> ${review.autoReview.summary}`);
|
|
3243
|
-
if (review.autoReview.findings.length > 0) {
|
|
3244
|
-
lines.push("");
|
|
3245
|
-
for (const f of review.autoReview.findings) {
|
|
3246
|
-
const loc = f.file ? f.line ? `${f.file}:${f.line}` : f.file : "";
|
|
3247
|
-
lines.push(`- \`${f.severity}\`${loc ? ` ${loc}` : ""}: ${f.message}`);
|
|
3248
|
-
}
|
|
3249
|
-
}
|
|
3250
|
-
}
|
|
3251
|
-
return lines.join("\n");
|
|
3252
|
-
}
|
|
3253
|
-
function prependReview(review, content) {
|
|
3254
|
-
const serialized = serializeReview(review);
|
|
3255
|
-
if (!content.trim()) {
|
|
3256
|
-
return `# Human Reviews
|
|
3257
|
-
|
|
3258
|
-
${HEADER_SENTINEL2} Each review block references a task and stage.*
|
|
3259
|
-
|
|
3260
|
-
---
|
|
3261
|
-
|
|
3262
|
-
${serialized}
|
|
3263
|
-
`;
|
|
3264
|
-
}
|
|
3265
|
-
const sentinelIdx = content.indexOf(HEADER_SENTINEL2);
|
|
3266
|
-
if (sentinelIdx === -1) {
|
|
3267
|
-
const firstSep = content.indexOf("\n---\n");
|
|
3268
|
-
if (firstSep === -1) return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
3269
|
-
const insertAt2 = firstSep + "\n---\n".length;
|
|
3270
|
-
return content.slice(0, insertAt2) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt2);
|
|
3271
|
-
}
|
|
3272
|
-
const afterSentinel = content.slice(sentinelIdx);
|
|
3273
|
-
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
3274
|
-
if (separatorIdx === -1) {
|
|
3275
|
-
return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
3276
|
-
}
|
|
3277
|
-
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
3278
|
-
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
3279
|
-
}
|
|
3280
|
-
var VALID_STATUSES = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
|
|
3281
|
-
var PHASES_START = "<!-- PHASES:START -->";
|
|
3282
|
-
var PHASES_END = "<!-- PHASES:END -->";
|
|
3283
|
-
function parsePhases(content) {
|
|
3284
|
-
const startIdx = content.indexOf(PHASES_START);
|
|
3285
|
-
const endIdx = content.indexOf(PHASES_END);
|
|
3286
|
-
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return [];
|
|
3287
|
-
const section = content.slice(startIdx + PHASES_START.length, endIdx);
|
|
3288
|
-
const yamlMatch = section.match(/```yaml\s*\n([\s\S]*?)```/);
|
|
3289
|
-
if (!yamlMatch) return [];
|
|
3290
|
-
const yamlBody = yamlMatch[1];
|
|
3291
|
-
const phases = [];
|
|
3292
|
-
const blocks = yamlBody.split(/^(?=\s*- id:)/m).filter((b) => b.trim());
|
|
3293
|
-
for (const block of blocks) {
|
|
3294
|
-
const phase = parsePhaseBlock(block);
|
|
3295
|
-
if (phase) phases.push(phase);
|
|
3296
|
-
}
|
|
3297
|
-
return phases.sort((a, b) => a.order - b.order);
|
|
3298
|
-
}
|
|
3299
|
-
function parseYamlField(block, field) {
|
|
3300
|
-
const pattern = new RegExp(`^\\s*${field}:\\s*(.+)$`, "m");
|
|
3301
|
-
const match = block.match(pattern);
|
|
3302
|
-
if (!match) return "";
|
|
3303
|
-
let value = match[1].trim();
|
|
3304
|
-
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
3305
|
-
value = value.slice(1, -1);
|
|
3306
|
-
}
|
|
3307
|
-
return value;
|
|
3308
|
-
}
|
|
3309
|
-
function parsePhaseBlock(block) {
|
|
3310
|
-
const idMatch = block.match(/^\s*- id:\s*(\S+)/m);
|
|
3311
|
-
if (!idMatch) return null;
|
|
3312
|
-
const id = idMatch[1];
|
|
3313
|
-
const slug = parseYamlField(block, "slug");
|
|
3314
|
-
const label = parseYamlField(block, "label");
|
|
3315
|
-
const description = parseYamlField(block, "description");
|
|
3316
|
-
const statusRaw = parseYamlField(block, "status");
|
|
3317
|
-
const orderRaw = parseYamlField(block, "order");
|
|
3318
|
-
if (!slug || !label || !statusRaw || !orderRaw) return null;
|
|
3319
|
-
const status = statusRaw;
|
|
3320
|
-
if (!VALID_STATUSES.has(status)) return null;
|
|
3321
|
-
const order = parseInt(orderRaw, 10);
|
|
3322
|
-
if (isNaN(order)) return null;
|
|
3323
|
-
return { id, slug, label, description, status, order };
|
|
3324
|
-
}
|
|
3325
|
-
function serializePhases(phases) {
|
|
3326
|
-
const sorted = [...phases].sort((a, b) => a.order - b.order);
|
|
3327
|
-
const yamlLines = ["phases:"];
|
|
3328
|
-
for (const p of sorted) {
|
|
3329
|
-
yamlLines.push(` - id: ${p.id}`);
|
|
3330
|
-
yamlLines.push(` slug: "${p.slug}"`);
|
|
3331
|
-
yamlLines.push(` label: "${p.label}"`);
|
|
3332
|
-
yamlLines.push(` description: "${p.description}"`);
|
|
3333
|
-
yamlLines.push(` status: "${p.status}"`);
|
|
3334
|
-
yamlLines.push(` order: ${p.order}`);
|
|
3335
|
-
}
|
|
3336
|
-
return yamlLines.join("\n");
|
|
3337
|
-
}
|
|
3338
|
-
var YAML_MARKER2 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
3339
|
-
var YAML_START2 = "<!-- PAPI-YAML-START -->";
|
|
3340
|
-
var YAML_END2 = "<!-- PAPI-YAML-END -->";
|
|
3341
|
-
var VALID_STATUSES2 = /* @__PURE__ */ new Set(["planning", "active", "complete"]);
|
|
3342
|
-
function toCycle(raw) {
|
|
3343
|
-
if (!VALID_STATUSES2.has(raw.status)) return null;
|
|
3344
|
-
const cycle = {
|
|
3345
|
-
id: raw.id,
|
|
3346
|
-
number: raw.number,
|
|
3347
|
-
status: raw.status,
|
|
3348
|
-
startDate: raw.start_date,
|
|
3349
|
-
goals: raw.goals ?? [],
|
|
3350
|
-
boardHealth: raw.board_health ?? "",
|
|
3351
|
-
taskIds: raw.task_ids ?? []
|
|
3352
|
-
};
|
|
3353
|
-
if (raw.end_date) cycle.endDate = raw.end_date;
|
|
3354
|
-
if (raw.user_id) cycle.userId = raw.user_id;
|
|
3355
|
-
if (raw.dependency_chain) cycle.dependencyChain = raw.dependency_chain;
|
|
3356
|
-
return cycle;
|
|
3357
|
-
}
|
|
3358
|
-
function fromCycle(cycle) {
|
|
3359
|
-
const raw = {
|
|
3360
|
-
id: cycle.id,
|
|
3361
|
-
number: cycle.number,
|
|
3362
|
-
status: cycle.status,
|
|
3363
|
-
start_date: cycle.startDate,
|
|
3364
|
-
goals: cycle.goals,
|
|
3365
|
-
board_health: cycle.boardHealth,
|
|
3366
|
-
task_ids: cycle.taskIds
|
|
3367
|
-
};
|
|
3368
|
-
if (cycle.endDate) raw.end_date = cycle.endDate;
|
|
3369
|
-
if (cycle.userId) raw.user_id = cycle.userId;
|
|
3370
|
-
if (cycle.dependencyChain) raw.dependency_chain = cycle.dependencyChain;
|
|
3371
|
-
return raw;
|
|
3372
|
-
}
|
|
3373
|
-
function extractYamlBlock2(content) {
|
|
3374
|
-
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
3375
|
-
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLES.md");
|
|
3376
|
-
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
3377
|
-
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
3378
|
-
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in CYCLES.md");
|
|
3379
|
-
const yamlStart = startIdx + YAML_START2.length;
|
|
3380
|
-
const endIdx = afterMarker.indexOf(YAML_END2, yamlStart);
|
|
3381
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLES.md");
|
|
3382
|
-
return afterMarker.slice(yamlStart, endIdx);
|
|
3383
|
-
}
|
|
3384
|
-
function parseCycles(content) {
|
|
3385
|
-
if (!content.trim()) return [];
|
|
3386
|
-
const yamlText = extractYamlBlock2(content);
|
|
3387
|
-
const data = yaml2.load(yamlText);
|
|
3388
|
-
return (data.cycles ?? []).map(toCycle).filter((s) => s !== null);
|
|
3389
|
-
}
|
|
3390
|
-
function serializeCycles(cycles, content) {
|
|
3391
|
-
const raw = cycles.map(fromCycle);
|
|
3392
|
-
const yamlStr = yaml2.dump({ cycles: raw }, { lineWidth: 120, quotingType: '"' });
|
|
3393
|
-
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
3394
|
-
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLES.md");
|
|
3395
|
-
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
3396
|
-
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
3397
|
-
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in CYCLES.md");
|
|
3398
|
-
const absStart = markerIdx + YAML_MARKER2.length + startIdx;
|
|
3399
|
-
const endIdx = afterMarker.indexOf(YAML_END2, startIdx);
|
|
3400
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLES.md");
|
|
3401
|
-
const absEnd = markerIdx + YAML_MARKER2.length + endIdx + YAML_END2.length;
|
|
3402
|
-
return content.slice(0, absStart) + YAML_START2 + "\n" + yamlStr + YAML_END2 + content.slice(absEnd);
|
|
3403
|
-
}
|
|
3404
|
-
function prependCycle(cycle, content) {
|
|
3405
|
-
if (!content.trim()) {
|
|
3406
|
-
const header = `# Cycles
|
|
3407
|
-
|
|
3408
|
-
<!-- PAPI-ADAPTER: parse the yaml block below -->
|
|
3409
|
-
|
|
3410
|
-
`;
|
|
3411
|
-
const raw = fromCycle(cycle);
|
|
3412
|
-
const yamlStr = yaml2.dump({ cycles: [raw] }, { lineWidth: 120, quotingType: '"' });
|
|
3413
|
-
return header + YAML_START2 + "\n" + yamlStr + YAML_END2 + "\n";
|
|
3414
|
-
}
|
|
3415
|
-
const existing = parseCycles(content).filter((c) => c.number !== cycle.number);
|
|
3416
|
-
const merged = [cycle, ...existing];
|
|
3417
|
-
return serializeCycles(merged, content);
|
|
3418
|
-
}
|
|
3419
|
-
var YAML_MARKER3 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
3420
|
-
var YAML_START3 = "<!-- PAPI-YAML-START -->";
|
|
3421
|
-
var YAML_END3 = "<!-- PAPI-YAML-END -->";
|
|
3422
|
-
function extractYamlBlock3(content) {
|
|
3423
|
-
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
3424
|
-
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
3425
|
-
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
3426
|
-
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
3427
|
-
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
3428
|
-
const yamlStart = startIdx + YAML_START3.length;
|
|
3429
|
-
const endIdx = afterMarker.indexOf(YAML_END3, yamlStart);
|
|
3430
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
3431
|
-
return afterMarker.slice(yamlStart, endIdx);
|
|
3432
|
-
}
|
|
3433
|
-
function parseRegistries(content) {
|
|
3434
|
-
if (!content.trim()) return { modules: [], epics: [] };
|
|
3435
|
-
const yamlText = extractYamlBlock3(content);
|
|
3436
|
-
const data = yaml3.load(yamlText);
|
|
3437
|
-
return {
|
|
3438
|
-
modules: data.modules ?? [],
|
|
3439
|
-
epics: data.epics ?? []
|
|
3440
|
-
};
|
|
3441
|
-
}
|
|
3442
|
-
function serializeRegistries(registries, content) {
|
|
3443
|
-
const yamlStr = yaml3.dump(
|
|
3444
|
-
{ modules: registries.modules, epics: registries.epics },
|
|
3445
|
-
{ lineWidth: 120, quotingType: '"' }
|
|
3446
|
-
);
|
|
3447
|
-
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
3448
|
-
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
3449
|
-
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
3450
|
-
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
3451
|
-
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
3452
|
-
const absStart = markerIdx + YAML_MARKER3.length + startIdx;
|
|
3453
|
-
const endIdx = afterMarker.indexOf(YAML_END3, startIdx);
|
|
3454
|
-
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
3455
|
-
const absEnd = markerIdx + YAML_MARKER3.length + endIdx + YAML_END3.length;
|
|
3456
|
-
return content.slice(0, absStart) + YAML_START3 + "\n" + yamlStr + YAML_END3 + content.slice(absEnd);
|
|
3457
|
-
}
|
|
3458
|
-
function displayIdNumber(displayId, prefix) {
|
|
3459
|
-
if (!displayId) return 0;
|
|
3460
|
-
const match = displayId.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
3461
|
-
return match ? parseInt(match[1], 10) : 0;
|
|
3462
|
-
}
|
|
3463
|
-
var MdFileAdapter = class {
|
|
3464
|
-
dir;
|
|
3465
|
-
constructor(projectDir) {
|
|
3466
|
-
this.dir = projectDir;
|
|
3467
|
-
}
|
|
3468
|
-
/** Resolve a filename to an absolute path within the .papi/ directory. */
|
|
3469
|
-
path(file) {
|
|
3470
|
-
return join(this.dir, file);
|
|
3471
|
-
}
|
|
3472
|
-
/** Read a .papi/ file as UTF-8 text. Throws a clear error if the file is missing. */
|
|
3473
|
-
async read(file) {
|
|
3474
|
-
try {
|
|
3475
|
-
return await readFile(this.path(file), "utf-8");
|
|
3476
|
-
} catch (err) {
|
|
3477
|
-
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
3478
|
-
throw new Error(`.papi/${file} not found. Run the setup tool to initialise your project.`);
|
|
3479
|
-
}
|
|
3480
|
-
throw err;
|
|
3481
|
-
}
|
|
3482
|
-
}
|
|
3483
|
-
/** Write UTF-8 text to a .papi/ file. */
|
|
3484
|
-
async write(file, content) {
|
|
3485
|
-
await writeFile(this.path(file), content, "utf-8");
|
|
3486
|
-
}
|
|
3487
|
-
// --- Planning Log ---
|
|
3488
|
-
/** Parse the full planning context into structured sections (reads from PLANNING_LOG.md + ACTIVE_DECISIONS.md + CYCLE_LOG.md). */
|
|
3489
|
-
async readPlanningLog() {
|
|
3490
|
-
const [planningContent, activeDecisionsContent, cycleLogContent] = await Promise.all([
|
|
3491
|
-
this.read("PLANNING_LOG.md"),
|
|
3492
|
-
this.readOptional("ACTIVE_DECISIONS.md"),
|
|
3493
|
-
this.readOptional("SPRINT_LOG.md")
|
|
3494
|
-
]);
|
|
3495
|
-
return parsePlanningLog(planningContent, activeDecisionsContent, cycleLogContent);
|
|
3496
|
-
}
|
|
3497
|
-
/** Read the Cycle Health table from PLANNING_LOG.md. */
|
|
3498
|
-
async getCycleHealth() {
|
|
3499
|
-
return parseCycleHealth(await this.read("PLANNING_LOG.md"));
|
|
3500
|
-
}
|
|
3501
|
-
/**
|
|
3502
|
-
* Read Active Decisions from ACTIVE_DECISIONS.md.
|
|
3503
|
-
*
|
|
3504
|
-
* Default filters out retired ADs (outcome ∈ abandoned/superseded/resolved or superseded=true).
|
|
3505
|
-
* Pass { includeRetired: true } for management/triage surfaces. See PapiAdapter docstring.
|
|
3506
|
-
*/
|
|
3507
|
-
async getActiveDecisions(options) {
|
|
3508
|
-
const content = await this.readOptional("ACTIVE_DECISIONS.md");
|
|
3509
|
-
if (!content) return [];
|
|
3510
|
-
const all = parseActiveDecisions(content);
|
|
3511
|
-
if (options?.includeRetired) return all;
|
|
3512
|
-
return all.filter(isLiveDecision);
|
|
3513
|
-
}
|
|
3514
|
-
/** Read cycle log entries (newest first), optionally limited to {@link limit} entries. */
|
|
3515
|
-
async getCycleLog(limit) {
|
|
3516
|
-
return parseCycleLog(await this.read("SPRINT_LOG.md"), limit);
|
|
3517
|
-
}
|
|
3518
|
-
async getCycleLogSince(cycleNumber) {
|
|
3519
|
-
const log = await this.getCycleLog();
|
|
3520
|
-
return log.filter((entry) => entry.cycleNumber >= cycleNumber);
|
|
3521
|
-
}
|
|
3522
|
-
/** Merge partial updates into the Cycle Health table and write back. */
|
|
3523
|
-
async setCycleHealth(updates) {
|
|
3524
|
-
const content = await this.read("PLANNING_LOG.md");
|
|
3525
|
-
const current = parseCycleHealth(content);
|
|
3526
|
-
const updated = { ...current, ...updates };
|
|
3527
|
-
await this.write("PLANNING_LOG.md", serializeCycleHealth(updated, content));
|
|
3528
|
-
}
|
|
3529
|
-
/** Prepend a new cycle log entry at the top of the Cycle Log section. */
|
|
3530
|
-
async writeCycleLogEntry(entry) {
|
|
3531
|
-
const patched = {
|
|
3532
|
-
...entry,
|
|
3533
|
-
uuid: entry.uuid || randomUUID6(),
|
|
3534
|
-
date: entry.date ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3535
|
-
};
|
|
3536
|
-
const content = await this.read("SPRINT_LOG.md");
|
|
3537
|
-
await this.write("SPRINT_LOG.md", prependCycleLogEntry(patched, content));
|
|
3538
|
-
}
|
|
3539
|
-
/** Write a strategy review — for md adapter, delegates to cycle log. */
|
|
3540
|
-
async writeStrategyReview(review) {
|
|
3541
|
-
await this.writeCycleLogEntry({
|
|
3542
|
-
uuid: randomUUID6(),
|
|
3543
|
-
cycleNumber: review.cycleNumber,
|
|
3544
|
-
title: review.title,
|
|
3545
|
-
content: review.content,
|
|
3546
|
-
notes: review.notes
|
|
3547
|
-
});
|
|
3548
|
-
}
|
|
3549
|
-
/**
|
|
3550
|
-
* Get the cycle number of the last strategy review.
|
|
3551
|
-
* task-2416 (C315): `excludeZoomOut` is accepted for interface parity with the pg
|
|
3552
|
-
* adapter. The md adapter already matches strategy-review titles only (zoom-out logs
|
|
3553
|
-
* carry a "Zoom-Out" title, not a strategy-review one), so the flag is a no-op here —
|
|
3554
|
-
* legacy/best-effort path.
|
|
3555
|
-
*/
|
|
3556
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3557
|
-
async getLastStrategyReviewCycle(_opts) {
|
|
3558
|
-
const log = await this.getCycleLog();
|
|
3559
|
-
const entry = log.find(
|
|
3560
|
-
(e) => /strategy.*review|strategic.*shift/i.test(e.title)
|
|
3561
|
-
);
|
|
3562
|
-
return entry?.cycleNumber ?? 0;
|
|
3563
|
-
}
|
|
3564
|
-
/** task-2416 (C315): cycle of the last zoom-out retrospective. md best-effort — matches the log title. */
|
|
3565
|
-
async getLastZoomOutCycle() {
|
|
3566
|
-
const log = await this.getCycleLog();
|
|
3567
|
-
const entry = log.find((e) => /zoom[-\s]?out/i.test(e.title));
|
|
3568
|
-
return entry?.cycleNumber ?? 0;
|
|
3569
|
-
}
|
|
3570
|
-
/** Get strategy reviews — md adapter returns empty (reviews live in cycle log). */
|
|
3571
|
-
async getStrategyReviews(_limit, _includeFullAnalysis) {
|
|
3572
|
-
return [];
|
|
3573
|
-
}
|
|
3574
|
-
/** Update or insert an Active Decision block by ID. */
|
|
3575
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3576
|
-
async updateActiveDecision(id, body, cycleNumber, _action) {
|
|
3577
|
-
const content = await this.readOptional("ACTIVE_DECISIONS.md") || "## Active Decisions\n\n";
|
|
3578
|
-
await this.write("ACTIVE_DECISIONS.md", updateActiveDecisionInContent(id, body, content, cycleNumber));
|
|
3579
|
-
}
|
|
3580
|
-
// --- Cycle Board ---
|
|
3581
|
-
/** Query the cycle board, optionally filtering by status/priority/phase/etc. */
|
|
3582
|
-
async queryBoard(options) {
|
|
3583
|
-
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3584
|
-
return options ? filterTasks(tasks, options) : tasks;
|
|
3585
|
-
}
|
|
3586
|
-
/** Look up a single task by ID, returning null if not found. */
|
|
3587
|
-
async getTask(id) {
|
|
3588
|
-
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3589
|
-
const found = tasks.find((t) => t.id === id);
|
|
3590
|
-
if (found) return found;
|
|
3591
|
-
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3592
|
-
if (!archiveContent) return null;
|
|
3593
|
-
return parseBoard(archiveContent).find((t) => t.id === id) ?? null;
|
|
3594
|
-
}
|
|
3595
|
-
/** Look up multiple tasks by ID in a single board read. */
|
|
3596
|
-
async getTasks(ids) {
|
|
3597
|
-
const idSet = new Set(ids);
|
|
3598
|
-
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3599
|
-
return tasks.filter((t) => idSet.has(t.id));
|
|
3600
|
-
}
|
|
3601
|
-
/** Warn if a phase name doesn't match any known phase label from PRODUCT_BRIEF.md. */
|
|
3602
|
-
async warnInvalidPhase(phase) {
|
|
3603
|
-
const phases = await this.readPhases();
|
|
3604
|
-
if (phases.length === 0) return;
|
|
3605
|
-
const knownLabels = new Set(phases.map((p) => p.label));
|
|
3606
|
-
if (!knownLabels.has(phase)) {
|
|
3607
|
-
console.warn(`[papi] Warning: phase "${phase}" does not match any known phase. Valid phases: ${[...knownLabels].join(", ")}`);
|
|
3608
|
-
}
|
|
3609
|
-
}
|
|
3610
|
-
/** Warn if a module name doesn't match any registered module in REGISTRIES.md. */
|
|
3611
|
-
async warnInvalidModule(module) {
|
|
3612
|
-
const registries = await this.readRegistries();
|
|
3613
|
-
if (registries.modules.length === 0) return;
|
|
3614
|
-
const known = new Set(registries.modules);
|
|
3615
|
-
if (!known.has(module)) {
|
|
3616
|
-
console.warn(`[papi] Warning: module "${module}" is not a registered module. Registered modules: ${registries.modules.join(", ")}`);
|
|
3617
|
-
}
|
|
3618
|
-
}
|
|
3619
|
-
/** Warn if an epic name doesn't match any registered epic in REGISTRIES.md. */
|
|
3620
|
-
async warnInvalidEpic(epic) {
|
|
3621
|
-
const registries = await this.readRegistries();
|
|
3622
|
-
if (registries.epics.length === 0) return;
|
|
3623
|
-
const known = new Set(registries.epics);
|
|
3624
|
-
if (!known.has(epic)) {
|
|
3625
|
-
console.warn(`[papi] Warning: epic "${epic}" is not a registered epic. Registered epics: ${registries.epics.join(", ")}`);
|
|
3626
|
-
}
|
|
3627
|
-
}
|
|
3628
|
-
/** Create a new task on the board with an auto-generated sequential ID. */
|
|
3629
|
-
async createTask(task) {
|
|
3630
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3631
|
-
const tasks = parseBoard(content);
|
|
3632
|
-
const createdAt = task.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3633
|
-
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3634
|
-
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3635
|
-
await this.warnInvalidPhase(task.phase);
|
|
3636
|
-
await this.warnInvalidModule(task.module);
|
|
3637
|
-
if (task.epic) await this.warnInvalidEpic(task.epic);
|
|
3638
|
-
if (task.dependsOn) {
|
|
3639
|
-
const allTaskIds = new Set([...tasks, ...archivedTasks].map((t) => t.id));
|
|
3640
|
-
const depIds = task.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3641
|
-
for (const depId of depIds) {
|
|
3642
|
-
if (!allTaskIds.has(depId)) {
|
|
3643
|
-
console.warn(`[papi] Warning: dependsOn references non-existent task "${depId}"`);
|
|
3644
|
-
}
|
|
3645
|
-
}
|
|
3646
|
-
}
|
|
3647
|
-
const uuid = task.uuid ?? randomUUID6();
|
|
3648
|
-
const id = nextTaskId([...tasks, ...archivedTasks]);
|
|
3649
|
-
const newTask = { ...task, uuid, createdAt, id, displayId: id };
|
|
3650
|
-
if (newTask.buildHandoff) {
|
|
3651
|
-
if (!newTask.buildHandoff.uuid) {
|
|
3652
|
-
newTask.buildHandoff = { ...newTask.buildHandoff, uuid: randomUUID6() };
|
|
3653
|
-
}
|
|
3654
|
-
if (!newTask.buildHandoff.displayId) {
|
|
3655
|
-
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
3656
|
-
newTask.buildHandoff = { ...newTask.buildHandoff, displayId: `ho-${maxNum + 1}` };
|
|
3657
|
-
}
|
|
3658
|
-
}
|
|
3659
|
-
tasks.push(newTask);
|
|
3660
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3661
|
-
return newTask;
|
|
3662
|
-
}
|
|
3663
|
-
/** Update one or more fields on an existing task. Throws if the task ID is not found. */
|
|
3664
|
-
async updateTask(id, updates, options) {
|
|
3665
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3666
|
-
const tasks = parseBoard(content);
|
|
3667
|
-
const idx = tasks.findIndex((t) => t.id === id);
|
|
3668
|
-
if (idx === -1) throw new Error(`Task ${id} not found`);
|
|
3669
|
-
if (updates.phase && updates.phase !== tasks[idx].phase) {
|
|
3670
|
-
await this.warnInvalidPhase(updates.phase);
|
|
3671
|
-
}
|
|
3672
|
-
if (updates.module && updates.module !== tasks[idx].module) {
|
|
3673
|
-
await this.warnInvalidModule(updates.module);
|
|
3674
|
-
}
|
|
3675
|
-
if (updates.epic && updates.epic !== tasks[idx].epic) {
|
|
3676
|
-
await this.warnInvalidEpic(updates.epic);
|
|
3677
|
-
}
|
|
3678
|
-
if (updates.status && updates.status !== tasks[idx].status && !options?.force) {
|
|
3679
|
-
const from = tasks[idx].status;
|
|
3680
|
-
const allowed = VALID_TRANSITIONS[from];
|
|
3681
|
-
if (!allowed.includes(updates.status)) {
|
|
3682
|
-
console.warn(`[papi] Warning: invalid status transition "${from}" \u2192 "${updates.status}" for task ${id}. Allowed from "${from}": ${allowed.length > 0 ? allowed.join(", ") : "none"}`);
|
|
3683
|
-
}
|
|
3684
|
-
}
|
|
3685
|
-
if (updates.status && updates.status !== tasks[idx].status) {
|
|
3686
|
-
const history = tasks[idx].stateHistory ?? [];
|
|
3687
|
-
history.push({ status: updates.status, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3688
|
-
updates = { ...updates, stateHistory: history };
|
|
3689
|
-
}
|
|
3690
|
-
if (updates.buildHandoff) {
|
|
3691
|
-
let handoff = { ...updates.buildHandoff };
|
|
3692
|
-
if (!handoff.uuid) handoff = { ...handoff, uuid: randomUUID6() };
|
|
3693
|
-
if (!handoff.displayId) {
|
|
3694
|
-
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
3695
|
-
handoff = { ...handoff, displayId: `ho-${maxNum + 1}` };
|
|
3696
|
-
}
|
|
3697
|
-
updates = { ...updates, buildHandoff: handoff };
|
|
3698
|
-
}
|
|
3699
|
-
tasks[idx] = { ...tasks[idx], ...updates };
|
|
3700
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3701
|
-
}
|
|
3702
|
-
/** Shorthand to update only the status field of a task. */
|
|
3703
|
-
async updateTaskStatus(id, status) {
|
|
3704
|
-
return this.updateTask(id, { status });
|
|
3705
|
-
}
|
|
3706
|
-
/**
|
|
3707
|
-
* task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
|
|
3708
|
-
* sets assigneeId only if the task is currently unclaimed. The markdown adapter
|
|
3709
|
-
* is single-process, so the read-check-write is trivially atomic here; the real
|
|
3710
|
-
* concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
|
|
3711
|
-
* claimed task, or null if it was already claimed or does not exist.
|
|
3712
|
-
*
|
|
3713
|
-
* task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
|
|
3714
|
-
* == null` — a task already pulled into someone's cycle is NOT in the pool and
|
|
3715
|
-
* cannot be claimed. Sets claimSource='pool' on success.
|
|
3716
|
-
*/
|
|
3717
|
-
async claimTask(taskId, assigneeId) {
|
|
3718
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3719
|
-
const tasks = parseBoard(content);
|
|
3720
|
-
const idx = tasks.findIndex((t) => t.id === taskId);
|
|
3721
|
-
if (idx === -1) return null;
|
|
3722
|
-
if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
|
|
3723
|
-
tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
|
|
3724
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3725
|
-
return tasks[idx];
|
|
3726
|
-
}
|
|
3727
|
-
/**
|
|
3728
|
-
* task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
|
|
3729
|
-
* only if the task is currently assigned to `assigneeId` and has not entered
|
|
3730
|
-
* review. Returns the unclaimed task, or null if the caller is not the claimer,
|
|
3731
|
-
* the task has progressed, or it does not exist.
|
|
3732
|
-
*/
|
|
3733
|
-
async unclaimTask(taskId, assigneeId) {
|
|
3734
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3735
|
-
const tasks = parseBoard(content);
|
|
3736
|
-
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
3737
|
-
if (idx === -1) return null;
|
|
3738
|
-
const t = tasks[idx];
|
|
3739
|
-
if (t.assigneeId !== assigneeId) return null;
|
|
3740
|
-
if (t.status === "In Review" || t.status === "Done") return null;
|
|
3741
|
-
const next = { ...t };
|
|
3742
|
-
delete next.assigneeId;
|
|
3743
|
-
delete next.claimSource;
|
|
3744
|
-
tasks[idx] = next;
|
|
3745
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3746
|
-
return tasks[idx];
|
|
3747
|
-
}
|
|
3748
|
-
/**
|
|
3749
|
-
* task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
|
|
3750
|
-
* is In Review and not yet claimed for review (reviewerId == null). First-claim-
|
|
3751
|
-
* wins. Returns the claimed task, or null if already review-claimed / not In
|
|
3752
|
-
* Review / missing.
|
|
3753
|
-
*/
|
|
3754
|
-
async claimReview(taskId, reviewerId) {
|
|
3755
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3756
|
-
const tasks = parseBoard(content);
|
|
3757
|
-
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
3758
|
-
if (idx === -1) return null;
|
|
3759
|
-
const t = tasks[idx];
|
|
3760
|
-
if (t.status !== "In Review") return null;
|
|
3761
|
-
if (t.reviewerId) return null;
|
|
3762
|
-
tasks[idx] = { ...t, reviewerId };
|
|
3763
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3764
|
-
return tasks[idx];
|
|
3765
|
-
}
|
|
3766
|
-
async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
|
|
3767
|
-
}
|
|
3768
|
-
// --- Build Reports ---
|
|
3769
|
-
/** Insert a new build report at the top of BUILD_REPORTS.md. */
|
|
3770
|
-
async appendBuildReport(report) {
|
|
3771
|
-
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3772
|
-
if (!boardTasks.some((t) => t.id === report.taskId)) {
|
|
3773
|
-
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3774
|
-
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3775
|
-
if (!archivedTasks.some((t) => t.id === report.taskId)) {
|
|
3776
|
-
console.warn(`[papi] Warning: BuildReport.taskId references non-existent task "${report.taskId}"`);
|
|
3777
|
-
}
|
|
3778
|
-
}
|
|
3779
|
-
const content = await this.read("BUILD_REPORTS.md");
|
|
3780
|
-
if (!report.uuid) {
|
|
3781
|
-
report = { ...report, uuid: randomUUID6() };
|
|
3782
|
-
}
|
|
3783
|
-
if (!report.displayId) {
|
|
3784
|
-
const existing = parseBuildReports(content);
|
|
3785
|
-
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "br")), 0);
|
|
3786
|
-
report = { ...report, displayId: `br-${maxNum + 1}` };
|
|
3787
|
-
}
|
|
3788
|
-
await this.write("BUILD_REPORTS.md", prependBuildReport(report, content));
|
|
3789
|
-
}
|
|
3790
|
-
/** Return the most recent {@link count} build reports. */
|
|
3791
|
-
async getRecentBuildReports(count) {
|
|
3792
|
-
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3793
|
-
return reports.slice(0, count);
|
|
3794
|
-
}
|
|
3795
|
-
/** Return the number of build reports for a specific task. */
|
|
3796
|
-
async getBuildReportCountForTask(taskId) {
|
|
3797
|
-
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3798
|
-
return reports.filter((r) => r.taskId === taskId).length;
|
|
3799
|
-
}
|
|
3800
|
-
/** Return all build reports from cycles >= {@link cycleNumber}. */
|
|
3801
|
-
async getBuildReportsSince(cycleNumber) {
|
|
3802
|
-
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3803
|
-
return reports.filter((r) => r.cycle >= cycleNumber);
|
|
3804
|
-
}
|
|
3805
|
-
// --- Human Reviews ---
|
|
3806
|
-
/** Return recent human reviews from REVIEWS.md (newest first), optionally limited to {@link count}. */
|
|
3807
|
-
async getRecentReviews(count) {
|
|
3808
|
-
const content = await this.readOptional("REVIEWS.md");
|
|
3809
|
-
if (!content) return [];
|
|
3810
|
-
const reviews = parseReviews(content);
|
|
3811
|
-
return count ? reviews.slice(0, count) : reviews;
|
|
3812
|
-
}
|
|
3813
|
-
/** Write a new human review to REVIEWS.md. */
|
|
3814
|
-
async writeReview(review) {
|
|
3815
|
-
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3816
|
-
if (!boardTasks.some((t) => t.id === review.taskId)) {
|
|
3817
|
-
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3818
|
-
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3819
|
-
if (!archivedTasks.some((t) => t.id === review.taskId)) {
|
|
3820
|
-
console.warn(`[papi] Warning: HumanReview.taskId references non-existent task "${review.taskId}"`);
|
|
3821
|
-
}
|
|
3822
|
-
}
|
|
3823
|
-
const content = await this.readOptional("REVIEWS.md");
|
|
3824
|
-
if (!review.uuid) {
|
|
3825
|
-
review = { ...review, uuid: randomUUID6() };
|
|
3826
|
-
}
|
|
3827
|
-
if (!review.displayId) {
|
|
3828
|
-
const existing = content ? parseReviews(content) : [];
|
|
3829
|
-
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "rv")), 0);
|
|
3830
|
-
review = { ...review, displayId: `rv-${maxNum + 1}` };
|
|
3831
|
-
}
|
|
3832
|
-
await this.write("REVIEWS.md", prependReview(review, content));
|
|
3833
|
-
}
|
|
3834
|
-
// --- Compression ---
|
|
3835
|
-
/** Compress old cycle log entries below {@link threshold} into a summary block. */
|
|
3836
|
-
async compressCycleLog(threshold, summary) {
|
|
3837
|
-
const content = await this.read("SPRINT_LOG.md");
|
|
3838
|
-
await this.write("SPRINT_LOG.md", compressCycleLogInContent(content, threshold, summary));
|
|
3839
|
-
}
|
|
3840
|
-
/** Compress old build reports below {@link threshold} into a summary block. */
|
|
3841
|
-
async compressBuildReports(threshold, summary) {
|
|
3842
|
-
const content = await this.read("BUILD_REPORTS.md");
|
|
3843
|
-
await this.write("BUILD_REPORTS.md", compressBuildReportsInContent(content, threshold, summary));
|
|
3844
|
-
}
|
|
3845
|
-
// --- Archival ---
|
|
3846
|
-
/** Read a .papi/ file, returning empty string if it doesn't exist. */
|
|
3847
|
-
async readOptional(file) {
|
|
3848
|
-
try {
|
|
3849
|
-
await access(this.path(file));
|
|
3850
|
-
return readFile(this.path(file), "utf-8");
|
|
3851
|
-
} catch (_err) {
|
|
3852
|
-
return "";
|
|
3853
|
-
}
|
|
3854
|
-
}
|
|
3855
|
-
/** Strip build_handoff and build_report from a task before archiving — these are already in BUILD_REPORTS.md. */
|
|
3856
|
-
stripHeavyFields(task) {
|
|
3857
|
-
const { buildHandoff, buildReport, ...rest } = task;
|
|
3858
|
-
return rest;
|
|
3859
|
-
}
|
|
3860
|
-
/** Append tasks to ARCHIVE_CYCLE_BOARD.md, stripping heavy fields and deduplicating by ID. */
|
|
3861
|
-
async appendToArchive(tasks) {
|
|
3862
|
-
const existing = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3863
|
-
const archiveContent = existing || "# PAPI Cycle Board \u2014 Archive\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\ntasks: []\n<!-- PAPI-YAML-END -->\n";
|
|
3864
|
-
const existingArchived = parseBoard(archiveContent);
|
|
3865
|
-
const existingIds = new Set(existingArchived.map((t) => t.id));
|
|
3866
|
-
const newArchive = tasks.filter((t) => !existingIds.has(t.id)).map((t) => this.stripHeavyFields(t));
|
|
3867
|
-
const merged = [...existingArchived, ...newArchive];
|
|
3868
|
-
await this.write("ARCHIVE_SPRINT_BOARD.md", serializeBoard(merged, archiveContent));
|
|
3869
|
-
}
|
|
3870
|
-
/** Archive tasks matching phases and/or statuses to ARCHIVE_CYCLE_BOARD.md and remove them from active board. */
|
|
3871
|
-
async archiveTasks(phases, statuses) {
|
|
3872
|
-
const content = await this.read("SPRINT_BOARD.md");
|
|
3873
|
-
const tasks = parseBoard(content);
|
|
3874
|
-
const phaseSet = new Set(phases.map((p) => p.toLowerCase()));
|
|
3875
|
-
const statusSet = statuses ? new Set(statuses.map((s) => s.toLowerCase())) : null;
|
|
3876
|
-
const keep = [];
|
|
3877
|
-
const archive = [];
|
|
3878
|
-
const hasPhaseFilter = phaseSet.size > 0;
|
|
3879
|
-
const hasStatusFilter = statusSet !== null;
|
|
3880
|
-
for (const task of tasks) {
|
|
3881
|
-
const matchesPhase = hasPhaseFilter && phaseSet.has(task.phase.toLowerCase());
|
|
3882
|
-
const matchesStatus = hasStatusFilter && statusSet.has(task.status.toLowerCase());
|
|
3883
|
-
const shouldArchive = hasPhaseFilter && hasStatusFilter ? matchesPhase && matchesStatus : matchesPhase || matchesStatus;
|
|
3884
|
-
if (shouldArchive) {
|
|
3885
|
-
archive.push(task);
|
|
3886
|
-
} else {
|
|
3887
|
-
keep.push(task);
|
|
3888
|
-
}
|
|
3889
|
-
}
|
|
3890
|
-
if (archive.length === 0) {
|
|
3891
|
-
return { archivedCount: 0, taskIds: [] };
|
|
3892
|
-
}
|
|
3893
|
-
await this.appendToArchive(archive);
|
|
3894
|
-
await this.write("SPRINT_BOARD.md", serializeBoard(keep, content));
|
|
3895
|
-
return { archivedCount: archive.length, taskIds: archive.map((t) => t.id) };
|
|
3896
|
-
}
|
|
3897
|
-
// --- Product Brief ---
|
|
3898
|
-
/** Read the raw PRODUCT_BRIEF.md content. */
|
|
3899
|
-
async readProductBrief() {
|
|
3900
|
-
return this.read("PRODUCT_BRIEF.md");
|
|
3901
|
-
}
|
|
3902
|
-
/** Overwrite PRODUCT_BRIEF.md with new content. */
|
|
3903
|
-
async updateProductBrief(content) {
|
|
3904
|
-
await this.write("PRODUCT_BRIEF.md", content);
|
|
3905
|
-
}
|
|
3906
|
-
async readDiscoveryCanvas() {
|
|
3907
|
-
return {};
|
|
3908
|
-
}
|
|
3909
|
-
async updateDiscoveryCanvas(_canvas) {
|
|
3910
|
-
}
|
|
3911
|
-
/** Read all phases from PHASES.md (falls back to PRODUCT_BRIEF.md for migration). */
|
|
3912
|
-
async readPhases() {
|
|
3913
|
-
const phasesContent = await this.readOptional("PHASES.md");
|
|
3914
|
-
if (phasesContent) return parsePhases(phasesContent);
|
|
3915
|
-
const briefContent = await this.readOptional("PRODUCT_BRIEF.md");
|
|
3916
|
-
return briefContent ? parsePhases(briefContent) : [];
|
|
3917
|
-
}
|
|
3918
|
-
/** Write phases to PHASES.md. */
|
|
3919
|
-
async writePhases(phases) {
|
|
3920
|
-
const content = await this.readOptional("PHASES.md");
|
|
3921
|
-
const existing = content || "";
|
|
3922
|
-
const yaml4 = serializePhases(phases);
|
|
3923
|
-
const PHASES_START2 = "<!-- PHASES:START -->";
|
|
3924
|
-
const PHASES_END2 = "<!-- PHASES:END -->";
|
|
3925
|
-
const newSection = `${PHASES_START2}
|
|
3926
|
-
|
|
3927
|
-
\`\`\`yaml
|
|
3928
|
-
${yaml4}
|
|
3929
|
-
\`\`\`
|
|
3930
|
-
|
|
3931
|
-
${PHASES_END2}`;
|
|
3932
|
-
const startIdx = existing.indexOf(PHASES_START2);
|
|
3933
|
-
const endIdx = existing.indexOf(PHASES_END2);
|
|
3934
|
-
if (startIdx !== -1 && endIdx !== -1) {
|
|
3935
|
-
await this.write("PHASES.md", existing.slice(0, startIdx) + newSection + existing.slice(endIdx + PHASES_END2.length));
|
|
3936
|
-
} else {
|
|
3937
|
-
await this.write("PHASES.md", `# Phases
|
|
3938
|
-
|
|
3939
|
-
${newSection}
|
|
3940
|
-
`);
|
|
3941
|
-
}
|
|
3942
|
-
}
|
|
3943
|
-
// --- Tool Call Metrics ---
|
|
3944
|
-
/** Append a tool call metric entry to METRICS.md. */
|
|
3945
|
-
async appendToolMetric(metric) {
|
|
3946
|
-
const content = await this.readOptional("METRICS.md");
|
|
3947
|
-
await this.write("METRICS.md", appendToolMetricToContent(metric, content));
|
|
3948
|
-
}
|
|
3949
|
-
/** Read all tool call metrics from METRICS.md. */
|
|
3950
|
-
async readToolMetrics() {
|
|
3951
|
-
const content = await this.readOptional("METRICS.md");
|
|
3952
|
-
if (!content) return [];
|
|
3953
|
-
return parseToolMetrics(content);
|
|
3954
|
-
}
|
|
3955
|
-
async hasToolMilestone(name) {
|
|
3956
|
-
const metrics = await this.readToolMetrics();
|
|
3957
|
-
return metrics.some((m) => m.tool === name);
|
|
3958
|
-
}
|
|
3959
|
-
/** Aggregate tool call metrics into a cost summary, optionally filtered by cycle. */
|
|
3960
|
-
async getCostSummary(cycleNumber) {
|
|
3961
|
-
const metrics = await this.readToolMetrics();
|
|
3962
|
-
return aggregateCostSummary(metrics, cycleNumber);
|
|
3963
|
-
}
|
|
3964
|
-
/** Read all cost snapshots from the Cost Summary section of METRICS.md. */
|
|
3965
|
-
async getCostSnapshots() {
|
|
3966
|
-
const content = await this.readOptional("METRICS.md");
|
|
3967
|
-
if (!content) return [];
|
|
3968
|
-
return parseCostSnapshots(content);
|
|
3969
|
-
}
|
|
3970
|
-
// --- Cycle Methodology Metrics ---
|
|
3971
|
-
/** Append a cycle metrics snapshot to CYCLE_METRICS.md. */
|
|
3972
|
-
async appendCycleMetrics(snapshot) {
|
|
3973
|
-
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
3974
|
-
await this.write("SPRINT_METRICS.md", appendSnapshotToContent(snapshot, content));
|
|
3975
|
-
}
|
|
3976
|
-
/** Read all cycle metrics snapshots from CYCLE_METRICS.md. */
|
|
3977
|
-
async readCycleMetrics() {
|
|
3978
|
-
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
3979
|
-
if (!content) return [];
|
|
3980
|
-
return parseSnapshots(content);
|
|
3981
|
-
}
|
|
3982
|
-
// --- Cycles ---
|
|
3983
|
-
/** Read all Cycle entities from CYCLES.md (newest first). */
|
|
3984
|
-
async readCycles() {
|
|
3985
|
-
const content = await this.readOptional("CYCLES.md");
|
|
3986
|
-
if (!content) return [];
|
|
3987
|
-
return parseCycles(content);
|
|
3988
|
-
}
|
|
3989
|
-
/** Write a new Cycle entity to CYCLES.md. */
|
|
3990
|
-
async createCycle(cycle) {
|
|
3991
|
-
const content = await this.readOptional("CYCLES.md");
|
|
3992
|
-
await this.write("CYCLES.md", prependCycle(cycle, content));
|
|
3993
|
-
}
|
|
3994
|
-
// --- Registries ---
|
|
3995
|
-
/** Read module and epic registries from REGISTRIES.md. */
|
|
3996
|
-
async readRegistries() {
|
|
3997
|
-
const content = await this.readOptional("REGISTRIES.md");
|
|
3998
|
-
if (!content) return { modules: [], epics: [] };
|
|
3999
|
-
return parseRegistries(content);
|
|
4000
|
-
}
|
|
4001
|
-
/** Overwrite REGISTRIES.md with updated registries. */
|
|
4002
|
-
async updateRegistries(registries) {
|
|
4003
|
-
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";
|
|
4004
|
-
await this.write("REGISTRIES.md", serializeRegistries(registries, content));
|
|
4005
|
-
}
|
|
4006
|
-
// --- Strategy Recommendations ---
|
|
4007
|
-
/**
|
|
4008
|
-
* Write a new strategy recommendation to STRATEGY_RECOMMENDATIONS.md.
|
|
4009
|
-
* File-based implementation stores as YAML entries.
|
|
4010
|
-
*/
|
|
4011
|
-
async writeRecommendation(rec) {
|
|
4012
|
-
const id = randomUUID6();
|
|
4013
|
-
const full = { id, ...rec };
|
|
4014
|
-
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
4015
|
-
const header = "# Strategy Recommendations\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\nrecommendations:\n";
|
|
4016
|
-
const footer = "<!-- PAPI-YAML-END -->\n";
|
|
4017
|
-
const entry = [
|
|
4018
|
-
` - id: ${full.id}`,
|
|
4019
|
-
` type: ${full.type}`,
|
|
4020
|
-
` status: ${full.status}`,
|
|
4021
|
-
` content: ${JSON.stringify(full.content)}`,
|
|
4022
|
-
` created_sprint: ${full.createdCycle}`,
|
|
4023
|
-
full.actionedCycle != null ? ` actioned_cycle: ${full.actionedCycle}` : null,
|
|
4024
|
-
full.target != null ? ` target: ${JSON.stringify(full.target)}` : null
|
|
4025
|
-
].filter(Boolean).join("\n");
|
|
4026
|
-
if (!content) {
|
|
4027
|
-
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
4028
|
-
${footer}`);
|
|
4029
|
-
} else {
|
|
4030
|
-
const insertPoint = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
4031
|
-
if (insertPoint === -1) {
|
|
4032
|
-
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
4033
|
-
${footer}`);
|
|
4034
|
-
} else {
|
|
4035
|
-
const updated = content.slice(0, insertPoint) + entry + "\n" + content.slice(insertPoint);
|
|
4036
|
-
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
4037
|
-
}
|
|
4038
|
-
}
|
|
4039
|
-
return full;
|
|
4040
|
-
}
|
|
4041
|
-
/** Read all pending (unactioned) strategy recommendations. */
|
|
4042
|
-
async getPendingRecommendations() {
|
|
4043
|
-
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
4044
|
-
if (!content) return [];
|
|
4045
|
-
const yamlStart = content.indexOf("<!-- PAPI-YAML-START -->");
|
|
4046
|
-
const yamlEnd = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
4047
|
-
if (yamlStart === -1 || yamlEnd === -1) return [];
|
|
4048
|
-
const yamlBlock = content.slice(yamlStart + "<!-- PAPI-YAML-START -->".length, yamlEnd).trim();
|
|
4049
|
-
const entries = yamlBlock.split(/(?=\s+-\s+id:)/);
|
|
4050
|
-
const recs = [];
|
|
4051
|
-
for (const block of entries) {
|
|
4052
|
-
const idMatch = block.match(/id:\s+(.+)/);
|
|
4053
|
-
const typeMatch = block.match(/type:\s+(.+)/);
|
|
4054
|
-
const statusMatch = block.match(/status:\s+(.+)/);
|
|
4055
|
-
const contentMatch = block.match(/content:\s+(.+)/);
|
|
4056
|
-
const createdMatch = block.match(/created_sprint:\s+(\d+)/);
|
|
4057
|
-
const actionedMatch = block.match(/actioned_cycle:\s+(\d+)/);
|
|
4058
|
-
if (!idMatch || !typeMatch || !statusMatch || !contentMatch || !createdMatch) continue;
|
|
4059
|
-
const status = statusMatch[1].trim();
|
|
4060
|
-
if (status !== "pending") continue;
|
|
4061
|
-
let parsedContent = contentMatch[1].trim();
|
|
4062
|
-
if (parsedContent.startsWith('"') && parsedContent.endsWith('"')) {
|
|
4063
|
-
try {
|
|
4064
|
-
parsedContent = JSON.parse(parsedContent);
|
|
4065
|
-
} catch {
|
|
4066
|
-
}
|
|
4067
|
-
}
|
|
4068
|
-
recs.push({
|
|
4069
|
-
id: idMatch[1].trim(),
|
|
4070
|
-
type: typeMatch[1].trim(),
|
|
4071
|
-
status: "pending",
|
|
4072
|
-
content: parsedContent,
|
|
4073
|
-
createdCycle: parseInt(createdMatch[1], 10),
|
|
4074
|
-
actionedCycle: actionedMatch ? parseInt(actionedMatch[1], 10) : void 0
|
|
4075
|
-
});
|
|
4076
|
-
}
|
|
4077
|
-
return recs;
|
|
4078
|
-
}
|
|
4079
|
-
/** Mark a recommendation as actioned. */
|
|
4080
|
-
async actionRecommendation(id, cycleNumber) {
|
|
4081
|
-
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
4082
|
-
if (!content) return;
|
|
4083
|
-
const idPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(\\s+status:\\s+)pending`);
|
|
4084
|
-
let updated = content.replace(idPattern, `$1$2actioned`);
|
|
4085
|
-
const entryPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(?=\\s+-\\s+id:|<!-- PAPI-YAML-END -->)`);
|
|
4086
|
-
const entryMatch = updated.match(entryPattern);
|
|
4087
|
-
if (entryMatch && !entryMatch[0].includes("actioned_cycle:")) {
|
|
4088
|
-
const cyclePattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+created_sprint:\\s+\\d+)\\n`);
|
|
4089
|
-
updated = updated.replace(cyclePattern, `$1
|
|
4090
|
-
actioned_cycle: ${cycleNumber}
|
|
4091
|
-
`);
|
|
4092
|
-
}
|
|
4093
|
-
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
4094
|
-
}
|
|
4095
|
-
// -------------------------------------------------------------------------
|
|
4096
|
-
// Strategy Review Agenda (markdown persistence)
|
|
4097
|
-
// -------------------------------------------------------------------------
|
|
4098
|
-
async addAgendaTopic(input) {
|
|
4099
|
-
const id = randomUUID6();
|
|
4100
|
-
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4101
|
-
const full = {
|
|
4102
|
-
id,
|
|
4103
|
-
topic: input.topic,
|
|
4104
|
-
source: input.source,
|
|
4105
|
-
sourceCycle: input.sourceCycle,
|
|
4106
|
-
status: "pending",
|
|
4107
|
-
createdAt
|
|
4108
|
-
};
|
|
4109
|
-
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
4110
|
-
const header = "# Strategy Review Agenda\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\ntopics:\n";
|
|
4111
|
-
const footer = "<!-- PAPI-YAML-END -->\n";
|
|
4112
|
-
const entry = [
|
|
4113
|
-
` - id: ${full.id}`,
|
|
4114
|
-
` topic: ${JSON.stringify(full.topic)}`,
|
|
4115
|
-
` source: ${full.source}`,
|
|
4116
|
-
full.sourceCycle != null ? ` source_cycle: ${full.sourceCycle}` : null,
|
|
4117
|
-
` status: ${full.status}`,
|
|
4118
|
-
` created_at: ${full.createdAt}`
|
|
4119
|
-
].filter(Boolean).join("\n");
|
|
4120
|
-
if (!content) {
|
|
4121
|
-
await this.write("STRATEGY_REVIEW_AGENDA.md", `${header}${entry}
|
|
4122
|
-
${footer}`);
|
|
4123
|
-
} else {
|
|
4124
|
-
const insertPoint = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
4125
|
-
if (insertPoint === -1) {
|
|
4126
|
-
await this.write("STRATEGY_REVIEW_AGENDA.md", `${header}${entry}
|
|
4127
|
-
${footer}`);
|
|
4128
|
-
} else {
|
|
4129
|
-
const updated = content.slice(0, insertPoint) + entry + "\n" + content.slice(insertPoint);
|
|
4130
|
-
await this.write("STRATEGY_REVIEW_AGENDA.md", updated);
|
|
4131
|
-
}
|
|
4132
|
-
}
|
|
4133
|
-
return full;
|
|
4134
|
-
}
|
|
4135
|
-
async getPendingAgendaTopics() {
|
|
4136
|
-
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
4137
|
-
if (!content) return [];
|
|
4138
|
-
const yamlStart = content.indexOf("<!-- PAPI-YAML-START -->");
|
|
4139
|
-
const yamlEnd = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
4140
|
-
if (yamlStart === -1 || yamlEnd === -1) return [];
|
|
4141
|
-
const yamlBlock = content.slice(yamlStart + "<!-- PAPI-YAML-START -->".length, yamlEnd).trim();
|
|
4142
|
-
const entries = yamlBlock.split(/(?=\s+-\s+id:)/);
|
|
4143
|
-
const topics = [];
|
|
4144
|
-
for (const block of entries) {
|
|
4145
|
-
const idMatch = block.match(/id:\s+(.+)/);
|
|
4146
|
-
const topicMatch = block.match(/topic:\s+(.+)/);
|
|
4147
|
-
const sourceMatch = block.match(/source:\s+(\S+)/);
|
|
4148
|
-
const statusMatch = block.match(/status:\s+(\S+)/);
|
|
4149
|
-
const createdMatch = block.match(/created_at:\s+(.+)/);
|
|
4150
|
-
const sourceCycleMatch = block.match(/source_cycle:\s+(\d+)/);
|
|
4151
|
-
if (!idMatch || !topicMatch || !sourceMatch || !statusMatch || !createdMatch) continue;
|
|
4152
|
-
if (statusMatch[1].trim() !== "pending") continue;
|
|
4153
|
-
let parsedTopic = topicMatch[1].trim();
|
|
4154
|
-
if (parsedTopic.startsWith('"') && parsedTopic.endsWith('"')) {
|
|
4155
|
-
try {
|
|
4156
|
-
parsedTopic = JSON.parse(parsedTopic);
|
|
4157
|
-
} catch {
|
|
4158
|
-
}
|
|
4159
|
-
}
|
|
4160
|
-
topics.push({
|
|
4161
|
-
id: idMatch[1].trim(),
|
|
4162
|
-
topic: parsedTopic,
|
|
4163
|
-
source: sourceMatch[1].trim(),
|
|
4164
|
-
sourceCycle: sourceCycleMatch ? parseInt(sourceCycleMatch[1], 10) : void 0,
|
|
4165
|
-
status: "pending",
|
|
4166
|
-
createdAt: createdMatch[1].trim()
|
|
4167
|
-
});
|
|
4168
|
-
}
|
|
4169
|
-
return topics;
|
|
4170
|
-
}
|
|
4171
|
-
async markAgendaTopicsAddressed(ids, cycleNumber) {
|
|
4172
|
-
if (ids.length === 0) return;
|
|
4173
|
-
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
4174
|
-
if (!content) return;
|
|
4175
|
-
let updated = content;
|
|
4176
|
-
const addressedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4177
|
-
for (const id of ids) {
|
|
4178
|
-
const statusPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+status:\\s+)pending`);
|
|
4179
|
-
updated = updated.replace(statusPattern, `$1addressed`);
|
|
4180
|
-
const insertionAnchor = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+created_at:\\s+[^\\n]+)\\n`);
|
|
4181
|
-
const match = updated.match(insertionAnchor);
|
|
4182
|
-
if (match && !match[0].includes("addressed_at:")) {
|
|
4183
|
-
updated = updated.replace(insertionAnchor, `$1
|
|
4184
|
-
addressed_at: ${addressedAt}
|
|
4185
|
-
addressed_in_review: ${cycleNumber}
|
|
4186
|
-
`);
|
|
4187
|
-
}
|
|
4188
|
-
}
|
|
4189
|
-
await this.write("STRATEGY_REVIEW_AGENDA.md", updated);
|
|
4190
|
-
}
|
|
4191
|
-
// -------------------------------------------------------------------------
|
|
4192
|
-
// Decision Events & Scores (markdown persistence)
|
|
4193
|
-
// -------------------------------------------------------------------------
|
|
4194
|
-
async appendDecisionEvent(event) {
|
|
4195
|
-
const id = randomUUID6();
|
|
4196
|
-
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4197
|
-
const full = { ...event, id, createdAt };
|
|
4198
|
-
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
4199
|
-
const header = "# Decision Events\n\n";
|
|
4200
|
-
const entry = `## ${full.decisionId} | ${full.eventType} | Cycle ${full.cycle}
|
|
4201
|
-
- **id:** ${full.id}
|
|
4202
|
-
- **source:** ${full.source}
|
|
4203
|
-
` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
|
|
4204
|
-
` : "") + (full.detail ? `- **detail:** ${full.detail}
|
|
4205
|
-
` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
|
|
4206
|
-
` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
|
|
4207
|
-
` : "") + `- **createdAt:** ${full.createdAt}
|
|
4208
|
-
|
|
4209
|
-
---
|
|
4210
|
-
|
|
4211
|
-
`;
|
|
4212
|
-
if (!content) {
|
|
4213
|
-
await this.write("DECISION_EVENTS.md", header + entry);
|
|
4214
|
-
} else {
|
|
4215
|
-
await this.write("DECISION_EVENTS.md", content + entry);
|
|
4216
|
-
}
|
|
4217
|
-
return full;
|
|
4218
|
-
}
|
|
4219
|
-
async getDecisionEvents(decisionId, limit) {
|
|
4220
|
-
const all = await this.parseDecisionEvents();
|
|
4221
|
-
const filtered = all.filter((e) => e.decisionId === decisionId);
|
|
4222
|
-
return limit ? filtered.slice(0, limit) : filtered;
|
|
4223
|
-
}
|
|
4224
|
-
async getDecisionEventsSince(cycle) {
|
|
4225
|
-
const all = await this.parseDecisionEvents();
|
|
4226
|
-
return all.filter((e) => e.cycle >= cycle);
|
|
4227
|
-
}
|
|
4228
|
-
async parseDecisionEvents() {
|
|
4229
|
-
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
4230
|
-
if (!content) return [];
|
|
4231
|
-
const events = [];
|
|
4232
|
-
const blocks = content.split("---").filter((b) => b.trim());
|
|
4233
|
-
for (const block of blocks) {
|
|
4234
|
-
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+(\S+)\s+\|\s+Cycle\s+(\d+)/m);
|
|
4235
|
-
if (!headingMatch) continue;
|
|
4236
|
-
const idMatch = block.match(/\*\*id:\*\*\s+(.+)/);
|
|
4237
|
-
const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
|
|
4238
|
-
const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
|
|
4239
|
-
const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
|
|
4240
|
-
const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
|
|
4241
|
-
const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
|
|
4242
|
-
const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
|
|
4243
|
-
if (!idMatch || !sourceMatch || !createdAtMatch) continue;
|
|
4244
|
-
let metricDelta;
|
|
4245
|
-
if (metricDeltaMatch?.[1]) {
|
|
4246
|
-
try {
|
|
4247
|
-
metricDelta = JSON.parse(metricDeltaMatch[1].trim());
|
|
4248
|
-
} catch {
|
|
4249
|
-
metricDelta = null;
|
|
4250
|
-
}
|
|
4251
|
-
}
|
|
4252
|
-
events.push({
|
|
4253
|
-
id: idMatch[1].trim(),
|
|
4254
|
-
decisionId: headingMatch[1],
|
|
4255
|
-
eventType: headingMatch[2],
|
|
4256
|
-
cycle: parseInt(headingMatch[3], 10),
|
|
4257
|
-
source: sourceMatch[1].trim(),
|
|
4258
|
-
sourceRef: sourceRefMatch?.[1]?.trim(),
|
|
4259
|
-
detail: detailMatch?.[1]?.trim(),
|
|
4260
|
-
evidenceRef: evidenceRefMatch?.[1]?.trim(),
|
|
4261
|
-
metricDelta,
|
|
4262
|
-
createdAt: createdAtMatch[1].trim()
|
|
4263
|
-
});
|
|
4264
|
-
}
|
|
4265
|
-
return events;
|
|
4266
|
-
}
|
|
4267
|
-
async writeDecisionScore(score) {
|
|
4268
|
-
const id = randomUUID6();
|
|
4269
|
-
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
4270
|
-
const totalScore = score.effort + score.risk + score.reversibility + score.scaleCost + score.lockIn;
|
|
4271
|
-
const full = { ...score, id, totalScore, createdAt };
|
|
4272
|
-
const content = await this.readOptional("DECISION_SCORES.md");
|
|
4273
|
-
const header = "# Decision Scores\n\n";
|
|
4274
|
-
const entry = `## ${full.decisionId} | Cycle ${full.cycle}
|
|
4275
|
-
- **id:** ${full.id}
|
|
4276
|
-
- **effort:** ${full.effort}
|
|
4277
|
-
- **risk:** ${full.risk}
|
|
4278
|
-
- **reversibility:** ${full.reversibility}
|
|
4279
|
-
- **scaleCost:** ${full.scaleCost}
|
|
4280
|
-
- **lockIn:** ${full.lockIn}
|
|
4281
|
-
- **totalScore:** ${full.totalScore}
|
|
4282
|
-
` + (full.rationale ? `- **rationale:** ${full.rationale}
|
|
4283
|
-
` : "") + `- **createdAt:** ${full.createdAt}
|
|
4284
|
-
|
|
4285
|
-
---
|
|
4286
|
-
|
|
4287
|
-
`;
|
|
4288
|
-
if (!content) {
|
|
4289
|
-
await this.write("DECISION_SCORES.md", header + entry);
|
|
4290
|
-
} else {
|
|
4291
|
-
await this.write("DECISION_SCORES.md", content + entry);
|
|
4292
|
-
}
|
|
4293
|
-
return full;
|
|
4294
|
-
}
|
|
4295
|
-
async getDecisionScores(decisionId) {
|
|
4296
|
-
const all = await this.parseDecisionScores();
|
|
4297
|
-
return all.filter((s) => s.decisionId === decisionId);
|
|
4298
|
-
}
|
|
4299
|
-
async getLatestDecisionScores() {
|
|
4300
|
-
const all = await this.parseDecisionScores();
|
|
4301
|
-
const latest = /* @__PURE__ */ new Map();
|
|
4302
|
-
for (const s of all) {
|
|
4303
|
-
const existing = latest.get(s.decisionId);
|
|
4304
|
-
if (!existing || s.cycle > existing.cycle) {
|
|
4305
|
-
latest.set(s.decisionId, s);
|
|
4306
|
-
}
|
|
4307
|
-
}
|
|
4308
|
-
return Array.from(latest.values());
|
|
4309
|
-
}
|
|
4310
|
-
async parseDecisionScores() {
|
|
4311
|
-
const content = await this.readOptional("DECISION_SCORES.md");
|
|
4312
|
-
if (!content) return [];
|
|
4313
|
-
const scores = [];
|
|
4314
|
-
const blocks = content.split("---").filter((b) => b.trim());
|
|
4315
|
-
for (const block of blocks) {
|
|
4316
|
-
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+Cycle\s+(\d+)/m);
|
|
4317
|
-
if (!headingMatch) continue;
|
|
4318
|
-
const field = (name) => block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s+(.+)`))?.[1]?.trim();
|
|
4319
|
-
const id = field("id");
|
|
4320
|
-
const createdAt = field("createdAt");
|
|
4321
|
-
if (!id || !createdAt) continue;
|
|
4322
|
-
scores.push({
|
|
4323
|
-
id,
|
|
4324
|
-
decisionId: headingMatch[1],
|
|
4325
|
-
cycle: parseInt(headingMatch[2], 10),
|
|
4326
|
-
effort: parseInt(field("effort") ?? "0", 10),
|
|
4327
|
-
risk: parseInt(field("risk") ?? "0", 10),
|
|
4328
|
-
reversibility: parseInt(field("reversibility") ?? "0", 10),
|
|
4329
|
-
scaleCost: parseInt(field("scaleCost") ?? "0", 10),
|
|
4330
|
-
lockIn: parseInt(field("lockIn") ?? "0", 10),
|
|
4331
|
-
totalScore: parseInt(field("totalScore") ?? "0", 10),
|
|
4332
|
-
rationale: field("rationale"),
|
|
4333
|
-
createdAt
|
|
4334
|
-
});
|
|
4335
|
-
}
|
|
4336
|
-
return scores;
|
|
4337
|
-
}
|
|
4338
|
-
// Entity reference tracking — no-op for md adapter (DB-only feature)
|
|
4339
|
-
async logEntityReferences(_refs) {
|
|
4340
|
-
}
|
|
4341
|
-
async getDecisionUsage(_currentCycle) {
|
|
4342
|
-
return [];
|
|
4343
|
-
}
|
|
4344
|
-
// --- North Star ---
|
|
4345
|
-
async getCurrentNorthStar() {
|
|
4346
|
-
const content = await this.read("PLANNING_LOG.md");
|
|
4347
|
-
const ns = parseNorthStar(content);
|
|
4348
|
-
return ns || null;
|
|
4349
|
-
}
|
|
4350
|
-
async getNorthStarSetCycle() {
|
|
4351
|
-
return null;
|
|
4352
|
-
}
|
|
4353
|
-
async getNorthStarStaleness() {
|
|
4354
|
-
return null;
|
|
4355
|
-
}
|
|
4356
|
-
async upsertNorthStar(statement, _cycleNumber) {
|
|
4357
|
-
const content = await this.read("PLANNING_LOG.md");
|
|
4358
|
-
const updated = upsertNorthStarInContent(content, statement);
|
|
4359
|
-
await this.write("PLANNING_LOG.md", updated);
|
|
4360
|
-
}
|
|
4361
|
-
};
|
|
4362
|
-
|
|
4363
|
-
// src/lib/path-identity.ts
|
|
4364
|
-
import fs from "fs";
|
|
4365
|
-
import path from "path";
|
|
4366
|
-
function realpathOrSelf(p) {
|
|
4367
|
-
try {
|
|
4368
|
-
return fs.realpathSync(p);
|
|
4369
|
-
} catch {
|
|
4370
|
-
return p;
|
|
4371
|
-
}
|
|
4372
|
-
}
|
|
4373
|
-
function checkProjectPathIdentity(opts) {
|
|
4374
|
-
const { storedPapiDir, projectName, cwd } = opts;
|
|
4375
|
-
const log = opts.log ?? ((msg) => console.error(msg));
|
|
4376
|
-
const allowMigrate = opts.allowMigrate ?? (process.env.PAPI_ALLOW_PATH_MIGRATE === "1" || process.env.PAPI_ALLOW_PATH_MIGRATE === "true");
|
|
4377
|
-
const realCwd = realpathOrSelf(cwd);
|
|
4378
|
-
const expectedPapiDir = path.join(realCwd, ".papi");
|
|
4379
|
-
if (!storedPapiDir || storedPapiDir.trim() === "" || storedPapiDir.trim() === ".") {
|
|
4380
|
-
log(`[papi] Backfilling project root for '${projectName}' from current cwd: ${realCwd}`);
|
|
4381
|
-
return { action: "backfill", newPapiDir: expectedPapiDir };
|
|
4382
|
-
}
|
|
4383
|
-
const trimmed = storedPapiDir.trim();
|
|
4384
|
-
const endsInPapi = trimmed.endsWith("/.papi") || trimmed.endsWith("\\.papi");
|
|
4385
|
-
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(trimmed);
|
|
4386
|
-
const realStored = realpathOrSelf(trimmed);
|
|
4387
|
-
let storedProjectRoot;
|
|
4388
|
-
if (endsInPapi) {
|
|
4389
|
-
storedProjectRoot = isWindowsPath ? path.win32.dirname(realStored) : path.dirname(realStored);
|
|
4390
|
-
} else {
|
|
4391
|
-
storedProjectRoot = realStored;
|
|
4392
|
-
}
|
|
4393
|
-
if (storedProjectRoot === realCwd) {
|
|
4394
|
-
if (!endsInPapi) {
|
|
4395
|
-
log(`[papi] Normalising legacy papi_dir for '${projectName}': ${trimmed} \u2192 ${expectedPapiDir}`);
|
|
4396
|
-
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
4397
|
-
}
|
|
4398
|
-
return { action: "ok" };
|
|
4399
|
-
}
|
|
4400
|
-
if (allowMigrate) {
|
|
4401
|
-
log(
|
|
4402
|
-
`[papi] PAPI_ALLOW_PATH_MIGRATE set \u2014 updating project '${projectName}' root: ${storedProjectRoot} \u2192 ${realCwd}`
|
|
4403
|
-
);
|
|
4404
|
-
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
4405
|
-
}
|
|
4406
|
-
throw new Error(
|
|
4407
|
-
`PAPI is configured for project '${projectName}' which was set up in ${storedProjectRoot},
|
|
4408
|
-
but you're running in ${realCwd}.
|
|
4409
|
-
|
|
4410
|
-
To fix:
|
|
4411
|
-
- cd to the right project directory, OR
|
|
4412
|
-
- run \`setup\` to attach this directory to a project, OR
|
|
4413
|
-
- update PAPI_PROJECT_ID in .mcp.json if you intentionally moved the project, OR
|
|
4414
|
-
- set PAPI_ALLOW_PATH_MIGRATE=1 to update the stored path on next boot.`
|
|
4415
|
-
);
|
|
4416
|
-
}
|
|
4417
|
-
|
|
4418
|
-
// src/lib/project-resolution.ts
|
|
4419
|
-
function assertWorkspaceMatch(input) {
|
|
4420
|
-
if (!input.workspacePath) return null;
|
|
4421
|
-
return checkProjectPathIdentity({
|
|
4422
|
-
storedPapiDir: input.storedPapiDir,
|
|
4423
|
-
projectName: input.projectName,
|
|
4424
|
-
cwd: input.workspacePath,
|
|
4425
|
-
allowMigrate: input.allowMigrate,
|
|
4426
|
-
log: input.log
|
|
4427
|
-
});
|
|
4428
|
-
}
|
|
4429
|
-
|
|
4430
|
-
// src/adapter-factory.ts
|
|
4431
|
-
function detectUserId() {
|
|
4432
|
-
try {
|
|
4433
|
-
const email = execSync("git config user.email", { encoding: "utf8", timeout: 5e3 }).trim();
|
|
4434
|
-
if (email) return email;
|
|
4435
|
-
} catch {
|
|
4436
|
-
}
|
|
4437
|
-
try {
|
|
4438
|
-
const ghUser = execSync("gh api user --jq .email", { encoding: "utf8", timeout: 1e4 }).trim();
|
|
4439
|
-
if (ghUser && ghUser !== "null") return ghUser;
|
|
4440
|
-
} catch {
|
|
4441
|
-
}
|
|
4442
|
-
return void 0;
|
|
4443
|
-
}
|
|
4444
|
-
var HOSTED_SUPABASE_URL2 = process.env["PAPI_HOSTED_SUPABASE_URL"] ?? "https://guewgygcpcmrcoppihzx.supabase.co";
|
|
4445
|
-
var HOSTED_PROXY_ENDPOINT = `${HOSTED_SUPABASE_URL2}/functions/v1/data-proxy`;
|
|
4446
|
-
var PLACEHOLDER_PATTERNS = [
|
|
4447
|
-
"<YOUR_DATABASE_URL>",
|
|
4448
|
-
"your-database-url",
|
|
4449
|
-
"your_database_url",
|
|
4450
|
-
"placeholder",
|
|
4451
|
-
"example.com",
|
|
4452
|
-
"localhost:5432/dbname",
|
|
4453
|
-
"user:password@host"
|
|
4454
|
-
];
|
|
4455
|
-
function validateDatabaseUrl(connectionString) {
|
|
4456
|
-
const lower = connectionString.toLowerCase().trim();
|
|
4457
|
-
if (PLACEHOLDER_PATTERNS.some((p) => lower.includes(p.toLowerCase()))) {
|
|
4458
|
-
throw new Error(
|
|
4459
|
-
"DATABASE_URL contains a placeholder value and is not configured.\nReplace it with your actual Supabase connection string in .mcp.json.\nIf you don't have one yet, contact the PAPI admin for access."
|
|
4460
|
-
);
|
|
4461
|
-
}
|
|
4462
|
-
if (!lower.startsWith("postgres://") && !lower.startsWith("postgresql://")) {
|
|
4463
|
-
throw new Error(
|
|
4464
|
-
`DATABASE_URL must be a PostgreSQL connection string (postgres:// or postgresql://).
|
|
4465
|
-
Got: "${connectionString.slice(0, 30)}..."
|
|
4466
|
-
Check your .mcp.json configuration.`
|
|
2174
|
+
if (!lower.startsWith("postgres://") && !lower.startsWith("postgresql://")) {
|
|
2175
|
+
throw new Error(
|
|
2176
|
+
`DATABASE_URL must be a PostgreSQL connection string (postgres:// or postgresql://).
|
|
2177
|
+
Got: "${connectionString.slice(0, 30)}..."
|
|
2178
|
+
Check your .mcp.json configuration.`
|
|
4467
2179
|
);
|
|
4468
2180
|
}
|
|
4469
2181
|
}
|
|
@@ -4473,9 +2185,6 @@ async function createAdapter(optionsOrType, maybePapiDir) {
|
|
|
4473
2185
|
const options = typeof optionsOrType === "string" ? { adapterType: optionsOrType, papiDir: maybePapiDir } : optionsOrType;
|
|
4474
2186
|
const { adapterType, papiDir, papiEndpoint } = options;
|
|
4475
2187
|
switch (adapterType) {
|
|
4476
|
-
case "md":
|
|
4477
|
-
_connectionStatus = "offline";
|
|
4478
|
-
return new MdFileAdapter(papiDir);
|
|
4479
2188
|
case "pg": {
|
|
4480
2189
|
const { PgAdapter, PgPapiAdapter, configFromEnv } = await import("@papi-ai/adapter-pg");
|
|
4481
2190
|
let projectId = process.env["PAPI_PROJECT_ID"];
|
|
@@ -4788,12 +2497,116 @@ Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI
|
|
|
4788
2497
|
default: {
|
|
4789
2498
|
const _exhaustive = adapterType;
|
|
4790
2499
|
throw new Error(
|
|
4791
|
-
`Unknown PAPI_ADAPTER value: "${_exhaustive}". Valid options: "
|
|
2500
|
+
`Unknown PAPI_ADAPTER value: "${_exhaustive}". Valid options: "pg", "proxy".`
|
|
4792
2501
|
);
|
|
4793
2502
|
}
|
|
4794
2503
|
}
|
|
4795
2504
|
}
|
|
4796
2505
|
|
|
2506
|
+
// ../shared/dist/index.js
|
|
2507
|
+
var CAPABILITY_REGISTRY = [
|
|
2508
|
+
{ key: "prReviewer", label: "Auto code review", description: "Runs a code review of the branch diff before accepting.", step: "review" },
|
|
2509
|
+
{ key: "securityScan", label: "Security scan", description: "Flags a security pass on risk-tier changes at review time.", step: "review" },
|
|
2510
|
+
{ key: "changelog", label: "Changelog & cycle update", description: "Curates a cycle-update post when a release ships.", step: "release" },
|
|
2511
|
+
{ key: "verifyHealthCheck", label: "Release health check", description: "Reminds you to verify cycle state before release.", step: "release" },
|
|
2512
|
+
{ key: "gestaltPreBuild", label: "Gestalt pre-build check", description: "Reads the whole cycle before building the first task.", step: "build" },
|
|
2513
|
+
{ key: "batchBuildRollup", label: "Batch build rollup", description: "Emits a cross-task summary after a batch build.", step: "build" },
|
|
2514
|
+
{ key: "modelRecommendation", label: "Model recommendation", description: "Suggests a model tier for each task.", step: "build" },
|
|
2515
|
+
{ key: "discoveredIssues", label: "Discovered issues surfacing", description: "Surfaces issues logged during builds at release.", step: "release" },
|
|
2516
|
+
{ key: "publishDirective", label: "Publish & registry updates", description: "Announces the release and updates MCP registry listings.", step: "release" },
|
|
2517
|
+
// task-2482 (C319): the release quality gate. When on AND a gate command is
|
|
2518
|
+
// configured (PAPI_GATE), release makes the host LLM run that command and
|
|
2519
|
+
// BLOCKS the tag/merge on failure (fail-closed, AD-58 — PAPI never runs it).
|
|
2520
|
+
{ key: "releaseGate", label: "Release quality gate", description: "Runs your test/build command before release and blocks on failure.", step: "release" },
|
|
2521
|
+
// task-2491 (C320): post-release deploy hook — the deploy half of the ship→verify
|
|
2522
|
+
// loop. UNLIKE every other capability this one defaults OFF (defaultEnabled:false):
|
|
2523
|
+
// running a deploy command is destructive, so it is strictly opt-in. When ON AND
|
|
2524
|
+
// papi.deploy (PAPI_DEPLOY) is set, release emits a directive telling the HOST to
|
|
2525
|
+
// run the deploy command AFTER the merge/tag (AD-58 — PAPI never runs it) and
|
|
2526
|
+
// records a deploy_hook progress step so the reactive hub shows the deploy step.
|
|
2527
|
+
{ key: "deployHook", label: "Post-release deploy", description: "Runs your deploy command after a release merges.", step: "release", defaultEnabled: false },
|
|
2528
|
+
// task-2833 (C341): enforce the handoff's acceptanceCriteria[] at build-complete.
|
|
2529
|
+
// When ON, a completed:"yes" build whose handoff lists acceptance criteria must
|
|
2530
|
+
// pass acceptance_confirmed:true or build_execute returns the checklist and does
|
|
2531
|
+
// NOT mark the task Done (non-destructive — the report is not discarded). Defaults
|
|
2532
|
+
// OFF: it changes the completion contract, so it is strictly opt-in until a project
|
|
2533
|
+
// chooses to hold builds to their own acceptance criteria.
|
|
2534
|
+
{ key: "acceptanceGate", label: "Acceptance-criteria gate", description: "Requires confirming the handoff acceptance criteria before a build completes.", step: "build", defaultEnabled: false },
|
|
2535
|
+
// task-2325 (C344): configurable workflow guardrails — let a user opt out of the
|
|
2536
|
+
// git/branch/commit/PR ceremony and PAPI-meta framing so PAPI adapts to their
|
|
2537
|
+
// workflow instead of imposing one. Every toggle DEFAULTS ON (no defaultEnabled)
|
|
2538
|
+
// so current behaviour is byte-identical until the user flips it off. The no-git
|
|
2539
|
+
// fallback that these ride on top of is a separate task (task-2353).
|
|
2540
|
+
{ key: "autoBranch", label: "Auto branch", description: "Creates a feature branch per task/cycle at build start.", step: "build" },
|
|
2541
|
+
{ key: "autoCommit", label: "Auto commit", description: "Commits your work automatically when a build completes.", step: "build" },
|
|
2542
|
+
{ key: "autoPush", label: "Auto push & PR", description: "Pushes the branch and opens a pull request on build complete.", step: "build" },
|
|
2543
|
+
{ key: "papiMetaFraming", label: "PAPI self-referential framing", description: "Includes PAPI-meta build-discipline framing in handoff output.", step: "build" }
|
|
2544
|
+
];
|
|
2545
|
+
var CAPABILITY_KEYS = CAPABILITY_REGISTRY.map((c) => c.key);
|
|
2546
|
+
var WAB_WINDOW_DAYS = 7;
|
|
2547
|
+
var WAB_WEEK_MS = WAB_WINDOW_DAYS * 24 * 60 * 60 * 1e3;
|
|
2548
|
+
var EFFORT_SCALE = {
|
|
2549
|
+
XS: 1,
|
|
2550
|
+
S: 2,
|
|
2551
|
+
M: 3,
|
|
2552
|
+
L: 4,
|
|
2553
|
+
XL: 5
|
|
2554
|
+
};
|
|
2555
|
+
function effortOrdinal(effort) {
|
|
2556
|
+
if (typeof effort !== "string") return void 0;
|
|
2557
|
+
const normalized = effort.trim().toUpperCase();
|
|
2558
|
+
return EFFORT_SCALE[normalized];
|
|
2559
|
+
}
|
|
2560
|
+
function isUnparsedEffort(effort) {
|
|
2561
|
+
if (typeof effort !== "string" || effort.trim().length === 0) return false;
|
|
2562
|
+
return effortOrdinal(effort) === void 0;
|
|
2563
|
+
}
|
|
2564
|
+
function calculateCycleMetrics(reports, currentCycle, window = 5) {
|
|
2565
|
+
const recentReports = reports.filter(
|
|
2566
|
+
(r) => r.cycle > currentCycle - window && r.cycle <= currentCycle
|
|
2567
|
+
);
|
|
2568
|
+
const unparsedEffortCount = recentReports.filter(
|
|
2569
|
+
(r) => isUnparsedEffort(r.actualEffort) || isUnparsedEffort(r.estimatedEffort)
|
|
2570
|
+
).length;
|
|
2571
|
+
const perCycle = /* @__PURE__ */ new Map();
|
|
2572
|
+
for (const r of recentReports) {
|
|
2573
|
+
const group = perCycle.get(r.cycle) ?? [];
|
|
2574
|
+
group.push(r);
|
|
2575
|
+
perCycle.set(r.cycle, group);
|
|
2576
|
+
}
|
|
2577
|
+
const accuracy = [];
|
|
2578
|
+
const velocity = [];
|
|
2579
|
+
const sortedCycles = [...perCycle.keys()].sort((a, b) => a - b);
|
|
2580
|
+
for (const cycle of sortedCycles) {
|
|
2581
|
+
const reps = perCycle.get(cycle);
|
|
2582
|
+
const deltas = [];
|
|
2583
|
+
for (const r of reps) {
|
|
2584
|
+
const actual = effortOrdinal(r.actualEffort);
|
|
2585
|
+
const estimated = effortOrdinal(r.estimatedEffort);
|
|
2586
|
+
if (actual !== void 0 && estimated !== void 0) {
|
|
2587
|
+
deltas.push(actual - estimated);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
if (deltas.length > 0) {
|
|
2591
|
+
accuracy.push({
|
|
2592
|
+
cycle,
|
|
2593
|
+
reports: deltas.length,
|
|
2594
|
+
matchRate: Math.round(deltas.filter((d) => d === 0).length / deltas.length * 100),
|
|
2595
|
+
mae: Math.round(deltas.reduce((s, d) => s + Math.abs(d), 0) / deltas.length * 10) / 10,
|
|
2596
|
+
bias: Math.round(deltas.reduce((s, d) => s + d, 0) / deltas.length * 10) / 10
|
|
2597
|
+
});
|
|
2598
|
+
}
|
|
2599
|
+
velocity.push({
|
|
2600
|
+
cycle,
|
|
2601
|
+
completed: reps.filter((r) => r.completed === "Yes").length,
|
|
2602
|
+
partial: reps.filter((r) => r.completed === "Partial").length,
|
|
2603
|
+
failed: reps.filter((r) => r.completed === "No").length,
|
|
2604
|
+
effortPoints: reps.reduce((s, r) => s + (effortOrdinal(r.actualEffort) ?? 0), 0)
|
|
2605
|
+
});
|
|
2606
|
+
}
|
|
2607
|
+
return { accuracy, velocity, unparsedEffortCount };
|
|
2608
|
+
}
|
|
2609
|
+
|
|
4797
2610
|
// src/lib/formatters.ts
|
|
4798
2611
|
var PLAN_FULL_NOTES_BUDGET_BYTES = Number(process.env.PAPI_PLAN_NOTES_BUDGET) || 6e4;
|
|
4799
2612
|
function effortWeight(size) {
|