@papi-ai/server 0.7.34 → 0.7.35
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 +4329 -0
- package/dist/index.js +1694 -489
- package/dist/prompts.js +58 -8
- package/package.json +2 -1
- package/skills/papi-cycle/papi-plan/SKILL.md +14 -0
- package/skills/papi-cycle/papi-strategy/SKILL.md +1 -1
|
@@ -0,0 +1,4329 @@
|
|
|
1
|
+
var __defProp = Object.defineProperty;
|
|
2
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
3
|
+
var __esm = (fn, res) => function __init() {
|
|
4
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
5
|
+
};
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/lib/git.ts
|
|
12
|
+
var git_exports = {};
|
|
13
|
+
__export(git_exports, {
|
|
14
|
+
AUTO_WRITTEN_PATHS: () => AUTO_WRITTEN_PATHS,
|
|
15
|
+
branchExists: () => branchExists,
|
|
16
|
+
checkoutBranch: () => checkoutBranch,
|
|
17
|
+
commitStagedOnly: () => commitStagedOnly,
|
|
18
|
+
createAndCheckoutBranch: () => createAndCheckoutBranch,
|
|
19
|
+
createPullRequest: () => createPullRequest,
|
|
20
|
+
createTag: () => createTag,
|
|
21
|
+
cycleBranchName: () => cycleBranchName,
|
|
22
|
+
deleteLocalBranch: () => deleteLocalBranch,
|
|
23
|
+
detectBoardMismatches: () => detectBoardMismatches,
|
|
24
|
+
detectUnrecordedCommits: () => detectUnrecordedCommits,
|
|
25
|
+
ensureLatestDevelop: () => ensureLatestDevelop,
|
|
26
|
+
ensureTagAtHead: () => ensureTagAtHead,
|
|
27
|
+
getBranchDiff: () => getBranchDiff,
|
|
28
|
+
getCommitsSinceTag: () => getCommitsSinceTag,
|
|
29
|
+
getCurrentBranch: () => getCurrentBranch,
|
|
30
|
+
getDocPathsTouchedOnBranch: () => getDocPathsTouchedOnBranch,
|
|
31
|
+
getFilesChangedBetween: () => getFilesChangedBetween,
|
|
32
|
+
getFilesChangedFromBase: () => getFilesChangedFromBase,
|
|
33
|
+
getHeadCommitSha: () => getHeadCommitSha,
|
|
34
|
+
getHeadCommitSubject: () => getHeadCommitSubject,
|
|
35
|
+
getLastCommitFiles: () => getLastCommitFiles,
|
|
36
|
+
getLatestTag: () => getLatestTag,
|
|
37
|
+
getModifiedFiles: () => getModifiedFiles,
|
|
38
|
+
getOriginRepoSlug: () => getOriginRepoSlug,
|
|
39
|
+
getOriginUrl: () => getOriginUrl,
|
|
40
|
+
getPullRequestUrl: () => getPullRequestUrl,
|
|
41
|
+
getRemoteBranchFiles: () => getRemoteBranchFiles,
|
|
42
|
+
getRootCommitHash: () => getRootCommitHash,
|
|
43
|
+
getStagedFiles: () => getStagedFiles,
|
|
44
|
+
getTagTarget: () => getTagTarget,
|
|
45
|
+
getTaskIdsOnBranch: () => getTaskIdsOnBranch,
|
|
46
|
+
getUnmergedBranches: () => getUnmergedBranches,
|
|
47
|
+
getUntrackedFiles: () => getUntrackedFiles,
|
|
48
|
+
gitPull: () => gitPull,
|
|
49
|
+
gitPush: () => gitPush,
|
|
50
|
+
hasRemote: () => hasRemote,
|
|
51
|
+
hasUncommittedChanges: () => hasUncommittedChanges,
|
|
52
|
+
hasUnpushedCommits: () => hasUnpushedCommits,
|
|
53
|
+
isBranchMergedInto: () => isBranchMergedInto,
|
|
54
|
+
isGhAvailable: () => isGhAvailable,
|
|
55
|
+
isGitAvailable: () => isGitAvailable,
|
|
56
|
+
isGitRepo: () => isGitRepo,
|
|
57
|
+
listGroupedCycleBranches: () => listGroupedCycleBranches,
|
|
58
|
+
listOrphanFeatBranches: () => listOrphanFeatBranches,
|
|
59
|
+
mergePullRequest: () => mergePullRequest,
|
|
60
|
+
normalizeGitUrl: () => normalizeGitUrl,
|
|
61
|
+
pickModuleCycleBranch: () => pickModuleCycleBranch,
|
|
62
|
+
resolveBaseBranch: () => resolveBaseBranch,
|
|
63
|
+
runAutoCommit: () => runAutoCommit,
|
|
64
|
+
squashMergePullRequest: () => squashMergePullRequest,
|
|
65
|
+
stageAllAndCommit: () => stageAllAndCommit,
|
|
66
|
+
stageDirAndCommit: () => stageDirAndCommit,
|
|
67
|
+
stagePathsAndCommit: () => stagePathsAndCommit,
|
|
68
|
+
tagExists: () => tagExists,
|
|
69
|
+
taskBranchName: () => taskBranchName,
|
|
70
|
+
withBaseBranchSync: () => withBaseBranchSync
|
|
71
|
+
});
|
|
72
|
+
import { execFileSync } from "child_process";
|
|
73
|
+
function isGitAvailable() {
|
|
74
|
+
try {
|
|
75
|
+
execFileSync("git", ["--version"], { stdio: "ignore" });
|
|
76
|
+
return true;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function isGitRepo(cwd) {
|
|
82
|
+
try {
|
|
83
|
+
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], {
|
|
84
|
+
cwd,
|
|
85
|
+
stdio: "ignore"
|
|
86
|
+
});
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function stageDirAndCommit(cwd, dir, message) {
|
|
93
|
+
try {
|
|
94
|
+
execFileSync("git", ["check-ignore", "-q", dir], { cwd });
|
|
95
|
+
return { committed: false, message: `Skipped commit \u2014 '${dir}' is gitignored.` };
|
|
96
|
+
} catch {
|
|
97
|
+
}
|
|
98
|
+
execFileSync("git", ["add", dir], { cwd });
|
|
99
|
+
const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
100
|
+
cwd,
|
|
101
|
+
encoding: "utf-8"
|
|
102
|
+
}).trim();
|
|
103
|
+
if (!staged) {
|
|
104
|
+
return { committed: false, message: "No changes to commit." };
|
|
105
|
+
}
|
|
106
|
+
execFileSync("git", ["commit", "-m", message], { cwd, encoding: "utf-8" });
|
|
107
|
+
return { committed: true, message };
|
|
108
|
+
}
|
|
109
|
+
function stageAllAndCommit(cwd, message) {
|
|
110
|
+
execFileSync("git", ["add", "."], { cwd });
|
|
111
|
+
const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
112
|
+
cwd,
|
|
113
|
+
encoding: "utf-8"
|
|
114
|
+
}).trim();
|
|
115
|
+
if (!staged) {
|
|
116
|
+
return { committed: false, message: "No changes to commit." };
|
|
117
|
+
}
|
|
118
|
+
execFileSync("git", ["commit", "-m", message], { cwd, encoding: "utf-8" });
|
|
119
|
+
return { committed: true, message };
|
|
120
|
+
}
|
|
121
|
+
function commitStagedOnly(cwd, message) {
|
|
122
|
+
const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
123
|
+
cwd,
|
|
124
|
+
encoding: "utf-8"
|
|
125
|
+
}).trim();
|
|
126
|
+
if (!staged) {
|
|
127
|
+
return { committed: false, message: "No staged changes to commit." };
|
|
128
|
+
}
|
|
129
|
+
execFileSync("git", ["commit", "-m", message], { cwd, encoding: "utf-8" });
|
|
130
|
+
return { committed: true, message };
|
|
131
|
+
}
|
|
132
|
+
function stagePathsAndCommit(cwd, paths, message) {
|
|
133
|
+
if (paths.length === 0) {
|
|
134
|
+
return { committed: false, message: "No paths to commit." };
|
|
135
|
+
}
|
|
136
|
+
execFileSync("git", ["add", "--", ...paths], { cwd });
|
|
137
|
+
const staged = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
138
|
+
cwd,
|
|
139
|
+
encoding: "utf-8"
|
|
140
|
+
}).trim();
|
|
141
|
+
if (!staged) {
|
|
142
|
+
return { committed: false, message: "No changes to commit." };
|
|
143
|
+
}
|
|
144
|
+
execFileSync("git", ["commit", "-m", message], { cwd, encoding: "utf-8" });
|
|
145
|
+
return { committed: true, message };
|
|
146
|
+
}
|
|
147
|
+
function getStagedFiles(cwd) {
|
|
148
|
+
try {
|
|
149
|
+
const out = execFileSync("git", ["diff", "--cached", "--name-only"], {
|
|
150
|
+
cwd,
|
|
151
|
+
encoding: "utf-8"
|
|
152
|
+
}).trim();
|
|
153
|
+
if (!out) return [];
|
|
154
|
+
return out.split("\n").filter((l) => l.length > 0);
|
|
155
|
+
} catch {
|
|
156
|
+
return [];
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function getModifiedFiles(cwd) {
|
|
160
|
+
try {
|
|
161
|
+
const out = execFileSync("git", ["status", "--porcelain"], {
|
|
162
|
+
cwd,
|
|
163
|
+
encoding: "utf-8"
|
|
164
|
+
}).replace(/\n+$/, "");
|
|
165
|
+
if (!out) return [];
|
|
166
|
+
const paths = /* @__PURE__ */ new Set();
|
|
167
|
+
for (const line of out.split("\n")) {
|
|
168
|
+
if (line.length < 4) continue;
|
|
169
|
+
const path3 = line.slice(3).trim();
|
|
170
|
+
if (path3) paths.add(path3);
|
|
171
|
+
}
|
|
172
|
+
return Array.from(paths);
|
|
173
|
+
} catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function getUntrackedFiles(cwd) {
|
|
178
|
+
try {
|
|
179
|
+
const out = execFileSync("git", ["ls-files", "--others", "--exclude-standard"], {
|
|
180
|
+
cwd,
|
|
181
|
+
encoding: "utf-8"
|
|
182
|
+
}).replace(/\n+$/, "");
|
|
183
|
+
if (!out) return [];
|
|
184
|
+
return out.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
185
|
+
} catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function getBranchDiff(cwd, base = "origin/main", maxBytes = 2e5) {
|
|
190
|
+
const refs = [`${base}...HEAD`, "main...HEAD"];
|
|
191
|
+
for (const ref of refs) {
|
|
192
|
+
try {
|
|
193
|
+
const out = execFileSync("git", ["diff", ref], {
|
|
194
|
+
cwd,
|
|
195
|
+
encoding: "utf-8",
|
|
196
|
+
maxBuffer: 32 * 1024 * 1024
|
|
197
|
+
});
|
|
198
|
+
if (out) {
|
|
199
|
+
return out.length > maxBytes ? `${out.slice(0, maxBytes)}
|
|
200
|
+
|
|
201
|
+
... [diff truncated at ${Math.round(maxBytes / 1024)} KB]` : out;
|
|
202
|
+
}
|
|
203
|
+
return "";
|
|
204
|
+
} catch {
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return "";
|
|
208
|
+
}
|
|
209
|
+
function getHeadCommitSubject(cwd) {
|
|
210
|
+
try {
|
|
211
|
+
const out = execFileSync("git", ["log", "-1", "--format=%s"], {
|
|
212
|
+
cwd,
|
|
213
|
+
encoding: "utf-8"
|
|
214
|
+
}).trim();
|
|
215
|
+
return out || null;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function getCurrentBranch(cwd) {
|
|
221
|
+
try {
|
|
222
|
+
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
223
|
+
cwd,
|
|
224
|
+
encoding: "utf-8"
|
|
225
|
+
}).trim();
|
|
226
|
+
} catch {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function hasUncommittedChanges(cwd, ignore) {
|
|
231
|
+
try {
|
|
232
|
+
const args = ["status", "--porcelain"];
|
|
233
|
+
if (ignore?.length) {
|
|
234
|
+
args.push("--", ".", ...ignore.map((p) => `:!${p}`));
|
|
235
|
+
}
|
|
236
|
+
const status = execFileSync("git", args, {
|
|
237
|
+
cwd,
|
|
238
|
+
encoding: "utf-8"
|
|
239
|
+
}).trim();
|
|
240
|
+
return status.length > 0;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function branchExists(cwd, branch) {
|
|
246
|
+
try {
|
|
247
|
+
execFileSync("git", ["rev-parse", "--verify", branch], {
|
|
248
|
+
cwd,
|
|
249
|
+
stdio: "ignore"
|
|
250
|
+
});
|
|
251
|
+
return true;
|
|
252
|
+
} catch {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function checkoutBranch(cwd, branch) {
|
|
257
|
+
try {
|
|
258
|
+
execFileSync("git", ["checkout", branch], { cwd, encoding: "utf-8" });
|
|
259
|
+
return { success: true, message: `Checked out branch '${branch}'.` };
|
|
260
|
+
} catch {
|
|
261
|
+
return { success: false, message: `Failed to checkout branch '${branch}'.` };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function createAndCheckoutBranch(cwd, branch) {
|
|
265
|
+
try {
|
|
266
|
+
execFileSync("git", ["checkout", "-b", branch], { cwd, encoding: "utf-8" });
|
|
267
|
+
return { success: true, message: `Created and checked out branch '${branch}'.` };
|
|
268
|
+
} catch {
|
|
269
|
+
return { success: false, message: `Failed to create branch '${branch}'.` };
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function hasRemote(cwd, remote = "origin") {
|
|
273
|
+
try {
|
|
274
|
+
const remotes = execFileSync("git", ["remote"], {
|
|
275
|
+
cwd,
|
|
276
|
+
encoding: "utf-8"
|
|
277
|
+
}).trim();
|
|
278
|
+
return remotes.split("\n").includes(remote);
|
|
279
|
+
} catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
function gitPull(cwd) {
|
|
284
|
+
try {
|
|
285
|
+
execFileSync("git", ["pull"], { cwd, encoding: "utf-8", timeout: GIT_NETWORK_TIMEOUT_MS });
|
|
286
|
+
return { success: true, message: "Pulled latest changes." };
|
|
287
|
+
} catch (err) {
|
|
288
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
289
|
+
const isTimeout = msg.includes("ETIMEDOUT") || msg.includes("killed");
|
|
290
|
+
return {
|
|
291
|
+
success: false,
|
|
292
|
+
message: isTimeout ? "Pull timed out after 30s." : `Pull failed: ${msg}`
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function gitPush(cwd, branch) {
|
|
297
|
+
try {
|
|
298
|
+
execFileSync("git", ["push", "-u", "origin", branch], {
|
|
299
|
+
cwd,
|
|
300
|
+
encoding: "utf-8",
|
|
301
|
+
timeout: GIT_NETWORK_TIMEOUT_MS
|
|
302
|
+
});
|
|
303
|
+
return { success: true, message: `Pushed branch '${branch}' to origin.` };
|
|
304
|
+
} catch (err) {
|
|
305
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
306
|
+
const isTimeout = msg.includes("ETIMEDOUT") || msg.includes("killed");
|
|
307
|
+
return {
|
|
308
|
+
success: false,
|
|
309
|
+
message: isTimeout ? `Push timed out after 30s.` : `Push failed: ${msg}`
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
function isGhAvailable() {
|
|
314
|
+
try {
|
|
315
|
+
execFileSync("gh", ["--version"], { stdio: "ignore" });
|
|
316
|
+
return true;
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function getOriginRepoSlug(cwd) {
|
|
322
|
+
try {
|
|
323
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
324
|
+
cwd,
|
|
325
|
+
encoding: "utf-8"
|
|
326
|
+
}).trim();
|
|
327
|
+
const sshMatch = url.match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
328
|
+
if (sshMatch) return sshMatch[1];
|
|
329
|
+
const httpsMatch = url.match(/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
|
330
|
+
if (httpsMatch) return httpsMatch[1];
|
|
331
|
+
return null;
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function getRootCommitHash(cwd) {
|
|
337
|
+
try {
|
|
338
|
+
const hash = execFileSync("git", ["rev-list", "--max-parents=0", "HEAD"], {
|
|
339
|
+
cwd,
|
|
340
|
+
encoding: "utf-8"
|
|
341
|
+
}).trim();
|
|
342
|
+
return hash || null;
|
|
343
|
+
} catch {
|
|
344
|
+
return null;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
function getOriginUrl(cwd) {
|
|
348
|
+
try {
|
|
349
|
+
const url = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
350
|
+
cwd,
|
|
351
|
+
encoding: "utf-8"
|
|
352
|
+
}).trim();
|
|
353
|
+
return url || null;
|
|
354
|
+
} catch {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
function normalizeGitUrl(url) {
|
|
359
|
+
if (!url) return null;
|
|
360
|
+
const normalized = url.trim();
|
|
361
|
+
const sshMatch = normalized.match(/^git@([^:]+):(.+?)(?:\.git)?$/);
|
|
362
|
+
if (sshMatch) {
|
|
363
|
+
return `https://${sshMatch[1]}/${sshMatch[2]}`;
|
|
364
|
+
}
|
|
365
|
+
const sshProtoMatch = normalized.match(/^ssh:\/\/git@([^/]+)\/(.+?)(?:\.git)?$/);
|
|
366
|
+
if (sshProtoMatch) {
|
|
367
|
+
return `https://${sshProtoMatch[1]}/${sshProtoMatch[2]}`;
|
|
368
|
+
}
|
|
369
|
+
if (normalized.startsWith("https://") || normalized.startsWith("http://")) {
|
|
370
|
+
return normalized.replace(/\.git$/, "");
|
|
371
|
+
}
|
|
372
|
+
return null;
|
|
373
|
+
}
|
|
374
|
+
function createPullRequest(cwd, branch, baseBranch, title, body) {
|
|
375
|
+
try {
|
|
376
|
+
const args = ["pr", "create", "--base", baseBranch, "--head", branch, "--title", title, "--body", body];
|
|
377
|
+
const repo = getOriginRepoSlug(cwd);
|
|
378
|
+
if (repo) {
|
|
379
|
+
args.push("--repo", repo);
|
|
380
|
+
}
|
|
381
|
+
const output = execFileSync("gh", args, { cwd, encoding: "utf-8" }).trim();
|
|
382
|
+
return { success: true, message: output };
|
|
383
|
+
} catch (err) {
|
|
384
|
+
return {
|
|
385
|
+
success: false,
|
|
386
|
+
message: `PR creation failed: ${err instanceof Error ? err.message : String(err)}`
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function sleepSync(ms) {
|
|
391
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
392
|
+
}
|
|
393
|
+
function mergePullRequest(cwd, branch) {
|
|
394
|
+
const repo = getOriginRepoSlug(cwd);
|
|
395
|
+
const baseArgs = ["pr", "merge", branch, "--merge", "--delete-branch"];
|
|
396
|
+
if (repo) {
|
|
397
|
+
baseArgs.push("--repo", repo);
|
|
398
|
+
}
|
|
399
|
+
for (let attempt = 1; attempt <= MERGE_MAX_RETRIES; attempt++) {
|
|
400
|
+
try {
|
|
401
|
+
execFileSync("gh", baseArgs, { cwd, encoding: "utf-8" });
|
|
402
|
+
return { success: true, message: `Merged PR for '${branch}' and deleted branch.` };
|
|
403
|
+
} catch (err) {
|
|
404
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
405
|
+
const isNotMergeable = msg.includes("not mergeable");
|
|
406
|
+
if (isNotMergeable && attempt < MERGE_MAX_RETRIES) {
|
|
407
|
+
sleepSync(MERGE_RETRY_DELAY_MS);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
return { success: false, message: `PR merge failed: ${msg}` };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { success: false, message: "PR merge failed: max retries exceeded" };
|
|
414
|
+
}
|
|
415
|
+
function deleteLocalBranch(cwd, branch) {
|
|
416
|
+
try {
|
|
417
|
+
execFileSync("git", ["branch", "-d", branch], { cwd, encoding: "utf-8" });
|
|
418
|
+
return { success: true, message: `Deleted local branch '${branch}'.` };
|
|
419
|
+
} catch (err) {
|
|
420
|
+
return {
|
|
421
|
+
success: false,
|
|
422
|
+
message: `Failed to delete local branch '${branch}': ${err instanceof Error ? err.message : String(err)}`
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function tagExists(cwd, tag) {
|
|
427
|
+
try {
|
|
428
|
+
execFileSync("git", ["rev-parse", "--verify", `refs/tags/${tag}`], {
|
|
429
|
+
cwd,
|
|
430
|
+
stdio: "ignore"
|
|
431
|
+
});
|
|
432
|
+
return true;
|
|
433
|
+
} catch {
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
function createTag(cwd, tag, message) {
|
|
438
|
+
try {
|
|
439
|
+
execFileSync("git", ["tag", "-a", tag, "-m", message], { cwd, encoding: "utf-8" });
|
|
440
|
+
return { success: true, message: `Created tag '${tag}'.` };
|
|
441
|
+
} catch (err) {
|
|
442
|
+
return {
|
|
443
|
+
success: false,
|
|
444
|
+
message: `Failed to create tag: ${err instanceof Error ? err.message : String(err)}`
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function getTagTarget(cwd, tag) {
|
|
449
|
+
try {
|
|
450
|
+
return execFileSync("git", ["rev-parse", `${tag}^{commit}`], {
|
|
451
|
+
cwd,
|
|
452
|
+
encoding: "utf-8"
|
|
453
|
+
}).trim() || null;
|
|
454
|
+
} catch {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function ensureTagAtHead(cwd, tag, message) {
|
|
459
|
+
if (!tagExists(cwd, tag)) {
|
|
460
|
+
return createTag(cwd, tag, message);
|
|
461
|
+
}
|
|
462
|
+
const target = getTagTarget(cwd, tag);
|
|
463
|
+
const head = getHeadCommitSha(cwd);
|
|
464
|
+
if (target && head && target === head) {
|
|
465
|
+
return {
|
|
466
|
+
success: true,
|
|
467
|
+
message: `Tag '${tag}' already exists at the current commit \u2014 continuing (partial-release recovery).`
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
success: false,
|
|
472
|
+
message: `tag "${tag}" already exists but points at ${target ? target.slice(0, 7) : "an unknown commit"}, not the current HEAD (${head ? head.slice(0, 7) : "unknown"}). If it is left over from an aborted release, delete it and re-run release: \`git tag -d ${tag}\` (and \`git push origin :refs/tags/${tag}\` if it was pushed). Otherwise use a different version.`
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
function getLatestTag(cwd) {
|
|
476
|
+
try {
|
|
477
|
+
return execFileSync("git", ["describe", "--tags", "--abbrev=0"], {
|
|
478
|
+
cwd,
|
|
479
|
+
encoding: "utf-8"
|
|
480
|
+
}).trim() || null;
|
|
481
|
+
} catch {
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
function getCommitsSinceTag(cwd, tag) {
|
|
486
|
+
try {
|
|
487
|
+
const output = execFileSync(
|
|
488
|
+
"git",
|
|
489
|
+
["log", `${tag}..HEAD`, "--oneline"],
|
|
490
|
+
{ cwd, encoding: "utf-8" }
|
|
491
|
+
).trim();
|
|
492
|
+
return output ? output.split("\n") : [];
|
|
493
|
+
} catch {
|
|
494
|
+
return [];
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
function getTaskIdsOnBranch(cwd, branch, baseBranch) {
|
|
498
|
+
try {
|
|
499
|
+
const output = execFileSync(
|
|
500
|
+
"git",
|
|
501
|
+
["log", `${baseBranch}..${branch}`, "--format=%s"],
|
|
502
|
+
{ cwd, encoding: "utf-8" }
|
|
503
|
+
).trim();
|
|
504
|
+
if (!output) return [];
|
|
505
|
+
const ids = /* @__PURE__ */ new Set();
|
|
506
|
+
const taskIdRe = /\((task-\d+)\)/g;
|
|
507
|
+
for (const subject of output.split("\n")) {
|
|
508
|
+
let match;
|
|
509
|
+
while ((match = taskIdRe.exec(subject)) !== null) {
|
|
510
|
+
ids.add(match[1]);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return Array.from(ids);
|
|
514
|
+
} catch {
|
|
515
|
+
return [];
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
function getDocPathsTouchedOnBranch(cwd, branch, baseBranch) {
|
|
519
|
+
try {
|
|
520
|
+
const output = execFileSync(
|
|
521
|
+
"git",
|
|
522
|
+
[
|
|
523
|
+
"log",
|
|
524
|
+
`${baseBranch}..${branch}`,
|
|
525
|
+
"--name-only",
|
|
526
|
+
"--pretty=format:",
|
|
527
|
+
"--",
|
|
528
|
+
"CLAUDE.md",
|
|
529
|
+
"**/CLAUDE.md",
|
|
530
|
+
"docs/**"
|
|
531
|
+
],
|
|
532
|
+
{ cwd, encoding: "utf-8" }
|
|
533
|
+
).trim();
|
|
534
|
+
if (!output) return [];
|
|
535
|
+
const paths = /* @__PURE__ */ new Set();
|
|
536
|
+
for (const line of output.split("\n")) {
|
|
537
|
+
const trimmed = line.trim();
|
|
538
|
+
if (trimmed.length > 0) paths.add(trimmed);
|
|
539
|
+
}
|
|
540
|
+
return Array.from(paths);
|
|
541
|
+
} catch {
|
|
542
|
+
return [];
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async function withBaseBranchSync(config, fn) {
|
|
546
|
+
const warnings = [];
|
|
547
|
+
if (!isGitAvailable() || !isGitRepo(config.projectRoot)) {
|
|
548
|
+
return { result: await fn(), warnings };
|
|
549
|
+
}
|
|
550
|
+
const baseBranch = resolveBaseBranch(config.projectRoot, config.baseBranch);
|
|
551
|
+
if (baseBranch !== config.baseBranch) {
|
|
552
|
+
warnings.push(`Base branch '${config.baseBranch}' not found \u2014 using '${baseBranch}'.`);
|
|
553
|
+
}
|
|
554
|
+
const currentBranch = getCurrentBranch(config.projectRoot);
|
|
555
|
+
const needsBranchSwitch = currentBranch !== null && currentBranch !== baseBranch;
|
|
556
|
+
let previousBranch = null;
|
|
557
|
+
if (needsBranchSwitch && hasUncommittedChanges(config.projectRoot, AUTO_WRITTEN_PATHS)) {
|
|
558
|
+
warnings.push("Skipping pull \u2014 uncommitted changes detected. Board data may be stale.");
|
|
559
|
+
} else if (needsBranchSwitch) {
|
|
560
|
+
const checkout = checkoutBranch(config.projectRoot, baseBranch);
|
|
561
|
+
if (!checkout.success) {
|
|
562
|
+
warnings.push(`Could not switch to ${baseBranch}: ${checkout.message} Board data may be stale.`);
|
|
563
|
+
} else {
|
|
564
|
+
previousBranch = currentBranch;
|
|
565
|
+
if (hasRemote(config.projectRoot)) {
|
|
566
|
+
const pull = gitPull(config.projectRoot);
|
|
567
|
+
if (!pull.success && config.abortOnConflict && /conflict/i.test(pull.message)) {
|
|
568
|
+
checkoutBranch(config.projectRoot, previousBranch);
|
|
569
|
+
return { result: void 0, warnings, abort: pull.message };
|
|
570
|
+
}
|
|
571
|
+
warnings.push(pull.success ? `Synced from ${baseBranch}.` : `Pull failed: ${pull.message}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
} else if (hasRemote(config.projectRoot)) {
|
|
575
|
+
const pull = gitPull(config.projectRoot);
|
|
576
|
+
if (!pull.success && config.abortOnConflict && /conflict/i.test(pull.message)) {
|
|
577
|
+
return { result: void 0, warnings, abort: pull.message };
|
|
578
|
+
}
|
|
579
|
+
warnings.push(pull.success ? `Synced from ${baseBranch}.` : `Pull failed: ${pull.message}`);
|
|
580
|
+
}
|
|
581
|
+
const result = await fn();
|
|
582
|
+
if (previousBranch) {
|
|
583
|
+
checkoutBranch(config.projectRoot, previousBranch);
|
|
584
|
+
}
|
|
585
|
+
return { result, warnings };
|
|
586
|
+
}
|
|
587
|
+
function hasUnpushedCommits(cwd) {
|
|
588
|
+
try {
|
|
589
|
+
const output = execFileSync(
|
|
590
|
+
"git",
|
|
591
|
+
["rev-list", "@{u}..HEAD", "--count"],
|
|
592
|
+
{ cwd, encoding: "utf-8" }
|
|
593
|
+
).trim();
|
|
594
|
+
return parseInt(output, 10) > 0;
|
|
595
|
+
} catch {
|
|
596
|
+
return false;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function ensureLatestDevelop(cwd, baseBranch) {
|
|
600
|
+
if (!isGitAvailable() || !isGitRepo(cwd)) {
|
|
601
|
+
return { pulled: false };
|
|
602
|
+
}
|
|
603
|
+
const current = getCurrentBranch(cwd);
|
|
604
|
+
if (!current || current !== baseBranch) {
|
|
605
|
+
return { pulled: false, warning: `Skipping pull \u2014 not on ${baseBranch} (on ${current ?? "unknown"}).` };
|
|
606
|
+
}
|
|
607
|
+
if (hasUncommittedChanges(cwd, AUTO_WRITTEN_PATHS)) {
|
|
608
|
+
return { pulled: false, warning: "Skipping pull \u2014 uncommitted changes detected." };
|
|
609
|
+
}
|
|
610
|
+
if (hasUnpushedCommits(cwd)) {
|
|
611
|
+
return { pulled: false, warning: "Skipping pull \u2014 unpushed commits on current branch." };
|
|
612
|
+
}
|
|
613
|
+
if (!hasRemote(cwd)) {
|
|
614
|
+
return { pulled: false };
|
|
615
|
+
}
|
|
616
|
+
const result = gitPull(cwd);
|
|
617
|
+
if (!result.success) {
|
|
618
|
+
return { pulled: false, warning: `Pull failed \u2014 using local data. ${result.message}` };
|
|
619
|
+
}
|
|
620
|
+
return { pulled: true };
|
|
621
|
+
}
|
|
622
|
+
function getUnmergedBranches(cwd, baseBranch) {
|
|
623
|
+
try {
|
|
624
|
+
const output = execFileSync(
|
|
625
|
+
"git",
|
|
626
|
+
["branch", "--no-merged", baseBranch],
|
|
627
|
+
{ cwd, encoding: "utf-8" }
|
|
628
|
+
).trim();
|
|
629
|
+
if (!output) return [];
|
|
630
|
+
return output.split("\n").map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
631
|
+
} catch {
|
|
632
|
+
return [];
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
function resolveBaseBranch(cwd, preferred) {
|
|
636
|
+
if (branchExists(cwd, preferred)) return preferred;
|
|
637
|
+
if (preferred !== "main" && branchExists(cwd, "main")) return "main";
|
|
638
|
+
if (preferred !== "master" && branchExists(cwd, "master")) return "master";
|
|
639
|
+
return preferred;
|
|
640
|
+
}
|
|
641
|
+
function detectBoardMismatches(cwd, tasks) {
|
|
642
|
+
const empty = { codeAhead: [], staleInProgress: [] };
|
|
643
|
+
if (!isGitAvailable() || !isGitRepo(cwd)) return empty;
|
|
644
|
+
try {
|
|
645
|
+
const mergedOutput = execFileSync("git", ["branch", "--merged", "HEAD"], {
|
|
646
|
+
cwd,
|
|
647
|
+
encoding: "utf-8",
|
|
648
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
649
|
+
});
|
|
650
|
+
const allOutput = execFileSync("git", ["branch"], {
|
|
651
|
+
cwd,
|
|
652
|
+
encoding: "utf-8",
|
|
653
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
654
|
+
});
|
|
655
|
+
const parseBranches = (raw) => new Set(raw.split("\n").map((l) => l.trim().replace(/^\* /, "")).filter(Boolean));
|
|
656
|
+
const mergedBranches = parseBranches(mergedOutput);
|
|
657
|
+
const allBranches = parseBranches(allOutput);
|
|
658
|
+
const codeAhead = [];
|
|
659
|
+
const staleInProgress = [];
|
|
660
|
+
for (const task of tasks) {
|
|
661
|
+
const branch = `feat/${task.displayId}`;
|
|
662
|
+
if (task.status === "Backlog" && mergedBranches.has(branch)) {
|
|
663
|
+
codeAhead.push({ displayId: task.displayId, title: task.title, branch });
|
|
664
|
+
}
|
|
665
|
+
if (task.status === "In Progress" && !allBranches.has(branch)) {
|
|
666
|
+
staleInProgress.push({ displayId: task.displayId, title: task.title });
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
return { codeAhead, staleInProgress };
|
|
670
|
+
} catch {
|
|
671
|
+
return empty;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
function detectUnrecordedCommits(cwd, baseBranch) {
|
|
675
|
+
if (!isGitAvailable() || !isGitRepo(cwd)) return [];
|
|
676
|
+
const latestTag = getLatestTag(cwd);
|
|
677
|
+
if (!latestTag) return [];
|
|
678
|
+
try {
|
|
679
|
+
const output = execFileSync(
|
|
680
|
+
"git",
|
|
681
|
+
["log", `${latestTag}..${baseBranch}`, "--oneline", "--max-count=20"],
|
|
682
|
+
{ cwd, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }
|
|
683
|
+
).trim();
|
|
684
|
+
if (!output) return [];
|
|
685
|
+
const CYCLE_PATTERNS = [
|
|
686
|
+
/feat\(task-\d+\):/,
|
|
687
|
+
// build_execute commits: feat(task-NNN): title
|
|
688
|
+
/^[a-f0-9]+ release:/,
|
|
689
|
+
// release commits
|
|
690
|
+
/^[a-f0-9]+ Merge /,
|
|
691
|
+
// merge commits from PRs
|
|
692
|
+
/chore\(task-/,
|
|
693
|
+
// task-related housekeeping
|
|
694
|
+
/chore: dogfood log/
|
|
695
|
+
// automated dogfood log entries post-release
|
|
696
|
+
];
|
|
697
|
+
return output.split("\n").filter((line) => line.trim() && !CYCLE_PATTERNS.some((p) => p.test(line))).map((line) => {
|
|
698
|
+
const spaceIdx = line.indexOf(" ");
|
|
699
|
+
return {
|
|
700
|
+
hash: line.slice(0, spaceIdx),
|
|
701
|
+
message: line.slice(spaceIdx + 1)
|
|
702
|
+
};
|
|
703
|
+
});
|
|
704
|
+
} catch {
|
|
705
|
+
return [];
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
function taskBranchName(taskId) {
|
|
709
|
+
return `feat/${taskId}`;
|
|
710
|
+
}
|
|
711
|
+
function cycleBranchName(cycleNumber, module) {
|
|
712
|
+
const slug = module.toLowerCase().replace(/&/g, "and").replace(/&/g, "and").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
713
|
+
return `feat/cycle-${cycleNumber}-${slug}`;
|
|
714
|
+
}
|
|
715
|
+
function getHeadCommitSha(cwd) {
|
|
716
|
+
try {
|
|
717
|
+
return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf-8" }).trim() || null;
|
|
718
|
+
} catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function runAutoCommit(projectRoot, commitFn) {
|
|
723
|
+
if (!isGitAvailable()) return "Auto-commit: skipped (git not found).";
|
|
724
|
+
if (!isGitRepo(projectRoot)) return "Auto-commit: skipped (not a git repository).";
|
|
725
|
+
try {
|
|
726
|
+
const result = commitFn();
|
|
727
|
+
return result.committed ? `Auto-committed: ${result.message}` : `Auto-commit: ${result.message}`;
|
|
728
|
+
} catch (err) {
|
|
729
|
+
return `Auto-commit failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
function getPullRequestUrl(cwd, branch) {
|
|
733
|
+
try {
|
|
734
|
+
const output = execFileSync(
|
|
735
|
+
"gh",
|
|
736
|
+
["pr", "view", branch, "--json", "url", "--jq", ".url"],
|
|
737
|
+
{ cwd, encoding: "utf-8" }
|
|
738
|
+
).trim();
|
|
739
|
+
return output || null;
|
|
740
|
+
} catch {
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
function squashMergePullRequest(cwd, branch) {
|
|
745
|
+
const repo = getOriginRepoSlug(cwd);
|
|
746
|
+
const baseArgs = ["pr", "merge", branch, "--squash", "--delete-branch"];
|
|
747
|
+
if (repo) baseArgs.push("--repo", repo);
|
|
748
|
+
for (let attempt = 1; attempt <= MERGE_MAX_RETRIES; attempt++) {
|
|
749
|
+
try {
|
|
750
|
+
execFileSync("gh", baseArgs, { cwd, encoding: "utf-8" });
|
|
751
|
+
return { success: true, message: `Squash-merged PR for '${branch}' and deleted branch.` };
|
|
752
|
+
} catch (err) {
|
|
753
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
754
|
+
if (msg.includes("not mergeable") && attempt < MERGE_MAX_RETRIES) {
|
|
755
|
+
sleepSync(MERGE_RETRY_DELAY_MS);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
return { success: false, message: `PR squash-merge failed: ${msg}` };
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
return { success: false, message: "PR squash-merge failed: max retries exceeded" };
|
|
762
|
+
}
|
|
763
|
+
function listGroupedCycleBranches(cwd, cycleNum, baseBranch) {
|
|
764
|
+
const prefix = `feat/cycle-${cycleNum}-`;
|
|
765
|
+
let remoteBranches = [];
|
|
766
|
+
try {
|
|
767
|
+
const remoteOutput = execFileSync(
|
|
768
|
+
"git",
|
|
769
|
+
["ls-remote", "--heads", "origin", `${prefix}*`],
|
|
770
|
+
{ cwd, encoding: "utf-8" }
|
|
771
|
+
).trim();
|
|
772
|
+
if (remoteOutput) {
|
|
773
|
+
const candidates = remoteOutput.split("\n").map((line) => line.split(" ")[1]?.replace("refs/heads/", "").trim()).filter((b) => !!b && b.startsWith(prefix));
|
|
774
|
+
remoteBranches = candidates.filter((branch) => {
|
|
775
|
+
try {
|
|
776
|
+
const branchTip = execFileSync(
|
|
777
|
+
"git",
|
|
778
|
+
["rev-parse", `origin/${branch}`],
|
|
779
|
+
{ cwd, encoding: "utf-8" }
|
|
780
|
+
).trim();
|
|
781
|
+
execFileSync(
|
|
782
|
+
"git",
|
|
783
|
+
["merge-base", "--is-ancestor", branchTip, baseBranch],
|
|
784
|
+
{ cwd, stdio: "ignore" }
|
|
785
|
+
);
|
|
786
|
+
return false;
|
|
787
|
+
} catch {
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
} catch {
|
|
793
|
+
}
|
|
794
|
+
const localUnmerged = getUnmergedBranches(cwd, baseBranch).filter((b) => b.startsWith(prefix));
|
|
795
|
+
const seen = new Set(remoteBranches);
|
|
796
|
+
for (const b of localUnmerged) {
|
|
797
|
+
seen.add(b);
|
|
798
|
+
}
|
|
799
|
+
return [...seen];
|
|
800
|
+
}
|
|
801
|
+
function isBranchMergedInto(cwd, branch, baseBranch) {
|
|
802
|
+
try {
|
|
803
|
+
const tip = execFileSync("git", ["rev-parse", branch], { cwd, encoding: "utf-8" }).trim();
|
|
804
|
+
execFileSync("git", ["merge-base", "--is-ancestor", tip, baseBranch], { cwd, stdio: "ignore" });
|
|
805
|
+
return true;
|
|
806
|
+
} catch {
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function pickModuleCycleBranch(candidates, cycleNumber, module) {
|
|
811
|
+
if (candidates.length === 0) return void 0;
|
|
812
|
+
const expected = cycleBranchName(cycleNumber, module);
|
|
813
|
+
return candidates.find((b) => b === expected);
|
|
814
|
+
}
|
|
815
|
+
function listOrphanFeatBranches(cwd, baseBranch) {
|
|
816
|
+
let remoteBranches = [];
|
|
817
|
+
try {
|
|
818
|
+
const remoteOutput = execFileSync(
|
|
819
|
+
"git",
|
|
820
|
+
["ls-remote", "--heads", "origin", "feat/*"],
|
|
821
|
+
{ cwd, encoding: "utf-8" }
|
|
822
|
+
).trim();
|
|
823
|
+
if (remoteOutput) {
|
|
824
|
+
const candidates = remoteOutput.split("\n").map((line) => line.split(" ")[1]?.replace("refs/heads/", "").trim()).filter((b) => !!b && b.startsWith("feat/") && !b.startsWith("feat/cycle-"));
|
|
825
|
+
remoteBranches = candidates.filter((branch) => {
|
|
826
|
+
try {
|
|
827
|
+
const branchTip = execFileSync(
|
|
828
|
+
"git",
|
|
829
|
+
["rev-parse", `origin/${branch}`],
|
|
830
|
+
{ cwd, encoding: "utf-8" }
|
|
831
|
+
).trim();
|
|
832
|
+
execFileSync(
|
|
833
|
+
"git",
|
|
834
|
+
["merge-base", "--is-ancestor", branchTip, baseBranch],
|
|
835
|
+
{ cwd, stdio: "ignore" }
|
|
836
|
+
);
|
|
837
|
+
return false;
|
|
838
|
+
} catch {
|
|
839
|
+
return true;
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
} catch {
|
|
844
|
+
}
|
|
845
|
+
const localUnmerged = getUnmergedBranches(cwd, baseBranch).filter(
|
|
846
|
+
(b) => b.startsWith("feat/") && !b.startsWith("feat/cycle-")
|
|
847
|
+
);
|
|
848
|
+
const seen = new Set(remoteBranches);
|
|
849
|
+
for (const b of localUnmerged) {
|
|
850
|
+
seen.add(b);
|
|
851
|
+
}
|
|
852
|
+
return [...seen];
|
|
853
|
+
}
|
|
854
|
+
function getFilesChangedFromBase(cwd, baseBranch) {
|
|
855
|
+
try {
|
|
856
|
+
const mergeBase = execFileSync("git", ["merge-base", baseBranch, "HEAD"], { cwd, encoding: "utf-8" }).trim();
|
|
857
|
+
const output = execFileSync("git", ["diff", "--name-only", mergeBase, "HEAD"], { cwd, encoding: "utf-8" }).trim();
|
|
858
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
859
|
+
} catch {
|
|
860
|
+
return [];
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function getFilesChangedBetween(cwd, fromRef, toRef) {
|
|
864
|
+
try {
|
|
865
|
+
const output = execFileSync(
|
|
866
|
+
"git",
|
|
867
|
+
["diff", "--name-only", fromRef, toRef],
|
|
868
|
+
{ cwd, encoding: "utf-8" }
|
|
869
|
+
).trim();
|
|
870
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
871
|
+
} catch {
|
|
872
|
+
return [];
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function getLastCommitFiles(cwd) {
|
|
876
|
+
try {
|
|
877
|
+
const output = execFileSync(
|
|
878
|
+
"git",
|
|
879
|
+
["diff", "--name-only", "HEAD~1", "HEAD"],
|
|
880
|
+
{ cwd, encoding: "utf-8" }
|
|
881
|
+
).trim();
|
|
882
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
883
|
+
} catch {
|
|
884
|
+
return [];
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function getRemoteBranchFiles(cwd, branch, baseBranch) {
|
|
888
|
+
try {
|
|
889
|
+
const output = execFileSync(
|
|
890
|
+
"git",
|
|
891
|
+
["diff", "--name-only", baseBranch, `origin/${branch}`],
|
|
892
|
+
{ cwd, encoding: "utf-8" }
|
|
893
|
+
).trim();
|
|
894
|
+
return output ? output.split("\n").filter(Boolean) : [];
|
|
895
|
+
} catch {
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
var AUTO_WRITTEN_PATHS, GIT_NETWORK_TIMEOUT_MS, MERGE_RETRY_DELAY_MS, MERGE_MAX_RETRIES;
|
|
900
|
+
var init_git = __esm({
|
|
901
|
+
"src/lib/git.ts"() {
|
|
902
|
+
"use strict";
|
|
903
|
+
AUTO_WRITTEN_PATHS = [".papi/*"];
|
|
904
|
+
GIT_NETWORK_TIMEOUT_MS = 6e4;
|
|
905
|
+
MERGE_RETRY_DELAY_MS = 2e3;
|
|
906
|
+
MERGE_MAX_RETRIES = 3;
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
// src/proxy-adapter.ts
|
|
911
|
+
var proxy_adapter_exports = {};
|
|
912
|
+
__export(proxy_adapter_exports, {
|
|
913
|
+
ProxyPapiAdapter: () => ProxyPapiAdapter
|
|
914
|
+
});
|
|
915
|
+
function snakeToCamel(str) {
|
|
916
|
+
return str.replace(/_([a-z0-9])/g, (_, c) => c.toUpperCase());
|
|
917
|
+
}
|
|
918
|
+
function parseIfDoubleEncoded(value) {
|
|
919
|
+
if (typeof value !== "string") return value;
|
|
920
|
+
try {
|
|
921
|
+
const parsed = JSON.parse(value);
|
|
922
|
+
return typeof parsed === "object" ? parsed : value;
|
|
923
|
+
} catch {
|
|
924
|
+
return value;
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
function transformKeys(obj) {
|
|
928
|
+
if (obj === null || obj === void 0) return obj;
|
|
929
|
+
if (Array.isArray(obj)) return obj.map(transformKeys);
|
|
930
|
+
if (typeof obj === "object" && obj !== null) {
|
|
931
|
+
const result = {};
|
|
932
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
933
|
+
const camelKey = snakeToCamel(key);
|
|
934
|
+
result[camelKey] = JSONB_PASSTHROUGH_KEYS.has(camelKey) ? parseIfDoubleEncoded(value) : transformKeys(value);
|
|
935
|
+
}
|
|
936
|
+
return result;
|
|
937
|
+
}
|
|
938
|
+
return obj;
|
|
939
|
+
}
|
|
940
|
+
function fixDisplayIdEntity(obj) {
|
|
941
|
+
if (obj.displayId !== void 0) {
|
|
942
|
+
obj.uuid = obj.id;
|
|
943
|
+
obj.id = obj.displayId;
|
|
944
|
+
}
|
|
945
|
+
return obj;
|
|
946
|
+
}
|
|
947
|
+
function fixDisplayIdEntities(data) {
|
|
948
|
+
if (Array.isArray(data)) return data.map((item) => fixDisplayIdEntity(item));
|
|
949
|
+
if (data && typeof data === "object") return fixDisplayIdEntity(data);
|
|
950
|
+
return data;
|
|
951
|
+
}
|
|
952
|
+
var JSONB_PASSTHROUGH_KEYS, DISPLAY_ID_METHODS, ProxyPapiAdapter;
|
|
953
|
+
var init_proxy_adapter = __esm({
|
|
954
|
+
"src/proxy-adapter.ts"() {
|
|
955
|
+
"use strict";
|
|
956
|
+
JSONB_PASSTHROUGH_KEYS = /* @__PURE__ */ new Set([
|
|
957
|
+
"buildHandoff",
|
|
958
|
+
"stateHistory",
|
|
959
|
+
"handoffAccuracy",
|
|
960
|
+
"briefImplications",
|
|
961
|
+
"autoReview",
|
|
962
|
+
"structuredData",
|
|
963
|
+
"data"
|
|
964
|
+
]);
|
|
965
|
+
DISPLAY_ID_METHODS = /* @__PURE__ */ new Set([
|
|
966
|
+
"queryBoard",
|
|
967
|
+
"getTask",
|
|
968
|
+
"getTasks",
|
|
969
|
+
"createTask",
|
|
970
|
+
"getRecentBuildReports",
|
|
971
|
+
"getBuildReportsSince",
|
|
972
|
+
"getRecentReviews",
|
|
973
|
+
"getActiveDecisions"
|
|
974
|
+
]);
|
|
975
|
+
ProxyPapiAdapter = class _ProxyPapiAdapter {
|
|
976
|
+
endpoint;
|
|
977
|
+
apiKey;
|
|
978
|
+
projectId;
|
|
979
|
+
constructor(config) {
|
|
980
|
+
this.endpoint = config.endpoint.replace(/\/$/, "");
|
|
981
|
+
this.apiKey = config.apiKey;
|
|
982
|
+
this.projectId = config.projectId ?? "";
|
|
983
|
+
}
|
|
984
|
+
/** Resolved project ID — available after ensureProject() completes. */
|
|
985
|
+
getProjectId() {
|
|
986
|
+
return this.projectId;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* task-2119: per-call project scope. Returns a fresh adapter bound to a
|
|
990
|
+
* different projectId — instantiation is a cheap allocation (fetch wrapper,
|
|
991
|
+
* no pool). Never mutates the session adapter, persists nothing. Ownership
|
|
992
|
+
* must be validated by the caller (listUserProjects) before use; the
|
|
993
|
+
* data-proxy's validateProjectAccess re-checks server-side regardless.
|
|
994
|
+
*/
|
|
995
|
+
withProject(projectId) {
|
|
996
|
+
return new _ProxyPapiAdapter({
|
|
997
|
+
endpoint: this.endpoint,
|
|
998
|
+
apiKey: this.apiKey,
|
|
999
|
+
projectId
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Ensure the authenticated user has a project. If none exists, auto-creates one.
|
|
1004
|
+
* Returns the project ID (existing or newly created). Idempotent and race-safe.
|
|
1005
|
+
*
|
|
1006
|
+
* Call this before any other adapter method when PAPI_PROJECT_ID is not configured.
|
|
1007
|
+
*/
|
|
1008
|
+
async ensureProject(projectName, repoUrl, papiDir, rootCommitHash) {
|
|
1009
|
+
const url = `${this.endpoint}/ensure-project`;
|
|
1010
|
+
const response = await fetch(url, {
|
|
1011
|
+
method: "POST",
|
|
1012
|
+
headers: {
|
|
1013
|
+
"Content-Type": "application/json",
|
|
1014
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
1015
|
+
},
|
|
1016
|
+
body: JSON.stringify({
|
|
1017
|
+
...projectName ? { projectName } : {},
|
|
1018
|
+
...repoUrl ? { repoUrl } : {},
|
|
1019
|
+
...papiDir ? { papiDir } : {},
|
|
1020
|
+
...rootCommitHash ? { rootCommitHash, resolutionMethod: "root_hash" } : {}
|
|
1021
|
+
}),
|
|
1022
|
+
// 20s ceiling — auto-provision can be slower than a normal invoke
|
|
1023
|
+
// (DB row create + RLS setup) but must never hang indefinitely.
|
|
1024
|
+
signal: AbortSignal.timeout(2e4)
|
|
1025
|
+
});
|
|
1026
|
+
if (!response.ok) {
|
|
1027
|
+
const errorBody = await response.text();
|
|
1028
|
+
let message;
|
|
1029
|
+
try {
|
|
1030
|
+
const parsed = JSON.parse(errorBody);
|
|
1031
|
+
message = parsed.error ?? errorBody;
|
|
1032
|
+
} catch {
|
|
1033
|
+
message = errorBody;
|
|
1034
|
+
}
|
|
1035
|
+
throw new Error(`Auto-provision failed (${response.status}): ${message}`);
|
|
1036
|
+
}
|
|
1037
|
+
const body = await response.json();
|
|
1038
|
+
if (!body.ok && body.error) {
|
|
1039
|
+
throw new Error(`Auto-provision failed: ${body.error}`);
|
|
1040
|
+
}
|
|
1041
|
+
this.projectId = body.projectId;
|
|
1042
|
+
return { projectId: body.projectId, projectName: body.projectName, created: body.created };
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Send an adapter method call to the proxy Edge Function.
|
|
1046
|
+
* Serializes { projectId, method, args } and deserializes the response.
|
|
1047
|
+
* Results are transformed from snake_case to camelCase to match pg adapter output.
|
|
1048
|
+
*/
|
|
1049
|
+
async invoke(method, args = []) {
|
|
1050
|
+
const url = `${this.endpoint}/invoke`;
|
|
1051
|
+
let response;
|
|
1052
|
+
try {
|
|
1053
|
+
response = await fetch(url, {
|
|
1054
|
+
method: "POST",
|
|
1055
|
+
headers: {
|
|
1056
|
+
"Content-Type": "application/json",
|
|
1057
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
1058
|
+
},
|
|
1059
|
+
body: JSON.stringify({
|
|
1060
|
+
projectId: this.projectId,
|
|
1061
|
+
method,
|
|
1062
|
+
args
|
|
1063
|
+
}),
|
|
1064
|
+
// Ceiling on every proxy call. Without this, a single hung edge-function
|
|
1065
|
+
// request would stall orient (15+ proxy calls) indefinitely. See PERF
|
|
1066
|
+
// investigation 2026-05-04 — this was the "orient loading forever"
|
|
1067
|
+
// class of incident for external users.
|
|
1068
|
+
signal: AbortSignal.timeout(15e3)
|
|
1069
|
+
});
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
if (err instanceof Error && err.name === "TimeoutError") {
|
|
1072
|
+
throw new Error(`Proxy timeout on ${method}: no response within 15s. The data-proxy edge function may be cold or overloaded \u2014 retry, or check Supabase status.`);
|
|
1073
|
+
}
|
|
1074
|
+
throw err;
|
|
1075
|
+
}
|
|
1076
|
+
if (!response.ok) {
|
|
1077
|
+
const errorBody = await response.text();
|
|
1078
|
+
let message;
|
|
1079
|
+
try {
|
|
1080
|
+
const parsed = JSON.parse(errorBody);
|
|
1081
|
+
message = parsed.error ?? errorBody;
|
|
1082
|
+
} catch {
|
|
1083
|
+
message = errorBody;
|
|
1084
|
+
}
|
|
1085
|
+
if (response.status === 401) {
|
|
1086
|
+
throw new Error(
|
|
1087
|
+
`Auth: Invalid API key \u2014 PAPI_DATA_API_KEY was rejected by the proxy.
|
|
1088
|
+
Check PAPI_DATA_API_KEY in your .mcp.json config. You can regenerate it from the PAPI dashboard.
|
|
1089
|
+
(${response.status} on ${method}: ${message})`
|
|
1090
|
+
);
|
|
1091
|
+
}
|
|
1092
|
+
if (response.status === 403 || response.status === 404) {
|
|
1093
|
+
throw new Error(
|
|
1094
|
+
`Auth: Project not found or access denied \u2014 PAPI_PROJECT_ID may be wrong.
|
|
1095
|
+
Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI dashboard settings.
|
|
1096
|
+
(${response.status} on ${method}: ${message})`
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
throw new Error(`Proxy error (${response.status}) on ${method}: ${message}`);
|
|
1100
|
+
}
|
|
1101
|
+
const body = await response.json();
|
|
1102
|
+
if (!body.ok && body.error) {
|
|
1103
|
+
const codeTag = body.code ? ` [${body.code}]` : "";
|
|
1104
|
+
throw new Error(`Proxy error on ${method}${codeTag}: ${body.error}`);
|
|
1105
|
+
}
|
|
1106
|
+
let result = transformKeys(body.result);
|
|
1107
|
+
if (DISPLAY_ID_METHODS.has(method)) {
|
|
1108
|
+
result = fixDisplayIdEntities(result);
|
|
1109
|
+
}
|
|
1110
|
+
return result;
|
|
1111
|
+
}
|
|
1112
|
+
/** Check if the proxy is reachable. */
|
|
1113
|
+
async probeConnection() {
|
|
1114
|
+
try {
|
|
1115
|
+
const response = await fetch(`${this.endpoint}/health`, {
|
|
1116
|
+
signal: AbortSignal.timeout(5e3)
|
|
1117
|
+
});
|
|
1118
|
+
return response.ok;
|
|
1119
|
+
} catch {
|
|
1120
|
+
return false;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* Fetch the proxy's reported version (task-1622 handshake).
|
|
1125
|
+
* Returns undefined if the proxy is unreachable or its response is malformed —
|
|
1126
|
+
* callers must treat undefined as "no signal", not as a mismatch.
|
|
1127
|
+
*/
|
|
1128
|
+
async getProxyVersion() {
|
|
1129
|
+
try {
|
|
1130
|
+
const response = await fetch(`${this.endpoint}/health`, {
|
|
1131
|
+
signal: AbortSignal.timeout(5e3)
|
|
1132
|
+
});
|
|
1133
|
+
if (!response.ok) return void 0;
|
|
1134
|
+
const body = await response.json();
|
|
1135
|
+
return typeof body.version === "string" ? body.version : void 0;
|
|
1136
|
+
} catch {
|
|
1137
|
+
return void 0;
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Validate API key and project access against the proxy.
|
|
1142
|
+
* Returns HTTP status so callers can distinguish 401 (bad key) from 403/404 (bad project).
|
|
1143
|
+
* Status 0 means a network error occurred.
|
|
1144
|
+
*/
|
|
1145
|
+
async probeAuth(projectId) {
|
|
1146
|
+
try {
|
|
1147
|
+
const response = await fetch(`${this.endpoint}/invoke`, {
|
|
1148
|
+
method: "POST",
|
|
1149
|
+
headers: {
|
|
1150
|
+
"Content-Type": "application/json",
|
|
1151
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
1152
|
+
},
|
|
1153
|
+
body: JSON.stringify({ projectId, method: "projectExists", args: [] }),
|
|
1154
|
+
signal: AbortSignal.timeout(5e3)
|
|
1155
|
+
});
|
|
1156
|
+
return { ok: response.ok, status: response.status };
|
|
1157
|
+
} catch {
|
|
1158
|
+
return { ok: false, status: 0 };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
// --- Planning & Health ---
|
|
1162
|
+
readPlanningLog() {
|
|
1163
|
+
return this.invoke("readPlanningLog");
|
|
1164
|
+
}
|
|
1165
|
+
getCycleHealth() {
|
|
1166
|
+
return this.invoke("getCycleHealth");
|
|
1167
|
+
}
|
|
1168
|
+
getActiveDecisions(options) {
|
|
1169
|
+
return this.invoke("getActiveDecisions", [options ?? {}]);
|
|
1170
|
+
}
|
|
1171
|
+
getCycleLog(limit) {
|
|
1172
|
+
return this.invoke("getCycleLog", [limit]);
|
|
1173
|
+
}
|
|
1174
|
+
getCycleLogSince(cycleNumber) {
|
|
1175
|
+
return this.invoke("getCycleLogSince", [cycleNumber]);
|
|
1176
|
+
}
|
|
1177
|
+
setCycleHealth(updates) {
|
|
1178
|
+
return this.invoke("setCycleHealth", [updates]);
|
|
1179
|
+
}
|
|
1180
|
+
writeCycleLogEntry(entry) {
|
|
1181
|
+
return this.invoke("writeCycleLogEntry", [entry]);
|
|
1182
|
+
}
|
|
1183
|
+
updateActiveDecision(id, body, cycleNumber) {
|
|
1184
|
+
return this.invoke("updateActiveDecision", [id, body, cycleNumber]);
|
|
1185
|
+
}
|
|
1186
|
+
upsertActiveDecision(id, body, title, confidence, cycleNumber) {
|
|
1187
|
+
return this.invoke("upsertActiveDecision", [id, body, title, confidence, cycleNumber]);
|
|
1188
|
+
}
|
|
1189
|
+
deleteActiveDecision(id) {
|
|
1190
|
+
return this.invoke("deleteActiveDecision", [id]);
|
|
1191
|
+
}
|
|
1192
|
+
// --- Board / Tasks ---
|
|
1193
|
+
queryBoard(options) {
|
|
1194
|
+
return this.invoke("queryBoard", [options]);
|
|
1195
|
+
}
|
|
1196
|
+
getTask(id) {
|
|
1197
|
+
return this.invoke("getTask", [id]);
|
|
1198
|
+
}
|
|
1199
|
+
getTasks(ids) {
|
|
1200
|
+
return this.invoke("getTasks", [ids]);
|
|
1201
|
+
}
|
|
1202
|
+
createTask(task) {
|
|
1203
|
+
return this.invoke("createTask", [task]);
|
|
1204
|
+
}
|
|
1205
|
+
updateTask(id, updates, options) {
|
|
1206
|
+
return this.invoke("updateTask", [id, updates, options]);
|
|
1207
|
+
}
|
|
1208
|
+
updateTaskStatus(id, status) {
|
|
1209
|
+
return this.invoke("updateTaskStatus", [id, status]);
|
|
1210
|
+
}
|
|
1211
|
+
// task-2155 (MU-3 task B): hosted/proxy claim write-path. The data-proxy gates
|
|
1212
|
+
// these via WRITE_METHODS (active editor may write, viewer cannot) and binds the
|
|
1213
|
+
// assignee to the bearer-derived caller server-side — the assigneeId passed here
|
|
1214
|
+
// is ignored by the edge function, so it cannot be spoofed to claim on another's
|
|
1215
|
+
// behalf. Mirrors pg-papi-adapter.claimTask/unclaimTask (proxy-parity enforced).
|
|
1216
|
+
claimTask(taskId, assigneeId) {
|
|
1217
|
+
return this.invoke("claimTask", [taskId, assigneeId]);
|
|
1218
|
+
}
|
|
1219
|
+
unclaimTask(taskId, assigneeId) {
|
|
1220
|
+
return this.invoke("unclaimTask", [taskId, assigneeId]);
|
|
1221
|
+
}
|
|
1222
|
+
recordTransition(taskId, fromStatus, toStatus, changedBy) {
|
|
1223
|
+
return this.invoke("recordTransition", [taskId, fromStatus, toStatus, changedBy]);
|
|
1224
|
+
}
|
|
1225
|
+
// --- Build Reports ---
|
|
1226
|
+
appendBuildReport(report) {
|
|
1227
|
+
return this.invoke("appendBuildReport", [report]);
|
|
1228
|
+
}
|
|
1229
|
+
getRecentBuildReports(count) {
|
|
1230
|
+
return this.invoke("getRecentBuildReports", [count]);
|
|
1231
|
+
}
|
|
1232
|
+
getBuildReportsSince(cycleNumber) {
|
|
1233
|
+
return this.invoke("getBuildReportsSince", [cycleNumber]);
|
|
1234
|
+
}
|
|
1235
|
+
// --- Reviews ---
|
|
1236
|
+
getRecentReviews(count) {
|
|
1237
|
+
return this.invoke("getRecentReviews", [count]);
|
|
1238
|
+
}
|
|
1239
|
+
writeReview(review) {
|
|
1240
|
+
return this.invoke("writeReview", [review]);
|
|
1241
|
+
}
|
|
1242
|
+
// --- Compression / Archive ---
|
|
1243
|
+
compressCycleLog(threshold, summary) {
|
|
1244
|
+
return this.invoke("compressCycleLog", [threshold, summary]);
|
|
1245
|
+
}
|
|
1246
|
+
compressBuildReports(threshold, summary) {
|
|
1247
|
+
return this.invoke("compressBuildReports", [threshold, summary]);
|
|
1248
|
+
}
|
|
1249
|
+
archiveTasks(phases, statuses) {
|
|
1250
|
+
return this.invoke("archiveTasks", [phases, statuses]);
|
|
1251
|
+
}
|
|
1252
|
+
// --- Product Brief & Discovery ---
|
|
1253
|
+
readProductBrief() {
|
|
1254
|
+
return this.invoke("readProductBrief");
|
|
1255
|
+
}
|
|
1256
|
+
updateProductBrief(content) {
|
|
1257
|
+
return this.invoke("updateProductBrief", [content]);
|
|
1258
|
+
}
|
|
1259
|
+
readDiscoveryCanvas() {
|
|
1260
|
+
return this.invoke("readDiscoveryCanvas");
|
|
1261
|
+
}
|
|
1262
|
+
updateDiscoveryCanvas(canvas) {
|
|
1263
|
+
return this.invoke("updateDiscoveryCanvas", [canvas]);
|
|
1264
|
+
}
|
|
1265
|
+
// --- Phases & Hierarchy ---
|
|
1266
|
+
readPhases() {
|
|
1267
|
+
return this.invoke("readPhases");
|
|
1268
|
+
}
|
|
1269
|
+
writePhases(phases) {
|
|
1270
|
+
return this.invoke("writePhases", [phases]);
|
|
1271
|
+
}
|
|
1272
|
+
readHorizons() {
|
|
1273
|
+
return this.invoke("readHorizons");
|
|
1274
|
+
}
|
|
1275
|
+
readStages(horizonId) {
|
|
1276
|
+
return this.invoke("readStages", [horizonId]);
|
|
1277
|
+
}
|
|
1278
|
+
updateStageStatus(stageId, status) {
|
|
1279
|
+
return this.invoke("updateStageStatus", [stageId, status]);
|
|
1280
|
+
}
|
|
1281
|
+
updateHorizonStatus(horizonId, status) {
|
|
1282
|
+
return this.invoke("updateHorizonStatus", [horizonId, status]);
|
|
1283
|
+
}
|
|
1284
|
+
getActiveStage() {
|
|
1285
|
+
return this.invoke("getActiveStage");
|
|
1286
|
+
}
|
|
1287
|
+
createHorizon(horizon) {
|
|
1288
|
+
return this.invoke("createHorizon", [horizon]);
|
|
1289
|
+
}
|
|
1290
|
+
createStage(stage) {
|
|
1291
|
+
return this.invoke("createStage", [stage]);
|
|
1292
|
+
}
|
|
1293
|
+
linkPhasesToStage(stageId) {
|
|
1294
|
+
return this.invoke("linkPhasesToStage", [stageId]);
|
|
1295
|
+
}
|
|
1296
|
+
// --- Metrics ---
|
|
1297
|
+
appendToolMetric(metric) {
|
|
1298
|
+
return this.invoke("appendToolMetric", [metric]);
|
|
1299
|
+
}
|
|
1300
|
+
readToolMetrics() {
|
|
1301
|
+
return this.invoke("readToolMetrics");
|
|
1302
|
+
}
|
|
1303
|
+
hasToolMilestone(name) {
|
|
1304
|
+
return this.invoke("hasToolMilestone", [name]);
|
|
1305
|
+
}
|
|
1306
|
+
insertPlanRun(entry) {
|
|
1307
|
+
return this.invoke("insertPlanRun", [entry]);
|
|
1308
|
+
}
|
|
1309
|
+
getCostSummary(cycleNumber) {
|
|
1310
|
+
return this.invoke("getCostSummary", [cycleNumber]);
|
|
1311
|
+
}
|
|
1312
|
+
getCostSnapshots() {
|
|
1313
|
+
return this.invoke("getCostSnapshots");
|
|
1314
|
+
}
|
|
1315
|
+
appendCycleMetrics(snapshot) {
|
|
1316
|
+
return this.invoke("appendCycleMetrics", [snapshot]);
|
|
1317
|
+
}
|
|
1318
|
+
readCycleMetrics() {
|
|
1319
|
+
return this.invoke("readCycleMetrics");
|
|
1320
|
+
}
|
|
1321
|
+
// --- Cycles ---
|
|
1322
|
+
readCycles() {
|
|
1323
|
+
return this.invoke("readCycles");
|
|
1324
|
+
}
|
|
1325
|
+
createCycle(cycle) {
|
|
1326
|
+
return this.invoke("createCycle", [cycle]);
|
|
1327
|
+
}
|
|
1328
|
+
getContextHashes(cycleNumber) {
|
|
1329
|
+
return this.invoke("getContextHashes", [cycleNumber]);
|
|
1330
|
+
}
|
|
1331
|
+
// --- Registries ---
|
|
1332
|
+
readRegistries() {
|
|
1333
|
+
return this.invoke("readRegistries");
|
|
1334
|
+
}
|
|
1335
|
+
updateRegistries(registries) {
|
|
1336
|
+
return this.invoke("updateRegistries", [registries]);
|
|
1337
|
+
}
|
|
1338
|
+
// --- Recommendations ---
|
|
1339
|
+
writeRecommendation(rec) {
|
|
1340
|
+
return this.invoke("writeRecommendation", [rec]);
|
|
1341
|
+
}
|
|
1342
|
+
getPendingRecommendations() {
|
|
1343
|
+
return this.invoke("getPendingRecommendations");
|
|
1344
|
+
}
|
|
1345
|
+
actionRecommendation(id, cycleNumber) {
|
|
1346
|
+
return this.invoke("actionRecommendation", [id, cycleNumber]);
|
|
1347
|
+
}
|
|
1348
|
+
dismissRecommendation(id, reason) {
|
|
1349
|
+
return this.invoke("dismissRecommendation", [id, reason]);
|
|
1350
|
+
}
|
|
1351
|
+
// --- Decision Events & Scores ---
|
|
1352
|
+
appendDecisionEvent(event) {
|
|
1353
|
+
return this.invoke("appendDecisionEvent", [event]);
|
|
1354
|
+
}
|
|
1355
|
+
getDecisionEvents(decisionId, limit) {
|
|
1356
|
+
return this.invoke("getDecisionEvents", [decisionId, limit]);
|
|
1357
|
+
}
|
|
1358
|
+
getDecisionEventsSince(cycle) {
|
|
1359
|
+
return this.invoke("getDecisionEventsSince", [cycle]);
|
|
1360
|
+
}
|
|
1361
|
+
writeDecisionScore(score) {
|
|
1362
|
+
return this.invoke("writeDecisionScore", [score]);
|
|
1363
|
+
}
|
|
1364
|
+
getDecisionScores(decisionId) {
|
|
1365
|
+
return this.invoke("getDecisionScores", [decisionId]);
|
|
1366
|
+
}
|
|
1367
|
+
getLatestDecisionScores() {
|
|
1368
|
+
return this.invoke("getLatestDecisionScores");
|
|
1369
|
+
}
|
|
1370
|
+
logEntityReferences(refs) {
|
|
1371
|
+
return this.invoke("logEntityReferences", [refs]);
|
|
1372
|
+
}
|
|
1373
|
+
getDecisionUsage(currentCycle) {
|
|
1374
|
+
return this.invoke("getDecisionUsage", [currentCycle]);
|
|
1375
|
+
}
|
|
1376
|
+
getContextUtilisation() {
|
|
1377
|
+
return this.invoke("getContextUtilisation");
|
|
1378
|
+
}
|
|
1379
|
+
// --- Strategy Reviews ---
|
|
1380
|
+
writeStrategyReview(review) {
|
|
1381
|
+
return this.invoke("writeStrategyReview", [review]);
|
|
1382
|
+
}
|
|
1383
|
+
getLastStrategyReviewCycle() {
|
|
1384
|
+
return this.invoke("getLastStrategyReviewCycle");
|
|
1385
|
+
}
|
|
1386
|
+
getStrategyReviews(limit, includeFullAnalysis) {
|
|
1387
|
+
return this.invoke("getStrategyReviews", [limit, includeFullAnalysis]);
|
|
1388
|
+
}
|
|
1389
|
+
// --- Dogfood ---
|
|
1390
|
+
writeDogfoodEntries(entries) {
|
|
1391
|
+
return this.invoke("writeDogfoodEntries", [entries]);
|
|
1392
|
+
}
|
|
1393
|
+
getDogfoodLog(limit) {
|
|
1394
|
+
return this.invoke("getDogfoodLog", [limit]);
|
|
1395
|
+
}
|
|
1396
|
+
getUnactionedDogfoodEntries(limit) {
|
|
1397
|
+
return this.invoke("getUnactionedDogfoodEntries", [limit]);
|
|
1398
|
+
}
|
|
1399
|
+
updateDogfoodEntryStatus(id, status, linkedTaskId) {
|
|
1400
|
+
return this.invoke("updateDogfoodEntryStatus", [id, status, linkedTaskId]);
|
|
1401
|
+
}
|
|
1402
|
+
// --- Harness inventory (task-1896) ---
|
|
1403
|
+
getHarnessInventory() {
|
|
1404
|
+
return this.invoke("getHarnessInventory");
|
|
1405
|
+
}
|
|
1406
|
+
replaceHarnessInventory(entries) {
|
|
1407
|
+
return this.invoke("replaceHarnessInventory", [entries]);
|
|
1408
|
+
}
|
|
1409
|
+
getHarnessState() {
|
|
1410
|
+
return this.invoke("getHarnessState");
|
|
1411
|
+
}
|
|
1412
|
+
setHarnessState(fingerprint) {
|
|
1413
|
+
return this.invoke("setHarnessState", [fingerprint]);
|
|
1414
|
+
}
|
|
1415
|
+
// --- North Star ---
|
|
1416
|
+
getCurrentNorthStar() {
|
|
1417
|
+
return this.invoke("getCurrentNorthStar");
|
|
1418
|
+
}
|
|
1419
|
+
getNorthStarSetCycle() {
|
|
1420
|
+
return this.invoke("getNorthStarSetCycle");
|
|
1421
|
+
}
|
|
1422
|
+
getNorthStarStaleness() {
|
|
1423
|
+
return this.invoke("getNorthStarStaleness");
|
|
1424
|
+
}
|
|
1425
|
+
upsertNorthStar(statement, cycleNumber) {
|
|
1426
|
+
return this.invoke("upsertNorthStar", [statement, cycleNumber]);
|
|
1427
|
+
}
|
|
1428
|
+
// --- Optional pg-only methods ---
|
|
1429
|
+
getEstimationCalibration() {
|
|
1430
|
+
return this.invoke("getEstimationCalibration");
|
|
1431
|
+
}
|
|
1432
|
+
getRecentTaskComments(limit) {
|
|
1433
|
+
return this.invoke("getRecentTaskComments", [limit]);
|
|
1434
|
+
}
|
|
1435
|
+
getRecommendationEffectiveness() {
|
|
1436
|
+
return this.invoke("getRecommendationEffectiveness");
|
|
1437
|
+
}
|
|
1438
|
+
getPlanContextSummary() {
|
|
1439
|
+
return this.invoke("getPlanContextSummary");
|
|
1440
|
+
}
|
|
1441
|
+
projectExists() {
|
|
1442
|
+
return this.invoke("projectExists");
|
|
1443
|
+
}
|
|
1444
|
+
getBuildReportCountForTask(taskId) {
|
|
1445
|
+
return this.invoke("getBuildReportCountForTask", [taskId]);
|
|
1446
|
+
}
|
|
1447
|
+
// --- Bug Reports ---
|
|
1448
|
+
submitBugReport(report) {
|
|
1449
|
+
return this.invoke("submitBugReport", [report]);
|
|
1450
|
+
}
|
|
1451
|
+
// --- Doc Registry ---
|
|
1452
|
+
registerDoc(entry) {
|
|
1453
|
+
return this.invoke("registerDoc", [entry]);
|
|
1454
|
+
}
|
|
1455
|
+
searchDocs(input) {
|
|
1456
|
+
return this.invoke("searchDocs", [input]);
|
|
1457
|
+
}
|
|
1458
|
+
getDoc(idOrPath) {
|
|
1459
|
+
return this.invoke("getDoc", [idOrPath]);
|
|
1460
|
+
}
|
|
1461
|
+
updateDocStatus(id, status, supersededBy) {
|
|
1462
|
+
return this.invoke("updateDocStatus", [id, status, supersededBy]);
|
|
1463
|
+
}
|
|
1464
|
+
// --- Cycle Learnings ---
|
|
1465
|
+
appendCycleLearnings(learnings) {
|
|
1466
|
+
return this.invoke("appendCycleLearnings", [learnings]);
|
|
1467
|
+
}
|
|
1468
|
+
getCycleLearnings(opts) {
|
|
1469
|
+
return this.invoke("getCycleLearnings", [opts]);
|
|
1470
|
+
}
|
|
1471
|
+
getCycleLearningPatterns() {
|
|
1472
|
+
return this.invoke("getCycleLearningPatterns", []);
|
|
1473
|
+
}
|
|
1474
|
+
updateCycleLearningActionRef(learningId, taskDisplayId) {
|
|
1475
|
+
return this.invoke("updateCycleLearningActionRef", [learningId, taskDisplayId]);
|
|
1476
|
+
}
|
|
1477
|
+
// --- Strategy Review Drafts ---
|
|
1478
|
+
savePendingReviewResponse(cycleNumber, rawResponse) {
|
|
1479
|
+
return this.invoke("savePendingReviewResponse", [cycleNumber, rawResponse]);
|
|
1480
|
+
}
|
|
1481
|
+
getPendingReviewResponse() {
|
|
1482
|
+
return this.invoke("getPendingReviewResponse", []);
|
|
1483
|
+
}
|
|
1484
|
+
clearPendingReviewResponse() {
|
|
1485
|
+
return this.invoke("clearPendingReviewResponse", []);
|
|
1486
|
+
}
|
|
1487
|
+
// --- Active Decisions ---
|
|
1488
|
+
confirmPendingActiveDecisions(cycleNumber) {
|
|
1489
|
+
return this.invoke("confirmPendingActiveDecisions", [cycleNumber]);
|
|
1490
|
+
}
|
|
1491
|
+
// --- Owner Action Queue counters (Team Ops) ---
|
|
1492
|
+
// Proxy-mode users get the orient owner-action nudges too. SECURITY: the
|
|
1493
|
+
// data-proxy ignores the client-supplied userId and scopes every count to
|
|
1494
|
+
// the AUTHENTICATED bearer's user_id server-side — the arg is advisory.
|
|
1495
|
+
getProjectOwnerUserId() {
|
|
1496
|
+
return this.invoke("getProjectOwnerUserId", []);
|
|
1497
|
+
}
|
|
1498
|
+
countOpenOwnerActions(userId, projectId) {
|
|
1499
|
+
return this.invoke("countOpenOwnerActions", [userId, projectId]);
|
|
1500
|
+
}
|
|
1501
|
+
countNudgedOwnerActions(userId, projectId) {
|
|
1502
|
+
return this.invoke("countNudgedOwnerActions", [userId, projectId]);
|
|
1503
|
+
}
|
|
1504
|
+
countOwnerActionsBlockingTasks(userId) {
|
|
1505
|
+
return this.invoke("countOwnerActionsBlockingTasks", [userId]);
|
|
1506
|
+
}
|
|
1507
|
+
countDueOwnerActions(userId, projectId) {
|
|
1508
|
+
return this.invoke("countDueOwnerActions", [userId, projectId]);
|
|
1509
|
+
}
|
|
1510
|
+
// --- Project metadata (path-identity guardrail) ---
|
|
1511
|
+
async getProjectInfo() {
|
|
1512
|
+
return this.invoke("getProjectInfo", []);
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* task-2052 (C288): owner-gate identity for the proxy transport. Both sides
|
|
1516
|
+
* come from the edge function — callerUserId is derived server-side from the
|
|
1517
|
+
* bearer token (never client-claimed), ownerUserId from the project row. This
|
|
1518
|
+
* is what lets release/review enforce the owner-gate on the proxy (previously
|
|
1519
|
+
* bypassed) without locking out a legitimate owner who has no PAPI_USER_ID set.
|
|
1520
|
+
*/
|
|
1521
|
+
async getOwnerIdentity() {
|
|
1522
|
+
return this.invoke("getOwnerIdentity", []);
|
|
1523
|
+
}
|
|
1524
|
+
// --- Contributor cohort (task-2029, C288) ---
|
|
1525
|
+
// Owner-only enforcement is BOTH tool-layer (resolveOwnerGate) and
|
|
1526
|
+
// server-side in the edge function (auth-derived caller vs project owner) —
|
|
1527
|
+
// defence in depth, since this client runs on the user's machine.
|
|
1528
|
+
async listContributors() {
|
|
1529
|
+
return this.invoke("listContributors", []);
|
|
1530
|
+
}
|
|
1531
|
+
async addContributorByEmail(email) {
|
|
1532
|
+
return this.invoke("addContributorByEmail", [email]);
|
|
1533
|
+
}
|
|
1534
|
+
async removeContributorByEmail(email) {
|
|
1535
|
+
return this.invoke("removeContributorByEmail", [email]);
|
|
1536
|
+
}
|
|
1537
|
+
async setProjectPapiDir(papiDir) {
|
|
1538
|
+
return this.invoke("setProjectPapiDir", [papiDir]);
|
|
1539
|
+
}
|
|
1540
|
+
// --- Project lifecycle (task-1888 / 1885-C) ---
|
|
1541
|
+
// These are USER-scoped (not project-scoped), so they hit dedicated auth-only
|
|
1542
|
+
// routes rather than the projectId-gated /invoke path — same pattern as
|
|
1543
|
+
// ensureProject. The bearer identifies the user server-side; the body never
|
|
1544
|
+
// carries a user id.
|
|
1545
|
+
/** POST helper for the auth-only lifecycle routes. */
|
|
1546
|
+
async postRoute(route, payload) {
|
|
1547
|
+
const response = await fetch(`${this.endpoint}/${route}`, {
|
|
1548
|
+
method: "POST",
|
|
1549
|
+
headers: {
|
|
1550
|
+
"Content-Type": "application/json",
|
|
1551
|
+
"Authorization": `Bearer ${this.apiKey}`
|
|
1552
|
+
},
|
|
1553
|
+
body: JSON.stringify(payload),
|
|
1554
|
+
signal: AbortSignal.timeout(15e3)
|
|
1555
|
+
});
|
|
1556
|
+
if (!response.ok) {
|
|
1557
|
+
const errorBody = await response.text();
|
|
1558
|
+
let message;
|
|
1559
|
+
try {
|
|
1560
|
+
message = JSON.parse(errorBody).error ?? errorBody;
|
|
1561
|
+
} catch {
|
|
1562
|
+
message = errorBody;
|
|
1563
|
+
}
|
|
1564
|
+
throw new Error(`Proxy error (${response.status}) on ${route}: ${message}`);
|
|
1565
|
+
}
|
|
1566
|
+
const body = await response.json();
|
|
1567
|
+
if (!body.ok && body.error) throw new Error(`Proxy error on ${route}: ${body.error}`);
|
|
1568
|
+
return body;
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* task-2061: the authenticated user's tier + metered tool-call count for
|
|
1572
|
+
* the current month. Identity is server-derived from the bearer at the
|
|
1573
|
+
* data-proxy; nothing client-supplied. Consumed by services/metering.ts.
|
|
1574
|
+
*/
|
|
1575
|
+
async getMeteredUsage() {
|
|
1576
|
+
const body = await this.postRoute("metering", {});
|
|
1577
|
+
return { tier: body.tier ?? "free", monthlyToolCalls: body.monthlyToolCalls ?? 0 };
|
|
1578
|
+
}
|
|
1579
|
+
async listUserProjects() {
|
|
1580
|
+
const body = await this.postRoute("project-list", {});
|
|
1581
|
+
return body.projects ?? [];
|
|
1582
|
+
}
|
|
1583
|
+
async createUserProject(input) {
|
|
1584
|
+
const r = await this.ensureProject(input.name, input.repoUrl, input.papiDir);
|
|
1585
|
+
return {
|
|
1586
|
+
projectId: r.projectId,
|
|
1587
|
+
name: r.projectName,
|
|
1588
|
+
slug: r.slug ?? "",
|
|
1589
|
+
papiDir: r.papiDir ?? input.papiDir ?? null,
|
|
1590
|
+
created: r.created
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
async switchUserProject(identifier, papiDir) {
|
|
1594
|
+
const body = await this.postRoute(
|
|
1595
|
+
"project-switch",
|
|
1596
|
+
{ identifier, ...papiDir ? { papiDir } : {} }
|
|
1597
|
+
);
|
|
1598
|
+
this.projectId = body.projectId;
|
|
1599
|
+
return { projectId: body.projectId, name: body.name, slug: body.slug, papiDir: body.papiDir ?? null, created: false };
|
|
1600
|
+
}
|
|
1601
|
+
// --- Atomic plan write-back ---
|
|
1602
|
+
async planWriteBack(payload) {
|
|
1603
|
+
const raw = await this.invoke("planWriteBack", [payload]);
|
|
1604
|
+
const map = new Map(Object.entries(raw.newTaskIdMap ?? {}));
|
|
1605
|
+
return { ...raw, newTaskIdMap: map };
|
|
1606
|
+
}
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
// src/cli/backfill-cycle-metrics.ts
|
|
1612
|
+
import { pathToFileURL } from "url";
|
|
1613
|
+
|
|
1614
|
+
// src/adapter-factory.ts
|
|
1615
|
+
import path2 from "path";
|
|
1616
|
+
import { execSync } from "child_process";
|
|
1617
|
+
|
|
1618
|
+
// ../adapter-md/dist/index.js
|
|
1619
|
+
import { readFile, writeFile, access } from "fs/promises";
|
|
1620
|
+
import { randomUUID as randomUUID6 } from "crypto";
|
|
1621
|
+
import { join } from "path";
|
|
1622
|
+
|
|
1623
|
+
// ../shared/dist/index.js
|
|
1624
|
+
var VALID_TRANSITIONS = {
|
|
1625
|
+
"Backlog": ["In Cycle", "Ready", "In Progress", "Blocked", "Cancelled", "Deferred", "Done"],
|
|
1626
|
+
"In Cycle": ["In Progress", "Backlog", "Blocked", "Cancelled"],
|
|
1627
|
+
"Ready": ["In Progress", "Backlog", "Blocked", "Cancelled"],
|
|
1628
|
+
"In Progress": ["In Review", "Backlog", "Blocked", "Cancelled"],
|
|
1629
|
+
"In Review": ["Done", "In Progress", "Blocked", "Cancelled"],
|
|
1630
|
+
"Done": [],
|
|
1631
|
+
"Blocked": ["Backlog", "Ready", "In Cycle", "In Progress", "Cancelled"],
|
|
1632
|
+
"Cancelled": [],
|
|
1633
|
+
"Deferred": ["Backlog", "Cancelled"]
|
|
1634
|
+
};
|
|
1635
|
+
var RETIRED_DECISION_OUTCOMES = ["resolved", "abandoned", "superseded"];
|
|
1636
|
+
function isLiveDecision(d) {
|
|
1637
|
+
if (d.superseded === true) return false;
|
|
1638
|
+
if (d.outcome != null && RETIRED_DECISION_OUTCOMES.includes(d.outcome)) return false;
|
|
1639
|
+
return true;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// ../adapter-md/dist/index.js
|
|
1643
|
+
import { randomUUID } from "crypto";
|
|
1644
|
+
import { randomUUID as randomUUID3 } from "crypto";
|
|
1645
|
+
import yaml from "js-yaml";
|
|
1646
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
1647
|
+
import { randomUUID as randomUUID4 } from "crypto";
|
|
1648
|
+
import { randomUUID as randomUUID5 } from "crypto";
|
|
1649
|
+
import yaml2 from "js-yaml";
|
|
1650
|
+
import yaml3 from "js-yaml";
|
|
1651
|
+
var VALID_TRANSITIONS2 = VALID_TRANSITIONS;
|
|
1652
|
+
var isLiveDecision2 = isLiveDecision;
|
|
1653
|
+
function extractSection(content, heading) {
|
|
1654
|
+
const headingPattern = new RegExp(`^## ${heading}\\s*$`, "m");
|
|
1655
|
+
const start = content.search(headingPattern);
|
|
1656
|
+
if (start === -1) return "";
|
|
1657
|
+
const afterHeading = content.slice(start);
|
|
1658
|
+
const nextSection = afterHeading.slice(1).search(/^## /m);
|
|
1659
|
+
return nextSection === -1 ? afterHeading : afterHeading.slice(0, nextSection + 1);
|
|
1660
|
+
}
|
|
1661
|
+
function extractSectionCompat(content, newHeading, legacyHeading) {
|
|
1662
|
+
const section = extractSection(content, newHeading);
|
|
1663
|
+
return section || extractSection(content, legacyHeading);
|
|
1664
|
+
}
|
|
1665
|
+
function parseCycleHealth(content) {
|
|
1666
|
+
const section = extractSectionCompat(content, "Cycle Health", "Sprint Health") || extractSection(content, "Session Health");
|
|
1667
|
+
const rows = /* @__PURE__ */ new Map();
|
|
1668
|
+
for (const line of section.split("\n")) {
|
|
1669
|
+
const match = line.match(/^\|\s*(.+?)\s*\|\s*(.+?)\s*\|$/);
|
|
1670
|
+
if (!match) continue;
|
|
1671
|
+
const key = match[1].trim().toLowerCase();
|
|
1672
|
+
const value = match[2].trim();
|
|
1673
|
+
if (key !== "metric") rows.set(key, value);
|
|
1674
|
+
}
|
|
1675
|
+
const get = (key) => rows.get(key) ?? "";
|
|
1676
|
+
return {
|
|
1677
|
+
totalCycles: parseInt(get("total cycles") || get("total sprints") || get("total sessions"), 10) || 0,
|
|
1678
|
+
cyclesSinceLastStrategyReview: parseInt(get("cycles since last strategy review") || get("sprints since last strategy review") || get("sessions since last strategy review"), 10) || 0,
|
|
1679
|
+
strategyReviewDue: get("strategy review due"),
|
|
1680
|
+
boardHealth: get("board health"),
|
|
1681
|
+
strategicDirection: get("strategic direction"),
|
|
1682
|
+
lastFullMode: parseInt(get("last full mode"), 10) || 0
|
|
1683
|
+
};
|
|
1684
|
+
}
|
|
1685
|
+
function serializeCycleHealth(health, content) {
|
|
1686
|
+
const section = extractSectionCompat(content, "Cycle Health", "Sprint Health") || extractSection(content, "Session Health");
|
|
1687
|
+
const fieldMap = {
|
|
1688
|
+
"Total cycles": String(health.totalCycles),
|
|
1689
|
+
"Cycles since last Strategy Review": String(health.cyclesSinceLastStrategyReview),
|
|
1690
|
+
"Strategy Review due": health.strategyReviewDue,
|
|
1691
|
+
"Board health": health.boardHealth,
|
|
1692
|
+
"Strategic direction": health.strategicDirection,
|
|
1693
|
+
"Last Full Mode": String(health.lastFullMode)
|
|
1694
|
+
};
|
|
1695
|
+
const legacyFieldMap = {
|
|
1696
|
+
"Total sprints": String(health.totalCycles),
|
|
1697
|
+
"Sprints since last Strategy Review": String(health.cyclesSinceLastStrategyReview),
|
|
1698
|
+
"Total sessions": String(health.totalCycles),
|
|
1699
|
+
"Sessions since last Strategy Review": String(health.cyclesSinceLastStrategyReview)
|
|
1700
|
+
};
|
|
1701
|
+
let updatedSection = section;
|
|
1702
|
+
for (const [metric, value] of Object.entries({ ...fieldMap, ...legacyFieldMap })) {
|
|
1703
|
+
const pattern = new RegExp(`(\\|\\s*${metric}\\s*\\|\\s*)(.+?)(\\s*\\|)`, "i");
|
|
1704
|
+
updatedSection = updatedSection.replace(pattern, `$1${value}$3`);
|
|
1705
|
+
}
|
|
1706
|
+
return content.replace(section, updatedSection);
|
|
1707
|
+
}
|
|
1708
|
+
function parseActiveDecisions(content) {
|
|
1709
|
+
const section = extractSection(content, "Active Decisions");
|
|
1710
|
+
const chunks = section.split(/^(?=### AD-\d+:)/m).map((c) => c.trim()).filter((c) => c.startsWith("### AD-"));
|
|
1711
|
+
return chunks.map((block) => {
|
|
1712
|
+
const headingMatch = block.match(
|
|
1713
|
+
/^### (AD-\d+):\s*(.+?)(?:\s*\[Confidence:\s*(HIGH|MEDIUM|LOW)\])?(?:\s*\[SUPERSEDED by (AD-\d+)\])?\s*$/m
|
|
1714
|
+
);
|
|
1715
|
+
if (!headingMatch) return null;
|
|
1716
|
+
const metaMatch = block.match(/<!-- papi:(?:created_sprint=(\d+))?\s*(?:modified_sprint=(\d+)\s*)*(?:uuid=(\S+))? -->/);
|
|
1717
|
+
const createdCycle = metaMatch?.[1] ? parseInt(metaMatch[1], 10) : void 0;
|
|
1718
|
+
const modifiedCycle = metaMatch?.[2] ? parseInt(metaMatch[2], 10) : void 0;
|
|
1719
|
+
const uuid = metaMatch?.[3] ?? randomUUID();
|
|
1720
|
+
return {
|
|
1721
|
+
uuid,
|
|
1722
|
+
id: headingMatch[1],
|
|
1723
|
+
displayId: headingMatch[1],
|
|
1724
|
+
title: headingMatch[2].trim(),
|
|
1725
|
+
confidence: headingMatch[3] ?? "HIGH",
|
|
1726
|
+
superseded: !!headingMatch[4],
|
|
1727
|
+
supersededBy: headingMatch[4],
|
|
1728
|
+
createdCycle,
|
|
1729
|
+
modifiedCycle,
|
|
1730
|
+
body: block
|
|
1731
|
+
};
|
|
1732
|
+
}).filter((d) => d !== null);
|
|
1733
|
+
}
|
|
1734
|
+
function stripTemporalMeta(body) {
|
|
1735
|
+
return body.replace(/\n?<!-- papi:(?:created_sprint=\d+)?\s*(?:modified_sprint=\d+\s*)*(?:uuid=\S+)? -->/g, "");
|
|
1736
|
+
}
|
|
1737
|
+
function buildTemporalMeta(createdCycle, modifiedCycle, uuid) {
|
|
1738
|
+
const parts = [];
|
|
1739
|
+
if (createdCycle != null) parts.push(`created_sprint=${createdCycle}`);
|
|
1740
|
+
if (modifiedCycle != null) parts.push(`modified_sprint=${modifiedCycle}`);
|
|
1741
|
+
if (uuid) parts.push(`uuid=${uuid}`);
|
|
1742
|
+
if (parts.length === 0) return "";
|
|
1743
|
+
return `
|
|
1744
|
+
<!-- papi:${parts.join(" ")} -->`;
|
|
1745
|
+
}
|
|
1746
|
+
function extractCreatedCycle(block) {
|
|
1747
|
+
const m = block.match(/<!-- papi:(?:created_sprint=(\d+))/);
|
|
1748
|
+
return m?.[1] ? parseInt(m[1], 10) : void 0;
|
|
1749
|
+
}
|
|
1750
|
+
function extractUuid(block) {
|
|
1751
|
+
const m = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
1752
|
+
return m?.[1];
|
|
1753
|
+
}
|
|
1754
|
+
function updateActiveDecisionInContent(id, newBody, content, cycleNumber) {
|
|
1755
|
+
if (!newBody) return content;
|
|
1756
|
+
const escapedId = id.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&");
|
|
1757
|
+
const pattern = new RegExp(`(### ${escapedId}:.*?)(?=^### AD-\\d+:|^## |$(?![\\s\\S]))`, "ms");
|
|
1758
|
+
const cleanBody = stripTemporalMeta(newBody);
|
|
1759
|
+
if (pattern.test(content)) {
|
|
1760
|
+
const existingMatch = content.match(pattern);
|
|
1761
|
+
const existingCreated = existingMatch ? extractCreatedCycle(existingMatch[0]) : void 0;
|
|
1762
|
+
const existingUuid = existingMatch ? extractUuid(existingMatch[0]) : void 0;
|
|
1763
|
+
const meta2 = cycleNumber != null ? buildTemporalMeta(existingCreated, cycleNumber, existingUuid) : existingUuid ? buildTemporalMeta(existingCreated, void 0, existingUuid) : "";
|
|
1764
|
+
return content.replace(pattern, cleanBody.trimEnd() + meta2 + "\n\n");
|
|
1765
|
+
}
|
|
1766
|
+
const meta = cycleNumber != null ? buildTemporalMeta(cycleNumber) : "";
|
|
1767
|
+
const sectionPattern = /^(#{1,2} Active Decisions\n)([\s\S]*?)(?=^#{1,2} |$(?![\s\S]))/m;
|
|
1768
|
+
const sectionMatch = content.match(sectionPattern);
|
|
1769
|
+
if (sectionMatch) {
|
|
1770
|
+
const sectionHeader = sectionMatch[1];
|
|
1771
|
+
const sectionBody = sectionMatch[2];
|
|
1772
|
+
const newSection = sectionHeader + sectionBody.trimEnd() + "\n\n" + cleanBody.trimEnd() + meta + "\n\n";
|
|
1773
|
+
return content.replace(sectionPattern, newSection);
|
|
1774
|
+
}
|
|
1775
|
+
return content;
|
|
1776
|
+
}
|
|
1777
|
+
function parseCycleLog(content, limit) {
|
|
1778
|
+
const section = extractSectionCompat(content, "Cycle Log", "Sprint Log");
|
|
1779
|
+
const chunks = section.split(/^(?=### (?:Cycle|Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Cycle|Sprint|Session) \d+ —/));
|
|
1780
|
+
const entries = chunks.map((block) => {
|
|
1781
|
+
const headingMatch = block.match(/^### (?:Cycle|Sprint|Session) (\d+) — (.+?)$/m);
|
|
1782
|
+
const cycleNumber = headingMatch ? parseInt(headingMatch[1], 10) : 0;
|
|
1783
|
+
const title = headingMatch ? headingMatch[2].trim() : block.split("\n")[0].replace(/^### /, "");
|
|
1784
|
+
const carryForwardMatch = block.match(/^- \*\*CARRY FORWARD:\*\*\s*(.+)$/m);
|
|
1785
|
+
const uuidMatch = block.match(/<!-- papi:.*?uuid=(\S+)/);
|
|
1786
|
+
const uuid = uuidMatch?.[1];
|
|
1787
|
+
const blockClean = block.replace(/\n?<!-- papi:.*?-->/g, "");
|
|
1788
|
+
const notesMatch = blockClean.match(/\*\*Cycle Notes:\*\*\s*([\s\S]*?)$/);
|
|
1789
|
+
const notes = notesMatch ? notesMatch[1].trim() : void 0;
|
|
1790
|
+
return {
|
|
1791
|
+
uuid: uuid ?? randomUUID(),
|
|
1792
|
+
cycleNumber,
|
|
1793
|
+
title,
|
|
1794
|
+
content: block,
|
|
1795
|
+
carryForward: carryForwardMatch ? carryForwardMatch[1].trim() : void 0,
|
|
1796
|
+
notes
|
|
1797
|
+
};
|
|
1798
|
+
});
|
|
1799
|
+
return limit ? entries.slice(0, limit) : entries;
|
|
1800
|
+
}
|
|
1801
|
+
function prependCycleLogEntry(entry, content) {
|
|
1802
|
+
const headingPattern = /^## (?:Cycle|Sprint|Session) Log\s*$/m;
|
|
1803
|
+
const headingMatch = content.match(headingPattern);
|
|
1804
|
+
if (!headingMatch || headingMatch.index === void 0) {
|
|
1805
|
+
throw new Error("Cycle Log section not found in Planning Log");
|
|
1806
|
+
}
|
|
1807
|
+
const insertPos = headingMatch.index + headingMatch[0].length;
|
|
1808
|
+
const before = content.slice(0, insertPos);
|
|
1809
|
+
const after = content.slice(insertPos);
|
|
1810
|
+
let entryContent = entry.content;
|
|
1811
|
+
if (entry.notes) {
|
|
1812
|
+
entryContent = `${entryContent}
|
|
1813
|
+
|
|
1814
|
+
**Cycle Notes:** ${entry.notes}`;
|
|
1815
|
+
}
|
|
1816
|
+
if (entry.uuid) {
|
|
1817
|
+
entryContent = `${entryContent}
|
|
1818
|
+
<!-- papi:uuid=${entry.uuid} -->`;
|
|
1819
|
+
}
|
|
1820
|
+
return `${before}
|
|
1821
|
+
|
|
1822
|
+
${entryContent}
|
|
1823
|
+
${after}`;
|
|
1824
|
+
}
|
|
1825
|
+
function parseNorthStar(content) {
|
|
1826
|
+
return extractSection(content, "North Star").replace(/^## North Star\s*/m, "").trim();
|
|
1827
|
+
}
|
|
1828
|
+
function upsertNorthStarInContent(content, statement) {
|
|
1829
|
+
const headingPattern = /^## North Star\s*$/m;
|
|
1830
|
+
const start = content.search(headingPattern);
|
|
1831
|
+
if (start === -1) {
|
|
1832
|
+
const cycleLogIdx = content.search(/^## (?:Cycle Log|Sprint Log)/m);
|
|
1833
|
+
const newSection = `## North Star
|
|
1834
|
+
|
|
1835
|
+
${statement}
|
|
1836
|
+
|
|
1837
|
+
`;
|
|
1838
|
+
if (cycleLogIdx === -1) {
|
|
1839
|
+
return content.trimEnd() + "\n\n" + newSection;
|
|
1840
|
+
}
|
|
1841
|
+
return content.slice(0, cycleLogIdx) + newSection + content.slice(cycleLogIdx);
|
|
1842
|
+
}
|
|
1843
|
+
const afterHeading = content.slice(start);
|
|
1844
|
+
const nextSection = afterHeading.slice(1).search(/^## /m);
|
|
1845
|
+
const sectionEnd = nextSection === -1 ? content.length : start + nextSection + 1;
|
|
1846
|
+
return content.slice(0, start) + `## North Star
|
|
1847
|
+
|
|
1848
|
+
${statement}
|
|
1849
|
+
|
|
1850
|
+
` + content.slice(sectionEnd);
|
|
1851
|
+
}
|
|
1852
|
+
function parseDeferred(content) {
|
|
1853
|
+
const section = extractSection(content, "Deferred / Parking Lot");
|
|
1854
|
+
return section.split("\n").filter((line) => line.match(/^-\s+/)).map((line) => line.replace(/^-\s+/, "").trim());
|
|
1855
|
+
}
|
|
1856
|
+
function compressCycleLogInContent(content, threshold, summary) {
|
|
1857
|
+
const section = extractSectionCompat(content, "Cycle Log", "Sprint Log");
|
|
1858
|
+
const chunks = section.split(/^(?=### (?:Cycle|Sprint|Session) \d+ —)/m).map((c) => c.trim()).filter((c) => c.match(/^### (?:Cycle|Sprint|Session) \d+ —/));
|
|
1859
|
+
const keep = [];
|
|
1860
|
+
let hasOld = false;
|
|
1861
|
+
for (const block of chunks) {
|
|
1862
|
+
const match = block.match(/^### (?:Cycle|Sprint|Session) (\d+) —/);
|
|
1863
|
+
const num = match ? parseInt(match[1], 10) : 0;
|
|
1864
|
+
if (num >= threshold) {
|
|
1865
|
+
keep.push(block);
|
|
1866
|
+
} else {
|
|
1867
|
+
hasOld = true;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
if (!hasOld) return content;
|
|
1871
|
+
const summaryBlock = `### Cycles 1\u2013${threshold - 1} \u2014 Compressed Summary
|
|
1872
|
+
|
|
1873
|
+
${summary}`;
|
|
1874
|
+
const newEntries = [...keep, summaryBlock].join("\n\n");
|
|
1875
|
+
const newSection = `## Cycle Log
|
|
1876
|
+
|
|
1877
|
+
${newEntries}
|
|
1878
|
+
`;
|
|
1879
|
+
return content.replace(section, newSection);
|
|
1880
|
+
}
|
|
1881
|
+
function parsePlanningLog(content, activeDecisionsContent, cycleLogContent) {
|
|
1882
|
+
return {
|
|
1883
|
+
cycleHealth: parseCycleHealth(content),
|
|
1884
|
+
northStar: parseNorthStar(content),
|
|
1885
|
+
activeDecisions: parseActiveDecisions(activeDecisionsContent ?? content),
|
|
1886
|
+
deferred: parseDeferred(content),
|
|
1887
|
+
cycleLog: cycleLogContent ? parseCycleLog(cycleLogContent) : []
|
|
1888
|
+
};
|
|
1889
|
+
}
|
|
1890
|
+
var VALID_EFFORT_SIZES = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
1891
|
+
var SECTION_HEADERS = [
|
|
1892
|
+
"SCOPE (DO THIS)",
|
|
1893
|
+
"SCOPE BOUNDARY (DO NOT DO THIS)",
|
|
1894
|
+
"ACCEPTANCE CRITERIA",
|
|
1895
|
+
"SECURITY CONSIDERATIONS",
|
|
1896
|
+
"PRE-BUILD VERIFICATION",
|
|
1897
|
+
"FILES LIKELY TOUCHED",
|
|
1898
|
+
"EFFORT"
|
|
1899
|
+
];
|
|
1900
|
+
function splitSections(text) {
|
|
1901
|
+
const sections = /* @__PURE__ */ new Map();
|
|
1902
|
+
const lines = text.split("\n");
|
|
1903
|
+
let currentSection = null;
|
|
1904
|
+
const sectionLines = [];
|
|
1905
|
+
const flush = () => {
|
|
1906
|
+
if (currentSection !== null) {
|
|
1907
|
+
sections.set(currentSection, sectionLines.join("\n").trim());
|
|
1908
|
+
sectionLines.length = 0;
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
for (const line of lines) {
|
|
1912
|
+
const trimmed = line.trim();
|
|
1913
|
+
const matched = SECTION_HEADERS.find((h) => trimmed === h);
|
|
1914
|
+
if (matched) {
|
|
1915
|
+
flush();
|
|
1916
|
+
currentSection = matched;
|
|
1917
|
+
} else if (currentSection !== null) {
|
|
1918
|
+
sectionLines.push(line);
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
flush();
|
|
1922
|
+
return sections;
|
|
1923
|
+
}
|
|
1924
|
+
function parseBulletList(text) {
|
|
1925
|
+
return text.split("\n").map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
|
|
1926
|
+
}
|
|
1927
|
+
function parseBulletsOnly(text) {
|
|
1928
|
+
return text.split("\n").filter((l) => /^\s*-\s/.test(l)).map((l) => l.replace(/^\s*-\s*/, "").trim()).filter((l) => l.length > 0);
|
|
1929
|
+
}
|
|
1930
|
+
function parseChecklist(text) {
|
|
1931
|
+
return text.split("\n").map((l) => l.replace(/^\s*\[[ x]]\s*/, "").trim()).filter((l) => l.length > 0);
|
|
1932
|
+
}
|
|
1933
|
+
function parseBuildHandoff(markdown) {
|
|
1934
|
+
if (!markdown.includes("BUILD HANDOFF")) return null;
|
|
1935
|
+
const taskIdMatch = markdown.match(/BUILD HANDOFF\s*—\s*(task-\d+)/);
|
|
1936
|
+
const taskTitleMatch = markdown.match(/^Task:\s*(.+)$/m);
|
|
1937
|
+
const cycleMatch = markdown.match(/^Cycle:\s*(\d+)$/m);
|
|
1938
|
+
const whyNowMatch = markdown.match(/^Why now:\s*([\s\S]*?)(?=\n\n|\nSCOPE)/m);
|
|
1939
|
+
const taskId = taskIdMatch?.[1] ?? "";
|
|
1940
|
+
const taskTitle = taskTitleMatch?.[1]?.trim() ?? "";
|
|
1941
|
+
const cycle = cycleMatch ? parseInt(cycleMatch[1], 10) : 0;
|
|
1942
|
+
const whyNow = whyNowMatch?.[1]?.replace(/\s+/g, " ").trim() ?? "";
|
|
1943
|
+
const uuidMatch = markdown.match(/^UUID:\s*(\S+)$/m);
|
|
1944
|
+
const uuid = uuidMatch?.[1];
|
|
1945
|
+
const displayIdMatch = markdown.match(/^Display ID:\s*(\S+)$/m);
|
|
1946
|
+
const displayId = displayIdMatch?.[1];
|
|
1947
|
+
const createdAtMatch = markdown.match(/^Created:\s*(.+)$/m);
|
|
1948
|
+
const createdAt = createdAtMatch?.[1]?.trim();
|
|
1949
|
+
const sections = splitSections(markdown);
|
|
1950
|
+
const effortText = (sections.get("EFFORT") ?? "").trim().toUpperCase();
|
|
1951
|
+
const effort = VALID_EFFORT_SIZES.has(effortText) ? effortText : "M";
|
|
1952
|
+
return {
|
|
1953
|
+
uuid: uuid ?? randomUUID2(),
|
|
1954
|
+
...displayId ? { displayId } : {},
|
|
1955
|
+
...createdAt ? { createdAt } : {},
|
|
1956
|
+
taskId,
|
|
1957
|
+
taskTitle,
|
|
1958
|
+
cycle,
|
|
1959
|
+
whyNow,
|
|
1960
|
+
scope: parseBulletList(sections.get("SCOPE (DO THIS)") ?? ""),
|
|
1961
|
+
scopeBoundary: parseBulletList(sections.get("SCOPE BOUNDARY (DO NOT DO THIS)") ?? ""),
|
|
1962
|
+
acceptanceCriteria: parseChecklist(sections.get("ACCEPTANCE CRITERIA") ?? ""),
|
|
1963
|
+
securityConsiderations: (sections.get("SECURITY CONSIDERATIONS") ?? "").trim(),
|
|
1964
|
+
verificationFiles: parseBulletsOnly(sections.get("PRE-BUILD VERIFICATION") ?? ""),
|
|
1965
|
+
filesLikelyTouched: parseBulletList(sections.get("FILES LIKELY TOUCHED") ?? ""),
|
|
1966
|
+
effort
|
|
1967
|
+
};
|
|
1968
|
+
}
|
|
1969
|
+
function ensureArray(value) {
|
|
1970
|
+
if (Array.isArray(value)) return value;
|
|
1971
|
+
if (typeof value === "string") {
|
|
1972
|
+
try {
|
|
1973
|
+
const parsed = JSON.parse(value);
|
|
1974
|
+
if (Array.isArray(parsed)) return parsed;
|
|
1975
|
+
} catch {
|
|
1976
|
+
}
|
|
1977
|
+
return value.trim() ? [value] : [];
|
|
1978
|
+
}
|
|
1979
|
+
return [];
|
|
1980
|
+
}
|
|
1981
|
+
function serializeBuildHandoff(raw) {
|
|
1982
|
+
let handoff;
|
|
1983
|
+
if (typeof raw === "string") {
|
|
1984
|
+
try {
|
|
1985
|
+
handoff = JSON.parse(raw);
|
|
1986
|
+
} catch {
|
|
1987
|
+
return raw;
|
|
1988
|
+
}
|
|
1989
|
+
} else {
|
|
1990
|
+
handoff = raw;
|
|
1991
|
+
}
|
|
1992
|
+
const lines = [];
|
|
1993
|
+
lines.push(`BUILD HANDOFF \u2014 ${handoff.taskId}`);
|
|
1994
|
+
if (handoff.uuid) lines.push(`UUID: ${handoff.uuid}`);
|
|
1995
|
+
if (handoff.displayId) lines.push(`Display ID: ${handoff.displayId}`);
|
|
1996
|
+
if (handoff.createdAt) lines.push(`Created: ${handoff.createdAt}`);
|
|
1997
|
+
lines.push(`Task: ${handoff.taskTitle}`);
|
|
1998
|
+
lines.push(`Cycle: ${handoff.cycle}`);
|
|
1999
|
+
lines.push(`Why now: ${handoff.whyNow}`);
|
|
2000
|
+
lines.push("");
|
|
2001
|
+
lines.push("SCOPE (DO THIS)");
|
|
2002
|
+
for (const item of ensureArray(handoff.scope)) {
|
|
2003
|
+
lines.push(`- ${item}`);
|
|
2004
|
+
}
|
|
2005
|
+
lines.push("");
|
|
2006
|
+
lines.push("SCOPE BOUNDARY (DO NOT DO THIS)");
|
|
2007
|
+
for (const item of ensureArray(handoff.scopeBoundary)) {
|
|
2008
|
+
lines.push(`- ${item}`);
|
|
2009
|
+
}
|
|
2010
|
+
lines.push("");
|
|
2011
|
+
lines.push("ACCEPTANCE CRITERIA");
|
|
2012
|
+
for (const item of ensureArray(handoff.acceptanceCriteria)) {
|
|
2013
|
+
lines.push(`[ ] ${item}`);
|
|
2014
|
+
}
|
|
2015
|
+
lines.push("");
|
|
2016
|
+
lines.push("SECURITY CONSIDERATIONS");
|
|
2017
|
+
lines.push(handoff.securityConsiderations ?? "");
|
|
2018
|
+
const verificationFiles = ensureArray(handoff.verificationFiles);
|
|
2019
|
+
if (verificationFiles.length > 0) {
|
|
2020
|
+
lines.push("");
|
|
2021
|
+
lines.push("PRE-BUILD VERIFICATION");
|
|
2022
|
+
lines.push("Before implementing, read these files and check if the functionality already exists:");
|
|
2023
|
+
for (const item of verificationFiles) {
|
|
2024
|
+
lines.push(`- ${item}`);
|
|
2025
|
+
}
|
|
2026
|
+
lines.push('If >80% of the scope is already implemented, call build_execute with completed="yes" and note "already built" in surprises instead of re-implementing.');
|
|
2027
|
+
}
|
|
2028
|
+
lines.push("");
|
|
2029
|
+
lines.push("FILES LIKELY TOUCHED");
|
|
2030
|
+
for (const item of ensureArray(handoff.filesLikelyTouched)) {
|
|
2031
|
+
lines.push(`- ${item}`);
|
|
2032
|
+
}
|
|
2033
|
+
lines.push("");
|
|
2034
|
+
lines.push("EFFORT");
|
|
2035
|
+
lines.push(handoff.effort ?? "M");
|
|
2036
|
+
return lines.join("\n");
|
|
2037
|
+
}
|
|
2038
|
+
var YAML_MARKER = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
2039
|
+
var YAML_START = "<!-- PAPI-YAML-START -->";
|
|
2040
|
+
var YAML_END = "<!-- PAPI-YAML-END -->";
|
|
2041
|
+
function toCycleTask(raw) {
|
|
2042
|
+
return {
|
|
2043
|
+
uuid: raw.uuid || randomUUID3(),
|
|
2044
|
+
id: raw.id,
|
|
2045
|
+
displayId: raw.id,
|
|
2046
|
+
title: raw.title,
|
|
2047
|
+
status: raw.status,
|
|
2048
|
+
priority: raw.priority,
|
|
2049
|
+
complexity: raw.complexity,
|
|
2050
|
+
module: raw.module,
|
|
2051
|
+
epic: raw.epic,
|
|
2052
|
+
phase: raw.phase,
|
|
2053
|
+
owner: raw.owner,
|
|
2054
|
+
reviewed: raw.reviewed ?? false,
|
|
2055
|
+
cycle: raw.cycle != null ? raw.cycle : void 0,
|
|
2056
|
+
createdCycle: raw.created_sprint != null ? raw.created_sprint : void 0,
|
|
2057
|
+
createdAt: raw.created_at || void 0,
|
|
2058
|
+
why: raw.why || void 0,
|
|
2059
|
+
dependsOn: raw.depends_on || void 0,
|
|
2060
|
+
notes: raw.notes || void 0,
|
|
2061
|
+
stateHistory: raw.state_history?.length ? raw.state_history.map((e) => ({ status: e.status, timestamp: e.timestamp })) : void 0,
|
|
2062
|
+
closureReason: raw.closure_reason || void 0,
|
|
2063
|
+
buildHandoff: raw.build_handoff ? parseBuildHandoff(raw.build_handoff) ?? void 0 : void 0,
|
|
2064
|
+
buildReport: raw.build_report || void 0,
|
|
2065
|
+
scopeClass: raw.scope_class === "brief" ? "brief" : "task",
|
|
2066
|
+
assigneeId: raw.assignee_id || void 0,
|
|
2067
|
+
claimSource: raw.claim_source === "pool" || raw.claim_source === "self_generated" ? raw.claim_source : void 0,
|
|
2068
|
+
reviewerId: raw.reviewer_id || void 0
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
function sanitizeDelimiters(value) {
|
|
2072
|
+
return value.replaceAll(YAML_END, "<!-- PAPI-YAML-END (sanitized) -->");
|
|
2073
|
+
}
|
|
2074
|
+
function fromCycleTask(task) {
|
|
2075
|
+
const raw = {
|
|
2076
|
+
uuid: task.uuid,
|
|
2077
|
+
id: task.id,
|
|
2078
|
+
title: task.title,
|
|
2079
|
+
status: task.status,
|
|
2080
|
+
priority: task.priority,
|
|
2081
|
+
complexity: task.complexity,
|
|
2082
|
+
module: task.module,
|
|
2083
|
+
epic: task.epic,
|
|
2084
|
+
phase: task.phase,
|
|
2085
|
+
owner: task.owner,
|
|
2086
|
+
reviewed: task.reviewed,
|
|
2087
|
+
depends_on: task.dependsOn ?? "",
|
|
2088
|
+
notes: task.notes ? sanitizeDelimiters(task.notes) : ""
|
|
2089
|
+
};
|
|
2090
|
+
if (task.cycle != null) raw.cycle = task.cycle;
|
|
2091
|
+
if (task.createdCycle != null) raw.created_sprint = task.createdCycle;
|
|
2092
|
+
if (task.createdAt) raw.created_at = task.createdAt;
|
|
2093
|
+
if (task.why) raw.why = task.why;
|
|
2094
|
+
if (task.stateHistory?.length) {
|
|
2095
|
+
raw.state_history = task.stateHistory.map((e) => ({ status: e.status, timestamp: e.timestamp }));
|
|
2096
|
+
}
|
|
2097
|
+
if (task.closureReason) raw.closure_reason = task.closureReason;
|
|
2098
|
+
if (task.buildHandoff) raw.build_handoff = sanitizeDelimiters(serializeBuildHandoff(task.buildHandoff));
|
|
2099
|
+
if (task.buildReport) raw.build_report = sanitizeDelimiters(task.buildReport);
|
|
2100
|
+
if (task.scopeClass && task.scopeClass !== "task") raw.scope_class = task.scopeClass;
|
|
2101
|
+
if (task.assigneeId) raw.assignee_id = task.assigneeId;
|
|
2102
|
+
if (task.claimSource) raw.claim_source = task.claimSource;
|
|
2103
|
+
if (task.reviewerId) raw.reviewer_id = task.reviewerId;
|
|
2104
|
+
return raw;
|
|
2105
|
+
}
|
|
2106
|
+
function mergeConflictHint(content) {
|
|
2107
|
+
if (/^[<=>]{7}/m.test(content)) {
|
|
2108
|
+
return " The file contains merge conflict markers (<<<<<<, ======, >>>>>>) \u2014 resolve them first.";
|
|
2109
|
+
}
|
|
2110
|
+
return "";
|
|
2111
|
+
}
|
|
2112
|
+
function extractYamlBlock(content) {
|
|
2113
|
+
const markerIdx = content.indexOf(YAML_MARKER);
|
|
2114
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLE_BOARD.md");
|
|
2115
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER.length);
|
|
2116
|
+
const startIdx = afterMarker.indexOf(YAML_START);
|
|
2117
|
+
if (startIdx !== -1) {
|
|
2118
|
+
const yamlStart = startIdx + YAML_START.length;
|
|
2119
|
+
const endIdx = afterMarker.indexOf(YAML_END, yamlStart);
|
|
2120
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLE_BOARD.md");
|
|
2121
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
2122
|
+
}
|
|
2123
|
+
const blockMatch = afterMarker.match(/```yaml\n([\s\S]*?)```/);
|
|
2124
|
+
if (!blockMatch) throw new Error("YAML block not found in CYCLE_BOARD.md");
|
|
2125
|
+
return blockMatch[1];
|
|
2126
|
+
}
|
|
2127
|
+
function parseBoard(content) {
|
|
2128
|
+
const yamlText = extractYamlBlock(content);
|
|
2129
|
+
let data;
|
|
2130
|
+
try {
|
|
2131
|
+
data = yaml.load(yamlText);
|
|
2132
|
+
} catch (err) {
|
|
2133
|
+
const yamlErr = err;
|
|
2134
|
+
const lineInfo = yamlErr.mark?.line != null ? ` (near line ${yamlErr.mark.line + 1} of YAML block)` : "";
|
|
2135
|
+
const hint = mergeConflictHint(yamlText);
|
|
2136
|
+
throw new Error(
|
|
2137
|
+
`YAML parse error in CYCLE_BOARD.md${lineInfo}. Check for syntax errors \u2014 unquoted special characters, bad indentation, or missing colons.${hint}`
|
|
2138
|
+
);
|
|
2139
|
+
}
|
|
2140
|
+
return (data.tasks ?? []).map(toCycleTask);
|
|
2141
|
+
}
|
|
2142
|
+
function serializeBoard(tasks, content) {
|
|
2143
|
+
const raw = tasks.map(fromCycleTask);
|
|
2144
|
+
const yamlStr = yaml.dump({ tasks: raw }, { lineWidth: 120, quotingType: '"' });
|
|
2145
|
+
const markerIdx = content.indexOf(YAML_MARKER);
|
|
2146
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLE_BOARD.md");
|
|
2147
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER.length);
|
|
2148
|
+
const htmlStartIdx = afterMarker.indexOf(YAML_START);
|
|
2149
|
+
if (htmlStartIdx !== -1) {
|
|
2150
|
+
const absStart = markerIdx + YAML_MARKER.length + htmlStartIdx;
|
|
2151
|
+
const endIdx = afterMarker.indexOf(YAML_END, htmlStartIdx);
|
|
2152
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLE_BOARD.md");
|
|
2153
|
+
const absEnd = markerIdx + YAML_MARKER.length + endIdx + YAML_END.length;
|
|
2154
|
+
return content.slice(0, absStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(absEnd);
|
|
2155
|
+
}
|
|
2156
|
+
const blockMatch = afterMarker.match(/```yaml\n[\s\S]*?```/);
|
|
2157
|
+
if (!blockMatch) throw new Error("YAML block not found in CYCLE_BOARD.md");
|
|
2158
|
+
const blockStart = markerIdx + YAML_MARKER.length + afterMarker.indexOf(blockMatch[0]);
|
|
2159
|
+
const blockEnd = blockStart + blockMatch[0].length;
|
|
2160
|
+
return content.slice(0, blockStart) + YAML_START + "\n" + yamlStr + YAML_END + content.slice(blockEnd);
|
|
2161
|
+
}
|
|
2162
|
+
function filterTasks(tasks, options) {
|
|
2163
|
+
return tasks.filter((task) => {
|
|
2164
|
+
if (options.status && !options.status.includes(task.status)) return false;
|
|
2165
|
+
if (options.priority && !options.priority.includes(task.priority)) return false;
|
|
2166
|
+
if (options.phase && !task.phase.toLowerCase().includes(options.phase.toLowerCase())) return false;
|
|
2167
|
+
if (options.reviewed !== void 0 && task.reviewed !== options.reviewed) return false;
|
|
2168
|
+
if (options.module && task.module !== options.module) return false;
|
|
2169
|
+
if (options.epic && task.epic !== options.epic) return false;
|
|
2170
|
+
if (options.assigneeId && task.assigneeId !== options.assigneeId) return false;
|
|
2171
|
+
return true;
|
|
2172
|
+
});
|
|
2173
|
+
}
|
|
2174
|
+
function nextTaskId(tasks) {
|
|
2175
|
+
const maxN = tasks.reduce((max, t) => {
|
|
2176
|
+
const match = t.id.match(/^task-(\d+)$/);
|
|
2177
|
+
return match ? Math.max(max, parseInt(match[1], 10)) : max;
|
|
2178
|
+
}, 0);
|
|
2179
|
+
return `task-${String(maxN + 1).padStart(3, "0")}`;
|
|
2180
|
+
}
|
|
2181
|
+
var VALID_EFFORT_SIZES2 = /* @__PURE__ */ new Set(["XS", "S", "M", "L", "XL"]);
|
|
2182
|
+
function parseEffortSize(value) {
|
|
2183
|
+
const normalized = value.trim().toUpperCase();
|
|
2184
|
+
return VALID_EFFORT_SIZES2.has(normalized) ? normalized : void 0;
|
|
2185
|
+
}
|
|
2186
|
+
var HEADER_SENTINEL = "*After each build";
|
|
2187
|
+
function parseField(block, field) {
|
|
2188
|
+
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
2189
|
+
const match = block.match(pattern);
|
|
2190
|
+
return match ? match[1].trim() : "";
|
|
2191
|
+
}
|
|
2192
|
+
function parseEffort(effortLine) {
|
|
2193
|
+
const match = effortLine.match(/^(\S+)\s+vs\s+estimated\s+(\S+)$/i);
|
|
2194
|
+
return match ? { actual: match[1], estimated: match[2] } : { actual: effortLine, estimated: "" };
|
|
2195
|
+
}
|
|
2196
|
+
function parseBuildReports(content) {
|
|
2197
|
+
const chunks = content.split(/^(?=### .+ — .+ — (?:Cycle|Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Cycle|Sprint|Session) \d+/));
|
|
2198
|
+
return chunks.map((block) => {
|
|
2199
|
+
const headingMatch = block.match(/^### (.+?) — (.+?) — (?:Cycle|Sprint|Session) (\d+)/);
|
|
2200
|
+
if (!headingMatch) return null;
|
|
2201
|
+
const effortLine = parseField(block, "Actual Effort");
|
|
2202
|
+
const { actual, estimated } = parseEffort(effortLine);
|
|
2203
|
+
const completedRaw = parseField(block, "Completed");
|
|
2204
|
+
const taskId = parseField(block, "Task ID") || "unknown";
|
|
2205
|
+
const uuidRaw = parseField(block, "UUID");
|
|
2206
|
+
const displayIdRaw = parseField(block, "Display ID");
|
|
2207
|
+
const scopeAccuracyRaw = parseField(block, "Scope Accuracy");
|
|
2208
|
+
const validScopeValues = /* @__PURE__ */ new Set(["accurate", "over-scoped", "under-scoped", "missed-context"]);
|
|
2209
|
+
const scopeAccuracy = scopeAccuracyRaw && validScopeValues.has(scopeAccuracyRaw) ? scopeAccuracyRaw : "accurate";
|
|
2210
|
+
const actualEffort = parseEffortSize(actual) ?? "M";
|
|
2211
|
+
const estimatedEffort = parseEffortSize(estimated) ?? "M";
|
|
2212
|
+
const createdAtRaw = parseField(block, "Created");
|
|
2213
|
+
const commitShaRaw = parseField(block, "Commit SHA");
|
|
2214
|
+
const filesChangedRaw = parseField(block, "Files Changed");
|
|
2215
|
+
const report = {
|
|
2216
|
+
uuid: uuidRaw ?? randomUUID4(),
|
|
2217
|
+
...displayIdRaw ? { displayId: displayIdRaw } : {},
|
|
2218
|
+
...createdAtRaw ? { createdAt: createdAtRaw } : {},
|
|
2219
|
+
taskId,
|
|
2220
|
+
taskName: headingMatch[1].trim(),
|
|
2221
|
+
date: headingMatch[2].trim(),
|
|
2222
|
+
cycle: parseInt(headingMatch[3], 10),
|
|
2223
|
+
completed: completedRaw.startsWith("Yes") ? "Yes" : completedRaw.startsWith("No") ? "No" : "Partial",
|
|
2224
|
+
actualEffort,
|
|
2225
|
+
estimatedEffort,
|
|
2226
|
+
surprises: parseField(block, "Surprises"),
|
|
2227
|
+
discoveredIssues: parseField(block, "Discovered Issues"),
|
|
2228
|
+
architectureNotes: parseField(block, "Architecture Notes"),
|
|
2229
|
+
scopeAccuracy
|
|
2230
|
+
};
|
|
2231
|
+
if (commitShaRaw) report.commitSha = commitShaRaw;
|
|
2232
|
+
if (filesChangedRaw) report.filesChanged = filesChangedRaw.split(",").map((f) => f.trim()).filter(Boolean);
|
|
2233
|
+
return report;
|
|
2234
|
+
}).filter((r) => r !== null);
|
|
2235
|
+
}
|
|
2236
|
+
function serializeBuildReport(report) {
|
|
2237
|
+
const lines = [
|
|
2238
|
+
`### ${report.taskName} \u2014 ${report.date} \u2014 Cycle ${report.cycle}`
|
|
2239
|
+
];
|
|
2240
|
+
if (report.uuid) lines.push(`- **UUID:** ${report.uuid}`);
|
|
2241
|
+
if (report.displayId) lines.push(`- **Display ID:** ${report.displayId}`);
|
|
2242
|
+
if (report.createdAt) lines.push(`- **Created:** ${report.createdAt}`);
|
|
2243
|
+
lines.push(
|
|
2244
|
+
`- **Task ID:** ${report.taskId}`,
|
|
2245
|
+
`- **Completed:** ${report.completed}`,
|
|
2246
|
+
`- **Actual Effort:** ${report.actualEffort} vs estimated ${report.estimatedEffort}`,
|
|
2247
|
+
`- **Surprises:** ${report.surprises || "None"}`,
|
|
2248
|
+
`- **Discovered Issues:** ${report.discoveredIssues || "None"}`,
|
|
2249
|
+
`- **Architecture Notes:** ${report.architectureNotes || "None"}`,
|
|
2250
|
+
`- **Scope Accuracy:** ${report.scopeAccuracy}`
|
|
2251
|
+
);
|
|
2252
|
+
if (report.commitSha) lines.push(`- **Commit SHA:** ${report.commitSha}`);
|
|
2253
|
+
if (report.filesChanged && report.filesChanged.length > 0) {
|
|
2254
|
+
lines.push(`- **Files Changed:** ${report.filesChanged.join(", ")}`);
|
|
2255
|
+
}
|
|
2256
|
+
return lines.join("\n");
|
|
2257
|
+
}
|
|
2258
|
+
function formatCompressedSummary(reports, cycleRange, aiSummary) {
|
|
2259
|
+
const dates = reports.map((r) => r.date).filter(Boolean);
|
|
2260
|
+
const dateRange = dates.length > 0 ? `${dates[dates.length - 1]} \u2013 ${dates[0]}` : "unknown";
|
|
2261
|
+
const completed = reports.filter((r) => r.completed === "Yes");
|
|
2262
|
+
const partial = reports.filter((r) => r.completed === "Partial");
|
|
2263
|
+
const failed = reports.filter((r) => r.completed === "No");
|
|
2264
|
+
const formatTaskList = (list) => list.map((r) => r.taskId !== "unknown" ? `${r.taskId} (${r.taskName})` : r.taskName).join(", ");
|
|
2265
|
+
const lines = [`### ${cycleRange} \u2014 Compressed Summary`];
|
|
2266
|
+
lines.push(`**Date range:** ${dateRange}`);
|
|
2267
|
+
lines.push(`**Reports:** ${reports.length}`);
|
|
2268
|
+
if (completed.length > 0) {
|
|
2269
|
+
lines.push(`**Completed:** ${formatTaskList(completed)}`);
|
|
2270
|
+
}
|
|
2271
|
+
if (partial.length > 0) {
|
|
2272
|
+
lines.push(`**Partial:** ${formatTaskList(partial)}`);
|
|
2273
|
+
}
|
|
2274
|
+
if (failed.length > 0) {
|
|
2275
|
+
lines.push(`**Failed:** ${formatTaskList(failed)}`);
|
|
2276
|
+
}
|
|
2277
|
+
const surprises = reports.map((r) => r.surprises).filter((s) => s && s !== "None" && s !== "None.");
|
|
2278
|
+
if (surprises.length > 0) {
|
|
2279
|
+
lines.push(`**Surprises:** ${surprises.join("; ")}`);
|
|
2280
|
+
}
|
|
2281
|
+
const issues = reports.map((r) => r.discoveredIssues).filter((s) => s && s !== "None" && s !== "None.");
|
|
2282
|
+
if (issues.length > 0) {
|
|
2283
|
+
lines.push(`**Discovered issues:** ${issues.join("; ")}`);
|
|
2284
|
+
}
|
|
2285
|
+
if (aiSummary) {
|
|
2286
|
+
lines.push(`**Key outcomes:** ${aiSummary}`);
|
|
2287
|
+
}
|
|
2288
|
+
return lines.join("\n");
|
|
2289
|
+
}
|
|
2290
|
+
function compressBuildReportsInContent(content, threshold, summary) {
|
|
2291
|
+
const chunks = content.split(/^(?=### .+ — .+ — (?:Cycle|Sprint|Session) \d+)/m).map((c) => c.trim()).filter((c) => c.match(/^### .+ — .+ — (?:Cycle|Sprint|Session) \d+/));
|
|
2292
|
+
const keep = [];
|
|
2293
|
+
const oldChunks = [];
|
|
2294
|
+
for (const block of chunks) {
|
|
2295
|
+
const match = block.match(/— (?:Cycle|Sprint|Session) (\d+)/);
|
|
2296
|
+
const num = match ? parseInt(match[1], 10) : 0;
|
|
2297
|
+
if (num >= threshold) {
|
|
2298
|
+
keep.push(block);
|
|
2299
|
+
} else {
|
|
2300
|
+
oldChunks.push(block);
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
if (oldChunks.length === 0) return content;
|
|
2304
|
+
const oldReports = parseBuildReports(oldChunks.join("\n\n---\n\n"));
|
|
2305
|
+
const cycleRange = `Cycles 1\u2013${threshold - 1}`;
|
|
2306
|
+
const summaryBlock = formatCompressedSummary(oldReports, cycleRange, summary);
|
|
2307
|
+
const firstReportIdx = content.search(/^### /m);
|
|
2308
|
+
const header = firstReportIdx === -1 ? content : content.slice(0, firstReportIdx);
|
|
2309
|
+
const entries = [...keep, summaryBlock].join("\n\n---\n\n");
|
|
2310
|
+
return header + entries + "\n";
|
|
2311
|
+
}
|
|
2312
|
+
function mergeTextField(existing, incoming) {
|
|
2313
|
+
if (!existing || existing === "None" || existing === "None.") return incoming;
|
|
2314
|
+
if (!incoming || incoming === "None" || incoming === "None.") return existing;
|
|
2315
|
+
if (existing === incoming) return existing;
|
|
2316
|
+
return `${existing} | ${incoming}`;
|
|
2317
|
+
}
|
|
2318
|
+
function mergeBuildReports(existing, incoming) {
|
|
2319
|
+
const uuid = incoming.uuid ?? existing.uuid;
|
|
2320
|
+
const displayId = incoming.displayId ?? existing.displayId;
|
|
2321
|
+
const merged = {
|
|
2322
|
+
uuid,
|
|
2323
|
+
...displayId ? { displayId } : {},
|
|
2324
|
+
taskId: incoming.taskId,
|
|
2325
|
+
taskName: incoming.taskName,
|
|
2326
|
+
date: incoming.date,
|
|
2327
|
+
cycle: incoming.cycle,
|
|
2328
|
+
completed: incoming.completed,
|
|
2329
|
+
actualEffort: incoming.actualEffort,
|
|
2330
|
+
estimatedEffort: incoming.estimatedEffort,
|
|
2331
|
+
surprises: mergeTextField(existing.surprises, incoming.surprises),
|
|
2332
|
+
discoveredIssues: mergeTextField(existing.discoveredIssues, incoming.discoveredIssues),
|
|
2333
|
+
architectureNotes: incoming.architectureNotes,
|
|
2334
|
+
scopeAccuracy: incoming.scopeAccuracy
|
|
2335
|
+
};
|
|
2336
|
+
if (incoming.createdAt ?? existing.createdAt) merged.createdAt = incoming.createdAt ?? existing.createdAt;
|
|
2337
|
+
if (incoming.commitSha ?? existing.commitSha) merged.commitSha = incoming.commitSha ?? existing.commitSha;
|
|
2338
|
+
if (incoming.filesChanged ?? existing.filesChanged) merged.filesChanged = incoming.filesChanged ?? existing.filesChanged;
|
|
2339
|
+
return merged;
|
|
2340
|
+
}
|
|
2341
|
+
function replaceBuildReport(existing, replacement, content) {
|
|
2342
|
+
const oldSerialized = serializeBuildReport(existing);
|
|
2343
|
+
const newSerialized = serializeBuildReport(replacement);
|
|
2344
|
+
const idx = content.indexOf(oldSerialized);
|
|
2345
|
+
if (idx !== -1) {
|
|
2346
|
+
return content.slice(0, idx) + newSerialized + content.slice(idx + oldSerialized.length);
|
|
2347
|
+
}
|
|
2348
|
+
const headingPattern = `### ${existing.taskName} \u2014 ${existing.date} \u2014 Cycle ${existing.cycle}`;
|
|
2349
|
+
let headingIdx = content.indexOf(headingPattern);
|
|
2350
|
+
if (headingIdx === -1) {
|
|
2351
|
+
const legacyPattern = `### ${existing.taskName} \u2014 ${existing.date} \u2014 Sprint ${existing.cycle}`;
|
|
2352
|
+
headingIdx = content.indexOf(legacyPattern);
|
|
2353
|
+
}
|
|
2354
|
+
if (headingIdx === -1) throw new Error(`Could not find existing build report for ${existing.taskId}`);
|
|
2355
|
+
const afterHeading = content.slice(headingIdx);
|
|
2356
|
+
const nextSeparator = afterHeading.indexOf("\n\n---\n");
|
|
2357
|
+
const blockEnd = nextSeparator === -1 ? content.length : headingIdx + nextSeparator;
|
|
2358
|
+
return content.slice(0, headingIdx) + newSerialized + content.slice(blockEnd);
|
|
2359
|
+
}
|
|
2360
|
+
function prependBuildReport(report, content) {
|
|
2361
|
+
if (report.taskId !== "unknown") {
|
|
2362
|
+
const existingReports = parseBuildReports(content);
|
|
2363
|
+
const existingReport = existingReports.find((r) => r.taskId === report.taskId);
|
|
2364
|
+
if (existingReport) {
|
|
2365
|
+
const merged = mergeBuildReports(existingReport, report);
|
|
2366
|
+
return replaceBuildReport(existingReport, merged, content);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
const sentinelIdx = content.indexOf(HEADER_SENTINEL);
|
|
2370
|
+
if (sentinelIdx === -1) throw new Error("Build Reports header sentinel not found");
|
|
2371
|
+
const afterSentinel = content.slice(sentinelIdx);
|
|
2372
|
+
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
2373
|
+
if (separatorIdx === -1) throw new Error("Separator after Build Reports header not found");
|
|
2374
|
+
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
2375
|
+
const serialized = serializeBuildReport(report);
|
|
2376
|
+
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
2377
|
+
}
|
|
2378
|
+
var TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Cycle | Context |";
|
|
2379
|
+
var TABLE_SEPARATOR = "|-----------|------|---------------|--------------|---------------|----------|-------|--------|---------|";
|
|
2380
|
+
var PREV_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model | Cycle |";
|
|
2381
|
+
var LEGACY_TABLE_HEADER = "| Timestamp | Tool | Duration (ms) | Input Tokens | Output Tokens | Cost ($) | Model |";
|
|
2382
|
+
var SECTION_HEADING = "## Tool Call Metrics";
|
|
2383
|
+
var FILE_TEMPLATE = `# PAPI Metrics
|
|
2384
|
+
|
|
2385
|
+
${SECTION_HEADING}
|
|
2386
|
+
|
|
2387
|
+
${TABLE_HEADER}
|
|
2388
|
+
${TABLE_SEPARATOR}
|
|
2389
|
+
`;
|
|
2390
|
+
function parseToolMetrics(content) {
|
|
2391
|
+
const lines = content.split("\n");
|
|
2392
|
+
const metrics = [];
|
|
2393
|
+
let inSection = false;
|
|
2394
|
+
let inTable = false;
|
|
2395
|
+
for (const line of lines) {
|
|
2396
|
+
if (line.startsWith(SECTION_HEADING)) {
|
|
2397
|
+
inSection = true;
|
|
2398
|
+
continue;
|
|
2399
|
+
}
|
|
2400
|
+
if (inSection && line.startsWith(COST_SECTION_HEADING)) {
|
|
2401
|
+
break;
|
|
2402
|
+
}
|
|
2403
|
+
if (!inSection) continue;
|
|
2404
|
+
if (line.startsWith(TABLE_SEPARATOR) || line.startsWith("|---")) {
|
|
2405
|
+
inTable = true;
|
|
2406
|
+
continue;
|
|
2407
|
+
}
|
|
2408
|
+
if (!inTable) continue;
|
|
2409
|
+
if (!line.startsWith("|")) {
|
|
2410
|
+
inTable = false;
|
|
2411
|
+
continue;
|
|
2412
|
+
}
|
|
2413
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2414
|
+
if (cells.length < 7) continue;
|
|
2415
|
+
const inputTokens = cells[3] !== "-" ? parseInt(cells[3].replace(/,/g, ""), 10) : void 0;
|
|
2416
|
+
const outputTokens = cells[4] !== "-" ? parseInt(cells[4].replace(/,/g, ""), 10) : void 0;
|
|
2417
|
+
const cost = cells[5] !== "-" ? parseFloat(cells[5]) : void 0;
|
|
2418
|
+
const model = cells[6] !== "-" ? cells[6] : void 0;
|
|
2419
|
+
const cycleRaw = cells.length >= 8 && cells[7] !== "-" ? parseInt(cells[7], 10) : void 0;
|
|
2420
|
+
const cycleNumber = cycleRaw !== void 0 && !isNaN(cycleRaw) ? cycleRaw : void 0;
|
|
2421
|
+
const contextRaw = cells.length >= 9 && cells[8] !== "-" ? parseInt(cells[8].replace(/,/g, ""), 10) : void 0;
|
|
2422
|
+
const contextBytes = contextRaw !== void 0 && !isNaN(contextRaw) ? contextRaw : void 0;
|
|
2423
|
+
const utilisationRaw = cells.length >= 10 && cells[9] !== "-" ? parseFloat(cells[9]) : void 0;
|
|
2424
|
+
const contextUtilisation = utilisationRaw !== void 0 && !isNaN(utilisationRaw) ? utilisationRaw : void 0;
|
|
2425
|
+
metrics.push({
|
|
2426
|
+
timestamp: cells[0],
|
|
2427
|
+
tool: cells[1],
|
|
2428
|
+
durationMs: parseInt(cells[2].replace(/,/g, ""), 10),
|
|
2429
|
+
...inputTokens !== void 0 && !isNaN(inputTokens) ? { inputTokens } : {},
|
|
2430
|
+
...outputTokens !== void 0 && !isNaN(outputTokens) ? { outputTokens } : {},
|
|
2431
|
+
...cost !== void 0 && !isNaN(cost) ? { estimatedCostUsd: cost } : {},
|
|
2432
|
+
...model ? { model } : {},
|
|
2433
|
+
...cycleNumber !== void 0 ? { cycleNumber } : {},
|
|
2434
|
+
...contextBytes !== void 0 ? { contextBytes } : {},
|
|
2435
|
+
...contextUtilisation !== void 0 ? { contextUtilisation } : {}
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
return metrics;
|
|
2439
|
+
}
|
|
2440
|
+
function formatNumber(n) {
|
|
2441
|
+
return n.toLocaleString("en-US");
|
|
2442
|
+
}
|
|
2443
|
+
function serializeToolMetric(metric) {
|
|
2444
|
+
const inputTokens = metric.inputTokens !== void 0 ? formatNumber(metric.inputTokens) : "-";
|
|
2445
|
+
const outputTokens = metric.outputTokens !== void 0 ? formatNumber(metric.outputTokens) : "-";
|
|
2446
|
+
const cost = metric.estimatedCostUsd !== void 0 ? metric.estimatedCostUsd.toFixed(4) : "-";
|
|
2447
|
+
const model = metric.model ?? "-";
|
|
2448
|
+
const cycle = metric.cycleNumber !== void 0 ? String(metric.cycleNumber) : "-";
|
|
2449
|
+
const context = metric.contextBytes !== void 0 ? formatNumber(metric.contextBytes) : "-";
|
|
2450
|
+
const utilisation = metric.contextUtilisation !== void 0 ? metric.contextUtilisation.toFixed(2) : "-";
|
|
2451
|
+
return `| ${metric.timestamp} | ${metric.tool} | ${formatNumber(metric.durationMs)} | ${inputTokens} | ${outputTokens} | ${cost} | ${model} | ${cycle} | ${context} | ${utilisation} |`;
|
|
2452
|
+
}
|
|
2453
|
+
function appendToolMetricToContent(metric, content) {
|
|
2454
|
+
if (!content.trim()) {
|
|
2455
|
+
return FILE_TEMPLATE + serializeToolMetric(metric) + "\n";
|
|
2456
|
+
}
|
|
2457
|
+
if (content.includes(LEGACY_TABLE_HEADER) && !content.includes(TABLE_HEADER)) {
|
|
2458
|
+
content = content.replace(LEGACY_TABLE_HEADER, TABLE_HEADER);
|
|
2459
|
+
content = content.replace(
|
|
2460
|
+
/\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|/,
|
|
2461
|
+
TABLE_SEPARATOR
|
|
2462
|
+
);
|
|
2463
|
+
}
|
|
2464
|
+
if (content.includes(PREV_TABLE_HEADER) && !content.includes(TABLE_HEADER)) {
|
|
2465
|
+
content = content.replace(PREV_TABLE_HEADER, TABLE_HEADER);
|
|
2466
|
+
content = content.replace(
|
|
2467
|
+
/\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|[-]+\|/,
|
|
2468
|
+
TABLE_SEPARATOR
|
|
2469
|
+
);
|
|
2470
|
+
}
|
|
2471
|
+
if (!content.includes(SECTION_HEADING)) {
|
|
2472
|
+
return content.trimEnd() + "\n\n" + SECTION_HEADING + "\n\n" + TABLE_HEADER + "\n" + TABLE_SEPARATOR + "\n" + serializeToolMetric(metric) + "\n";
|
|
2473
|
+
}
|
|
2474
|
+
const costIdx = content.indexOf(COST_SECTION_HEADING);
|
|
2475
|
+
if (costIdx === -1) {
|
|
2476
|
+
return content.trimEnd() + "\n" + serializeToolMetric(metric) + "\n";
|
|
2477
|
+
}
|
|
2478
|
+
const before = content.slice(0, costIdx).trimEnd();
|
|
2479
|
+
const after = content.slice(costIdx);
|
|
2480
|
+
return before + "\n" + serializeToolMetric(metric) + "\n\n" + after;
|
|
2481
|
+
}
|
|
2482
|
+
function aggregateCostSummary(metrics, cycleNumber) {
|
|
2483
|
+
const filtered = cycleNumber !== void 0 ? metrics.filter((m) => m.cycleNumber === cycleNumber) : metrics;
|
|
2484
|
+
let totalCostUsd = 0;
|
|
2485
|
+
let totalInputTokens = 0;
|
|
2486
|
+
let totalOutputTokens = 0;
|
|
2487
|
+
const byCommand = /* @__PURE__ */ new Map();
|
|
2488
|
+
for (const m of filtered) {
|
|
2489
|
+
totalCostUsd += m.estimatedCostUsd ?? 0;
|
|
2490
|
+
totalInputTokens += m.inputTokens ?? 0;
|
|
2491
|
+
totalOutputTokens += m.outputTokens ?? 0;
|
|
2492
|
+
const entry = byCommand.get(m.tool) ?? { cost: 0, calls: 0 };
|
|
2493
|
+
entry.cost += m.estimatedCostUsd ?? 0;
|
|
2494
|
+
entry.calls += 1;
|
|
2495
|
+
byCommand.set(m.tool, entry);
|
|
2496
|
+
}
|
|
2497
|
+
const costByCommand = Array.from(byCommand.entries()).map(([command, { cost, calls }]) => ({
|
|
2498
|
+
command,
|
|
2499
|
+
totalCostUsd: cost,
|
|
2500
|
+
calls,
|
|
2501
|
+
avgCostUsd: calls > 0 ? cost / calls : 0
|
|
2502
|
+
})).sort((a, b) => b.totalCostUsd - a.totalCostUsd);
|
|
2503
|
+
const mostExpensiveCommand = costByCommand.length > 0 ? costByCommand[0].command : null;
|
|
2504
|
+
return {
|
|
2505
|
+
totalCostUsd,
|
|
2506
|
+
totalInputTokens,
|
|
2507
|
+
totalOutputTokens,
|
|
2508
|
+
totalCalls: filtered.length,
|
|
2509
|
+
costByCommand,
|
|
2510
|
+
mostExpensiveCommand,
|
|
2511
|
+
avgCostPerCall: filtered.length > 0 ? totalCostUsd / filtered.length : 0
|
|
2512
|
+
};
|
|
2513
|
+
}
|
|
2514
|
+
var COST_SECTION_HEADING = "## Cost Summary";
|
|
2515
|
+
var COST_TABLE_SEPARATOR = "|--------|------|----------------|--------------|---------------|-------|";
|
|
2516
|
+
function parseCostSnapshots(content) {
|
|
2517
|
+
const lines = content.split("\n");
|
|
2518
|
+
const snapshots = [];
|
|
2519
|
+
let inTable = false;
|
|
2520
|
+
for (const line of lines) {
|
|
2521
|
+
if (line.startsWith(COST_TABLE_SEPARATOR)) {
|
|
2522
|
+
inTable = true;
|
|
2523
|
+
continue;
|
|
2524
|
+
}
|
|
2525
|
+
if (!inTable) continue;
|
|
2526
|
+
if (!line.startsWith("|")) {
|
|
2527
|
+
inTable = false;
|
|
2528
|
+
continue;
|
|
2529
|
+
}
|
|
2530
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2531
|
+
if (cells.length < 6) continue;
|
|
2532
|
+
snapshots.push({
|
|
2533
|
+
cycle: parseInt(cells[0], 10),
|
|
2534
|
+
date: cells[1],
|
|
2535
|
+
totalCostUsd: parseFloat(cells[2]),
|
|
2536
|
+
totalInputTokens: parseInt(cells[3].replace(/,/g, ""), 10),
|
|
2537
|
+
totalOutputTokens: parseInt(cells[4].replace(/,/g, ""), 10),
|
|
2538
|
+
totalCalls: parseInt(cells[5].replace(/,/g, ""), 10)
|
|
2539
|
+
});
|
|
2540
|
+
}
|
|
2541
|
+
return snapshots;
|
|
2542
|
+
}
|
|
2543
|
+
var FILE_HEADING = "# Cycle Methodology Metrics";
|
|
2544
|
+
var ACCURACY_HEADER = "| Cycle | Reports | Match Rate | MAE | Bias |";
|
|
2545
|
+
var ACCURACY_SEPARATOR = "|--------|---------|------------|-----|------|";
|
|
2546
|
+
var VELOCITY_HEADER = "| Cycle | Completed | Partial | Failed | Effort Points |";
|
|
2547
|
+
var VELOCITY_SEPARATOR = "|--------|-----------|---------|--------|---------------|";
|
|
2548
|
+
function serializeAccuracyRow(a) {
|
|
2549
|
+
return `| ${a.cycle} | ${a.reports} | ${a.matchRate}% | ${a.mae} | ${a.bias >= 0 ? "+" : ""}${a.bias} |`;
|
|
2550
|
+
}
|
|
2551
|
+
function serializeVelocityRow(v) {
|
|
2552
|
+
return `| ${v.cycle} | ${v.completed} | ${v.partial} | ${v.failed} | ${v.effortPoints} |`;
|
|
2553
|
+
}
|
|
2554
|
+
function serializeSnapshot(snapshot) {
|
|
2555
|
+
const lines = [];
|
|
2556
|
+
lines.push(`## Cycle ${snapshot.cycle} Snapshot \u2014 ${snapshot.date}`);
|
|
2557
|
+
lines.push("");
|
|
2558
|
+
lines.push("### Estimation Accuracy (last 5 cycles)");
|
|
2559
|
+
lines.push(ACCURACY_HEADER);
|
|
2560
|
+
lines.push(ACCURACY_SEPARATOR);
|
|
2561
|
+
for (const a of snapshot.accuracy) {
|
|
2562
|
+
lines.push(serializeAccuracyRow(a));
|
|
2563
|
+
}
|
|
2564
|
+
lines.push("");
|
|
2565
|
+
lines.push("### Cycle Velocity");
|
|
2566
|
+
lines.push(VELOCITY_HEADER);
|
|
2567
|
+
lines.push(VELOCITY_SEPARATOR);
|
|
2568
|
+
for (const v of snapshot.velocity) {
|
|
2569
|
+
lines.push(serializeVelocityRow(v));
|
|
2570
|
+
}
|
|
2571
|
+
return lines.join("\n");
|
|
2572
|
+
}
|
|
2573
|
+
function appendSnapshotToContent(snapshot, content) {
|
|
2574
|
+
const block = serializeSnapshot(snapshot);
|
|
2575
|
+
if (!content.trim()) {
|
|
2576
|
+
return FILE_HEADING + "\n\n" + block + "\n";
|
|
2577
|
+
}
|
|
2578
|
+
const marker = `## Cycle ${snapshot.cycle} Snapshot`;
|
|
2579
|
+
const legacyMarker = `## Sprint ${snapshot.cycle} Snapshot`;
|
|
2580
|
+
let markerIdx = content.indexOf(marker);
|
|
2581
|
+
if (markerIdx === -1) markerIdx = content.indexOf(legacyMarker);
|
|
2582
|
+
if (markerIdx !== -1) {
|
|
2583
|
+
let nextSnapshotIdx = content.indexOf("\n## Cycle ", markerIdx + marker.length);
|
|
2584
|
+
if (nextSnapshotIdx === -1) nextSnapshotIdx = content.indexOf("\n## Sprint ", markerIdx + marker.length);
|
|
2585
|
+
const before = content.slice(0, markerIdx).trimEnd();
|
|
2586
|
+
const after = nextSnapshotIdx !== -1 ? content.slice(nextSnapshotIdx) : "";
|
|
2587
|
+
return before + "\n\n" + block + (after ? after : "\n");
|
|
2588
|
+
}
|
|
2589
|
+
return content.trimEnd() + "\n\n" + block + "\n";
|
|
2590
|
+
}
|
|
2591
|
+
function parseSnapshots(content) {
|
|
2592
|
+
if (!content.trim()) return [];
|
|
2593
|
+
const snapshots = [];
|
|
2594
|
+
const headerRegex = /^## (?:Cycle|Sprint) (\d+) Snapshot — (\S+)/gm;
|
|
2595
|
+
let match;
|
|
2596
|
+
const headers = [];
|
|
2597
|
+
while ((match = headerRegex.exec(content)) !== null) {
|
|
2598
|
+
headers.push({
|
|
2599
|
+
cycle: parseInt(match[1], 10),
|
|
2600
|
+
date: match[2],
|
|
2601
|
+
index: match.index
|
|
2602
|
+
});
|
|
2603
|
+
}
|
|
2604
|
+
for (let i = 0; i < headers.length; i++) {
|
|
2605
|
+
const start = headers[i].index;
|
|
2606
|
+
const end = i + 1 < headers.length ? headers[i + 1].index : content.length;
|
|
2607
|
+
const block = content.slice(start, end);
|
|
2608
|
+
const accuracy = parseAccuracyTable(block);
|
|
2609
|
+
const velocity = parseVelocityTable(block);
|
|
2610
|
+
snapshots.push({
|
|
2611
|
+
cycle: headers[i].cycle,
|
|
2612
|
+
date: headers[i].date,
|
|
2613
|
+
accuracy,
|
|
2614
|
+
velocity
|
|
2615
|
+
});
|
|
2616
|
+
}
|
|
2617
|
+
return snapshots;
|
|
2618
|
+
}
|
|
2619
|
+
function parseAccuracyTable(block) {
|
|
2620
|
+
const rows = [];
|
|
2621
|
+
const lines = block.split("\n");
|
|
2622
|
+
let inTable = false;
|
|
2623
|
+
for (const line of lines) {
|
|
2624
|
+
if (line.startsWith(ACCURACY_SEPARATOR)) {
|
|
2625
|
+
inTable = true;
|
|
2626
|
+
continue;
|
|
2627
|
+
}
|
|
2628
|
+
if (!inTable) continue;
|
|
2629
|
+
if (!line.startsWith("|")) {
|
|
2630
|
+
inTable = false;
|
|
2631
|
+
continue;
|
|
2632
|
+
}
|
|
2633
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2634
|
+
if (cells.length < 5) continue;
|
|
2635
|
+
rows.push({
|
|
2636
|
+
cycle: parseInt(cells[0], 10),
|
|
2637
|
+
reports: parseInt(cells[1], 10),
|
|
2638
|
+
matchRate: parseInt(cells[2].replace("%", ""), 10),
|
|
2639
|
+
mae: parseFloat(cells[3]),
|
|
2640
|
+
bias: parseFloat(cells[4])
|
|
2641
|
+
});
|
|
2642
|
+
}
|
|
2643
|
+
return rows;
|
|
2644
|
+
}
|
|
2645
|
+
function parseVelocityTable(block) {
|
|
2646
|
+
const rows = [];
|
|
2647
|
+
const lines = block.split("\n");
|
|
2648
|
+
let inTable = false;
|
|
2649
|
+
for (const line of lines) {
|
|
2650
|
+
if (line.startsWith(VELOCITY_SEPARATOR)) {
|
|
2651
|
+
inTable = true;
|
|
2652
|
+
continue;
|
|
2653
|
+
}
|
|
2654
|
+
if (!inTable) continue;
|
|
2655
|
+
if (!line.startsWith("|")) {
|
|
2656
|
+
inTable = false;
|
|
2657
|
+
continue;
|
|
2658
|
+
}
|
|
2659
|
+
const cells = line.split("|").map((c) => c.trim()).filter(Boolean);
|
|
2660
|
+
if (cells.length < 5) continue;
|
|
2661
|
+
rows.push({
|
|
2662
|
+
cycle: parseInt(cells[0], 10),
|
|
2663
|
+
completed: parseInt(cells[1], 10),
|
|
2664
|
+
partial: parseInt(cells[2], 10),
|
|
2665
|
+
failed: parseInt(cells[3], 10),
|
|
2666
|
+
effortPoints: parseInt(cells[4], 10)
|
|
2667
|
+
});
|
|
2668
|
+
}
|
|
2669
|
+
return rows;
|
|
2670
|
+
}
|
|
2671
|
+
var HEADER_SENTINEL2 = "*Reviews are stored newest-first.";
|
|
2672
|
+
var VALID_STAGES = /* @__PURE__ */ new Set(["handoff-review", "build-acceptance"]);
|
|
2673
|
+
var VALID_VERDICTS = /* @__PURE__ */ new Set(["approve", "accept", "request-changes", "reject"]);
|
|
2674
|
+
function parseField2(block, field) {
|
|
2675
|
+
const pattern = new RegExp(`^- \\*\\*${field}:\\*\\*\\s*(.+)$`, "m");
|
|
2676
|
+
const match = block.match(pattern);
|
|
2677
|
+
return match ? match[1].trim() : "";
|
|
2678
|
+
}
|
|
2679
|
+
function parseReviews(content) {
|
|
2680
|
+
if (!content.trim()) return [];
|
|
2681
|
+
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}/));
|
|
2682
|
+
return chunks.map((block) => {
|
|
2683
|
+
const headingMatch = block.match(/^### (task-\S+) — (.+?) — (\d{4}-\d{2}-\d{2}[T\d:.Z]*)/);
|
|
2684
|
+
if (!headingMatch) return null;
|
|
2685
|
+
const taskId = headingMatch[1];
|
|
2686
|
+
const stageRaw = headingMatch[2].trim();
|
|
2687
|
+
const date = headingMatch[3];
|
|
2688
|
+
const stage = stageRaw.toLowerCase().replace(/\s+/g, "-");
|
|
2689
|
+
if (!VALID_STAGES.has(stage)) return null;
|
|
2690
|
+
const reviewer = parseField2(block, "Reviewer");
|
|
2691
|
+
const verdictRaw = parseField2(block, "Verdict");
|
|
2692
|
+
if (!VALID_VERDICTS.has(verdictRaw)) return null;
|
|
2693
|
+
const verdict = verdictRaw;
|
|
2694
|
+
const cycle = parseInt(parseField2(block, "Cycle"), 10);
|
|
2695
|
+
if (isNaN(cycle)) return null;
|
|
2696
|
+
const comments = parseField2(block, "Comments");
|
|
2697
|
+
const uuidRaw = parseField2(block, "UUID");
|
|
2698
|
+
const displayIdRaw = parseField2(block, "Display ID");
|
|
2699
|
+
const review = { uuid: uuidRaw ?? randomUUID5(), ...displayIdRaw ? { displayId: displayIdRaw } : {}, taskId, stage, reviewer, verdict, cycle, date, comments };
|
|
2700
|
+
const handoffRevRaw = parseField2(block, "Handoff Revision");
|
|
2701
|
+
if (handoffRevRaw) {
|
|
2702
|
+
const parsed = parseInt(handoffRevRaw, 10);
|
|
2703
|
+
if (!isNaN(parsed)) review.handoffRevision = parsed;
|
|
2704
|
+
}
|
|
2705
|
+
const buildCommitSha = parseField2(block, "Build Commit SHA");
|
|
2706
|
+
if (buildCommitSha) review.buildCommitSha = buildCommitSha;
|
|
2707
|
+
return review;
|
|
2708
|
+
}).filter((r) => r !== null);
|
|
2709
|
+
}
|
|
2710
|
+
var STAGE_DISPLAY = {
|
|
2711
|
+
"handoff-review": "Handoff Review",
|
|
2712
|
+
"build-acceptance": "Build Acceptance"
|
|
2713
|
+
};
|
|
2714
|
+
function serializeReview(review) {
|
|
2715
|
+
const stageDisplay = STAGE_DISPLAY[review.stage];
|
|
2716
|
+
const lines = [
|
|
2717
|
+
`### ${review.taskId} \u2014 ${stageDisplay} \u2014 ${review.date}`,
|
|
2718
|
+
""
|
|
2719
|
+
];
|
|
2720
|
+
if (review.uuid) lines.push(`- **UUID:** ${review.uuid}`);
|
|
2721
|
+
if (review.displayId) lines.push(`- **Display ID:** ${review.displayId}`);
|
|
2722
|
+
lines.push(
|
|
2723
|
+
`- **Reviewer:** ${review.reviewer}`,
|
|
2724
|
+
`- **Verdict:** ${review.verdict}`,
|
|
2725
|
+
`- **Cycle:** ${review.cycle}`,
|
|
2726
|
+
`- **Comments:** ${review.comments}`
|
|
2727
|
+
);
|
|
2728
|
+
if (review.handoffRevision !== void 0) lines.push(`- **Handoff Revision:** ${review.handoffRevision}`);
|
|
2729
|
+
if (review.buildCommitSha) lines.push(`- **Build Commit SHA:** ${review.buildCommitSha}`);
|
|
2730
|
+
if (review.autoReview) {
|
|
2731
|
+
lines.push("", `#### Auto-Review (${review.autoReview.verdict})`);
|
|
2732
|
+
lines.push(`> ${review.autoReview.summary}`);
|
|
2733
|
+
if (review.autoReview.findings.length > 0) {
|
|
2734
|
+
lines.push("");
|
|
2735
|
+
for (const f of review.autoReview.findings) {
|
|
2736
|
+
const loc = f.file ? f.line ? `${f.file}:${f.line}` : f.file : "";
|
|
2737
|
+
lines.push(`- \`${f.severity}\`${loc ? ` ${loc}` : ""}: ${f.message}`);
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
return lines.join("\n");
|
|
2742
|
+
}
|
|
2743
|
+
function prependReview(review, content) {
|
|
2744
|
+
const serialized = serializeReview(review);
|
|
2745
|
+
if (!content.trim()) {
|
|
2746
|
+
return `# Human Reviews
|
|
2747
|
+
|
|
2748
|
+
${HEADER_SENTINEL2} Each review block references a task and stage.*
|
|
2749
|
+
|
|
2750
|
+
---
|
|
2751
|
+
|
|
2752
|
+
${serialized}
|
|
2753
|
+
`;
|
|
2754
|
+
}
|
|
2755
|
+
const sentinelIdx = content.indexOf(HEADER_SENTINEL2);
|
|
2756
|
+
if (sentinelIdx === -1) {
|
|
2757
|
+
const firstSep = content.indexOf("\n---\n");
|
|
2758
|
+
if (firstSep === -1) return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
2759
|
+
const insertAt2 = firstSep + "\n---\n".length;
|
|
2760
|
+
return content.slice(0, insertAt2) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt2);
|
|
2761
|
+
}
|
|
2762
|
+
const afterSentinel = content.slice(sentinelIdx);
|
|
2763
|
+
const separatorIdx = afterSentinel.indexOf("\n---\n");
|
|
2764
|
+
if (separatorIdx === -1) {
|
|
2765
|
+
return content.trimEnd() + "\n\n---\n\n" + serialized + "\n";
|
|
2766
|
+
}
|
|
2767
|
+
const insertAt = sentinelIdx + separatorIdx + "\n---\n".length;
|
|
2768
|
+
return content.slice(0, insertAt) + "\n" + serialized + "\n\n---\n" + content.slice(insertAt);
|
|
2769
|
+
}
|
|
2770
|
+
var VALID_STATUSES = /* @__PURE__ */ new Set(["Not Started", "In Progress", "Done", "Deferred"]);
|
|
2771
|
+
var PHASES_START = "<!-- PHASES:START -->";
|
|
2772
|
+
var PHASES_END = "<!-- PHASES:END -->";
|
|
2773
|
+
function parsePhases(content) {
|
|
2774
|
+
const startIdx = content.indexOf(PHASES_START);
|
|
2775
|
+
const endIdx = content.indexOf(PHASES_END);
|
|
2776
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) return [];
|
|
2777
|
+
const section = content.slice(startIdx + PHASES_START.length, endIdx);
|
|
2778
|
+
const yamlMatch = section.match(/```yaml\s*\n([\s\S]*?)```/);
|
|
2779
|
+
if (!yamlMatch) return [];
|
|
2780
|
+
const yamlBody = yamlMatch[1];
|
|
2781
|
+
const phases = [];
|
|
2782
|
+
const blocks = yamlBody.split(/^(?=\s*- id:)/m).filter((b) => b.trim());
|
|
2783
|
+
for (const block of blocks) {
|
|
2784
|
+
const phase = parsePhaseBlock(block);
|
|
2785
|
+
if (phase) phases.push(phase);
|
|
2786
|
+
}
|
|
2787
|
+
return phases.sort((a, b) => a.order - b.order);
|
|
2788
|
+
}
|
|
2789
|
+
function parseYamlField(block, field) {
|
|
2790
|
+
const pattern = new RegExp(`^\\s*${field}:\\s*(.+)$`, "m");
|
|
2791
|
+
const match = block.match(pattern);
|
|
2792
|
+
if (!match) return "";
|
|
2793
|
+
let value = match[1].trim();
|
|
2794
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
2795
|
+
value = value.slice(1, -1);
|
|
2796
|
+
}
|
|
2797
|
+
return value;
|
|
2798
|
+
}
|
|
2799
|
+
function parsePhaseBlock(block) {
|
|
2800
|
+
const idMatch = block.match(/^\s*- id:\s*(\S+)/m);
|
|
2801
|
+
if (!idMatch) return null;
|
|
2802
|
+
const id = idMatch[1];
|
|
2803
|
+
const slug = parseYamlField(block, "slug");
|
|
2804
|
+
const label = parseYamlField(block, "label");
|
|
2805
|
+
const description = parseYamlField(block, "description");
|
|
2806
|
+
const statusRaw = parseYamlField(block, "status");
|
|
2807
|
+
const orderRaw = parseYamlField(block, "order");
|
|
2808
|
+
if (!slug || !label || !statusRaw || !orderRaw) return null;
|
|
2809
|
+
const status = statusRaw;
|
|
2810
|
+
if (!VALID_STATUSES.has(status)) return null;
|
|
2811
|
+
const order = parseInt(orderRaw, 10);
|
|
2812
|
+
if (isNaN(order)) return null;
|
|
2813
|
+
return { id, slug, label, description, status, order };
|
|
2814
|
+
}
|
|
2815
|
+
function serializePhases(phases) {
|
|
2816
|
+
const sorted = [...phases].sort((a, b) => a.order - b.order);
|
|
2817
|
+
const yamlLines = ["phases:"];
|
|
2818
|
+
for (const p of sorted) {
|
|
2819
|
+
yamlLines.push(` - id: ${p.id}`);
|
|
2820
|
+
yamlLines.push(` slug: "${p.slug}"`);
|
|
2821
|
+
yamlLines.push(` label: "${p.label}"`);
|
|
2822
|
+
yamlLines.push(` description: "${p.description}"`);
|
|
2823
|
+
yamlLines.push(` status: "${p.status}"`);
|
|
2824
|
+
yamlLines.push(` order: ${p.order}`);
|
|
2825
|
+
}
|
|
2826
|
+
return yamlLines.join("\n");
|
|
2827
|
+
}
|
|
2828
|
+
var YAML_MARKER2 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
2829
|
+
var YAML_START2 = "<!-- PAPI-YAML-START -->";
|
|
2830
|
+
var YAML_END2 = "<!-- PAPI-YAML-END -->";
|
|
2831
|
+
var VALID_STATUSES2 = /* @__PURE__ */ new Set(["planning", "active", "complete"]);
|
|
2832
|
+
function toCycle(raw) {
|
|
2833
|
+
if (!VALID_STATUSES2.has(raw.status)) return null;
|
|
2834
|
+
const cycle = {
|
|
2835
|
+
id: raw.id,
|
|
2836
|
+
number: raw.number,
|
|
2837
|
+
status: raw.status,
|
|
2838
|
+
startDate: raw.start_date,
|
|
2839
|
+
goals: raw.goals ?? [],
|
|
2840
|
+
boardHealth: raw.board_health ?? "",
|
|
2841
|
+
taskIds: raw.task_ids ?? []
|
|
2842
|
+
};
|
|
2843
|
+
if (raw.end_date) cycle.endDate = raw.end_date;
|
|
2844
|
+
if (raw.user_id) cycle.userId = raw.user_id;
|
|
2845
|
+
return cycle;
|
|
2846
|
+
}
|
|
2847
|
+
function fromCycle(cycle) {
|
|
2848
|
+
const raw = {
|
|
2849
|
+
id: cycle.id,
|
|
2850
|
+
number: cycle.number,
|
|
2851
|
+
status: cycle.status,
|
|
2852
|
+
start_date: cycle.startDate,
|
|
2853
|
+
goals: cycle.goals,
|
|
2854
|
+
board_health: cycle.boardHealth,
|
|
2855
|
+
task_ids: cycle.taskIds
|
|
2856
|
+
};
|
|
2857
|
+
if (cycle.endDate) raw.end_date = cycle.endDate;
|
|
2858
|
+
if (cycle.userId) raw.user_id = cycle.userId;
|
|
2859
|
+
return raw;
|
|
2860
|
+
}
|
|
2861
|
+
function extractYamlBlock2(content) {
|
|
2862
|
+
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
2863
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLES.md");
|
|
2864
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
2865
|
+
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
2866
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in CYCLES.md");
|
|
2867
|
+
const yamlStart = startIdx + YAML_START2.length;
|
|
2868
|
+
const endIdx = afterMarker.indexOf(YAML_END2, yamlStart);
|
|
2869
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLES.md");
|
|
2870
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
2871
|
+
}
|
|
2872
|
+
function parseCycles(content) {
|
|
2873
|
+
if (!content.trim()) return [];
|
|
2874
|
+
const yamlText = extractYamlBlock2(content);
|
|
2875
|
+
const data = yaml2.load(yamlText);
|
|
2876
|
+
return (data.cycles ?? []).map(toCycle).filter((s) => s !== null);
|
|
2877
|
+
}
|
|
2878
|
+
function serializeCycles(cycles, content) {
|
|
2879
|
+
const raw = cycles.map(fromCycle);
|
|
2880
|
+
const yamlStr = yaml2.dump({ cycles: raw }, { lineWidth: 120, quotingType: '"' });
|
|
2881
|
+
const markerIdx = content.indexOf(YAML_MARKER2);
|
|
2882
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in CYCLES.md");
|
|
2883
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER2.length);
|
|
2884
|
+
const startIdx = afterMarker.indexOf(YAML_START2);
|
|
2885
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in CYCLES.md");
|
|
2886
|
+
const absStart = markerIdx + YAML_MARKER2.length + startIdx;
|
|
2887
|
+
const endIdx = afterMarker.indexOf(YAML_END2, startIdx);
|
|
2888
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in CYCLES.md");
|
|
2889
|
+
const absEnd = markerIdx + YAML_MARKER2.length + endIdx + YAML_END2.length;
|
|
2890
|
+
return content.slice(0, absStart) + YAML_START2 + "\n" + yamlStr + YAML_END2 + content.slice(absEnd);
|
|
2891
|
+
}
|
|
2892
|
+
function prependCycle(cycle, content) {
|
|
2893
|
+
if (!content.trim()) {
|
|
2894
|
+
const header = `# Cycles
|
|
2895
|
+
|
|
2896
|
+
<!-- PAPI-ADAPTER: parse the yaml block below -->
|
|
2897
|
+
|
|
2898
|
+
`;
|
|
2899
|
+
const raw = fromCycle(cycle);
|
|
2900
|
+
const yamlStr = yaml2.dump({ cycles: [raw] }, { lineWidth: 120, quotingType: '"' });
|
|
2901
|
+
return header + YAML_START2 + "\n" + yamlStr + YAML_END2 + "\n";
|
|
2902
|
+
}
|
|
2903
|
+
const existing = parseCycles(content);
|
|
2904
|
+
const merged = [cycle, ...existing];
|
|
2905
|
+
return serializeCycles(merged, content);
|
|
2906
|
+
}
|
|
2907
|
+
var YAML_MARKER3 = "<!-- PAPI-ADAPTER: parse the yaml block below -->";
|
|
2908
|
+
var YAML_START3 = "<!-- PAPI-YAML-START -->";
|
|
2909
|
+
var YAML_END3 = "<!-- PAPI-YAML-END -->";
|
|
2910
|
+
function extractYamlBlock3(content) {
|
|
2911
|
+
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
2912
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
2913
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
2914
|
+
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
2915
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
2916
|
+
const yamlStart = startIdx + YAML_START3.length;
|
|
2917
|
+
const endIdx = afterMarker.indexOf(YAML_END3, yamlStart);
|
|
2918
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
2919
|
+
return afterMarker.slice(yamlStart, endIdx);
|
|
2920
|
+
}
|
|
2921
|
+
function parseRegistries(content) {
|
|
2922
|
+
if (!content.trim()) return { modules: [], epics: [] };
|
|
2923
|
+
const yamlText = extractYamlBlock3(content);
|
|
2924
|
+
const data = yaml3.load(yamlText);
|
|
2925
|
+
return {
|
|
2926
|
+
modules: data.modules ?? [],
|
|
2927
|
+
epics: data.epics ?? []
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
function serializeRegistries(registries, content) {
|
|
2931
|
+
const yamlStr = yaml3.dump(
|
|
2932
|
+
{ modules: registries.modules, epics: registries.epics },
|
|
2933
|
+
{ lineWidth: 120, quotingType: '"' }
|
|
2934
|
+
);
|
|
2935
|
+
const markerIdx = content.indexOf(YAML_MARKER3);
|
|
2936
|
+
if (markerIdx === -1) throw new Error("PAPI-ADAPTER marker not found in REGISTRIES.md");
|
|
2937
|
+
const afterMarker = content.slice(markerIdx + YAML_MARKER3.length);
|
|
2938
|
+
const startIdx = afterMarker.indexOf(YAML_START3);
|
|
2939
|
+
if (startIdx === -1) throw new Error("PAPI-YAML-START marker not found in REGISTRIES.md");
|
|
2940
|
+
const absStart = markerIdx + YAML_MARKER3.length + startIdx;
|
|
2941
|
+
const endIdx = afterMarker.indexOf(YAML_END3, startIdx);
|
|
2942
|
+
if (endIdx === -1) throw new Error("PAPI-YAML-END marker not found in REGISTRIES.md");
|
|
2943
|
+
const absEnd = markerIdx + YAML_MARKER3.length + endIdx + YAML_END3.length;
|
|
2944
|
+
return content.slice(0, absStart) + YAML_START3 + "\n" + yamlStr + YAML_END3 + content.slice(absEnd);
|
|
2945
|
+
}
|
|
2946
|
+
function displayIdNumber(displayId, prefix) {
|
|
2947
|
+
if (!displayId) return 0;
|
|
2948
|
+
const match = displayId.match(new RegExp(`^${prefix}-(\\d+)$`));
|
|
2949
|
+
return match ? parseInt(match[1], 10) : 0;
|
|
2950
|
+
}
|
|
2951
|
+
var MdFileAdapter = class {
|
|
2952
|
+
dir;
|
|
2953
|
+
constructor(projectDir) {
|
|
2954
|
+
this.dir = projectDir;
|
|
2955
|
+
}
|
|
2956
|
+
/** Resolve a filename to an absolute path within the .papi/ directory. */
|
|
2957
|
+
path(file) {
|
|
2958
|
+
return join(this.dir, file);
|
|
2959
|
+
}
|
|
2960
|
+
/** Read a .papi/ file as UTF-8 text. Throws a clear error if the file is missing. */
|
|
2961
|
+
async read(file) {
|
|
2962
|
+
try {
|
|
2963
|
+
return await readFile(this.path(file), "utf-8");
|
|
2964
|
+
} catch (err) {
|
|
2965
|
+
if (err instanceof Error && "code" in err && err.code === "ENOENT") {
|
|
2966
|
+
throw new Error(`.papi/${file} not found. Run the setup tool to initialise your project.`);
|
|
2967
|
+
}
|
|
2968
|
+
throw err;
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
/** Write UTF-8 text to a .papi/ file. */
|
|
2972
|
+
async write(file, content) {
|
|
2973
|
+
await writeFile(this.path(file), content, "utf-8");
|
|
2974
|
+
}
|
|
2975
|
+
// --- Planning Log ---
|
|
2976
|
+
/** Parse the full planning context into structured sections (reads from PLANNING_LOG.md + ACTIVE_DECISIONS.md + CYCLE_LOG.md). */
|
|
2977
|
+
async readPlanningLog() {
|
|
2978
|
+
const [planningContent, activeDecisionsContent, cycleLogContent] = await Promise.all([
|
|
2979
|
+
this.read("PLANNING_LOG.md"),
|
|
2980
|
+
this.readOptional("ACTIVE_DECISIONS.md"),
|
|
2981
|
+
this.readOptional("SPRINT_LOG.md")
|
|
2982
|
+
]);
|
|
2983
|
+
return parsePlanningLog(planningContent, activeDecisionsContent, cycleLogContent);
|
|
2984
|
+
}
|
|
2985
|
+
/** Read the Cycle Health table from PLANNING_LOG.md. */
|
|
2986
|
+
async getCycleHealth() {
|
|
2987
|
+
return parseCycleHealth(await this.read("PLANNING_LOG.md"));
|
|
2988
|
+
}
|
|
2989
|
+
/**
|
|
2990
|
+
* Read Active Decisions from ACTIVE_DECISIONS.md.
|
|
2991
|
+
*
|
|
2992
|
+
* Default filters out retired ADs (outcome ∈ abandoned/superseded/resolved or superseded=true).
|
|
2993
|
+
* Pass { includeRetired: true } for management/triage surfaces. See PapiAdapter docstring.
|
|
2994
|
+
*/
|
|
2995
|
+
async getActiveDecisions(options) {
|
|
2996
|
+
const content = await this.readOptional("ACTIVE_DECISIONS.md");
|
|
2997
|
+
if (!content) return [];
|
|
2998
|
+
const all = parseActiveDecisions(content);
|
|
2999
|
+
if (options?.includeRetired) return all;
|
|
3000
|
+
return all.filter(isLiveDecision2);
|
|
3001
|
+
}
|
|
3002
|
+
/** Read cycle log entries (newest first), optionally limited to {@link limit} entries. */
|
|
3003
|
+
async getCycleLog(limit) {
|
|
3004
|
+
return parseCycleLog(await this.read("SPRINT_LOG.md"), limit);
|
|
3005
|
+
}
|
|
3006
|
+
async getCycleLogSince(cycleNumber) {
|
|
3007
|
+
const log = await this.getCycleLog();
|
|
3008
|
+
return log.filter((entry) => entry.cycleNumber >= cycleNumber);
|
|
3009
|
+
}
|
|
3010
|
+
/** Merge partial updates into the Cycle Health table and write back. */
|
|
3011
|
+
async setCycleHealth(updates) {
|
|
3012
|
+
const content = await this.read("PLANNING_LOG.md");
|
|
3013
|
+
const current = parseCycleHealth(content);
|
|
3014
|
+
const updated = { ...current, ...updates };
|
|
3015
|
+
await this.write("PLANNING_LOG.md", serializeCycleHealth(updated, content));
|
|
3016
|
+
}
|
|
3017
|
+
/** Prepend a new cycle log entry at the top of the Cycle Log section. */
|
|
3018
|
+
async writeCycleLogEntry(entry) {
|
|
3019
|
+
const patched = {
|
|
3020
|
+
...entry,
|
|
3021
|
+
uuid: entry.uuid || randomUUID6(),
|
|
3022
|
+
date: entry.date ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3023
|
+
};
|
|
3024
|
+
const content = await this.read("SPRINT_LOG.md");
|
|
3025
|
+
await this.write("SPRINT_LOG.md", prependCycleLogEntry(patched, content));
|
|
3026
|
+
}
|
|
3027
|
+
/** Write a strategy review — for md adapter, delegates to cycle log. */
|
|
3028
|
+
async writeStrategyReview(review) {
|
|
3029
|
+
await this.writeCycleLogEntry({
|
|
3030
|
+
uuid: randomUUID6(),
|
|
3031
|
+
cycleNumber: review.cycleNumber,
|
|
3032
|
+
title: review.title,
|
|
3033
|
+
content: review.content,
|
|
3034
|
+
notes: review.notes
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
/** Get the cycle number of the last strategy review. */
|
|
3038
|
+
async getLastStrategyReviewCycle() {
|
|
3039
|
+
const log = await this.getCycleLog();
|
|
3040
|
+
const entry = log.find(
|
|
3041
|
+
(e) => /strategy.*review|strategic.*shift/i.test(e.title)
|
|
3042
|
+
);
|
|
3043
|
+
return entry?.cycleNumber ?? 0;
|
|
3044
|
+
}
|
|
3045
|
+
/** Get strategy reviews — md adapter returns empty (reviews live in cycle log). */
|
|
3046
|
+
async getStrategyReviews(_limit, _includeFullAnalysis) {
|
|
3047
|
+
return [];
|
|
3048
|
+
}
|
|
3049
|
+
/** Update or insert an Active Decision block by ID. */
|
|
3050
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
3051
|
+
async updateActiveDecision(id, body, cycleNumber, _action) {
|
|
3052
|
+
const content = await this.readOptional("ACTIVE_DECISIONS.md") || "## Active Decisions\n\n";
|
|
3053
|
+
await this.write("ACTIVE_DECISIONS.md", updateActiveDecisionInContent(id, body, content, cycleNumber));
|
|
3054
|
+
}
|
|
3055
|
+
// --- Cycle Board ---
|
|
3056
|
+
/** Query the cycle board, optionally filtering by status/priority/phase/etc. */
|
|
3057
|
+
async queryBoard(options) {
|
|
3058
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3059
|
+
return options ? filterTasks(tasks, options) : tasks;
|
|
3060
|
+
}
|
|
3061
|
+
/** Look up a single task by ID, returning null if not found. */
|
|
3062
|
+
async getTask(id) {
|
|
3063
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3064
|
+
const found = tasks.find((t) => t.id === id);
|
|
3065
|
+
if (found) return found;
|
|
3066
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3067
|
+
if (!archiveContent) return null;
|
|
3068
|
+
return parseBoard(archiveContent).find((t) => t.id === id) ?? null;
|
|
3069
|
+
}
|
|
3070
|
+
/** Look up multiple tasks by ID in a single board read. */
|
|
3071
|
+
async getTasks(ids) {
|
|
3072
|
+
const idSet = new Set(ids);
|
|
3073
|
+
const tasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3074
|
+
return tasks.filter((t) => idSet.has(t.id));
|
|
3075
|
+
}
|
|
3076
|
+
/** Warn if a phase name doesn't match any known phase label from PRODUCT_BRIEF.md. */
|
|
3077
|
+
async warnInvalidPhase(phase) {
|
|
3078
|
+
const phases = await this.readPhases();
|
|
3079
|
+
if (phases.length === 0) return;
|
|
3080
|
+
const knownLabels = new Set(phases.map((p) => p.label));
|
|
3081
|
+
if (!knownLabels.has(phase)) {
|
|
3082
|
+
console.warn(`[papi] Warning: phase "${phase}" does not match any known phase. Valid phases: ${[...knownLabels].join(", ")}`);
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
/** Warn if a module name doesn't match any registered module in REGISTRIES.md. */
|
|
3086
|
+
async warnInvalidModule(module) {
|
|
3087
|
+
const registries = await this.readRegistries();
|
|
3088
|
+
if (registries.modules.length === 0) return;
|
|
3089
|
+
const known = new Set(registries.modules);
|
|
3090
|
+
if (!known.has(module)) {
|
|
3091
|
+
console.warn(`[papi] Warning: module "${module}" is not a registered module. Registered modules: ${registries.modules.join(", ")}`);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
/** Warn if an epic name doesn't match any registered epic in REGISTRIES.md. */
|
|
3095
|
+
async warnInvalidEpic(epic) {
|
|
3096
|
+
const registries = await this.readRegistries();
|
|
3097
|
+
if (registries.epics.length === 0) return;
|
|
3098
|
+
const known = new Set(registries.epics);
|
|
3099
|
+
if (!known.has(epic)) {
|
|
3100
|
+
console.warn(`[papi] Warning: epic "${epic}" is not a registered epic. Registered epics: ${registries.epics.join(", ")}`);
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
/** Create a new task on the board with an auto-generated sequential ID. */
|
|
3104
|
+
async createTask(task) {
|
|
3105
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3106
|
+
const tasks = parseBoard(content);
|
|
3107
|
+
const createdAt = task.createdAt ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
3108
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3109
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3110
|
+
await this.warnInvalidPhase(task.phase);
|
|
3111
|
+
await this.warnInvalidModule(task.module);
|
|
3112
|
+
if (task.epic) await this.warnInvalidEpic(task.epic);
|
|
3113
|
+
if (task.dependsOn) {
|
|
3114
|
+
const allTaskIds = new Set([...tasks, ...archivedTasks].map((t) => t.id));
|
|
3115
|
+
const depIds = task.dependsOn.split(",").map((s) => s.trim()).filter(Boolean);
|
|
3116
|
+
for (const depId of depIds) {
|
|
3117
|
+
if (!allTaskIds.has(depId)) {
|
|
3118
|
+
console.warn(`[papi] Warning: dependsOn references non-existent task "${depId}"`);
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
const uuid = task.uuid ?? randomUUID6();
|
|
3123
|
+
const id = nextTaskId([...tasks, ...archivedTasks]);
|
|
3124
|
+
const newTask = { ...task, uuid, createdAt, id, displayId: id };
|
|
3125
|
+
if (newTask.buildHandoff) {
|
|
3126
|
+
if (!newTask.buildHandoff.uuid) {
|
|
3127
|
+
newTask.buildHandoff = { ...newTask.buildHandoff, uuid: randomUUID6() };
|
|
3128
|
+
}
|
|
3129
|
+
if (!newTask.buildHandoff.displayId) {
|
|
3130
|
+
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
3131
|
+
newTask.buildHandoff = { ...newTask.buildHandoff, displayId: `ho-${maxNum + 1}` };
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
tasks.push(newTask);
|
|
3135
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3136
|
+
return newTask;
|
|
3137
|
+
}
|
|
3138
|
+
/** Update one or more fields on an existing task. Throws if the task ID is not found. */
|
|
3139
|
+
async updateTask(id, updates, options) {
|
|
3140
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3141
|
+
const tasks = parseBoard(content);
|
|
3142
|
+
const idx = tasks.findIndex((t) => t.id === id);
|
|
3143
|
+
if (idx === -1) throw new Error(`Task ${id} not found`);
|
|
3144
|
+
if (updates.phase && updates.phase !== tasks[idx].phase) {
|
|
3145
|
+
await this.warnInvalidPhase(updates.phase);
|
|
3146
|
+
}
|
|
3147
|
+
if (updates.module && updates.module !== tasks[idx].module) {
|
|
3148
|
+
await this.warnInvalidModule(updates.module);
|
|
3149
|
+
}
|
|
3150
|
+
if (updates.epic && updates.epic !== tasks[idx].epic) {
|
|
3151
|
+
await this.warnInvalidEpic(updates.epic);
|
|
3152
|
+
}
|
|
3153
|
+
if (updates.status && updates.status !== tasks[idx].status && !options?.force) {
|
|
3154
|
+
const from = tasks[idx].status;
|
|
3155
|
+
const allowed = VALID_TRANSITIONS2[from];
|
|
3156
|
+
if (!allowed.includes(updates.status)) {
|
|
3157
|
+
console.warn(`[papi] Warning: invalid status transition "${from}" \u2192 "${updates.status}" for task ${id}. Allowed from "${from}": ${allowed.length > 0 ? allowed.join(", ") : "none"}`);
|
|
3158
|
+
}
|
|
3159
|
+
}
|
|
3160
|
+
if (updates.status && updates.status !== tasks[idx].status) {
|
|
3161
|
+
const history = tasks[idx].stateHistory ?? [];
|
|
3162
|
+
history.push({ status: updates.status, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3163
|
+
updates = { ...updates, stateHistory: history };
|
|
3164
|
+
}
|
|
3165
|
+
if (updates.buildHandoff) {
|
|
3166
|
+
let handoff = { ...updates.buildHandoff };
|
|
3167
|
+
if (!handoff.uuid) handoff = { ...handoff, uuid: randomUUID6() };
|
|
3168
|
+
if (!handoff.displayId) {
|
|
3169
|
+
const maxNum = tasks.reduce((max, t) => Math.max(max, displayIdNumber(t.buildHandoff?.displayId, "ho")), 0);
|
|
3170
|
+
handoff = { ...handoff, displayId: `ho-${maxNum + 1}` };
|
|
3171
|
+
}
|
|
3172
|
+
updates = { ...updates, buildHandoff: handoff };
|
|
3173
|
+
}
|
|
3174
|
+
tasks[idx] = { ...tasks[idx], ...updates };
|
|
3175
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3176
|
+
}
|
|
3177
|
+
/** Shorthand to update only the status field of a task. */
|
|
3178
|
+
async updateTaskStatus(id, status) {
|
|
3179
|
+
return this.updateTask(id, { status });
|
|
3180
|
+
}
|
|
3181
|
+
/**
|
|
3182
|
+
* task-1763 (C293): atomic compare-and-swap task claim. First-claim-wins —
|
|
3183
|
+
* sets assigneeId only if the task is currently unclaimed. The markdown adapter
|
|
3184
|
+
* is single-process, so the read-check-write is trivially atomic here; the real
|
|
3185
|
+
* concurrency guarantee lives in the pg adapter's RETURNING CAS. Returns the
|
|
3186
|
+
* claimed task, or null if it was already claimed or does not exist.
|
|
3187
|
+
*
|
|
3188
|
+
* task-2071 (MU-3): the pooled-task invariant is `assigneeId == null && cycle
|
|
3189
|
+
* == null` — a task already pulled into someone's cycle is NOT in the pool and
|
|
3190
|
+
* cannot be claimed. Sets claimSource='pool' on success.
|
|
3191
|
+
*/
|
|
3192
|
+
async claimTask(taskId, assigneeId) {
|
|
3193
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3194
|
+
const tasks = parseBoard(content);
|
|
3195
|
+
const idx = tasks.findIndex((t) => t.id === taskId);
|
|
3196
|
+
if (idx === -1) return null;
|
|
3197
|
+
if (tasks[idx].assigneeId || tasks[idx].cycle != null) return null;
|
|
3198
|
+
tasks[idx] = { ...tasks[idx], assigneeId, claimSource: "pool" };
|
|
3199
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3200
|
+
return tasks[idx];
|
|
3201
|
+
}
|
|
3202
|
+
/**
|
|
3203
|
+
* task-2071 (C293, MU-3): claimer-only release. Clears assigneeId + claimSource
|
|
3204
|
+
* only if the task is currently assigned to `assigneeId` and has not entered
|
|
3205
|
+
* review. Returns the unclaimed task, or null if the caller is not the claimer,
|
|
3206
|
+
* the task has progressed, or it does not exist.
|
|
3207
|
+
*/
|
|
3208
|
+
async unclaimTask(taskId, assigneeId) {
|
|
3209
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3210
|
+
const tasks = parseBoard(content);
|
|
3211
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
3212
|
+
if (idx === -1) return null;
|
|
3213
|
+
const t = tasks[idx];
|
|
3214
|
+
if (t.assigneeId !== assigneeId) return null;
|
|
3215
|
+
if (t.status === "In Review" || t.status === "Done") return null;
|
|
3216
|
+
const next = { ...t };
|
|
3217
|
+
delete next.assigneeId;
|
|
3218
|
+
delete next.claimSource;
|
|
3219
|
+
tasks[idx] = next;
|
|
3220
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3221
|
+
return tasks[idx];
|
|
3222
|
+
}
|
|
3223
|
+
/**
|
|
3224
|
+
* task-2072 (C293, MU-4): atomic review claim. Sets reviewerId only if the task
|
|
3225
|
+
* is In Review and not yet claimed for review (reviewerId == null). First-claim-
|
|
3226
|
+
* wins. Returns the claimed task, or null if already review-claimed / not In
|
|
3227
|
+
* Review / missing.
|
|
3228
|
+
*/
|
|
3229
|
+
async claimReview(taskId, reviewerId) {
|
|
3230
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3231
|
+
const tasks = parseBoard(content);
|
|
3232
|
+
const idx = tasks.findIndex((t2) => t2.id === taskId);
|
|
3233
|
+
if (idx === -1) return null;
|
|
3234
|
+
const t = tasks[idx];
|
|
3235
|
+
if (t.status !== "In Review") return null;
|
|
3236
|
+
if (t.reviewerId) return null;
|
|
3237
|
+
tasks[idx] = { ...t, reviewerId };
|
|
3238
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(tasks, content));
|
|
3239
|
+
return tasks[idx];
|
|
3240
|
+
}
|
|
3241
|
+
async recordTransition(_taskId, _fromStatus, _toStatus, _changedBy) {
|
|
3242
|
+
}
|
|
3243
|
+
// --- Build Reports ---
|
|
3244
|
+
/** Insert a new build report at the top of BUILD_REPORTS.md. */
|
|
3245
|
+
async appendBuildReport(report) {
|
|
3246
|
+
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3247
|
+
if (!boardTasks.some((t) => t.id === report.taskId)) {
|
|
3248
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3249
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3250
|
+
if (!archivedTasks.some((t) => t.id === report.taskId)) {
|
|
3251
|
+
console.warn(`[papi] Warning: BuildReport.taskId references non-existent task "${report.taskId}"`);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
const content = await this.read("BUILD_REPORTS.md");
|
|
3255
|
+
if (!report.uuid) {
|
|
3256
|
+
report = { ...report, uuid: randomUUID6() };
|
|
3257
|
+
}
|
|
3258
|
+
if (!report.displayId) {
|
|
3259
|
+
const existing = parseBuildReports(content);
|
|
3260
|
+
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "br")), 0);
|
|
3261
|
+
report = { ...report, displayId: `br-${maxNum + 1}` };
|
|
3262
|
+
}
|
|
3263
|
+
await this.write("BUILD_REPORTS.md", prependBuildReport(report, content));
|
|
3264
|
+
}
|
|
3265
|
+
/** Return the most recent {@link count} build reports. */
|
|
3266
|
+
async getRecentBuildReports(count) {
|
|
3267
|
+
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3268
|
+
return reports.slice(0, count);
|
|
3269
|
+
}
|
|
3270
|
+
/** Return the number of build reports for a specific task. */
|
|
3271
|
+
async getBuildReportCountForTask(taskId) {
|
|
3272
|
+
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3273
|
+
return reports.filter((r) => r.taskId === taskId).length;
|
|
3274
|
+
}
|
|
3275
|
+
/** Return all build reports from cycles >= {@link cycleNumber}. */
|
|
3276
|
+
async getBuildReportsSince(cycleNumber) {
|
|
3277
|
+
const reports = parseBuildReports(await this.read("BUILD_REPORTS.md"));
|
|
3278
|
+
return reports.filter((r) => r.cycle >= cycleNumber);
|
|
3279
|
+
}
|
|
3280
|
+
// --- Human Reviews ---
|
|
3281
|
+
/** Return recent human reviews from REVIEWS.md (newest first), optionally limited to {@link count}. */
|
|
3282
|
+
async getRecentReviews(count) {
|
|
3283
|
+
const content = await this.readOptional("REVIEWS.md");
|
|
3284
|
+
if (!content) return [];
|
|
3285
|
+
const reviews = parseReviews(content);
|
|
3286
|
+
return count ? reviews.slice(0, count) : reviews;
|
|
3287
|
+
}
|
|
3288
|
+
/** Write a new human review to REVIEWS.md. */
|
|
3289
|
+
async writeReview(review) {
|
|
3290
|
+
const boardTasks = parseBoard(await this.read("SPRINT_BOARD.md"));
|
|
3291
|
+
if (!boardTasks.some((t) => t.id === review.taskId)) {
|
|
3292
|
+
const archiveContent = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3293
|
+
const archivedTasks = archiveContent ? parseBoard(archiveContent) : [];
|
|
3294
|
+
if (!archivedTasks.some((t) => t.id === review.taskId)) {
|
|
3295
|
+
console.warn(`[papi] Warning: HumanReview.taskId references non-existent task "${review.taskId}"`);
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
const content = await this.readOptional("REVIEWS.md");
|
|
3299
|
+
if (!review.uuid) {
|
|
3300
|
+
review = { ...review, uuid: randomUUID6() };
|
|
3301
|
+
}
|
|
3302
|
+
if (!review.displayId) {
|
|
3303
|
+
const existing = content ? parseReviews(content) : [];
|
|
3304
|
+
const maxNum = existing.reduce((max, r) => Math.max(max, displayIdNumber(r.displayId, "rv")), 0);
|
|
3305
|
+
review = { ...review, displayId: `rv-${maxNum + 1}` };
|
|
3306
|
+
}
|
|
3307
|
+
await this.write("REVIEWS.md", prependReview(review, content));
|
|
3308
|
+
}
|
|
3309
|
+
// --- Compression ---
|
|
3310
|
+
/** Compress old cycle log entries below {@link threshold} into a summary block. */
|
|
3311
|
+
async compressCycleLog(threshold, summary) {
|
|
3312
|
+
const content = await this.read("SPRINT_LOG.md");
|
|
3313
|
+
await this.write("SPRINT_LOG.md", compressCycleLogInContent(content, threshold, summary));
|
|
3314
|
+
}
|
|
3315
|
+
/** Compress old build reports below {@link threshold} into a summary block. */
|
|
3316
|
+
async compressBuildReports(threshold, summary) {
|
|
3317
|
+
const content = await this.read("BUILD_REPORTS.md");
|
|
3318
|
+
await this.write("BUILD_REPORTS.md", compressBuildReportsInContent(content, threshold, summary));
|
|
3319
|
+
}
|
|
3320
|
+
// --- Archival ---
|
|
3321
|
+
/** Read a .papi/ file, returning empty string if it doesn't exist. */
|
|
3322
|
+
async readOptional(file) {
|
|
3323
|
+
try {
|
|
3324
|
+
await access(this.path(file));
|
|
3325
|
+
return readFile(this.path(file), "utf-8");
|
|
3326
|
+
} catch (_err) {
|
|
3327
|
+
return "";
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
/** Strip build_handoff and build_report from a task before archiving — these are already in BUILD_REPORTS.md. */
|
|
3331
|
+
stripHeavyFields(task) {
|
|
3332
|
+
const { buildHandoff, buildReport, ...rest } = task;
|
|
3333
|
+
return rest;
|
|
3334
|
+
}
|
|
3335
|
+
/** Append tasks to ARCHIVE_CYCLE_BOARD.md, stripping heavy fields and deduplicating by ID. */
|
|
3336
|
+
async appendToArchive(tasks) {
|
|
3337
|
+
const existing = await this.readOptional("ARCHIVE_SPRINT_BOARD.md");
|
|
3338
|
+
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";
|
|
3339
|
+
const existingArchived = parseBoard(archiveContent);
|
|
3340
|
+
const existingIds = new Set(existingArchived.map((t) => t.id));
|
|
3341
|
+
const newArchive = tasks.filter((t) => !existingIds.has(t.id)).map((t) => this.stripHeavyFields(t));
|
|
3342
|
+
const merged = [...existingArchived, ...newArchive];
|
|
3343
|
+
await this.write("ARCHIVE_SPRINT_BOARD.md", serializeBoard(merged, archiveContent));
|
|
3344
|
+
}
|
|
3345
|
+
/** Archive tasks matching phases and/or statuses to ARCHIVE_CYCLE_BOARD.md and remove them from active board. */
|
|
3346
|
+
async archiveTasks(phases, statuses) {
|
|
3347
|
+
const content = await this.read("SPRINT_BOARD.md");
|
|
3348
|
+
const tasks = parseBoard(content);
|
|
3349
|
+
const phaseSet = new Set(phases.map((p) => p.toLowerCase()));
|
|
3350
|
+
const statusSet = statuses ? new Set(statuses.map((s) => s.toLowerCase())) : null;
|
|
3351
|
+
const keep = [];
|
|
3352
|
+
const archive = [];
|
|
3353
|
+
const hasPhaseFilter = phaseSet.size > 0;
|
|
3354
|
+
const hasStatusFilter = statusSet !== null;
|
|
3355
|
+
for (const task of tasks) {
|
|
3356
|
+
const matchesPhase = hasPhaseFilter && phaseSet.has(task.phase.toLowerCase());
|
|
3357
|
+
const matchesStatus = hasStatusFilter && statusSet.has(task.status.toLowerCase());
|
|
3358
|
+
const shouldArchive = hasPhaseFilter && hasStatusFilter ? matchesPhase && matchesStatus : matchesPhase || matchesStatus;
|
|
3359
|
+
if (shouldArchive) {
|
|
3360
|
+
archive.push(task);
|
|
3361
|
+
} else {
|
|
3362
|
+
keep.push(task);
|
|
3363
|
+
}
|
|
3364
|
+
}
|
|
3365
|
+
if (archive.length === 0) {
|
|
3366
|
+
return { archivedCount: 0, taskIds: [] };
|
|
3367
|
+
}
|
|
3368
|
+
await this.appendToArchive(archive);
|
|
3369
|
+
await this.write("SPRINT_BOARD.md", serializeBoard(keep, content));
|
|
3370
|
+
return { archivedCount: archive.length, taskIds: archive.map((t) => t.id) };
|
|
3371
|
+
}
|
|
3372
|
+
// --- Product Brief ---
|
|
3373
|
+
/** Read the raw PRODUCT_BRIEF.md content. */
|
|
3374
|
+
async readProductBrief() {
|
|
3375
|
+
return this.read("PRODUCT_BRIEF.md");
|
|
3376
|
+
}
|
|
3377
|
+
/** Overwrite PRODUCT_BRIEF.md with new content. */
|
|
3378
|
+
async updateProductBrief(content) {
|
|
3379
|
+
await this.write("PRODUCT_BRIEF.md", content);
|
|
3380
|
+
}
|
|
3381
|
+
async readDiscoveryCanvas() {
|
|
3382
|
+
return {};
|
|
3383
|
+
}
|
|
3384
|
+
async updateDiscoveryCanvas(_canvas) {
|
|
3385
|
+
}
|
|
3386
|
+
/** Read all phases from PHASES.md (falls back to PRODUCT_BRIEF.md for migration). */
|
|
3387
|
+
async readPhases() {
|
|
3388
|
+
const phasesContent = await this.readOptional("PHASES.md");
|
|
3389
|
+
if (phasesContent) return parsePhases(phasesContent);
|
|
3390
|
+
const briefContent = await this.readOptional("PRODUCT_BRIEF.md");
|
|
3391
|
+
return briefContent ? parsePhases(briefContent) : [];
|
|
3392
|
+
}
|
|
3393
|
+
/** Write phases to PHASES.md. */
|
|
3394
|
+
async writePhases(phases) {
|
|
3395
|
+
const content = await this.readOptional("PHASES.md");
|
|
3396
|
+
const existing = content || "";
|
|
3397
|
+
const yaml4 = serializePhases(phases);
|
|
3398
|
+
const PHASES_START2 = "<!-- PHASES:START -->";
|
|
3399
|
+
const PHASES_END2 = "<!-- PHASES:END -->";
|
|
3400
|
+
const newSection = `${PHASES_START2}
|
|
3401
|
+
|
|
3402
|
+
\`\`\`yaml
|
|
3403
|
+
${yaml4}
|
|
3404
|
+
\`\`\`
|
|
3405
|
+
|
|
3406
|
+
${PHASES_END2}`;
|
|
3407
|
+
const startIdx = existing.indexOf(PHASES_START2);
|
|
3408
|
+
const endIdx = existing.indexOf(PHASES_END2);
|
|
3409
|
+
if (startIdx !== -1 && endIdx !== -1) {
|
|
3410
|
+
await this.write("PHASES.md", existing.slice(0, startIdx) + newSection + existing.slice(endIdx + PHASES_END2.length));
|
|
3411
|
+
} else {
|
|
3412
|
+
await this.write("PHASES.md", `# Phases
|
|
3413
|
+
|
|
3414
|
+
${newSection}
|
|
3415
|
+
`);
|
|
3416
|
+
}
|
|
3417
|
+
}
|
|
3418
|
+
// --- Tool Call Metrics ---
|
|
3419
|
+
/** Append a tool call metric entry to METRICS.md. */
|
|
3420
|
+
async appendToolMetric(metric) {
|
|
3421
|
+
const content = await this.readOptional("METRICS.md");
|
|
3422
|
+
await this.write("METRICS.md", appendToolMetricToContent(metric, content));
|
|
3423
|
+
}
|
|
3424
|
+
/** Read all tool call metrics from METRICS.md. */
|
|
3425
|
+
async readToolMetrics() {
|
|
3426
|
+
const content = await this.readOptional("METRICS.md");
|
|
3427
|
+
if (!content) return [];
|
|
3428
|
+
return parseToolMetrics(content);
|
|
3429
|
+
}
|
|
3430
|
+
async hasToolMilestone(name) {
|
|
3431
|
+
const metrics = await this.readToolMetrics();
|
|
3432
|
+
return metrics.some((m) => m.tool === name);
|
|
3433
|
+
}
|
|
3434
|
+
/** Aggregate tool call metrics into a cost summary, optionally filtered by cycle. */
|
|
3435
|
+
async getCostSummary(cycleNumber) {
|
|
3436
|
+
const metrics = await this.readToolMetrics();
|
|
3437
|
+
return aggregateCostSummary(metrics, cycleNumber);
|
|
3438
|
+
}
|
|
3439
|
+
/** Read all cost snapshots from the Cost Summary section of METRICS.md. */
|
|
3440
|
+
async getCostSnapshots() {
|
|
3441
|
+
const content = await this.readOptional("METRICS.md");
|
|
3442
|
+
if (!content) return [];
|
|
3443
|
+
return parseCostSnapshots(content);
|
|
3444
|
+
}
|
|
3445
|
+
// --- Cycle Methodology Metrics ---
|
|
3446
|
+
/** Append a cycle metrics snapshot to CYCLE_METRICS.md. */
|
|
3447
|
+
async appendCycleMetrics(snapshot) {
|
|
3448
|
+
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
3449
|
+
await this.write("SPRINT_METRICS.md", appendSnapshotToContent(snapshot, content));
|
|
3450
|
+
}
|
|
3451
|
+
/** Read all cycle metrics snapshots from CYCLE_METRICS.md. */
|
|
3452
|
+
async readCycleMetrics() {
|
|
3453
|
+
const content = await this.readOptional("SPRINT_METRICS.md");
|
|
3454
|
+
if (!content) return [];
|
|
3455
|
+
return parseSnapshots(content);
|
|
3456
|
+
}
|
|
3457
|
+
// --- Cycles ---
|
|
3458
|
+
/** Read all Cycle entities from CYCLES.md (newest first). */
|
|
3459
|
+
async readCycles() {
|
|
3460
|
+
const content = await this.readOptional("CYCLES.md");
|
|
3461
|
+
if (!content) return [];
|
|
3462
|
+
return parseCycles(content);
|
|
3463
|
+
}
|
|
3464
|
+
/** Write a new Cycle entity to CYCLES.md. */
|
|
3465
|
+
async createCycle(cycle) {
|
|
3466
|
+
const content = await this.readOptional("CYCLES.md");
|
|
3467
|
+
await this.write("CYCLES.md", prependCycle(cycle, content));
|
|
3468
|
+
}
|
|
3469
|
+
// --- Registries ---
|
|
3470
|
+
/** Read module and epic registries from REGISTRIES.md. */
|
|
3471
|
+
async readRegistries() {
|
|
3472
|
+
const content = await this.readOptional("REGISTRIES.md");
|
|
3473
|
+
if (!content) return { modules: [], epics: [] };
|
|
3474
|
+
return parseRegistries(content);
|
|
3475
|
+
}
|
|
3476
|
+
/** Overwrite REGISTRIES.md with updated registries. */
|
|
3477
|
+
async updateRegistries(registries) {
|
|
3478
|
+
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";
|
|
3479
|
+
await this.write("REGISTRIES.md", serializeRegistries(registries, content));
|
|
3480
|
+
}
|
|
3481
|
+
// --- Strategy Recommendations ---
|
|
3482
|
+
/**
|
|
3483
|
+
* Write a new strategy recommendation to STRATEGY_RECOMMENDATIONS.md.
|
|
3484
|
+
* File-based implementation stores as YAML entries.
|
|
3485
|
+
*/
|
|
3486
|
+
async writeRecommendation(rec) {
|
|
3487
|
+
const id = randomUUID6();
|
|
3488
|
+
const full = { id, ...rec };
|
|
3489
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
3490
|
+
const header = "# Strategy Recommendations\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\nrecommendations:\n";
|
|
3491
|
+
const footer = "<!-- PAPI-YAML-END -->\n";
|
|
3492
|
+
const entry = [
|
|
3493
|
+
` - id: ${full.id}`,
|
|
3494
|
+
` type: ${full.type}`,
|
|
3495
|
+
` status: ${full.status}`,
|
|
3496
|
+
` content: ${JSON.stringify(full.content)}`,
|
|
3497
|
+
` created_sprint: ${full.createdCycle}`,
|
|
3498
|
+
full.actionedCycle != null ? ` actioned_cycle: ${full.actionedCycle}` : null,
|
|
3499
|
+
full.target != null ? ` target: ${JSON.stringify(full.target)}` : null
|
|
3500
|
+
].filter(Boolean).join("\n");
|
|
3501
|
+
if (!content) {
|
|
3502
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
3503
|
+
${footer}`);
|
|
3504
|
+
} else {
|
|
3505
|
+
const insertPoint = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
3506
|
+
if (insertPoint === -1) {
|
|
3507
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", `${header}${entry}
|
|
3508
|
+
${footer}`);
|
|
3509
|
+
} else {
|
|
3510
|
+
const updated = content.slice(0, insertPoint) + entry + "\n" + content.slice(insertPoint);
|
|
3511
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
3512
|
+
}
|
|
3513
|
+
}
|
|
3514
|
+
return full;
|
|
3515
|
+
}
|
|
3516
|
+
/** Read all pending (unactioned) strategy recommendations. */
|
|
3517
|
+
async getPendingRecommendations() {
|
|
3518
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
3519
|
+
if (!content) return [];
|
|
3520
|
+
const yamlStart = content.indexOf("<!-- PAPI-YAML-START -->");
|
|
3521
|
+
const yamlEnd = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
3522
|
+
if (yamlStart === -1 || yamlEnd === -1) return [];
|
|
3523
|
+
const yamlBlock = content.slice(yamlStart + "<!-- PAPI-YAML-START -->".length, yamlEnd).trim();
|
|
3524
|
+
const entries = yamlBlock.split(/(?=\s+-\s+id:)/);
|
|
3525
|
+
const recs = [];
|
|
3526
|
+
for (const block of entries) {
|
|
3527
|
+
const idMatch = block.match(/id:\s+(.+)/);
|
|
3528
|
+
const typeMatch = block.match(/type:\s+(.+)/);
|
|
3529
|
+
const statusMatch = block.match(/status:\s+(.+)/);
|
|
3530
|
+
const contentMatch = block.match(/content:\s+(.+)/);
|
|
3531
|
+
const createdMatch = block.match(/created_sprint:\s+(\d+)/);
|
|
3532
|
+
const actionedMatch = block.match(/actioned_cycle:\s+(\d+)/);
|
|
3533
|
+
if (!idMatch || !typeMatch || !statusMatch || !contentMatch || !createdMatch) continue;
|
|
3534
|
+
const status = statusMatch[1].trim();
|
|
3535
|
+
if (status !== "pending") continue;
|
|
3536
|
+
let parsedContent = contentMatch[1].trim();
|
|
3537
|
+
if (parsedContent.startsWith('"') && parsedContent.endsWith('"')) {
|
|
3538
|
+
try {
|
|
3539
|
+
parsedContent = JSON.parse(parsedContent);
|
|
3540
|
+
} catch {
|
|
3541
|
+
}
|
|
3542
|
+
}
|
|
3543
|
+
recs.push({
|
|
3544
|
+
id: idMatch[1].trim(),
|
|
3545
|
+
type: typeMatch[1].trim(),
|
|
3546
|
+
status: "pending",
|
|
3547
|
+
content: parsedContent,
|
|
3548
|
+
createdCycle: parseInt(createdMatch[1], 10),
|
|
3549
|
+
actionedCycle: actionedMatch ? parseInt(actionedMatch[1], 10) : void 0
|
|
3550
|
+
});
|
|
3551
|
+
}
|
|
3552
|
+
return recs;
|
|
3553
|
+
}
|
|
3554
|
+
/** Mark a recommendation as actioned. */
|
|
3555
|
+
async actionRecommendation(id, cycleNumber) {
|
|
3556
|
+
const content = await this.readOptional("STRATEGY_RECOMMENDATIONS.md");
|
|
3557
|
+
if (!content) return;
|
|
3558
|
+
const idPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(\\s+status:\\s+)pending`);
|
|
3559
|
+
let updated = content.replace(idPattern, `$1$2actioned`);
|
|
3560
|
+
const entryPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?)(?=\\s+-\\s+id:|<!-- PAPI-YAML-END -->)`);
|
|
3561
|
+
const entryMatch = updated.match(entryPattern);
|
|
3562
|
+
if (entryMatch && !entryMatch[0].includes("actioned_cycle:")) {
|
|
3563
|
+
const cyclePattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+created_sprint:\\s+\\d+)\\n`);
|
|
3564
|
+
updated = updated.replace(cyclePattern, `$1
|
|
3565
|
+
actioned_cycle: ${cycleNumber}
|
|
3566
|
+
`);
|
|
3567
|
+
}
|
|
3568
|
+
await this.write("STRATEGY_RECOMMENDATIONS.md", updated);
|
|
3569
|
+
}
|
|
3570
|
+
// -------------------------------------------------------------------------
|
|
3571
|
+
// Strategy Review Agenda (markdown persistence)
|
|
3572
|
+
// -------------------------------------------------------------------------
|
|
3573
|
+
async addAgendaTopic(input) {
|
|
3574
|
+
const id = randomUUID6();
|
|
3575
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3576
|
+
const full = {
|
|
3577
|
+
id,
|
|
3578
|
+
topic: input.topic,
|
|
3579
|
+
source: input.source,
|
|
3580
|
+
sourceCycle: input.sourceCycle,
|
|
3581
|
+
status: "pending",
|
|
3582
|
+
createdAt
|
|
3583
|
+
};
|
|
3584
|
+
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
3585
|
+
const header = "# Strategy Review Agenda\n\n<!-- PAPI-ADAPTER: parse the yaml block below -->\n\n<!-- PAPI-YAML-START -->\ntopics:\n";
|
|
3586
|
+
const footer = "<!-- PAPI-YAML-END -->\n";
|
|
3587
|
+
const entry = [
|
|
3588
|
+
` - id: ${full.id}`,
|
|
3589
|
+
` topic: ${JSON.stringify(full.topic)}`,
|
|
3590
|
+
` source: ${full.source}`,
|
|
3591
|
+
full.sourceCycle != null ? ` source_cycle: ${full.sourceCycle}` : null,
|
|
3592
|
+
` status: ${full.status}`,
|
|
3593
|
+
` created_at: ${full.createdAt}`
|
|
3594
|
+
].filter(Boolean).join("\n");
|
|
3595
|
+
if (!content) {
|
|
3596
|
+
await this.write("STRATEGY_REVIEW_AGENDA.md", `${header}${entry}
|
|
3597
|
+
${footer}`);
|
|
3598
|
+
} else {
|
|
3599
|
+
const insertPoint = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
3600
|
+
if (insertPoint === -1) {
|
|
3601
|
+
await this.write("STRATEGY_REVIEW_AGENDA.md", `${header}${entry}
|
|
3602
|
+
${footer}`);
|
|
3603
|
+
} else {
|
|
3604
|
+
const updated = content.slice(0, insertPoint) + entry + "\n" + content.slice(insertPoint);
|
|
3605
|
+
await this.write("STRATEGY_REVIEW_AGENDA.md", updated);
|
|
3606
|
+
}
|
|
3607
|
+
}
|
|
3608
|
+
return full;
|
|
3609
|
+
}
|
|
3610
|
+
async getPendingAgendaTopics() {
|
|
3611
|
+
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
3612
|
+
if (!content) return [];
|
|
3613
|
+
const yamlStart = content.indexOf("<!-- PAPI-YAML-START -->");
|
|
3614
|
+
const yamlEnd = content.indexOf("<!-- PAPI-YAML-END -->");
|
|
3615
|
+
if (yamlStart === -1 || yamlEnd === -1) return [];
|
|
3616
|
+
const yamlBlock = content.slice(yamlStart + "<!-- PAPI-YAML-START -->".length, yamlEnd).trim();
|
|
3617
|
+
const entries = yamlBlock.split(/(?=\s+-\s+id:)/);
|
|
3618
|
+
const topics = [];
|
|
3619
|
+
for (const block of entries) {
|
|
3620
|
+
const idMatch = block.match(/id:\s+(.+)/);
|
|
3621
|
+
const topicMatch = block.match(/topic:\s+(.+)/);
|
|
3622
|
+
const sourceMatch = block.match(/source:\s+(\S+)/);
|
|
3623
|
+
const statusMatch = block.match(/status:\s+(\S+)/);
|
|
3624
|
+
const createdMatch = block.match(/created_at:\s+(.+)/);
|
|
3625
|
+
const sourceCycleMatch = block.match(/source_cycle:\s+(\d+)/);
|
|
3626
|
+
if (!idMatch || !topicMatch || !sourceMatch || !statusMatch || !createdMatch) continue;
|
|
3627
|
+
if (statusMatch[1].trim() !== "pending") continue;
|
|
3628
|
+
let parsedTopic = topicMatch[1].trim();
|
|
3629
|
+
if (parsedTopic.startsWith('"') && parsedTopic.endsWith('"')) {
|
|
3630
|
+
try {
|
|
3631
|
+
parsedTopic = JSON.parse(parsedTopic);
|
|
3632
|
+
} catch {
|
|
3633
|
+
}
|
|
3634
|
+
}
|
|
3635
|
+
topics.push({
|
|
3636
|
+
id: idMatch[1].trim(),
|
|
3637
|
+
topic: parsedTopic,
|
|
3638
|
+
source: sourceMatch[1].trim(),
|
|
3639
|
+
sourceCycle: sourceCycleMatch ? parseInt(sourceCycleMatch[1], 10) : void 0,
|
|
3640
|
+
status: "pending",
|
|
3641
|
+
createdAt: createdMatch[1].trim()
|
|
3642
|
+
});
|
|
3643
|
+
}
|
|
3644
|
+
return topics;
|
|
3645
|
+
}
|
|
3646
|
+
async markAgendaTopicsAddressed(ids, cycleNumber) {
|
|
3647
|
+
if (ids.length === 0) return;
|
|
3648
|
+
const content = await this.readOptional("STRATEGY_REVIEW_AGENDA.md");
|
|
3649
|
+
if (!content) return;
|
|
3650
|
+
let updated = content;
|
|
3651
|
+
const addressedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3652
|
+
for (const id of ids) {
|
|
3653
|
+
const statusPattern = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+status:\\s+)pending`);
|
|
3654
|
+
updated = updated.replace(statusPattern, `$1addressed`);
|
|
3655
|
+
const insertionAnchor = new RegExp(`(\\s+-\\s+id:\\s+${id}\\n(?:.*\\n)*?\\s+created_at:\\s+[^\\n]+)\\n`);
|
|
3656
|
+
const match = updated.match(insertionAnchor);
|
|
3657
|
+
if (match && !match[0].includes("addressed_at:")) {
|
|
3658
|
+
updated = updated.replace(insertionAnchor, `$1
|
|
3659
|
+
addressed_at: ${addressedAt}
|
|
3660
|
+
addressed_in_review: ${cycleNumber}
|
|
3661
|
+
`);
|
|
3662
|
+
}
|
|
3663
|
+
}
|
|
3664
|
+
await this.write("STRATEGY_REVIEW_AGENDA.md", updated);
|
|
3665
|
+
}
|
|
3666
|
+
// -------------------------------------------------------------------------
|
|
3667
|
+
// Decision Events & Scores (markdown persistence)
|
|
3668
|
+
// -------------------------------------------------------------------------
|
|
3669
|
+
async appendDecisionEvent(event) {
|
|
3670
|
+
const id = randomUUID6();
|
|
3671
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3672
|
+
const full = { ...event, id, createdAt };
|
|
3673
|
+
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
3674
|
+
const header = "# Decision Events\n\n";
|
|
3675
|
+
const entry = `## ${full.decisionId} | ${full.eventType} | Cycle ${full.cycle}
|
|
3676
|
+
- **id:** ${full.id}
|
|
3677
|
+
- **source:** ${full.source}
|
|
3678
|
+
` + (full.sourceRef ? `- **sourceRef:** ${full.sourceRef}
|
|
3679
|
+
` : "") + (full.detail ? `- **detail:** ${full.detail}
|
|
3680
|
+
` : "") + (full.evidenceRef ? `- **evidenceRef:** ${full.evidenceRef}
|
|
3681
|
+
` : "") + (full.metricDelta ? `- **metricDelta:** ${JSON.stringify(full.metricDelta)}
|
|
3682
|
+
` : "") + `- **createdAt:** ${full.createdAt}
|
|
3683
|
+
|
|
3684
|
+
---
|
|
3685
|
+
|
|
3686
|
+
`;
|
|
3687
|
+
if (!content) {
|
|
3688
|
+
await this.write("DECISION_EVENTS.md", header + entry);
|
|
3689
|
+
} else {
|
|
3690
|
+
await this.write("DECISION_EVENTS.md", content + entry);
|
|
3691
|
+
}
|
|
3692
|
+
return full;
|
|
3693
|
+
}
|
|
3694
|
+
async getDecisionEvents(decisionId, limit) {
|
|
3695
|
+
const all = await this.parseDecisionEvents();
|
|
3696
|
+
const filtered = all.filter((e) => e.decisionId === decisionId);
|
|
3697
|
+
return limit ? filtered.slice(0, limit) : filtered;
|
|
3698
|
+
}
|
|
3699
|
+
async getDecisionEventsSince(cycle) {
|
|
3700
|
+
const all = await this.parseDecisionEvents();
|
|
3701
|
+
return all.filter((e) => e.cycle >= cycle);
|
|
3702
|
+
}
|
|
3703
|
+
async parseDecisionEvents() {
|
|
3704
|
+
const content = await this.readOptional("DECISION_EVENTS.md");
|
|
3705
|
+
if (!content) return [];
|
|
3706
|
+
const events = [];
|
|
3707
|
+
const blocks = content.split("---").filter((b) => b.trim());
|
|
3708
|
+
for (const block of blocks) {
|
|
3709
|
+
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+(\S+)\s+\|\s+Cycle\s+(\d+)/m);
|
|
3710
|
+
if (!headingMatch) continue;
|
|
3711
|
+
const idMatch = block.match(/\*\*id:\*\*\s+(.+)/);
|
|
3712
|
+
const sourceMatch = block.match(/\*\*source:\*\*\s+(.+)/);
|
|
3713
|
+
const sourceRefMatch = block.match(/\*\*sourceRef:\*\*\s+(.+)/);
|
|
3714
|
+
const detailMatch = block.match(/\*\*detail:\*\*\s+(.+)/);
|
|
3715
|
+
const evidenceRefMatch = block.match(/\*\*evidenceRef:\*\*\s+(.+)/);
|
|
3716
|
+
const metricDeltaMatch = block.match(/\*\*metricDelta:\*\*\s+(.+)/);
|
|
3717
|
+
const createdAtMatch = block.match(/\*\*createdAt:\*\*\s+(.+)/);
|
|
3718
|
+
if (!idMatch || !sourceMatch || !createdAtMatch) continue;
|
|
3719
|
+
let metricDelta;
|
|
3720
|
+
if (metricDeltaMatch?.[1]) {
|
|
3721
|
+
try {
|
|
3722
|
+
metricDelta = JSON.parse(metricDeltaMatch[1].trim());
|
|
3723
|
+
} catch {
|
|
3724
|
+
metricDelta = null;
|
|
3725
|
+
}
|
|
3726
|
+
}
|
|
3727
|
+
events.push({
|
|
3728
|
+
id: idMatch[1].trim(),
|
|
3729
|
+
decisionId: headingMatch[1],
|
|
3730
|
+
eventType: headingMatch[2],
|
|
3731
|
+
cycle: parseInt(headingMatch[3], 10),
|
|
3732
|
+
source: sourceMatch[1].trim(),
|
|
3733
|
+
sourceRef: sourceRefMatch?.[1]?.trim(),
|
|
3734
|
+
detail: detailMatch?.[1]?.trim(),
|
|
3735
|
+
evidenceRef: evidenceRefMatch?.[1]?.trim(),
|
|
3736
|
+
metricDelta,
|
|
3737
|
+
createdAt: createdAtMatch[1].trim()
|
|
3738
|
+
});
|
|
3739
|
+
}
|
|
3740
|
+
return events;
|
|
3741
|
+
}
|
|
3742
|
+
async writeDecisionScore(score) {
|
|
3743
|
+
const id = randomUUID6();
|
|
3744
|
+
const createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3745
|
+
const totalScore = score.effort + score.risk + score.reversibility + score.scaleCost + score.lockIn;
|
|
3746
|
+
const full = { ...score, id, totalScore, createdAt };
|
|
3747
|
+
const content = await this.readOptional("DECISION_SCORES.md");
|
|
3748
|
+
const header = "# Decision Scores\n\n";
|
|
3749
|
+
const entry = `## ${full.decisionId} | Cycle ${full.cycle}
|
|
3750
|
+
- **id:** ${full.id}
|
|
3751
|
+
- **effort:** ${full.effort}
|
|
3752
|
+
- **risk:** ${full.risk}
|
|
3753
|
+
- **reversibility:** ${full.reversibility}
|
|
3754
|
+
- **scaleCost:** ${full.scaleCost}
|
|
3755
|
+
- **lockIn:** ${full.lockIn}
|
|
3756
|
+
- **totalScore:** ${full.totalScore}
|
|
3757
|
+
` + (full.rationale ? `- **rationale:** ${full.rationale}
|
|
3758
|
+
` : "") + `- **createdAt:** ${full.createdAt}
|
|
3759
|
+
|
|
3760
|
+
---
|
|
3761
|
+
|
|
3762
|
+
`;
|
|
3763
|
+
if (!content) {
|
|
3764
|
+
await this.write("DECISION_SCORES.md", header + entry);
|
|
3765
|
+
} else {
|
|
3766
|
+
await this.write("DECISION_SCORES.md", content + entry);
|
|
3767
|
+
}
|
|
3768
|
+
return full;
|
|
3769
|
+
}
|
|
3770
|
+
async getDecisionScores(decisionId) {
|
|
3771
|
+
const all = await this.parseDecisionScores();
|
|
3772
|
+
return all.filter((s) => s.decisionId === decisionId);
|
|
3773
|
+
}
|
|
3774
|
+
async getLatestDecisionScores() {
|
|
3775
|
+
const all = await this.parseDecisionScores();
|
|
3776
|
+
const latest = /* @__PURE__ */ new Map();
|
|
3777
|
+
for (const s of all) {
|
|
3778
|
+
const existing = latest.get(s.decisionId);
|
|
3779
|
+
if (!existing || s.cycle > existing.cycle) {
|
|
3780
|
+
latest.set(s.decisionId, s);
|
|
3781
|
+
}
|
|
3782
|
+
}
|
|
3783
|
+
return Array.from(latest.values());
|
|
3784
|
+
}
|
|
3785
|
+
async parseDecisionScores() {
|
|
3786
|
+
const content = await this.readOptional("DECISION_SCORES.md");
|
|
3787
|
+
if (!content) return [];
|
|
3788
|
+
const scores = [];
|
|
3789
|
+
const blocks = content.split("---").filter((b) => b.trim());
|
|
3790
|
+
for (const block of blocks) {
|
|
3791
|
+
const headingMatch = block.match(/^##\s+(\S+)\s+\|\s+Cycle\s+(\d+)/m);
|
|
3792
|
+
if (!headingMatch) continue;
|
|
3793
|
+
const field = (name) => block.match(new RegExp(`\\*\\*${name}:\\*\\*\\s+(.+)`))?.[1]?.trim();
|
|
3794
|
+
const id = field("id");
|
|
3795
|
+
const createdAt = field("createdAt");
|
|
3796
|
+
if (!id || !createdAt) continue;
|
|
3797
|
+
scores.push({
|
|
3798
|
+
id,
|
|
3799
|
+
decisionId: headingMatch[1],
|
|
3800
|
+
cycle: parseInt(headingMatch[2], 10),
|
|
3801
|
+
effort: parseInt(field("effort") ?? "0", 10),
|
|
3802
|
+
risk: parseInt(field("risk") ?? "0", 10),
|
|
3803
|
+
reversibility: parseInt(field("reversibility") ?? "0", 10),
|
|
3804
|
+
scaleCost: parseInt(field("scaleCost") ?? "0", 10),
|
|
3805
|
+
lockIn: parseInt(field("lockIn") ?? "0", 10),
|
|
3806
|
+
totalScore: parseInt(field("totalScore") ?? "0", 10),
|
|
3807
|
+
rationale: field("rationale"),
|
|
3808
|
+
createdAt
|
|
3809
|
+
});
|
|
3810
|
+
}
|
|
3811
|
+
return scores;
|
|
3812
|
+
}
|
|
3813
|
+
// Entity reference tracking — no-op for md adapter (DB-only feature)
|
|
3814
|
+
async logEntityReferences(_refs) {
|
|
3815
|
+
}
|
|
3816
|
+
async getDecisionUsage(_currentCycle) {
|
|
3817
|
+
return [];
|
|
3818
|
+
}
|
|
3819
|
+
// --- North Star ---
|
|
3820
|
+
async getCurrentNorthStar() {
|
|
3821
|
+
const content = await this.read("PLANNING_LOG.md");
|
|
3822
|
+
const ns = parseNorthStar(content);
|
|
3823
|
+
return ns || null;
|
|
3824
|
+
}
|
|
3825
|
+
async getNorthStarSetCycle() {
|
|
3826
|
+
return null;
|
|
3827
|
+
}
|
|
3828
|
+
async getNorthStarStaleness() {
|
|
3829
|
+
return null;
|
|
3830
|
+
}
|
|
3831
|
+
async upsertNorthStar(statement, _cycleNumber) {
|
|
3832
|
+
const content = await this.read("PLANNING_LOG.md");
|
|
3833
|
+
const updated = upsertNorthStarInContent(content, statement);
|
|
3834
|
+
await this.write("PLANNING_LOG.md", updated);
|
|
3835
|
+
}
|
|
3836
|
+
};
|
|
3837
|
+
|
|
3838
|
+
// src/lib/path-identity.ts
|
|
3839
|
+
import fs from "fs";
|
|
3840
|
+
import path from "path";
|
|
3841
|
+
function realpathOrSelf(p) {
|
|
3842
|
+
try {
|
|
3843
|
+
return fs.realpathSync(p);
|
|
3844
|
+
} catch {
|
|
3845
|
+
return p;
|
|
3846
|
+
}
|
|
3847
|
+
}
|
|
3848
|
+
function checkProjectPathIdentity(opts) {
|
|
3849
|
+
const { storedPapiDir, projectName, cwd } = opts;
|
|
3850
|
+
const log = opts.log ?? ((msg) => console.error(msg));
|
|
3851
|
+
const allowMigrate = opts.allowMigrate ?? (process.env.PAPI_ALLOW_PATH_MIGRATE === "1" || process.env.PAPI_ALLOW_PATH_MIGRATE === "true");
|
|
3852
|
+
const realCwd = realpathOrSelf(cwd);
|
|
3853
|
+
const expectedPapiDir = path.join(realCwd, ".papi");
|
|
3854
|
+
if (!storedPapiDir || storedPapiDir.trim() === "" || storedPapiDir.trim() === ".") {
|
|
3855
|
+
log(`[papi] Backfilling project root for '${projectName}' from current cwd: ${realCwd}`);
|
|
3856
|
+
return { action: "backfill", newPapiDir: expectedPapiDir };
|
|
3857
|
+
}
|
|
3858
|
+
const trimmed = storedPapiDir.trim();
|
|
3859
|
+
const endsInPapi = trimmed.endsWith("/.papi") || trimmed.endsWith("\\.papi");
|
|
3860
|
+
const isWindowsPath = /^[A-Za-z]:[\\/]/.test(trimmed);
|
|
3861
|
+
const realStored = realpathOrSelf(trimmed);
|
|
3862
|
+
let storedProjectRoot;
|
|
3863
|
+
if (endsInPapi) {
|
|
3864
|
+
storedProjectRoot = isWindowsPath ? path.win32.dirname(realStored) : path.dirname(realStored);
|
|
3865
|
+
} else {
|
|
3866
|
+
storedProjectRoot = realStored;
|
|
3867
|
+
}
|
|
3868
|
+
if (storedProjectRoot === realCwd) {
|
|
3869
|
+
if (!endsInPapi) {
|
|
3870
|
+
log(`[papi] Normalising legacy papi_dir for '${projectName}': ${trimmed} \u2192 ${expectedPapiDir}`);
|
|
3871
|
+
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
3872
|
+
}
|
|
3873
|
+
return { action: "ok" };
|
|
3874
|
+
}
|
|
3875
|
+
if (allowMigrate) {
|
|
3876
|
+
log(
|
|
3877
|
+
`[papi] PAPI_ALLOW_PATH_MIGRATE set \u2014 updating project '${projectName}' root: ${storedProjectRoot} \u2192 ${realCwd}`
|
|
3878
|
+
);
|
|
3879
|
+
return { action: "migrate", newPapiDir: expectedPapiDir };
|
|
3880
|
+
}
|
|
3881
|
+
throw new Error(
|
|
3882
|
+
`PAPI is configured for project '${projectName}' which was set up in ${storedProjectRoot},
|
|
3883
|
+
but you're running in ${realCwd}.
|
|
3884
|
+
|
|
3885
|
+
To fix:
|
|
3886
|
+
- cd to the right project directory, OR
|
|
3887
|
+
- run \`setup\` to attach this directory to a project, OR
|
|
3888
|
+
- update PAPI_PROJECT_ID in .mcp.json if you intentionally moved the project, OR
|
|
3889
|
+
- set PAPI_ALLOW_PATH_MIGRATE=1 to update the stored path on next boot.`
|
|
3890
|
+
);
|
|
3891
|
+
}
|
|
3892
|
+
|
|
3893
|
+
// src/lib/project-resolution.ts
|
|
3894
|
+
function assertWorkspaceMatch(input) {
|
|
3895
|
+
if (!input.workspacePath) return null;
|
|
3896
|
+
return checkProjectPathIdentity({
|
|
3897
|
+
storedPapiDir: input.storedPapiDir,
|
|
3898
|
+
projectName: input.projectName,
|
|
3899
|
+
cwd: input.workspacePath,
|
|
3900
|
+
allowMigrate: input.allowMigrate,
|
|
3901
|
+
log: input.log
|
|
3902
|
+
});
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
// src/adapter-factory.ts
|
|
3906
|
+
function detectUserId() {
|
|
3907
|
+
try {
|
|
3908
|
+
const email = execSync("git config user.email", { encoding: "utf8", timeout: 5e3 }).trim();
|
|
3909
|
+
if (email) return email;
|
|
3910
|
+
} catch {
|
|
3911
|
+
}
|
|
3912
|
+
try {
|
|
3913
|
+
const ghUser = execSync("gh api user --jq .email", { encoding: "utf8", timeout: 1e4 }).trim();
|
|
3914
|
+
if (ghUser && ghUser !== "null") return ghUser;
|
|
3915
|
+
} catch {
|
|
3916
|
+
}
|
|
3917
|
+
return void 0;
|
|
3918
|
+
}
|
|
3919
|
+
var HOSTED_SUPABASE_URL = process.env["PAPI_HOSTED_SUPABASE_URL"] ?? "https://guewgygcpcmrcoppihzx.supabase.co";
|
|
3920
|
+
var HOSTED_PROXY_ENDPOINT = `${HOSTED_SUPABASE_URL}/functions/v1/data-proxy`;
|
|
3921
|
+
var PLACEHOLDER_PATTERNS = [
|
|
3922
|
+
"<YOUR_DATABASE_URL>",
|
|
3923
|
+
"your-database-url",
|
|
3924
|
+
"your_database_url",
|
|
3925
|
+
"placeholder",
|
|
3926
|
+
"example.com",
|
|
3927
|
+
"localhost:5432/dbname",
|
|
3928
|
+
"user:password@host"
|
|
3929
|
+
];
|
|
3930
|
+
function validateDatabaseUrl(connectionString) {
|
|
3931
|
+
const lower = connectionString.toLowerCase().trim();
|
|
3932
|
+
if (PLACEHOLDER_PATTERNS.some((p) => lower.includes(p.toLowerCase()))) {
|
|
3933
|
+
throw new Error(
|
|
3934
|
+
"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."
|
|
3935
|
+
);
|
|
3936
|
+
}
|
|
3937
|
+
if (!lower.startsWith("postgres://") && !lower.startsWith("postgresql://")) {
|
|
3938
|
+
throw new Error(
|
|
3939
|
+
`DATABASE_URL must be a PostgreSQL connection string (postgres:// or postgresql://).
|
|
3940
|
+
Got: "${connectionString.slice(0, 30)}..."
|
|
3941
|
+
Check your .mcp.json configuration.`
|
|
3942
|
+
);
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
var _connectionStatus = "offline";
|
|
3946
|
+
var _pathIdentityChecked = false;
|
|
3947
|
+
async function createAdapter(optionsOrType, maybePapiDir) {
|
|
3948
|
+
const options = typeof optionsOrType === "string" ? { adapterType: optionsOrType, papiDir: maybePapiDir } : optionsOrType;
|
|
3949
|
+
const { adapterType, papiDir, papiEndpoint } = options;
|
|
3950
|
+
switch (adapterType) {
|
|
3951
|
+
case "md":
|
|
3952
|
+
_connectionStatus = "offline";
|
|
3953
|
+
return new MdFileAdapter(papiDir);
|
|
3954
|
+
case "pg": {
|
|
3955
|
+
const { PgAdapter, PgPapiAdapter, configFromEnv } = await import("@papi-ai/adapter-pg");
|
|
3956
|
+
let projectId = process.env["PAPI_PROJECT_ID"];
|
|
3957
|
+
const projectRoot = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
|
|
3958
|
+
let rootHash = null;
|
|
3959
|
+
let originUrl = null;
|
|
3960
|
+
try {
|
|
3961
|
+
const { getRootCommitHash: getRootCommitHash2, getOriginUrl: getOriginUrl2 } = await Promise.resolve().then(() => (init_git(), git_exports));
|
|
3962
|
+
rootHash = getRootCommitHash2(projectRoot);
|
|
3963
|
+
originUrl = getOriginUrl2(projectRoot);
|
|
3964
|
+
} catch {
|
|
3965
|
+
}
|
|
3966
|
+
const resolveUserId = process.env["PAPI_USER_ID"] ?? detectUserId() ?? null;
|
|
3967
|
+
if (!projectId) {
|
|
3968
|
+
const method = options.resolutionMethod ?? "root_hash";
|
|
3969
|
+
if (method === "manual") {
|
|
3970
|
+
throw new Error(
|
|
3971
|
+
'PAPI_PROJECT_ID is required when resolution method is "manual". Set it to the UUID of your project in .mcp.json.'
|
|
3972
|
+
);
|
|
3973
|
+
}
|
|
3974
|
+
const config2 = papiEndpoint ? { connectionString: papiEndpoint } : configFromEnv();
|
|
3975
|
+
validateDatabaseUrl(config2.connectionString);
|
|
3976
|
+
const probeAdapter = new PgAdapter(config2);
|
|
3977
|
+
try {
|
|
3978
|
+
const { normalizeGitUrl: normalizeGitUrl2 } = await Promise.resolve().then(() => (init_git(), git_exports));
|
|
3979
|
+
if (method === "root_hash") {
|
|
3980
|
+
if (rootHash) {
|
|
3981
|
+
const match = await probeAdapter.findProjectByRootHash(rootHash, resolveUserId);
|
|
3982
|
+
if (match) {
|
|
3983
|
+
projectId = match.id;
|
|
3984
|
+
console.error(`[papi] \u2713 Resolved project via root commit hash: ${match.name ?? match.slug} (${match.id})`);
|
|
3985
|
+
} else {
|
|
3986
|
+
console.error(`[papi] \u26A0 No project matched root commit hash ${rootHash.slice(0, 8)}\u2026`);
|
|
3987
|
+
}
|
|
3988
|
+
} else {
|
|
3989
|
+
console.error("[papi] \u26A0 Not a git repo (or shallow clone) \u2014 cannot resolve by root hash.");
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
if (method === "repo_url") {
|
|
3993
|
+
const normalized = originUrl ? normalizeGitUrl2(originUrl) : null;
|
|
3994
|
+
if (normalized) {
|
|
3995
|
+
const match = await probeAdapter.findProjectByRepoUrl(normalized, resolveUserId);
|
|
3996
|
+
if (match) {
|
|
3997
|
+
projectId = match.id;
|
|
3998
|
+
console.error(`[papi] \u2713 Resolved project via repo URL: ${match.name ?? match.slug} (${match.id})`);
|
|
3999
|
+
} else {
|
|
4000
|
+
console.error(`[papi] \u26A0 No project matched repo URL ${normalized}`);
|
|
4001
|
+
}
|
|
4002
|
+
} else {
|
|
4003
|
+
console.error("[papi] \u26A0 No git remote found \u2014 cannot resolve by repo URL.");
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
} finally {
|
|
4007
|
+
try {
|
|
4008
|
+
await probeAdapter.close();
|
|
4009
|
+
} catch {
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
}
|
|
4013
|
+
if (!projectId) {
|
|
4014
|
+
throw new Error(
|
|
4015
|
+
"PAPI_PROJECT_ID is required when using PostgreSQL storage. Set it to the UUID of your project in the database, or switch resolution method to root_hash or repo_url."
|
|
4016
|
+
);
|
|
4017
|
+
}
|
|
4018
|
+
const config = papiEndpoint ? { connectionString: papiEndpoint } : configFromEnv();
|
|
4019
|
+
validateDatabaseUrl(config.connectionString);
|
|
4020
|
+
const { ensureSchema } = await import("@papi-ai/adapter-pg");
|
|
4021
|
+
try {
|
|
4022
|
+
await ensureSchema(config);
|
|
4023
|
+
} catch (err) {
|
|
4024
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4025
|
+
throw new Error(
|
|
4026
|
+
`PAPI schema creation failed: ${msg}
|
|
4027
|
+
This usually means DATABASE_URL is wrong, the database is unreachable, or the connection role lacks permission to create tables/extensions. Verify DATABASE_URL points to your Supabase pooler (port 5432, session mode) and uses the service role connection, then restart the MCP server.`
|
|
4028
|
+
);
|
|
4029
|
+
}
|
|
4030
|
+
const pgAdapter = new PgAdapter(config);
|
|
4031
|
+
try {
|
|
4032
|
+
const existing = await pgAdapter.getProject(projectId);
|
|
4033
|
+
if (!existing) {
|
|
4034
|
+
const slug = path2.basename(projectRoot) || "unnamed";
|
|
4035
|
+
let userId = process.env["PAPI_USER_ID"] ?? void 0;
|
|
4036
|
+
if (!userId) {
|
|
4037
|
+
userId = detectUserId();
|
|
4038
|
+
if (userId) {
|
|
4039
|
+
console.error(`[papi] Auto-detected user identity: ${userId}`);
|
|
4040
|
+
console.error("[papi] Set PAPI_USER_ID in .mcp.json to make this explicit.");
|
|
4041
|
+
} else {
|
|
4042
|
+
console.error("[papi] \u26A0 No PAPI_USER_ID set and auto-detection failed.");
|
|
4043
|
+
console.error("[papi] Project will have no user scope \u2014 it may be visible to all dashboard users.");
|
|
4044
|
+
console.error("[papi] Set PAPI_USER_ID in your .mcp.json env to fix this.");
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
let skipCreate = false;
|
|
4048
|
+
if (userId) {
|
|
4049
|
+
const bySlug = await pgAdapter.listProjects({ slug });
|
|
4050
|
+
const userDup = bySlug.find((p) => p.user_id === userId);
|
|
4051
|
+
if (userDup) {
|
|
4052
|
+
console.error(`[papi] \u26A0 Project '${slug}' already exists for this user (id: ${userDup.id}).`);
|
|
4053
|
+
console.error(`[papi] Update PAPI_PROJECT_ID=${userDup.id} in .mcp.json to avoid a duplicate.`);
|
|
4054
|
+
skipCreate = true;
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
if (!skipCreate) {
|
|
4058
|
+
await pgAdapter.createProject({
|
|
4059
|
+
id: projectId,
|
|
4060
|
+
slug,
|
|
4061
|
+
name: slug,
|
|
4062
|
+
papi_dir: papiDir,
|
|
4063
|
+
user_id: userId,
|
|
4064
|
+
root_commit_hash: rootHash ?? void 0,
|
|
4065
|
+
repo_url: originUrl ?? void 0,
|
|
4066
|
+
resolution_method: rootHash ? "root_hash" : "manual"
|
|
4067
|
+
});
|
|
4068
|
+
}
|
|
4069
|
+
} else {
|
|
4070
|
+
if (!existing.root_commit_hash && rootHash) {
|
|
4071
|
+
try {
|
|
4072
|
+
await pgAdapter.updateProject(projectId, {
|
|
4073
|
+
root_commit_hash: rootHash,
|
|
4074
|
+
repo_url: originUrl ?? void 0,
|
|
4075
|
+
resolution_method: "root_hash"
|
|
4076
|
+
});
|
|
4077
|
+
} catch (err) {
|
|
4078
|
+
console.error(
|
|
4079
|
+
`[papi] \u26A0 Failed to backfill root_commit_hash: ${err instanceof Error ? err.message : String(err)}`
|
|
4080
|
+
);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
if (!_pathIdentityChecked) {
|
|
4084
|
+
_pathIdentityChecked = true;
|
|
4085
|
+
const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
|
|
4086
|
+
const projectName = existing.name ?? existing.slug ?? projectId;
|
|
4087
|
+
const result = assertWorkspaceMatch({
|
|
4088
|
+
storedPapiDir: existing.papi_dir,
|
|
4089
|
+
projectName,
|
|
4090
|
+
workspacePath: cwd
|
|
4091
|
+
});
|
|
4092
|
+
if (result && (result.action === "backfill" || result.action === "migrate")) {
|
|
4093
|
+
try {
|
|
4094
|
+
await pgAdapter.updateProject(projectId, { papi_dir: result.newPapiDir });
|
|
4095
|
+
} catch (err) {
|
|
4096
|
+
console.error(
|
|
4097
|
+
`[papi] Failed to persist papi_dir update: ${err instanceof Error ? err.message : String(err)}`
|
|
4098
|
+
);
|
|
4099
|
+
}
|
|
4100
|
+
}
|
|
4101
|
+
}
|
|
4102
|
+
if (existing.user_id) {
|
|
4103
|
+
const configuredUserId = process.env["PAPI_USER_ID"] ?? detectUserId();
|
|
4104
|
+
if (configuredUserId && existing.user_id !== configuredUserId) {
|
|
4105
|
+
console.error(`[papi] \u26A0 PAPI_PROJECT_ID=${projectId} belongs to a different user.`);
|
|
4106
|
+
console.error("[papi] Run papi setup or update PAPI_PROJECT_ID in .mcp.json.");
|
|
4107
|
+
}
|
|
4108
|
+
}
|
|
4109
|
+
}
|
|
4110
|
+
} catch (err) {
|
|
4111
|
+
if (err instanceof Error && err.message.startsWith("PAPI is configured for project")) {
|
|
4112
|
+
throw err;
|
|
4113
|
+
}
|
|
4114
|
+
} finally {
|
|
4115
|
+
try {
|
|
4116
|
+
await pgAdapter.close();
|
|
4117
|
+
} catch {
|
|
4118
|
+
}
|
|
4119
|
+
}
|
|
4120
|
+
const adapter = new PgPapiAdapter(config, projectId);
|
|
4121
|
+
try {
|
|
4122
|
+
await adapter.initRls();
|
|
4123
|
+
} catch {
|
|
4124
|
+
}
|
|
4125
|
+
const connected = await adapter.probeConnection();
|
|
4126
|
+
if (connected) {
|
|
4127
|
+
_connectionStatus = "connected";
|
|
4128
|
+
console.error("[papi] \u2713 Supabase connected");
|
|
4129
|
+
} else {
|
|
4130
|
+
_connectionStatus = "degraded";
|
|
4131
|
+
console.error("[papi] \u2717 Supabase unreachable \u2014 running in degraded mode, data may be stale");
|
|
4132
|
+
console.error("[papi] Check your DATABASE_URL in .mcp.json \u2014 is the connection string correct?");
|
|
4133
|
+
}
|
|
4134
|
+
return adapter;
|
|
4135
|
+
}
|
|
4136
|
+
case "proxy": {
|
|
4137
|
+
const { ProxyPapiAdapter: ProxyPapiAdapter2 } = await Promise.resolve().then(() => (init_proxy_adapter(), proxy_adapter_exports));
|
|
4138
|
+
const dashboardUrl = process.env["PAPI_DASHBOARD_URL"] || "https://getpapi.ai";
|
|
4139
|
+
const projectId = process.env["PAPI_PROJECT_ID"];
|
|
4140
|
+
const dataApiKey = process.env["PAPI_DATA_API_KEY"];
|
|
4141
|
+
if (!dataApiKey) {
|
|
4142
|
+
throw new Error(
|
|
4143
|
+
`PAPI needs an account to store your project data.
|
|
4144
|
+
|
|
4145
|
+
Get started in 3 steps:
|
|
4146
|
+
1. Sign up at ${dashboardUrl}/login
|
|
4147
|
+
2. Complete the onboarding wizard \u2014 it generates your .mcp.json config
|
|
4148
|
+
3. Download the config, place it in your project root, and restart Claude Code
|
|
4149
|
+
|
|
4150
|
+
Already have an account? Make sure PAPI_DATA_API_KEY is set in your .mcp.json env config.`
|
|
4151
|
+
);
|
|
4152
|
+
}
|
|
4153
|
+
const dataEndpoint = process.env["PAPI_DATA_ENDPOINT"] || HOSTED_PROXY_ENDPOINT;
|
|
4154
|
+
const adapter = new ProxyPapiAdapter2({
|
|
4155
|
+
endpoint: dataEndpoint,
|
|
4156
|
+
apiKey: dataApiKey,
|
|
4157
|
+
projectId: projectId || void 0
|
|
4158
|
+
});
|
|
4159
|
+
const connected = await adapter.probeConnection();
|
|
4160
|
+
if (!connected) {
|
|
4161
|
+
_connectionStatus = "degraded";
|
|
4162
|
+
console.error("[papi] \u2717 Data proxy unreachable \u2014 running in degraded mode");
|
|
4163
|
+
console.error("[papi] Check your PAPI_DATA_ENDPOINT configuration.");
|
|
4164
|
+
} else if (projectId) {
|
|
4165
|
+
const auth = await adapter.probeAuth(projectId);
|
|
4166
|
+
if (!auth.ok) {
|
|
4167
|
+
if (auth.status === 401) {
|
|
4168
|
+
throw new Error(
|
|
4169
|
+
"PAPI_DATA_API_KEY is invalid \u2014 authentication failed.\nCheck the key in your .mcp.json config. You can generate a new key from the PAPI dashboard."
|
|
4170
|
+
);
|
|
4171
|
+
} else if (auth.status === 403 || auth.status === 404) {
|
|
4172
|
+
throw new Error(
|
|
4173
|
+
`PAPI_PROJECT_ID "${projectId}" was not found or you don't have access.
|
|
4174
|
+
Check PAPI_PROJECT_ID in your .mcp.json config. Find your project ID in the PAPI dashboard settings.`
|
|
4175
|
+
);
|
|
4176
|
+
} else if (auth.status !== 0) {
|
|
4177
|
+
_connectionStatus = "degraded";
|
|
4178
|
+
console.error(`[papi] \u26A0 Auth check returned ${auth.status} \u2014 running in degraded mode`);
|
|
4179
|
+
}
|
|
4180
|
+
} else {
|
|
4181
|
+
_connectionStatus = "connected";
|
|
4182
|
+
console.error("[papi] \u2713 Data proxy connected");
|
|
4183
|
+
if (!_pathIdentityChecked) {
|
|
4184
|
+
_pathIdentityChecked = true;
|
|
4185
|
+
try {
|
|
4186
|
+
const info = await adapter.getProjectInfo();
|
|
4187
|
+
if (info) {
|
|
4188
|
+
const cwd = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
|
|
4189
|
+
const projectName = info.name || info.slug || projectId;
|
|
4190
|
+
const result = assertWorkspaceMatch({
|
|
4191
|
+
storedPapiDir: info.papi_dir,
|
|
4192
|
+
projectName,
|
|
4193
|
+
workspacePath: cwd
|
|
4194
|
+
});
|
|
4195
|
+
if (result && (result.action === "backfill" || result.action === "migrate")) {
|
|
4196
|
+
try {
|
|
4197
|
+
await adapter.setProjectPapiDir(result.newPapiDir);
|
|
4198
|
+
} catch (err) {
|
|
4199
|
+
console.error(
|
|
4200
|
+
`[papi] Failed to persist papi_dir update via proxy: ${err instanceof Error ? err.message : String(err)}`
|
|
4201
|
+
);
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
} catch (err) {
|
|
4206
|
+
if (err instanceof Error && err.message.startsWith("PAPI is configured for project")) {
|
|
4207
|
+
throw err;
|
|
4208
|
+
}
|
|
4209
|
+
console.error(
|
|
4210
|
+
`[papi] \u26A0 Path-identity check skipped: ${err instanceof Error ? err.message : String(err)}`
|
|
4211
|
+
);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
}
|
|
4215
|
+
} else {
|
|
4216
|
+
_connectionStatus = "connected";
|
|
4217
|
+
console.error("[papi] \u2713 Data proxy reachable \u2014 project will be auto-provisioned");
|
|
4218
|
+
}
|
|
4219
|
+
if (!projectId && connected) {
|
|
4220
|
+
try {
|
|
4221
|
+
const { getOriginRepoSlug: getOriginRepoSlug2, getRootCommitHash: getRootCommitHash2, getOriginUrl: getOriginUrl2 } = await Promise.resolve().then(() => (init_git(), git_exports));
|
|
4222
|
+
const projectRoot = options.projectRoot ?? process.env["PAPI_PROJECT_DIR"] ?? process.cwd();
|
|
4223
|
+
const repoSlug = getOriginRepoSlug2(projectRoot);
|
|
4224
|
+
const projectName = repoSlug ? repoSlug.split("/").pop() || path2.basename(projectRoot) || "My Project" : path2.basename(projectRoot) || "My Project";
|
|
4225
|
+
const rootHash = getRootCommitHash2(projectRoot);
|
|
4226
|
+
const originUrl = getOriginUrl2(projectRoot);
|
|
4227
|
+
const result = await adapter.ensureProject(projectName, originUrl ?? void 0, void 0, rootHash ?? void 0);
|
|
4228
|
+
if (result.created) {
|
|
4229
|
+
console.error(`[papi] \u2713 Project "${result.projectName}" auto-provisioned (${result.projectId})`);
|
|
4230
|
+
console.error("[papi] Tip: add PAPI_PROJECT_ID to .mcp.json to skip this check on future starts.");
|
|
4231
|
+
} else {
|
|
4232
|
+
console.error(`[papi] \u2713 Using project "${result.projectName}" (${result.projectId})`);
|
|
4233
|
+
}
|
|
4234
|
+
} catch (err) {
|
|
4235
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
4236
|
+
console.error(`[papi] \u26A0 Auto-provision failed: ${msg}`);
|
|
4237
|
+
console.error("[papi] Set PAPI_PROJECT_ID in .mcp.json to connect to an existing project.");
|
|
4238
|
+
}
|
|
4239
|
+
}
|
|
4240
|
+
return adapter;
|
|
4241
|
+
}
|
|
4242
|
+
default: {
|
|
4243
|
+
const _exhaustive = adapterType;
|
|
4244
|
+
throw new Error(
|
|
4245
|
+
`Unknown PAPI_ADAPTER value: "${_exhaustive}". Valid options: "md", "pg", "proxy".`
|
|
4246
|
+
);
|
|
4247
|
+
}
|
|
4248
|
+
}
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
// src/lib/formatters.ts
|
|
4252
|
+
var EFFORT_MAP = { XS: 1, S: 2, M: 3, L: 5, XL: 8 };
|
|
4253
|
+
function computeSnapshotsFromBuildReports(reports) {
|
|
4254
|
+
if (reports.length === 0) return [];
|
|
4255
|
+
const byCycleMap = /* @__PURE__ */ new Map();
|
|
4256
|
+
for (const r of reports) {
|
|
4257
|
+
const existing = byCycleMap.get(r.cycle) ?? [];
|
|
4258
|
+
existing.push(r);
|
|
4259
|
+
byCycleMap.set(r.cycle, existing);
|
|
4260
|
+
}
|
|
4261
|
+
const snapshots = [];
|
|
4262
|
+
for (const [sn, cycleReports] of byCycleMap) {
|
|
4263
|
+
const completed = cycleReports.filter((r) => r.completed === "Yes").length;
|
|
4264
|
+
const total = cycleReports.length;
|
|
4265
|
+
const withEffort = cycleReports.filter((r) => r.estimatedEffort && r.actualEffort);
|
|
4266
|
+
const accurate = withEffort.filter((r) => r.estimatedEffort === r.actualEffort).length;
|
|
4267
|
+
const matchRate = withEffort.length > 0 ? Math.round(accurate / withEffort.length * 100) : 0;
|
|
4268
|
+
let effortPoints = 0;
|
|
4269
|
+
for (const r of cycleReports) {
|
|
4270
|
+
effortPoints += EFFORT_MAP[r.actualEffort] ?? 3;
|
|
4271
|
+
}
|
|
4272
|
+
snapshots.push({
|
|
4273
|
+
cycle: sn,
|
|
4274
|
+
date: (/* @__PURE__ */ new Date()).toISOString(),
|
|
4275
|
+
accuracy: [{ cycle: sn, reports: total, matchRate, mae: 0, bias: 0 }],
|
|
4276
|
+
velocity: [{ cycle: sn, completed, partial: 0, failed: total - completed, effortPoints }]
|
|
4277
|
+
});
|
|
4278
|
+
}
|
|
4279
|
+
snapshots.sort((a, b) => a.cycle - b.cycle);
|
|
4280
|
+
return snapshots;
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
// src/cli/backfill-cycle-metrics.ts
|
|
4284
|
+
async function runBackfillCycleMetrics(cliArgs) {
|
|
4285
|
+
const dryRun = cliArgs.includes("--dry");
|
|
4286
|
+
if (!process.env["DATABASE_URL"]) {
|
|
4287
|
+
console.error("backfill-cycle-metrics: DATABASE_URL is required (this backfill runs against the pg adapter).");
|
|
4288
|
+
return 1;
|
|
4289
|
+
}
|
|
4290
|
+
const adapter = await createAdapter({
|
|
4291
|
+
adapterType: "pg",
|
|
4292
|
+
papiDir: process.env["PAPI_PROJECT_DIR"] ?? process.cwd()
|
|
4293
|
+
});
|
|
4294
|
+
if (!adapter.getBuildReportsSince || !adapter.appendCycleMetrics) {
|
|
4295
|
+
console.error("backfill-cycle-metrics: the resolved adapter does not support build-report reads + metric writes.");
|
|
4296
|
+
return 1;
|
|
4297
|
+
}
|
|
4298
|
+
const reports = await adapter.getBuildReportsSince(0);
|
|
4299
|
+
const snapshots = computeSnapshotsFromBuildReports(reports);
|
|
4300
|
+
console.error(
|
|
4301
|
+
`backfill-cycle-metrics: ${reports.length} build_report(s) \u2192 ${snapshots.length} cycle snapshot(s)${dryRun ? " [dry-run \u2014 nothing written]" : ""}.`
|
|
4302
|
+
);
|
|
4303
|
+
if (dryRun) {
|
|
4304
|
+
for (const s of snapshots) {
|
|
4305
|
+
const v = s.velocity[0];
|
|
4306
|
+
const a = s.accuracy[0];
|
|
4307
|
+
console.error(
|
|
4308
|
+
` C${s.cycle}: ${v?.completed ?? 0}/${a?.reports ?? 0} done, ${v?.effortPoints ?? 0}pts, ${a?.matchRate ?? 0}% est-match`
|
|
4309
|
+
);
|
|
4310
|
+
}
|
|
4311
|
+
return 0;
|
|
4312
|
+
}
|
|
4313
|
+
let written = 0;
|
|
4314
|
+
for (const snapshot of snapshots) {
|
|
4315
|
+
await adapter.appendCycleMetrics(snapshot);
|
|
4316
|
+
written++;
|
|
4317
|
+
}
|
|
4318
|
+
console.error(`backfill-cycle-metrics: upserted ${written} cycle snapshot(s).`);
|
|
4319
|
+
return 0;
|
|
4320
|
+
}
|
|
4321
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
4322
|
+
void runBackfillCycleMetrics(process.argv.slice(2)).then((code) => process.exit(code)).catch((err) => {
|
|
4323
|
+
console.error("backfill-cycle-metrics failed:", err instanceof Error ? err.message : err);
|
|
4324
|
+
process.exit(1);
|
|
4325
|
+
});
|
|
4326
|
+
}
|
|
4327
|
+
export {
|
|
4328
|
+
runBackfillCycleMetrics
|
|
4329
|
+
};
|