@difflab/pi 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -9
- package/agents/diffpi-orchestrator.md +6 -4
- package/agents/diffpi-planner.md +3 -3
- package/agents/diffpi-reviewer.md +4 -4
- package/agents/diffpi-worker.md +1 -1
- package/dist/assets.d.ts +0 -1
- package/dist/assets.d.ts.map +1 -1
- package/dist/cli.js +417 -84
- package/dist/commands/plan.d.ts +2 -2
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/review.d.ts +2 -2
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/environment.d.ts +2 -0
- package/dist/environment.d.ts.map +1 -1
- package/dist/extensions/index.js +1778 -3799
- package/dist/extensions/tuicrx.d.ts +8 -1
- package/dist/extensions/tuicrx.d.ts.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +891 -292
- package/dist/plan/markdown.d.ts +4 -1
- package/dist/plan/markdown.d.ts.map +1 -1
- package/dist/{tools/plan-schema.d.ts → plan/schema.d.ts} +113 -75
- package/dist/plan/schema.d.ts.map +1 -0
- package/dist/plan/store.d.ts +39 -1
- package/dist/plan/store.d.ts.map +1 -1
- package/dist/plan/types.d.ts +27 -3
- package/dist/plan/types.d.ts.map +1 -1
- package/dist/review/github-review-backend.d.ts.map +1 -1
- package/dist/review/gitlab-review-backend.d.ts.map +1 -1
- package/dist/review/index.d.ts +6 -6
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/local-review-backend.d.ts +3 -0
- package/dist/review/local-review-backend.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +3 -2
- package/dist/review/review-backend.d.ts.map +1 -1
- package/dist/review/review-markdown.d.ts +4 -1
- package/dist/review/review-markdown.d.ts.map +1 -1
- package/dist/review/review-state.d.ts +20 -0
- package/dist/review/review-state.d.ts.map +1 -0
- package/dist/review/types.d.ts +28 -0
- package/dist/review/types.d.ts.map +1 -1
- package/dist/store.d.ts +2 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/tools/index.js +1632 -575
- package/dist/tools/plan.d.ts.map +1 -1
- package/dist/tools/review.d.ts +38 -0
- package/dist/tools/review.d.ts.map +1 -1
- package/package.json +2 -4
- package/skills/plan/SKILL.md +8 -14
- package/skills/plan/references/design-quality.md +41 -0
- package/skills/plan/references/workflows/annotate.md +11 -0
- package/skills/plan/references/workflows/finalize.md +7 -0
- package/skills/plan/references/workflows/go.md +8 -0
- package/skills/plan/references/workflows/help.md +10 -0
- package/skills/plan/references/workflows/init.md +5 -0
- package/skills/plan/references/workflows/new.md +7 -0
- package/skills/plan/references/workflows/update.md +7 -0
- package/skills/review/SKILL.md +7 -7
- package/skills/review/references/review-standards.md +8 -1
- package/skills/review/references/workflows/address.md +11 -0
- package/skills/review/references/workflows/auto.md +18 -0
- package/skills/review/references/workflows/complete.md +7 -0
- package/skills/review/references/workflows/edit.md +7 -0
- package/skills/review/references/workflows/help.md +36 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +19 -0
- package/skills/review/references/workflows/open.md +3 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/skills/review/references/workflows/status.md +3 -0
- package/templates/plan/PLAN.md +2 -2
- package/templates/plan/implementation.md +16 -10
- package/dist/commands.d.ts +0 -22
- package/dist/commands.d.ts.map +0 -1
- package/dist/extensions/cmdtsx.d.ts +0 -2
- package/dist/extensions/cmdtsx.d.ts.map +0 -1
- package/dist/tools/plan-schema.d.ts.map +0 -1
- package/dist/workflow.d.ts +0 -80
- package/dist/workflow.d.ts.map +0 -1
- package/workflows/plan/annotate.md +0 -13
- package/workflows/plan/finalize.md +0 -15
- package/workflows/plan/go.md +0 -20
- package/workflows/plan/help.md +0 -20
- package/workflows/plan/init.md +0 -13
- package/workflows/plan/new.md +0 -20
- package/workflows/plan/update.md +0 -18
- package/workflows/review/address.md +0 -8
- package/workflows/review/auto.md +0 -8
- package/workflows/review/complete.md +0 -7
- package/workflows/review/edit.md +0 -7
- package/workflows/review/help.md +0 -14
- package/workflows/review/merge.md +0 -6
- package/workflows/review/new.md +0 -7
- package/workflows/review/publish.md +0 -6
package/dist/index.js
CHANGED
|
@@ -605,6 +605,16 @@ function diffpiLaunchName(cwd, workflow) {
|
|
|
605
605
|
}
|
|
606
606
|
async function openFileAdjacent(path, opts) {
|
|
607
607
|
const env = opts.env ?? process.env;
|
|
608
|
+
const mux = detectMux(env);
|
|
609
|
+
if (mux !== "none") {
|
|
610
|
+
const editor = await resolveEditor(env);
|
|
611
|
+
if (editor) {
|
|
612
|
+
const command = persistentEditorArgs(editor, path, opts.cwd, env.SHELL);
|
|
613
|
+
const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", [editor, path].join(" "), command);
|
|
614
|
+
if (opened)
|
|
615
|
+
return opened;
|
|
616
|
+
}
|
|
617
|
+
}
|
|
608
618
|
const ide = detectIde(env);
|
|
609
619
|
if (ide !== "unknown") {
|
|
610
620
|
const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
|
|
@@ -614,16 +624,6 @@ async function openFileAdjacent(path, opts) {
|
|
|
614
624
|
return { launched: true, via: ide, command: `${ideCommand} ${path}` };
|
|
615
625
|
}
|
|
616
626
|
}
|
|
617
|
-
const mux = detectMux(env);
|
|
618
|
-
if (mux !== "none") {
|
|
619
|
-
const editor = await resolveEditor(env);
|
|
620
|
-
if (editor) {
|
|
621
|
-
const command = [editor, path];
|
|
622
|
-
const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
|
|
623
|
-
if (opened)
|
|
624
|
-
return opened;
|
|
625
|
-
}
|
|
626
|
-
}
|
|
627
627
|
return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
|
|
628
628
|
}
|
|
629
629
|
async function openInNewTab(command, opts) {
|
|
@@ -658,10 +658,15 @@ async function openInNewTab(command, opts) {
|
|
|
658
658
|
function screenWindowArgs(command, cwd, name) {
|
|
659
659
|
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
660
660
|
}
|
|
661
|
+
function persistentEditorArgs(editor, path, cwd, shell = "sh") {
|
|
662
|
+
return ["bash", "-lc", 'cd -- "$1" && shift && "$@"; exec "$0" -i', shell, cwd, editor, path];
|
|
663
|
+
}
|
|
661
664
|
async function resolveEditor(env) {
|
|
662
|
-
const configured
|
|
663
|
-
|
|
664
|
-
|
|
665
|
+
for (const configured of [env.EDITOR, env.VISUAL]) {
|
|
666
|
+
const editor = configured?.trim();
|
|
667
|
+
if (editor && await findExecutable(editor))
|
|
668
|
+
return editor;
|
|
669
|
+
}
|
|
665
670
|
for (const candidate of ["hx", "nvim", "vim"])
|
|
666
671
|
if (await findExecutable(candidate))
|
|
667
672
|
return candidate;
|
|
@@ -678,7 +683,7 @@ async function packageVersion() {
|
|
|
678
683
|
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
679
684
|
return value.version;
|
|
680
685
|
}
|
|
681
|
-
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
686
|
+
async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
|
|
682
687
|
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
683
688
|
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
684
689
|
if (result.code === 0)
|
|
@@ -688,7 +693,7 @@ async function openMuxTab(mux, command, cwd, name, printable) {
|
|
|
688
693
|
return { launched: true, via: "zellij-run", command: printable };
|
|
689
694
|
}
|
|
690
695
|
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
691
|
-
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
696
|
+
const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
692
697
|
if (result.code === 0)
|
|
693
698
|
return { launched: true, via: "tmux", command: printable };
|
|
694
699
|
}
|
|
@@ -1301,6 +1306,9 @@ function yymmdd(date = new Date) {
|
|
|
1301
1306
|
function reviewRecordName(target, date = new Date) {
|
|
1302
1307
|
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
1303
1308
|
}
|
|
1309
|
+
function isLocalResponse(body) {
|
|
1310
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
1311
|
+
}
|
|
1304
1312
|
function dedupeFindings(findings) {
|
|
1305
1313
|
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
1306
1314
|
const byKey = new Map;
|
|
@@ -1346,6 +1354,18 @@ function renderCommentBody(finding) {
|
|
|
1346
1354
|
return `${prefix}${finding.body}${reference}`;
|
|
1347
1355
|
}
|
|
1348
1356
|
// src/review/review-markdown.ts
|
|
1357
|
+
import { z as z4 } from "zod";
|
|
1358
|
+
var reviewThreadRecordSchema = z4.object({
|
|
1359
|
+
id: z4.string().min(1),
|
|
1360
|
+
file: z4.string().nullable().optional().transform((value) => value ?? undefined),
|
|
1361
|
+
line: z4.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
|
|
1362
|
+
body: z4.string(),
|
|
1363
|
+
author: z4.string().optional(),
|
|
1364
|
+
resolved: z4.boolean(),
|
|
1365
|
+
addressed: z4.boolean().optional(),
|
|
1366
|
+
question: z4.boolean(),
|
|
1367
|
+
replies: z4.array(z4.string()).optional()
|
|
1368
|
+
});
|
|
1349
1369
|
function renderReviewDoc(input) {
|
|
1350
1370
|
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
1351
1371
|
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
@@ -1387,6 +1407,88 @@ function renderReviewDoc(input) {
|
|
|
1387
1407
|
`).trimEnd()}
|
|
1388
1408
|
`;
|
|
1389
1409
|
}
|
|
1410
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
1411
|
+
const records = threads.map((thread) => ({
|
|
1412
|
+
id: thread.id,
|
|
1413
|
+
file: thread.file,
|
|
1414
|
+
line: thread.line,
|
|
1415
|
+
body: thread.body,
|
|
1416
|
+
author: thread.author,
|
|
1417
|
+
resolved: thread.resolved,
|
|
1418
|
+
addressed: thread.addressed,
|
|
1419
|
+
question: thread.question,
|
|
1420
|
+
replies: thread.replies
|
|
1421
|
+
}));
|
|
1422
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
1423
|
+
const lines = [
|
|
1424
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
1425
|
+
`# Review threads: ${title}`,
|
|
1426
|
+
"",
|
|
1427
|
+
"## Metadata",
|
|
1428
|
+
"",
|
|
1429
|
+
`- Target: ${target}`,
|
|
1430
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
1431
|
+
];
|
|
1432
|
+
if (options.number !== undefined)
|
|
1433
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
1434
|
+
lines.push("", "## Replies", "");
|
|
1435
|
+
for (const thread of threads) {
|
|
1436
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1437
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
1438
|
+
}
|
|
1439
|
+
lines.push("## Source comments", "");
|
|
1440
|
+
for (const thread of threads) {
|
|
1441
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", "**Source comment**", "", thread.body, "", "**Recorded response**", "", thread.reply?.trim() || "_No response recorded._", "", "---", "");
|
|
1442
|
+
}
|
|
1443
|
+
return `${lines.join(`
|
|
1444
|
+
`).trimEnd()}
|
|
1445
|
+
`;
|
|
1446
|
+
}
|
|
1447
|
+
function parseThreadArtifact(content) {
|
|
1448
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
1449
|
+
if (!payload)
|
|
1450
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
1451
|
+
let threads;
|
|
1452
|
+
try {
|
|
1453
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1454
|
+
threads = z4.array(reviewThreadRecordSchema).parse(decoded);
|
|
1455
|
+
} catch {
|
|
1456
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
1457
|
+
}
|
|
1458
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
1459
|
+
return threads.map((thread) => {
|
|
1460
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1461
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
1462
|
+
`;
|
|
1463
|
+
const endMarker = `
|
|
1464
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
1465
|
+
const start = replies.indexOf(startMarker);
|
|
1466
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
1467
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
1468
|
+
return reply ? { ...thread, reply } : thread;
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
1472
|
+
const threads = parseThreadArtifact(content);
|
|
1473
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
1474
|
+
if (!thread)
|
|
1475
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
1476
|
+
thread.reply = body;
|
|
1477
|
+
thread.addressed = true;
|
|
1478
|
+
if (question !== undefined)
|
|
1479
|
+
thread.question = question;
|
|
1480
|
+
if (resolved !== undefined)
|
|
1481
|
+
thread.resolved = resolved;
|
|
1482
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
1483
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
1484
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
1485
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
1486
|
+
return renderThreadArtifact(title, target, threads, {
|
|
1487
|
+
timestamp,
|
|
1488
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
1489
|
+
url: pr?.[2]
|
|
1490
|
+
});
|
|
1491
|
+
}
|
|
1390
1492
|
// src/review/github-review-backend.ts
|
|
1391
1493
|
function GitHubReviewBackend(vcs, number) {
|
|
1392
1494
|
const pendingReview = async () => {
|
|
@@ -1448,6 +1550,9 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
1448
1550
|
id: thread.id,
|
|
1449
1551
|
file: thread.path ?? undefined,
|
|
1450
1552
|
line: thread.line ?? undefined,
|
|
1553
|
+
rootCommentId: comment?.id,
|
|
1554
|
+
commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
|
|
1555
|
+
commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
|
|
1451
1556
|
body,
|
|
1452
1557
|
author: comment?.author?.login,
|
|
1453
1558
|
resolved: thread.isResolved,
|
|
@@ -1457,6 +1562,7 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
1457
1562
|
});
|
|
1458
1563
|
};
|
|
1459
1564
|
return {
|
|
1565
|
+
kind: "remote",
|
|
1460
1566
|
async stage(draft) {
|
|
1461
1567
|
const pending = await pendingReview();
|
|
1462
1568
|
if (!pending) {
|
|
@@ -1514,6 +1620,33 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
1514
1620
|
await ghChecked(args);
|
|
1515
1621
|
}
|
|
1516
1622
|
},
|
|
1623
|
+
async readDraft() {
|
|
1624
|
+
const pending = await pendingReview();
|
|
1625
|
+
if (!pending)
|
|
1626
|
+
return { comments: [], body: "" };
|
|
1627
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
1628
|
+
const comments = await ghChecked([
|
|
1629
|
+
"api",
|
|
1630
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
1631
|
+
]);
|
|
1632
|
+
let reviewData;
|
|
1633
|
+
let commentData;
|
|
1634
|
+
try {
|
|
1635
|
+
reviewData = JSON.parse(review.stdout);
|
|
1636
|
+
commentData = JSON.parse(comments.stdout);
|
|
1637
|
+
} catch {
|
|
1638
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
1639
|
+
}
|
|
1640
|
+
return {
|
|
1641
|
+
body: reviewData.body ?? "",
|
|
1642
|
+
comments: commentData.map((comment) => ({
|
|
1643
|
+
file: comment.path,
|
|
1644
|
+
line: comment.line ?? comment.original_line ?? undefined,
|
|
1645
|
+
side: comment.side ?? undefined,
|
|
1646
|
+
body: comment.body
|
|
1647
|
+
}))
|
|
1648
|
+
};
|
|
1649
|
+
},
|
|
1517
1650
|
listThreads,
|
|
1518
1651
|
async reply(input) {
|
|
1519
1652
|
const threads = await listThreads();
|
|
@@ -1530,14 +1663,32 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
1530
1663
|
`body=${input.body}`
|
|
1531
1664
|
]);
|
|
1532
1665
|
}
|
|
1533
|
-
if (input.resolve)
|
|
1666
|
+
if (input.resolve)
|
|
1667
|
+
await this.setResolved?.(input.threadId, true);
|
|
1668
|
+
},
|
|
1669
|
+
async setResolved(threadId, resolved) {
|
|
1670
|
+
await ghChecked([
|
|
1671
|
+
"api",
|
|
1672
|
+
"graphql",
|
|
1673
|
+
"-f",
|
|
1674
|
+
`query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
|
|
1675
|
+
"-f",
|
|
1676
|
+
`threadId=${threadId}`
|
|
1677
|
+
]);
|
|
1678
|
+
},
|
|
1679
|
+
async deleteThread(threadId) {
|
|
1680
|
+
const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
|
|
1681
|
+
const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
|
|
1682
|
+
if (!commentNodeIds.length)
|
|
1683
|
+
throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
|
|
1684
|
+
for (const commentId of commentNodeIds) {
|
|
1534
1685
|
await ghChecked([
|
|
1535
1686
|
"api",
|
|
1536
1687
|
"graphql",
|
|
1537
1688
|
"-f",
|
|
1538
|
-
`query=${
|
|
1689
|
+
`query=${GITHUB_DELETE_COMMENT_MUTATION}`,
|
|
1539
1690
|
"-f",
|
|
1540
|
-
`
|
|
1691
|
+
`commentId=${commentId}`
|
|
1541
1692
|
]);
|
|
1542
1693
|
}
|
|
1543
1694
|
},
|
|
@@ -1556,11 +1707,13 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
1556
1707
|
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
1557
1708
|
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
1558
1709
|
}
|
|
1559
|
-
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
1710
|
+
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{id,databaseId,body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
1560
1711
|
var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
|
|
1561
1712
|
var GITHUB_ADD_FILE_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,subjectType:FILE}){thread{id}}}`;
|
|
1562
1713
|
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
1563
1714
|
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
1715
|
+
var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
1716
|
+
var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
|
|
1564
1717
|
|
|
1565
1718
|
// src/review/gitlab-review-backend.ts
|
|
1566
1719
|
function GitLabReviewBackend(vcs, number) {
|
|
@@ -1597,6 +1750,7 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
1597
1750
|
});
|
|
1598
1751
|
};
|
|
1599
1752
|
return {
|
|
1753
|
+
kind: "remote",
|
|
1600
1754
|
async stage(draft) {
|
|
1601
1755
|
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
1602
1756
|
if (draft.body.trim()) {
|
|
@@ -1625,6 +1779,36 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
1625
1779
|
});
|
|
1626
1780
|
}
|
|
1627
1781
|
},
|
|
1782
|
+
async readDraft() {
|
|
1783
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1784
|
+
let notes;
|
|
1785
|
+
try {
|
|
1786
|
+
notes = JSON.parse(result.stdout);
|
|
1787
|
+
} catch {
|
|
1788
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
1789
|
+
}
|
|
1790
|
+
const comments = [];
|
|
1791
|
+
const body = [];
|
|
1792
|
+
for (const note of notes) {
|
|
1793
|
+
if (!note.note)
|
|
1794
|
+
continue;
|
|
1795
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
1796
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
1797
|
+
if (file && line) {
|
|
1798
|
+
comments.push({
|
|
1799
|
+
file,
|
|
1800
|
+
line,
|
|
1801
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
1802
|
+
body: note.note
|
|
1803
|
+
});
|
|
1804
|
+
} else {
|
|
1805
|
+
body.push(note.note);
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
return { comments, body: body.join(`
|
|
1809
|
+
|
|
1810
|
+
`) };
|
|
1811
|
+
},
|
|
1628
1812
|
listThreads,
|
|
1629
1813
|
async reply(input) {
|
|
1630
1814
|
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
@@ -1636,7 +1820,15 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
1636
1820
|
});
|
|
1637
1821
|
}
|
|
1638
1822
|
if (input.resolve)
|
|
1639
|
-
await
|
|
1823
|
+
await this.setResolved?.(input.threadId, true);
|
|
1824
|
+
},
|
|
1825
|
+
async setResolved(threadId, resolved) {
|
|
1826
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
1827
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
|
|
1828
|
+
},
|
|
1829
|
+
async deleteThread(threadId) {
|
|
1830
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
1831
|
+
await glabChecked(["api", "--method", "DELETE", endpoint]);
|
|
1640
1832
|
},
|
|
1641
1833
|
async publish(event) {
|
|
1642
1834
|
assertReviewEventSupported(vcs.provider, event);
|
|
@@ -1676,105 +1868,12 @@ function hasGitlabDraftNotes(input) {
|
|
|
1676
1868
|
}
|
|
1677
1869
|
}
|
|
1678
1870
|
|
|
1679
|
-
// src/review/review-backend.ts
|
|
1680
|
-
|
|
1681
|
-
if (vcs.provider === "github")
|
|
1682
|
-
return GitHubReviewBackend(vcs, number);
|
|
1683
|
-
if (vcs.provider === "gitlab")
|
|
1684
|
-
return GitLabReviewBackend(vcs, number);
|
|
1685
|
-
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1686
|
-
}
|
|
1687
|
-
// src/review/local-reviews.ts
|
|
1688
|
-
import { mkdir as mkdir4, readdir as readdir2, readFile as readFile6, unlink as unlink2, writeFile as writeFile3 } from "node:fs/promises";
|
|
1689
|
-
import { join as join9 } from "node:path";
|
|
1690
|
-
|
|
1691
|
-
// src/store.ts
|
|
1692
|
-
import { createHash } from "node:crypto";
|
|
1693
|
-
import { lstat, mkdir as mkdir3, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
|
|
1694
|
-
import { homedir as homedir4 } from "node:os";
|
|
1695
|
-
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path";
|
|
1696
|
-
var STORE_LINK = ".diffpi";
|
|
1697
|
-
var LEGACY_STORE_LINK = join8(".pi", "diffpi");
|
|
1698
|
-
async function computeProjectSlug(cwd) {
|
|
1699
|
-
return projectSlug(await inspectGitRepository(cwd));
|
|
1700
|
-
}
|
|
1701
|
-
function storeGlobalRoot(homeDir = homedir4()) {
|
|
1702
|
-
return join8(homeDir, ".difflab", "diffpi", "projects");
|
|
1703
|
-
}
|
|
1704
|
-
async function ensureStore(cwd, homeDir = homedir4()) {
|
|
1705
|
-
const repository = await inspectGitRepository(cwd);
|
|
1706
|
-
const root = repository.root;
|
|
1707
|
-
const slug = projectSlug(repository);
|
|
1708
|
-
const dest = join8(storeGlobalRoot(homeDir), slug);
|
|
1709
|
-
const link = join8(root, STORE_LINK);
|
|
1710
|
-
await mkdir3(dest, { recursive: true });
|
|
1711
|
-
try {
|
|
1712
|
-
await assertStoreLink(link, dest);
|
|
1713
|
-
} catch (error) {
|
|
1714
|
-
if (error.code !== "ENOENT")
|
|
1715
|
-
throw error;
|
|
1716
|
-
await symlink(dest, link);
|
|
1717
|
-
}
|
|
1718
|
-
await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
|
|
1719
|
-
return { slug, root, dest, link, linked: true };
|
|
1720
|
-
}
|
|
1721
|
-
async function storeDir(cwd, homeDir = homedir4()) {
|
|
1722
|
-
const store = await ensureStore(cwd, homeDir);
|
|
1723
|
-
return store.dest;
|
|
1724
|
-
}
|
|
1725
|
-
async function plansDir(cwd, homeDir = homedir4()) {
|
|
1726
|
-
const store = await ensureStore(cwd, homeDir);
|
|
1727
|
-
const dir = join8(store.link, "plan");
|
|
1728
|
-
await mkdir3(dir, { recursive: true });
|
|
1729
|
-
return dir;
|
|
1730
|
-
}
|
|
1731
|
-
async function reviewsDir(cwd, homeDir = homedir4()) {
|
|
1732
|
-
const store = await ensureStore(cwd, homeDir);
|
|
1733
|
-
const dir = join8(store.link, "review");
|
|
1734
|
-
await mkdir3(dir, { recursive: true });
|
|
1735
|
-
return dir;
|
|
1736
|
-
}
|
|
1737
|
-
async function assertStoreLink(path, dest) {
|
|
1738
|
-
const entry = await lstat(path);
|
|
1739
|
-
if (!entry.isSymbolicLink())
|
|
1740
|
-
throw new Error(`${path} exists and is not a symlink.`);
|
|
1741
|
-
const target = await symlinkTarget(path);
|
|
1742
|
-
if (target !== await canonicalPath2(dest))
|
|
1743
|
-
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1744
|
-
}
|
|
1745
|
-
async function removeLegacyStoreLink(path, dest) {
|
|
1746
|
-
try {
|
|
1747
|
-
const entry = await lstat(path);
|
|
1748
|
-
if (!entry.isSymbolicLink())
|
|
1749
|
-
return;
|
|
1750
|
-
const target = await symlinkTarget(path);
|
|
1751
|
-
if (target === await canonicalPath2(dest))
|
|
1752
|
-
await unlink(path);
|
|
1753
|
-
} catch (error) {
|
|
1754
|
-
if (error.code !== "ENOENT")
|
|
1755
|
-
throw error;
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1758
|
-
async function symlinkTarget(path) {
|
|
1759
|
-
const target = await readlink(path);
|
|
1760
|
-
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname4(path), target));
|
|
1761
|
-
}
|
|
1762
|
-
async function canonicalPath2(path) {
|
|
1763
|
-
try {
|
|
1764
|
-
return await realpath2(path);
|
|
1765
|
-
} catch {
|
|
1766
|
-
return resolve2(path);
|
|
1767
|
-
}
|
|
1768
|
-
}
|
|
1769
|
-
function projectSlug(repository) {
|
|
1770
|
-
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1771
|
-
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1772
|
-
return `${readable}-${digest}`;
|
|
1773
|
-
}
|
|
1871
|
+
// src/review/local-review-backend.ts
|
|
1872
|
+
import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
|
|
1774
1873
|
|
|
1775
1874
|
// src/extensions/tuicrx.ts
|
|
1776
|
-
import { readFile as readFile5, realpath as
|
|
1777
|
-
import { resolve as
|
|
1875
|
+
import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
|
|
1876
|
+
import { resolve as resolve2 } from "node:path";
|
|
1778
1877
|
async function tuicrAvailable() {
|
|
1779
1878
|
return Boolean(await findExecutable("tuicr"));
|
|
1780
1879
|
}
|
|
@@ -1850,7 +1949,7 @@ async function resolvePrSession(cwd, owner, repo, number) {
|
|
|
1850
1949
|
});
|
|
1851
1950
|
}
|
|
1852
1951
|
async function findMatchingSession(sessions, cwd, branch) {
|
|
1853
|
-
const repository = await
|
|
1952
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1854
1953
|
for (const session of sessions) {
|
|
1855
1954
|
if (session.kind !== "local")
|
|
1856
1955
|
continue;
|
|
@@ -1858,7 +1957,7 @@ async function findMatchingSession(sessions, cwd, branch) {
|
|
|
1858
1957
|
const data = await readSession(session.path);
|
|
1859
1958
|
if (data.branch_name !== branch || !data.repo_path)
|
|
1860
1959
|
continue;
|
|
1861
|
-
if (await
|
|
1960
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1862
1961
|
return session;
|
|
1863
1962
|
} catch {}
|
|
1864
1963
|
}
|
|
@@ -1872,33 +1971,53 @@ async function readSession(path) {
|
|
|
1872
1971
|
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1873
1972
|
}
|
|
1874
1973
|
}
|
|
1875
|
-
function
|
|
1876
|
-
const
|
|
1877
|
-
const
|
|
1974
|
+
function toFindings(session, options = {}) {
|
|
1975
|
+
const comments = [];
|
|
1976
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
1977
|
+
const publishableBody = (comment) => {
|
|
1878
1978
|
const author = commentAuthor(comment);
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
...location,
|
|
1882
|
-
body: comment.content,
|
|
1883
|
-
...author ? { author } : {},
|
|
1884
|
-
resolved: false,
|
|
1885
|
-
question: /\?\s*$/.test(comment.content.trim())
|
|
1886
|
-
});
|
|
1979
|
+
const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
1980
|
+
return route ? withRemoteProvenance(comment.content, route) : comment.content;
|
|
1887
1981
|
};
|
|
1888
|
-
|
|
1889
|
-
add(comment);
|
|
1982
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
|
|
1890
1983
|
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1891
|
-
for (const
|
|
1892
|
-
|
|
1984
|
+
for (const fileComment of entry.file_comments ?? []) {
|
|
1985
|
+
if (!include(fileComment))
|
|
1986
|
+
continue;
|
|
1987
|
+
const comment = {
|
|
1988
|
+
file,
|
|
1989
|
+
body: fileComment.content,
|
|
1990
|
+
...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
|
|
1991
|
+
};
|
|
1992
|
+
const author = commentAuthor(fileComment);
|
|
1993
|
+
if (author)
|
|
1994
|
+
comment.author = author;
|
|
1995
|
+
comments.push(comment);
|
|
1996
|
+
}
|
|
1893
1997
|
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1894
1998
|
const line = Number.parseInt(lineKey, 10);
|
|
1895
1999
|
if (!Number.isFinite(line))
|
|
1896
2000
|
continue;
|
|
1897
|
-
for (const
|
|
1898
|
-
|
|
2001
|
+
for (const lineComment of lineComments) {
|
|
2002
|
+
if (!include(lineComment))
|
|
2003
|
+
continue;
|
|
2004
|
+
const comment = {
|
|
2005
|
+
file,
|
|
2006
|
+
line,
|
|
2007
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
2008
|
+
body: lineComment.content,
|
|
2009
|
+
...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
|
|
2010
|
+
};
|
|
2011
|
+
const author = commentAuthor(lineComment);
|
|
2012
|
+
if (author)
|
|
2013
|
+
comment.author = author;
|
|
2014
|
+
comments.push(comment);
|
|
2015
|
+
}
|
|
1899
2016
|
}
|
|
1900
2017
|
}
|
|
1901
|
-
return
|
|
2018
|
+
return { comments, body: bodyParts.join(`
|
|
2019
|
+
|
|
2020
|
+
`) };
|
|
1902
2021
|
}
|
|
1903
2022
|
async function requireTuicr() {
|
|
1904
2023
|
if (!await tuicrAvailable())
|
|
@@ -1907,70 +2026,207 @@ async function requireTuicr() {
|
|
|
1907
2026
|
function commentAuthor(comment) {
|
|
1908
2027
|
return comment.username ?? comment.author;
|
|
1909
2028
|
}
|
|
1910
|
-
async function
|
|
2029
|
+
async function canonicalPath2(path) {
|
|
1911
2030
|
try {
|
|
1912
|
-
return await
|
|
2031
|
+
return await realpath2(path);
|
|
1913
2032
|
} catch {
|
|
1914
|
-
return
|
|
2033
|
+
return resolve2(path);
|
|
1915
2034
|
}
|
|
1916
2035
|
}
|
|
1917
2036
|
|
|
1918
|
-
// src/review/local-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2037
|
+
// src/review/local-review-backend.ts
|
|
2038
|
+
function LocalReviewBackend(options) {
|
|
2039
|
+
return {
|
|
2040
|
+
kind: "local",
|
|
2041
|
+
async stage(draft) {
|
|
2042
|
+
for (const comment of draft.comments) {
|
|
2043
|
+
await addComment(options.session, comment.body, {
|
|
2044
|
+
targetFile: comment.file,
|
|
2045
|
+
line: comment.line,
|
|
2046
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
2047
|
+
username: options.author
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
if (draft.body.trim())
|
|
2051
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
2052
|
+
},
|
|
2053
|
+
async readDraft() {
|
|
2054
|
+
return toFindings(await readSession(options.session), { excludeLocalResponses: true });
|
|
2055
|
+
},
|
|
2056
|
+
async listThreads() {
|
|
2057
|
+
try {
|
|
2058
|
+
return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
|
|
2059
|
+
} catch (error) {
|
|
2060
|
+
if (error.code === "ENOENT")
|
|
2061
|
+
return [];
|
|
2062
|
+
throw error;
|
|
2063
|
+
}
|
|
2064
|
+
},
|
|
2065
|
+
async reply(input) {
|
|
2066
|
+
const content = await readFile6(options.artifactPath, "utf8");
|
|
2067
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
2068
|
+
},
|
|
2069
|
+
async publish() {
|
|
2070
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
2071
|
+
}
|
|
1936
2072
|
};
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
// src/review/review-backend.ts
|
|
2076
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
2077
|
+
if (vcs.provider === "github")
|
|
2078
|
+
return GitHubReviewBackend(vcs, number);
|
|
2079
|
+
if (vcs.provider === "gitlab")
|
|
2080
|
+
return GitLabReviewBackend(vcs, number);
|
|
2081
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
2082
|
+
}
|
|
2083
|
+
function createLocalReviewBackend(options) {
|
|
2084
|
+
return LocalReviewBackend(options);
|
|
2085
|
+
}
|
|
2086
|
+
// src/review/review-state.ts
|
|
2087
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2088
|
+
import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
2089
|
+
import { join as join9 } from "node:path";
|
|
2090
|
+
import { z as z5 } from "zod";
|
|
2091
|
+
|
|
2092
|
+
// src/store.ts
|
|
2093
|
+
import { createHash } from "node:crypto";
|
|
2094
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
2095
|
+
import { homedir as homedir4 } from "node:os";
|
|
2096
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
|
|
2097
|
+
var STORE_LINK = ".diffpi";
|
|
2098
|
+
var LEGACY_STORE_LINK = join8(".pi", "diffpi");
|
|
2099
|
+
async function computeProjectSlug(cwd) {
|
|
2100
|
+
return projectSlug(await inspectGitRepository(cwd));
|
|
2101
|
+
}
|
|
2102
|
+
function storeGlobalRoot(homeDir = homedir4()) {
|
|
2103
|
+
return join8(homeDir, ".difflab", "diffpi", "projects");
|
|
2104
|
+
}
|
|
2105
|
+
async function ensureStore(cwd, homeDir = homedir4()) {
|
|
2106
|
+
const repository = await inspectGitRepository(cwd);
|
|
2107
|
+
const root = repository.root;
|
|
2108
|
+
const slug = projectSlug(repository);
|
|
2109
|
+
const dest = join8(storeGlobalRoot(homeDir), slug);
|
|
2110
|
+
const link = join8(root, STORE_LINK);
|
|
2111
|
+
await mkdir3(dest, { recursive: true });
|
|
1949
2112
|
try {
|
|
1950
|
-
|
|
2113
|
+
await assertStoreLink(link, dest);
|
|
1951
2114
|
} catch (error) {
|
|
1952
|
-
if (error.code
|
|
2115
|
+
if (error.code !== "ENOENT")
|
|
2116
|
+
throw error;
|
|
2117
|
+
await symlink(dest, link);
|
|
2118
|
+
}
|
|
2119
|
+
await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
|
|
2120
|
+
return { slug, root, dest, link, linked: true };
|
|
2121
|
+
}
|
|
2122
|
+
async function storeDir(cwd, homeDir = homedir4()) {
|
|
2123
|
+
const store = await ensureStore(cwd, homeDir);
|
|
2124
|
+
return store.dest;
|
|
2125
|
+
}
|
|
2126
|
+
async function plansDir(cwd, homeDir = homedir4()) {
|
|
2127
|
+
const store = await ensureStore(cwd, homeDir);
|
|
2128
|
+
const dir = join8(store.link, "plan");
|
|
2129
|
+
await mkdir3(dir, { recursive: true });
|
|
2130
|
+
return dir;
|
|
2131
|
+
}
|
|
2132
|
+
async function reviewsDir(cwd, homeDir = homedir4()) {
|
|
2133
|
+
const store = await ensureStore(cwd, homeDir);
|
|
2134
|
+
const dir = join8(store.link, "review");
|
|
2135
|
+
await mkdir3(dir, { recursive: true });
|
|
2136
|
+
return dir;
|
|
2137
|
+
}
|
|
2138
|
+
async function sessionsDir(cwd, homeDir = homedir4()) {
|
|
2139
|
+
const store = await ensureStore(cwd, homeDir);
|
|
2140
|
+
const dir = join8(store.link, "sessions");
|
|
2141
|
+
await mkdir3(dir, { recursive: true });
|
|
2142
|
+
return dir;
|
|
2143
|
+
}
|
|
2144
|
+
async function assertStoreLink(path, dest) {
|
|
2145
|
+
const entry = await lstat(path);
|
|
2146
|
+
if (!entry.isSymbolicLink())
|
|
2147
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
2148
|
+
const target = await symlinkTarget(path);
|
|
2149
|
+
if (target !== await canonicalPath3(dest))
|
|
2150
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
2151
|
+
}
|
|
2152
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
2153
|
+
try {
|
|
2154
|
+
const entry = await lstat(path);
|
|
2155
|
+
if (!entry.isSymbolicLink())
|
|
1953
2156
|
return;
|
|
1954
|
-
|
|
2157
|
+
const target = await symlinkTarget(path);
|
|
2158
|
+
if (target === await canonicalPath3(dest))
|
|
2159
|
+
await unlink(path);
|
|
2160
|
+
} catch (error) {
|
|
2161
|
+
if (error.code !== "ENOENT")
|
|
2162
|
+
throw error;
|
|
1955
2163
|
}
|
|
1956
2164
|
}
|
|
1957
|
-
async function
|
|
1958
|
-
|
|
2165
|
+
async function symlinkTarget(path) {
|
|
2166
|
+
const target = await readlink(path);
|
|
2167
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
|
|
1959
2168
|
}
|
|
1960
|
-
async function
|
|
2169
|
+
async function canonicalPath3(path) {
|
|
1961
2170
|
try {
|
|
1962
|
-
return
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
2171
|
+
return await realpath3(path);
|
|
2172
|
+
} catch {
|
|
2173
|
+
return resolve3(path);
|
|
2174
|
+
}
|
|
2175
|
+
}
|
|
2176
|
+
function projectSlug(repository) {
|
|
2177
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
2178
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
2179
|
+
return `${readable}-${digest}`;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
// src/review/review-state.ts
|
|
2183
|
+
var reviewPublicationStateSchema = z5.object({
|
|
2184
|
+
target: z5.string().optional(),
|
|
2185
|
+
bodies: z5.array(z5.string()).default([]),
|
|
2186
|
+
comments: z5.array(z5.string()).default([]),
|
|
2187
|
+
stagedComments: z5.array(z5.string()).default([]),
|
|
2188
|
+
replies: z5.array(z5.string()).default([]),
|
|
2189
|
+
overlayPath: z5.string().optional()
|
|
2190
|
+
});
|
|
2191
|
+
function reviewBodyFingerprint(body) {
|
|
2192
|
+
return digest(body);
|
|
2193
|
+
}
|
|
2194
|
+
function reviewCommentFingerprint(comment) {
|
|
2195
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
2196
|
+
}
|
|
2197
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
2198
|
+
return digest(`${threadId}\x00${body}`);
|
|
2199
|
+
}
|
|
2200
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
2201
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
2202
|
+
}
|
|
2203
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
2204
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
2205
|
+
const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
2206
|
+
try {
|
|
2207
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
|
|
2208
|
+
return { path, state: { ...parsed, target } };
|
|
1966
2209
|
} catch (error) {
|
|
1967
|
-
if (error.code === "ENOENT")
|
|
1968
|
-
return
|
|
2210
|
+
if (error.code === "ENOENT") {
|
|
2211
|
+
return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
|
|
2212
|
+
}
|
|
2213
|
+
if (error instanceof SyntaxError || error instanceof z5.ZodError) {
|
|
2214
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
2215
|
+
}
|
|
1969
2216
|
throw error;
|
|
1970
2217
|
}
|
|
1971
2218
|
}
|
|
2219
|
+
async function saveReviewPublicationState(path, state) {
|
|
2220
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
2221
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
2222
|
+
`, "utf8");
|
|
2223
|
+
await rename(temp, path);
|
|
2224
|
+
}
|
|
2225
|
+
function digest(value) {
|
|
2226
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
2227
|
+
}
|
|
1972
2228
|
// src/mcp.ts
|
|
1973
|
-
import { mkdir as
|
|
2229
|
+
import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
|
|
1974
2230
|
import { homedir as homedir5 } from "node:os";
|
|
1975
2231
|
import { dirname as dirname5, join as join10 } from "node:path";
|
|
1976
2232
|
var mcp = {
|
|
@@ -1988,8 +2244,8 @@ var mcp = {
|
|
|
1988
2244
|
const next = { ...current, mcpServers: nextServers };
|
|
1989
2245
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1990
2246
|
if (changed && !options.dryRun) {
|
|
1991
|
-
await
|
|
1992
|
-
await
|
|
2247
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
2248
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
1993
2249
|
`, "utf8");
|
|
1994
2250
|
}
|
|
1995
2251
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -2018,7 +2274,7 @@ function getParsedConfig(content, path) {
|
|
|
2018
2274
|
}
|
|
2019
2275
|
async function getOptionalFile2(path) {
|
|
2020
2276
|
try {
|
|
2021
|
-
return await
|
|
2277
|
+
return await readFile8(path, "utf8");
|
|
2022
2278
|
} catch (error) {
|
|
2023
2279
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
2024
2280
|
return;
|
|
@@ -2033,7 +2289,7 @@ import {
|
|
|
2033
2289
|
getAgentDir,
|
|
2034
2290
|
parseFrontmatter as parseFrontmatter2
|
|
2035
2291
|
} from "@earendil-works/pi-coding-agent";
|
|
2036
|
-
import { readFile as
|
|
2292
|
+
import { readFile as readFile9 } from "node:fs/promises";
|
|
2037
2293
|
import { homedir as homedir6 } from "node:os";
|
|
2038
2294
|
import { basename as basename4, extname, join as join11 } from "node:path";
|
|
2039
2295
|
async function discoverAgentModes(options) {
|
|
@@ -2232,7 +2488,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
2232
2488
|
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2233
2489
|
const path = join11(directory, entry.name);
|
|
2234
2490
|
try {
|
|
2235
|
-
const content = await
|
|
2491
|
+
const content = await readFile9(path, "utf8");
|
|
2236
2492
|
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
2237
2493
|
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
2238
2494
|
continue;
|
|
@@ -2288,7 +2544,7 @@ function isModeBaseline(value) {
|
|
|
2288
2544
|
return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
|
|
2289
2545
|
}
|
|
2290
2546
|
// src/pi.ts
|
|
2291
|
-
import { mkdir as
|
|
2547
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2292
2548
|
import { homedir as homedir7 } from "node:os";
|
|
2293
2549
|
import { dirname as dirname6, join as join12 } from "node:path";
|
|
2294
2550
|
var pi = {
|
|
@@ -2307,8 +2563,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
|
|
|
2307
2563
|
const currentText = await readTextIfExists(path);
|
|
2308
2564
|
const changed = currentText !== content;
|
|
2309
2565
|
if (changed && !dryRun) {
|
|
2310
|
-
await
|
|
2311
|
-
await
|
|
2566
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2567
|
+
await writeFile6(path, content, "utf8");
|
|
2312
2568
|
}
|
|
2313
2569
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
2314
2570
|
}
|
|
@@ -2344,8 +2600,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
2344
2600
|
const next = update(current);
|
|
2345
2601
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
2346
2602
|
if (changed && !dryRun) {
|
|
2347
|
-
await
|
|
2348
|
-
await
|
|
2603
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2604
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
2349
2605
|
`, "utf8");
|
|
2350
2606
|
}
|
|
2351
2607
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -2378,16 +2634,17 @@ function parseJsonObject(content, path) {
|
|
|
2378
2634
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
2379
2635
|
}
|
|
2380
2636
|
// src/plan/store.ts
|
|
2381
|
-
import {
|
|
2637
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
2638
|
+
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile12, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
|
|
2382
2639
|
import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
|
|
2383
2640
|
|
|
2384
2641
|
// src/extensions/fsx.ts
|
|
2385
|
-
import { mkdir as
|
|
2642
|
+
import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
|
|
2386
2643
|
import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
|
|
2387
2644
|
async function atomicWrite(path, content) {
|
|
2388
2645
|
const temp = join13(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
|
|
2389
|
-
await
|
|
2390
|
-
await
|
|
2646
|
+
await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
2647
|
+
await rename2(temp, path);
|
|
2391
2648
|
}
|
|
2392
2649
|
async function withDirectoryLock(lockDir, operation, options = {}) {
|
|
2393
2650
|
const waitMs = options.waitMs ?? 5000;
|
|
@@ -2395,7 +2652,7 @@ async function withDirectoryLock(lockDir, operation, options = {}) {
|
|
|
2395
2652
|
const started = Date.now();
|
|
2396
2653
|
while (true) {
|
|
2397
2654
|
try {
|
|
2398
|
-
await
|
|
2655
|
+
await mkdir6(lockDir);
|
|
2399
2656
|
break;
|
|
2400
2657
|
} catch (error) {
|
|
2401
2658
|
if (error.code !== "EEXIST")
|
|
@@ -2417,7 +2674,7 @@ function sleep(ms) {
|
|
|
2417
2674
|
|
|
2418
2675
|
// src/log.ts
|
|
2419
2676
|
import { randomUUID } from "node:crypto";
|
|
2420
|
-
import { appendFile, readFile as
|
|
2677
|
+
import { appendFile, readFile as readFile10 } from "node:fs/promises";
|
|
2421
2678
|
async function appendLogEntry(logPath, event) {
|
|
2422
2679
|
const entry = {
|
|
2423
2680
|
...event,
|
|
@@ -2431,7 +2688,7 @@ async function appendLogEntry(logPath, event) {
|
|
|
2431
2688
|
}
|
|
2432
2689
|
|
|
2433
2690
|
// src/templates.ts
|
|
2434
|
-
import { readFile as
|
|
2691
|
+
import { readFile as readFile11 } from "node:fs/promises";
|
|
2435
2692
|
import { homedir as homedir8 } from "node:os";
|
|
2436
2693
|
import { join as join14, normalize } from "node:path";
|
|
2437
2694
|
async function loadTemplate(name, options = {}) {
|
|
@@ -2458,7 +2715,7 @@ function templateRelativePath(name) {
|
|
|
2458
2715
|
}
|
|
2459
2716
|
async function readOptionalFile(path) {
|
|
2460
2717
|
try {
|
|
2461
|
-
return await
|
|
2718
|
+
return await readFile11(path, "utf8");
|
|
2462
2719
|
} catch (error) {
|
|
2463
2720
|
if (error.code === "ENOENT")
|
|
2464
2721
|
return;
|
|
@@ -7135,11 +7392,13 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7135
7392
|
if (!taskIds.has(dependency))
|
|
7136
7393
|
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
7137
7394
|
}
|
|
7138
|
-
if (options.strict && task.
|
|
7139
|
-
add("
|
|
7395
|
+
if (options.strict && isPlaceholder(task.title))
|
|
7396
|
+
add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
|
|
7140
7397
|
}
|
|
7141
7398
|
if (options.strict && phase.tasks.length === 0)
|
|
7142
7399
|
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
7400
|
+
if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
|
|
7401
|
+
add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
|
|
7143
7402
|
}
|
|
7144
7403
|
for (const cycle of dependencyCycles(plan))
|
|
7145
7404
|
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
@@ -7148,10 +7407,131 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7148
7407
|
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
7149
7408
|
if (options.strict && designWords > 800)
|
|
7150
7409
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
7151
|
-
if (options.strict
|
|
7152
|
-
|
|
7410
|
+
if (options.strict) {
|
|
7411
|
+
if (plan.phases.length === 0)
|
|
7412
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
7413
|
+
if (isPlaceholder(plan.intent))
|
|
7414
|
+
add("placeholder", "error", "Intent must contain concrete content.", "intent");
|
|
7415
|
+
if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
|
|
7416
|
+
add("requirements", "error", "Requirements must contain concrete content.", "requirements");
|
|
7417
|
+
for (const [key, value] of Object.entries(plan.design)) {
|
|
7418
|
+
if (isPlaceholder(value))
|
|
7419
|
+
add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
|
|
7420
|
+
}
|
|
7421
|
+
}
|
|
7422
|
+
return issues;
|
|
7423
|
+
}
|
|
7424
|
+
function validatePlanBriefs(plan, briefs) {
|
|
7425
|
+
const issues = [];
|
|
7426
|
+
const phaseIds = plan.phases.map((phase) => phase.id);
|
|
7427
|
+
const briefIds = briefs.map((brief) => brief.phaseId);
|
|
7428
|
+
if (!sameIds(phaseIds, briefIds))
|
|
7429
|
+
issues.push({
|
|
7430
|
+
code: "brief-phase-ids",
|
|
7431
|
+
severity: "error",
|
|
7432
|
+
message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
|
|
7433
|
+
});
|
|
7434
|
+
for (const [index, phase] of plan.phases.entries()) {
|
|
7435
|
+
const brief = briefs[index];
|
|
7436
|
+
if (!brief || brief.phaseId !== phase.id)
|
|
7437
|
+
continue;
|
|
7438
|
+
if (isPlaceholder(brief.summary))
|
|
7439
|
+
issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
|
|
7440
|
+
const taskIds = phase.tasks.map((task) => task.id);
|
|
7441
|
+
const briefTaskIds = brief.tasks.map((task) => task.taskId);
|
|
7442
|
+
if (!sameIds(taskIds, briefTaskIds))
|
|
7443
|
+
issues.push({
|
|
7444
|
+
code: "brief-task-ids",
|
|
7445
|
+
severity: "error",
|
|
7446
|
+
message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
|
|
7447
|
+
});
|
|
7448
|
+
for (const task of brief.tasks) {
|
|
7449
|
+
for (const [field, values] of [
|
|
7450
|
+
["ordered steps", task.steps],
|
|
7451
|
+
["file scopes", task.fileScopes],
|
|
7452
|
+
["acceptance criteria", task.acceptanceCriteria]
|
|
7453
|
+
]) {
|
|
7454
|
+
if (!values.length || values.some(isPlaceholder))
|
|
7455
|
+
issues.push({
|
|
7456
|
+
code: "brief-incomplete",
|
|
7457
|
+
severity: "error",
|
|
7458
|
+
message: `Brief task ${task.taskId} needs concrete ${field}.`,
|
|
7459
|
+
path: task.taskId
|
|
7460
|
+
});
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7463
|
+
for (const [field, values] of [
|
|
7464
|
+
["API changes", brief.apiChanges],
|
|
7465
|
+
["libraries", brief.libraries],
|
|
7466
|
+
["constraints", brief.constraints]
|
|
7467
|
+
]) {
|
|
7468
|
+
if (values.some(isPlaceholder))
|
|
7469
|
+
issues.push({
|
|
7470
|
+
code: "brief-placeholder",
|
|
7471
|
+
severity: "error",
|
|
7472
|
+
message: `Brief ${phase.id} has placeholder ${field}.`,
|
|
7473
|
+
path: phase.id
|
|
7474
|
+
});
|
|
7475
|
+
}
|
|
7476
|
+
}
|
|
7153
7477
|
return issues;
|
|
7154
7478
|
}
|
|
7479
|
+
function renderImplementationBrief(planRevision, ordinal, phase, brief) {
|
|
7480
|
+
const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
|
|
7481
|
+
const tasks = phase.tasks.map((task, index) => {
|
|
7482
|
+
const details = brief.tasks[index];
|
|
7483
|
+
if (!details || details.taskId !== task.id)
|
|
7484
|
+
throw new Error(`Missing implementation details for ${task.id}.`);
|
|
7485
|
+
return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
|
|
7486
|
+
### Task: ${task.title}
|
|
7487
|
+
|
|
7488
|
+
#### Ordered Steps
|
|
7489
|
+
|
|
7490
|
+
${bulletList(details.steps)}
|
|
7491
|
+
|
|
7492
|
+
#### File Scopes
|
|
7493
|
+
|
|
7494
|
+
${bulletList(details.fileScopes)}
|
|
7495
|
+
|
|
7496
|
+
#### Acceptance Criteria
|
|
7497
|
+
|
|
7498
|
+
${bulletList(details.acceptanceCriteria)}
|
|
7499
|
+
|
|
7500
|
+
<!-- /diffpi-brief-task -->`;
|
|
7501
|
+
}).join(`
|
|
7502
|
+
|
|
7503
|
+
`);
|
|
7504
|
+
return `<!-- diffpi-implementation: ${json(marker)} -->
|
|
7505
|
+
# Phase ${ordinal}: ${phase.title}
|
|
7506
|
+
|
|
7507
|
+
- **Phase ID:** ${phase.id}
|
|
7508
|
+
- **Plan Revision:** ${planRevision}
|
|
7509
|
+
|
|
7510
|
+
## Summary
|
|
7511
|
+
|
|
7512
|
+
${brief.summary}
|
|
7513
|
+
|
|
7514
|
+
## Objective
|
|
7515
|
+
|
|
7516
|
+
${phase.objective}
|
|
7517
|
+
|
|
7518
|
+
## Tasks
|
|
7519
|
+
|
|
7520
|
+
${tasks}
|
|
7521
|
+
|
|
7522
|
+
## API Changes
|
|
7523
|
+
|
|
7524
|
+
${bulletList(brief.apiChanges, "None.")}
|
|
7525
|
+
|
|
7526
|
+
## Libraries and Algorithms
|
|
7527
|
+
|
|
7528
|
+
${bulletList(brief.libraries, "None.")}
|
|
7529
|
+
|
|
7530
|
+
## Implementation Constraints
|
|
7531
|
+
|
|
7532
|
+
${bulletList(brief.constraints, "None.")}
|
|
7533
|
+
`;
|
|
7534
|
+
}
|
|
7155
7535
|
function renderPlanDocument(plan, previousSource) {
|
|
7156
7536
|
if (previousSource && canPatch(previousSource, plan))
|
|
7157
7537
|
return patchMarkers(previousSource, plan);
|
|
@@ -7160,6 +7540,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7160
7540
|
id: plan.id,
|
|
7161
7541
|
revision: plan.revision,
|
|
7162
7542
|
branch: plan.branch,
|
|
7543
|
+
issueId: plan.issueId,
|
|
7544
|
+
issueUrl: plan.issueUrl,
|
|
7163
7545
|
status: plan.status,
|
|
7164
7546
|
execution: plan.execution,
|
|
7165
7547
|
createdAt: plan.createdAt,
|
|
@@ -7168,6 +7550,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7168
7550
|
const metadata = import_mdast_builder.list("unordered", [
|
|
7169
7551
|
["Plan ID", plan.id],
|
|
7170
7552
|
["Branch", plan.branch],
|
|
7553
|
+
...plan.issueId ? [["Issue", plan.issueId]] : [],
|
|
7554
|
+
...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
|
|
7171
7555
|
["Status", plan.status],
|
|
7172
7556
|
["Revision", String(plan.revision)]
|
|
7173
7557
|
].map(([label, value]) => import_mdast_builder.listItem(import_mdast_builder.paragraph([import_mdast_builder.strong(import_mdast_builder.text(`${label}:`)), import_mdast_builder.text(` ${value}`)]))));
|
|
@@ -7189,7 +7573,7 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7189
7573
|
import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
|
|
7190
7574
|
import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
|
|
7191
7575
|
import_mdast_builder.heading(2, import_mdast_builder.text("Implementation")),
|
|
7192
|
-
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with
|
|
7576
|
+
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
|
|
7193
7577
|
import_mdast_builder.heading(2, import_mdast_builder.text("References")),
|
|
7194
7578
|
references
|
|
7195
7579
|
]);
|
|
@@ -7263,10 +7647,7 @@ function renderTask(task) {
|
|
|
7263
7647
|
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
7264
7648
|
return `<!-- diffpi-task: ${json(marker)} -->
|
|
7265
7649
|
- [${checked}] **${task.title}**
|
|
7266
|
-
${taskList("Steps", task.steps ?? [])}
|
|
7267
7650
|
- Dependencies: ${list3(task.dependencies)}
|
|
7268
|
-
${taskList("File scopes", task.fileScopes)}
|
|
7269
|
-
${taskList("Acceptance criteria", task.acceptanceCriteria)}
|
|
7270
7651
|
<!-- /diffpi-task -->`;
|
|
7271
7652
|
}
|
|
7272
7653
|
function parsePhases(input, ref) {
|
|
@@ -7316,10 +7697,7 @@ function parseTasks(input, ref) {
|
|
|
7316
7697
|
return {
|
|
7317
7698
|
...marker,
|
|
7318
7699
|
title,
|
|
7319
|
-
|
|
7320
|
-
dependencies: parseListValue(body, "Dependencies"),
|
|
7321
|
-
fileScopes: parseListValue(body, "File scopes"),
|
|
7322
|
-
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
7700
|
+
dependencies: parseListValue(body, "Dependencies")
|
|
7323
7701
|
};
|
|
7324
7702
|
});
|
|
7325
7703
|
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
@@ -7346,6 +7724,8 @@ function patchMarkers(source, plan) {
|
|
|
7346
7724
|
id: plan.id,
|
|
7347
7725
|
revision: plan.revision,
|
|
7348
7726
|
branch: plan.branch,
|
|
7727
|
+
issueId: plan.issueId,
|
|
7728
|
+
issueUrl: plan.issueUrl,
|
|
7349
7729
|
status: plan.status,
|
|
7350
7730
|
execution: plan.execution,
|
|
7351
7731
|
createdAt: plan.createdAt,
|
|
@@ -7395,19 +7775,12 @@ function contentShape(plan) {
|
|
|
7395
7775
|
requirements: plan.requirements,
|
|
7396
7776
|
design: plan.design,
|
|
7397
7777
|
references: plan.references,
|
|
7398
|
-
phases: plan.phases.map((
|
|
7399
|
-
id,
|
|
7400
|
-
title,
|
|
7401
|
-
objective,
|
|
7402
|
-
dependencies,
|
|
7403
|
-
tasks: tasks.map(({ id
|
|
7404
|
-
id: taskId,
|
|
7405
|
-
title: taskTitle,
|
|
7406
|
-
steps,
|
|
7407
|
-
dependencies: taskDependencies,
|
|
7408
|
-
fileScopes,
|
|
7409
|
-
acceptanceCriteria
|
|
7410
|
-
}))
|
|
7778
|
+
phases: plan.phases.map((phase) => ({
|
|
7779
|
+
id: phase.id,
|
|
7780
|
+
title: phase.title,
|
|
7781
|
+
objective: phase.objective,
|
|
7782
|
+
dependencies: phase.dependencies,
|
|
7783
|
+
tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
|
|
7411
7784
|
}))
|
|
7412
7785
|
};
|
|
7413
7786
|
}
|
|
@@ -7507,6 +7880,13 @@ function assertUniqueIds(ids, label) {
|
|
|
7507
7880
|
function cleanPlaceholder(value) {
|
|
7508
7881
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
7509
7882
|
}
|
|
7883
|
+
function isPlaceholder(value) {
|
|
7884
|
+
const normalized = cleanPlaceholder(value).trim();
|
|
7885
|
+
return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
|
|
7886
|
+
}
|
|
7887
|
+
function sameIds(expected, actual) {
|
|
7888
|
+
return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
|
|
7889
|
+
}
|
|
7510
7890
|
function parseMarker(value, label) {
|
|
7511
7891
|
try {
|
|
7512
7892
|
return JSON.parse(value);
|
|
@@ -7514,15 +7894,14 @@ function parseMarker(value, label) {
|
|
|
7514
7894
|
throw new Error(`Malformed ${label} marker JSON.`);
|
|
7515
7895
|
}
|
|
7516
7896
|
}
|
|
7517
|
-
function
|
|
7897
|
+
function bulletList(values, empty = "") {
|
|
7518
7898
|
if (!values.length)
|
|
7519
|
-
return
|
|
7520
|
-
|
|
7899
|
+
return empty;
|
|
7900
|
+
return values.flatMap((value) => {
|
|
7521
7901
|
const [first = "", ...continuations] = value.split(`
|
|
7522
7902
|
`);
|
|
7523
|
-
return [
|
|
7524
|
-
})
|
|
7525
|
-
return [` - ${label}:`, ...items].join(`
|
|
7903
|
+
return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
|
|
7904
|
+
}).join(`
|
|
7526
7905
|
`);
|
|
7527
7906
|
}
|
|
7528
7907
|
function list3(values) {
|
|
@@ -7542,7 +7921,7 @@ function planRecordName(shortSlug, date = new Date) {
|
|
|
7542
7921
|
}
|
|
7543
7922
|
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
7544
7923
|
const root = await plansDir(cwd, options.homeDir);
|
|
7545
|
-
const entries = await
|
|
7924
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
7546
7925
|
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
7547
7926
|
const records = [];
|
|
7548
7927
|
for (const entry of entries) {
|
|
@@ -7569,6 +7948,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7569
7948
|
}
|
|
7570
7949
|
function createPlanStore(options = {}) {
|
|
7571
7950
|
const now = options.now ?? (() => new Date);
|
|
7951
|
+
const read = async (cwd, query, filters) => {
|
|
7952
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
7953
|
+
if (!result.record) {
|
|
7954
|
+
if (result.ambiguous)
|
|
7955
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
7956
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
7957
|
+
}
|
|
7958
|
+
return result.record;
|
|
7959
|
+
};
|
|
7572
7960
|
return {
|
|
7573
7961
|
context(cwd, query, filters) {
|
|
7574
7962
|
return resolvePlan(cwd, query, filters, options);
|
|
@@ -7577,57 +7965,77 @@ function createPlanStore(options = {}) {
|
|
|
7577
7965
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7578
7966
|
const id = planRecordName(input.shortSlug, now());
|
|
7579
7967
|
const dir = join16(root, id);
|
|
7580
|
-
await
|
|
7581
|
-
try {
|
|
7582
|
-
await mkdir8(dir);
|
|
7583
|
-
} catch (error) {
|
|
7584
|
-
if (error.code === "EEXIST")
|
|
7585
|
-
throw new Error(`Plan ${id} already exists.`);
|
|
7586
|
-
throw error;
|
|
7587
|
-
}
|
|
7968
|
+
await createPlanDirectory(root, dir, id);
|
|
7588
7969
|
try {
|
|
7589
7970
|
const timestamp = now().toISOString();
|
|
7590
7971
|
const template = await loadTemplate("plan/PLAN", {
|
|
7591
7972
|
homeDir: options.homeDir,
|
|
7592
7973
|
bundledDir: options.bundledTemplatesDir
|
|
7593
7974
|
});
|
|
7975
|
+
const title = input.title?.trim() || titleFromSlug(input.shortSlug);
|
|
7594
7976
|
const source = renderTemplate(template.content, {
|
|
7595
7977
|
id,
|
|
7596
7978
|
branch: input.branch,
|
|
7597
|
-
title
|
|
7979
|
+
title,
|
|
7598
7980
|
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
7599
|
-
issue_id: input.issueId?.trim() || "
|
|
7600
|
-
issue_url: input.issueUrl?.trim() || "
|
|
7981
|
+
issue_id: input.issueId?.trim() || "",
|
|
7982
|
+
issue_url: input.issueUrl?.trim() || "",
|
|
7601
7983
|
created_at: timestamp,
|
|
7602
7984
|
updated_at: timestamp
|
|
7603
7985
|
});
|
|
7604
7986
|
const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
|
|
7605
|
-
|
|
7606
|
-
|
|
7607
|
-
|
|
7608
|
-
|
|
7609
|
-
planPath: join16(dir, "PLAN.md"),
|
|
7610
|
-
logPath: join16(dir, "logs.txt"),
|
|
7611
|
-
implementationDir: join16(dir, "implementation"),
|
|
7612
|
-
document,
|
|
7613
|
-
source
|
|
7614
|
-
};
|
|
7987
|
+
const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
|
|
7988
|
+
await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
|
|
7989
|
+
await writeLatestView(dir, document, source, []);
|
|
7990
|
+
return await readRecord(dir);
|
|
7615
7991
|
} catch (error) {
|
|
7616
7992
|
await rm2(dir, { recursive: true, force: true });
|
|
7617
7993
|
throw error;
|
|
7618
7994
|
}
|
|
7619
7995
|
},
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
if (
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7996
|
+
read,
|
|
7997
|
+
async applyRevision(cwd, request) {
|
|
7998
|
+
if (request.mode === "create") {
|
|
7999
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
8000
|
+
const id = planRecordName(request.shortSlug, now());
|
|
8001
|
+
const dir = join16(root, id);
|
|
8002
|
+
await createPlanDirectory(root, dir, id);
|
|
8003
|
+
try {
|
|
8004
|
+
const timestamp = now().toISOString();
|
|
8005
|
+
const document = normalizeRevision(undefined, request, id, 0, timestamp);
|
|
8006
|
+
assertCompleteRevision(document, request.briefs);
|
|
8007
|
+
const source = renderPlanDocument(document);
|
|
8008
|
+
parsePlanDocument(source, join16(dir, "PLAN.md"));
|
|
8009
|
+
await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
|
|
8010
|
+
await writeLatestView(dir, document, source, request.briefs);
|
|
8011
|
+
return await readRecord(dir);
|
|
8012
|
+
} catch (error) {
|
|
8013
|
+
await rm2(dir, { recursive: true, force: true });
|
|
8014
|
+
throw error;
|
|
8015
|
+
}
|
|
7626
8016
|
}
|
|
7627
|
-
|
|
8017
|
+
const initial = await read(cwd, request.plan);
|
|
8018
|
+
return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
|
|
8019
|
+
const current = await readRecord(initial.dir);
|
|
8020
|
+
if (current.document.revision !== request.expectedPlanRevision)
|
|
8021
|
+
throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
|
|
8022
|
+
const revision = current.document.revision + 1;
|
|
8023
|
+
const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
|
|
8024
|
+
assertCompleteRevision(document, request.briefs);
|
|
8025
|
+
const source = renderPlanDocument(document);
|
|
8026
|
+
parsePlanDocument(source, current.planPath);
|
|
8027
|
+
await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
|
|
8028
|
+
try {
|
|
8029
|
+
await writeLatestView(current.dir, document, source, request.briefs);
|
|
8030
|
+
} catch (error) {
|
|
8031
|
+
await rm2(join16(current.dir, "revisions", String(revision)), { recursive: true, force: true });
|
|
8032
|
+
throw error;
|
|
8033
|
+
}
|
|
8034
|
+
return await readRecord(current.dir);
|
|
8035
|
+
}, { operation: "apply revision" });
|
|
7628
8036
|
},
|
|
7629
8037
|
async mutate(cwd, query, operation, update) {
|
|
7630
|
-
const initial = await
|
|
8038
|
+
const initial = await read(cwd, query);
|
|
7631
8039
|
return withDirectoryLock(join16(initial.dir, ".lock"), async () => {
|
|
7632
8040
|
const current = await readRecord(initial.dir);
|
|
7633
8041
|
const next = await update(structuredClone(current.document));
|
|
@@ -7635,18 +8043,17 @@ function createPlanStore(options = {}) {
|
|
|
7635
8043
|
throw new Error("A plan mutation cannot change the plan ID.");
|
|
7636
8044
|
const document = {
|
|
7637
8045
|
...next,
|
|
7638
|
-
revision: current.document.revision
|
|
8046
|
+
revision: current.document.revision,
|
|
7639
8047
|
updatedAt: now().toISOString()
|
|
7640
8048
|
};
|
|
7641
8049
|
const source = renderPlanDocument(document, current.source);
|
|
7642
8050
|
parsePlanDocument(source, current.planPath);
|
|
7643
8051
|
await atomicWrite(current.planPath, source);
|
|
7644
|
-
|
|
7645
|
-
return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
|
|
8052
|
+
return { ...current, document, source };
|
|
7646
8053
|
}, { operation });
|
|
7647
8054
|
},
|
|
7648
8055
|
async log(cwd, query, event) {
|
|
7649
|
-
const record = await
|
|
8056
|
+
const record = await read(cwd, query);
|
|
7650
8057
|
return appendLogEntry(record.logPath, event);
|
|
7651
8058
|
}
|
|
7652
8059
|
};
|
|
@@ -7654,7 +8061,7 @@ function createPlanStore(options = {}) {
|
|
|
7654
8061
|
async function readRecord(dir) {
|
|
7655
8062
|
const planPath = join16(dir, "PLAN.md");
|
|
7656
8063
|
const logPath = join16(dir, "logs.txt");
|
|
7657
|
-
const source = await
|
|
8064
|
+
const source = await readFile12(planPath, "utf8");
|
|
7658
8065
|
const document = parsePlanDocument(source, planPath);
|
|
7659
8066
|
return {
|
|
7660
8067
|
id: basename6(dir),
|
|
@@ -7666,29 +8073,208 @@ async function readRecord(dir) {
|
|
|
7666
8073
|
source
|
|
7667
8074
|
};
|
|
7668
8075
|
}
|
|
7669
|
-
async function
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
8076
|
+
async function createPlanDirectory(root, dir, id) {
|
|
8077
|
+
await assertContained(root, dirname8(dir));
|
|
8078
|
+
try {
|
|
8079
|
+
await mkdir7(dir);
|
|
8080
|
+
} catch (error) {
|
|
8081
|
+
if (error.code === "EEXIST")
|
|
8082
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
8083
|
+
throw error;
|
|
8084
|
+
}
|
|
8085
|
+
}
|
|
8086
|
+
async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
|
|
8087
|
+
if (!request.text.trim())
|
|
8088
|
+
throw new Error("An exact non-empty authoring request is required.");
|
|
8089
|
+
if (request.kind === "annotation" && !request.response?.trim())
|
|
8090
|
+
throw new Error("Annotation requests require a non-empty response.");
|
|
8091
|
+
const revisionsDir = join16(planDir, "revisions");
|
|
8092
|
+
await mkdir7(revisionsDir, { recursive: true });
|
|
8093
|
+
const target = join16(revisionsDir, String(document.revision));
|
|
8094
|
+
try {
|
|
8095
|
+
await readFile12(join16(target, "metadata.json"), "utf8");
|
|
8096
|
+
throw new Error(`Revision ${document.revision} already exists and is immutable.`);
|
|
8097
|
+
} catch (error) {
|
|
8098
|
+
if (error.code !== "ENOENT")
|
|
8099
|
+
throw error;
|
|
8100
|
+
}
|
|
8101
|
+
const staging = join16(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
|
|
8102
|
+
await mkdir7(staging);
|
|
8103
|
+
try {
|
|
8104
|
+
const requestSource = request.kind === "annotation" ? `## Original annotation
|
|
8105
|
+
${request.text}
|
|
8106
|
+
|
|
8107
|
+
## LLM response
|
|
8108
|
+
${request.response}` : request.text;
|
|
8109
|
+
await atomicWrite(join16(staging, "request.md"), requestSource);
|
|
8110
|
+
await atomicWrite(join16(staging, "metadata.json"), `${JSON.stringify({
|
|
8111
|
+
schemaVersion: 1,
|
|
8112
|
+
id: document.id,
|
|
8113
|
+
revision: document.revision,
|
|
8114
|
+
requestKind: request.kind,
|
|
8115
|
+
requestSha256: sha256(request.text),
|
|
8116
|
+
...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
|
|
8117
|
+
createdAt: document.updatedAt
|
|
8118
|
+
}, null, 2)}
|
|
8119
|
+
`);
|
|
8120
|
+
await atomicWrite(join16(staging, "PLAN.md"), source);
|
|
8121
|
+
await writeImplementationDirectory(staging, document, briefs);
|
|
8122
|
+
await rename3(staging, target);
|
|
8123
|
+
} catch (error) {
|
|
8124
|
+
await rm2(staging, { recursive: true, force: true });
|
|
8125
|
+
throw error;
|
|
8126
|
+
}
|
|
8127
|
+
}
|
|
8128
|
+
async function writeLatestView(planDir, document, source, briefs) {
|
|
8129
|
+
const current = join16(planDir, "implementation");
|
|
8130
|
+
const stagingRoot = join16(planDir, `.latest-${randomUUID2()}`);
|
|
8131
|
+
const stagedImplementation = join16(stagingRoot, "implementation");
|
|
8132
|
+
const backup = join16(planDir, `.implementation-backup-${randomUUID2()}`);
|
|
8133
|
+
await mkdir7(stagingRoot);
|
|
8134
|
+
await writeImplementationDirectory(stagingRoot, document, briefs);
|
|
8135
|
+
let backedUp = false;
|
|
8136
|
+
try {
|
|
7678
8137
|
try {
|
|
7679
|
-
await
|
|
8138
|
+
await rename3(current, backup);
|
|
8139
|
+
backedUp = true;
|
|
7680
8140
|
} catch (error) {
|
|
7681
8141
|
if (error.code !== "ENOENT")
|
|
7682
8142
|
throw error;
|
|
7683
|
-
const tasks = phase.tasks.map((task) => `- ${task.id}: ${task.title}`).join("\\n") || "- Add implementation tasks.";
|
|
7684
|
-
await atomicWrite(path, renderTemplate(template.content, {
|
|
7685
|
-
phase_id: phase.id,
|
|
7686
|
-
phase_title: phase.title,
|
|
7687
|
-
phase_objective: phase.objective,
|
|
7688
|
-
phase_tasks: tasks
|
|
7689
|
-
}));
|
|
7690
8143
|
}
|
|
7691
|
-
|
|
8144
|
+
await rename3(stagedImplementation, current);
|
|
8145
|
+
await atomicWrite(join16(planDir, "PLAN.md"), source);
|
|
8146
|
+
if (backedUp)
|
|
8147
|
+
await rm2(backup, { recursive: true, force: true });
|
|
8148
|
+
} catch (error) {
|
|
8149
|
+
await rm2(current, { recursive: true, force: true });
|
|
8150
|
+
if (backedUp)
|
|
8151
|
+
await rename3(backup, current);
|
|
8152
|
+
throw error;
|
|
8153
|
+
} finally {
|
|
8154
|
+
await rm2(stagingRoot, { recursive: true, force: true });
|
|
8155
|
+
}
|
|
8156
|
+
}
|
|
8157
|
+
async function writeImplementationDirectory(baseDir, document, briefs) {
|
|
8158
|
+
const implementationDir = join16(baseDir, "implementation");
|
|
8159
|
+
await mkdir7(implementationDir, { recursive: true });
|
|
8160
|
+
for (const [index, phase] of document.phases.entries()) {
|
|
8161
|
+
const brief = briefs[index];
|
|
8162
|
+
if (!brief)
|
|
8163
|
+
throw new Error(`Missing implementation brief for phase ${phase.id}.`);
|
|
8164
|
+
await atomicWrite(join16(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
|
|
8165
|
+
}
|
|
8166
|
+
}
|
|
8167
|
+
function normalizeRevision(current, request, id, revision, updatedAt) {
|
|
8168
|
+
if (current?.status === "completed")
|
|
8169
|
+
throw new Error("Completed plans cannot be amended.");
|
|
8170
|
+
if (current?.execution?.active && request.request.kind !== "blocker")
|
|
8171
|
+
throw new Error("An active execution accepts only a blocker amendment.");
|
|
8172
|
+
const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
|
|
8173
|
+
for (const phase of current?.phases ?? []) {
|
|
8174
|
+
if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
|
|
8175
|
+
throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
|
|
8176
|
+
}
|
|
8177
|
+
const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
|
|
8178
|
+
const createdAt = current?.createdAt ?? updatedAt;
|
|
8179
|
+
return {
|
|
8180
|
+
schemaVersion: 1,
|
|
8181
|
+
id,
|
|
8182
|
+
revision,
|
|
8183
|
+
title: request.title,
|
|
8184
|
+
branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
|
|
8185
|
+
issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
|
|
8186
|
+
issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
|
|
8187
|
+
intent: request.intent,
|
|
8188
|
+
requirements: request.requirements,
|
|
8189
|
+
design: request.design,
|
|
8190
|
+
references: request.references,
|
|
8191
|
+
phases,
|
|
8192
|
+
status: nextPlanStatus(current),
|
|
8193
|
+
execution: current?.execution,
|
|
8194
|
+
createdAt,
|
|
8195
|
+
updatedAt
|
|
8196
|
+
};
|
|
8197
|
+
}
|
|
8198
|
+
function reconcilePhase(current, draft, plan) {
|
|
8199
|
+
if (!current)
|
|
8200
|
+
return {
|
|
8201
|
+
...draft,
|
|
8202
|
+
revision: 0,
|
|
8203
|
+
status: "pending",
|
|
8204
|
+
gate: { phaseRevision: 0, status: "pending", results: [] },
|
|
8205
|
+
tasks: draft.tasks.map(newTask)
|
|
8206
|
+
};
|
|
8207
|
+
if (current.status === "completed") {
|
|
8208
|
+
if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
|
|
8209
|
+
throw new Error(`Completed phase ${current.id} cannot be changed.`);
|
|
8210
|
+
return current;
|
|
8211
|
+
}
|
|
8212
|
+
const byId = new Map(current.tasks.map((task) => [task.id, task]));
|
|
8213
|
+
for (const task of current.tasks) {
|
|
8214
|
+
if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
|
|
8215
|
+
throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
|
|
8216
|
+
}
|
|
8217
|
+
const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
|
|
8218
|
+
const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
|
|
8219
|
+
const revision = changed ? current.revision + 1 : current.revision;
|
|
8220
|
+
const wasBlocked = current.status === "blocked";
|
|
8221
|
+
return {
|
|
8222
|
+
...current,
|
|
8223
|
+
...draft,
|
|
8224
|
+
revision,
|
|
8225
|
+
tasks,
|
|
8226
|
+
status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
|
|
8227
|
+
gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
|
|
8228
|
+
blocker: wasBlocked ? undefined : current.blocker
|
|
8229
|
+
};
|
|
8230
|
+
}
|
|
8231
|
+
function reconcileTask(current, draft) {
|
|
8232
|
+
if (!current)
|
|
8233
|
+
return newTask(draft);
|
|
8234
|
+
const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
|
|
8235
|
+
if ((current.status === "completed" || current.status === "in_progress") && changed)
|
|
8236
|
+
throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
|
|
8237
|
+
if (current.status === "completed" || current.status === "in_progress")
|
|
8238
|
+
return current;
|
|
8239
|
+
return {
|
|
8240
|
+
...current,
|
|
8241
|
+
...draft,
|
|
8242
|
+
revision: changed ? current.revision + 1 : current.revision,
|
|
8243
|
+
status: current.status === "blocked" ? "pending" : current.status,
|
|
8244
|
+
owner: current.status === "blocked" ? undefined : current.owner,
|
|
8245
|
+
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
8246
|
+
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
8247
|
+
};
|
|
8248
|
+
}
|
|
8249
|
+
function newTask(draft) {
|
|
8250
|
+
return { ...draft, revision: 0, status: "pending" };
|
|
8251
|
+
}
|
|
8252
|
+
function phaseContent(phase) {
|
|
8253
|
+
return {
|
|
8254
|
+
id: phase.id,
|
|
8255
|
+
title: phase.title,
|
|
8256
|
+
objective: phase.objective,
|
|
8257
|
+
dependencies: phase.dependencies,
|
|
8258
|
+
tasks: phase.tasks.map(taskContent)
|
|
8259
|
+
};
|
|
8260
|
+
}
|
|
8261
|
+
function taskContent(task) {
|
|
8262
|
+
return { id: task.id, title: task.title, dependencies: task.dependencies };
|
|
8263
|
+
}
|
|
8264
|
+
function nextPlanStatus(current) {
|
|
8265
|
+
if (!current)
|
|
8266
|
+
return "draft";
|
|
8267
|
+
if (current.execution?.active)
|
|
8268
|
+
return current.status === "blocked" ? "in_progress" : current.status;
|
|
8269
|
+
return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
|
|
8270
|
+
}
|
|
8271
|
+
function assertCompleteRevision(document, briefs) {
|
|
8272
|
+
const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
|
|
8273
|
+
if (errors.length)
|
|
8274
|
+
throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
8275
|
+
}
|
|
8276
|
+
function sha256(value) {
|
|
8277
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
7692
8278
|
}
|
|
7693
8279
|
async function assertContained(root, candidate) {
|
|
7694
8280
|
const canonicalRoot = await realpath4(root);
|
|
@@ -7721,11 +8307,14 @@ function stripDate(value) {
|
|
|
7721
8307
|
return value.replace(/^\d{6}-/, "");
|
|
7722
8308
|
}
|
|
7723
8309
|
function titleFromSlug(value) {
|
|
7724
|
-
|
|
8310
|
+
const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
|
|
8311
|
+
return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
|
|
8312
|
+
return `${separator ? " " : ""}${letter.toUpperCase()}`;
|
|
8313
|
+
});
|
|
7725
8314
|
}
|
|
7726
8315
|
// src/setup.ts
|
|
7727
8316
|
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
7728
|
-
import { readdir as
|
|
8317
|
+
import { readdir as readdir3, readFile as readFile13 } from "node:fs/promises";
|
|
7729
8318
|
import { homedir as homedir9 } from "node:os";
|
|
7730
8319
|
import { basename as basename7, join as join17 } from "node:path";
|
|
7731
8320
|
var MISE_DEPENDENCIES = [
|
|
@@ -7828,11 +8417,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
7828
8417
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7829
8418
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
7830
8419
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
7831
|
-
const entries = (await
|
|
8420
|
+
const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
7832
8421
|
const actions = [];
|
|
7833
8422
|
for (const entry of entries) {
|
|
7834
8423
|
const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
|
|
7835
|
-
const source = await
|
|
8424
|
+
const source = await readFile13(join17(bundledAgentsDir, entry.name), "utf8");
|
|
7836
8425
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
7837
8426
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
7838
8427
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -8037,7 +8626,7 @@ function reportProgress(options, message) {
|
|
|
8037
8626
|
options.onProgress?.(message);
|
|
8038
8627
|
}
|
|
8039
8628
|
async function packageVersion2() {
|
|
8040
|
-
const source = await
|
|
8629
|
+
const source = await readFile13(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
8041
8630
|
let value;
|
|
8042
8631
|
try {
|
|
8043
8632
|
value = JSON.parse(source);
|
|
@@ -8062,11 +8651,11 @@ export {
|
|
|
8062
8651
|
addComment,
|
|
8063
8652
|
assertGitHubMergeReady,
|
|
8064
8653
|
assertReviewEventSupported,
|
|
8065
|
-
captureLocalReview,
|
|
8066
8654
|
checkConventionalSubject,
|
|
8067
8655
|
computeProjectSlug,
|
|
8068
8656
|
createForge,
|
|
8069
8657
|
createForgeBackend,
|
|
8658
|
+
createLocalReviewBackend,
|
|
8070
8659
|
createModeController,
|
|
8071
8660
|
createPlanStore,
|
|
8072
8661
|
createRemoteReviewBackend,
|
|
@@ -8099,6 +8688,7 @@ export {
|
|
|
8099
8688
|
launch,
|
|
8100
8689
|
listSessions,
|
|
8101
8690
|
loadDiffpiConfig,
|
|
8691
|
+
loadReviewPublicationState,
|
|
8102
8692
|
loadTemplate,
|
|
8103
8693
|
localReviewAuthor,
|
|
8104
8694
|
mcp,
|
|
@@ -8107,28 +8697,37 @@ export {
|
|
|
8107
8697
|
openInNewTab,
|
|
8108
8698
|
parseGitlabDiffRefs,
|
|
8109
8699
|
parseRemote,
|
|
8700
|
+
parseThreadArtifact,
|
|
8110
8701
|
pi,
|
|
8111
8702
|
plansDir,
|
|
8112
|
-
readLocalReview,
|
|
8113
8703
|
readSession,
|
|
8114
8704
|
renderReviewDoc,
|
|
8115
8705
|
renderTemplate,
|
|
8706
|
+
renderThreadArtifact,
|
|
8116
8707
|
resolveAgentMode,
|
|
8117
8708
|
resolveAgentModelPreferences,
|
|
8118
8709
|
resolvePrSession,
|
|
8119
8710
|
resolveReviewSession,
|
|
8120
8711
|
resolveSession,
|
|
8712
|
+
reviewBodyFingerprint,
|
|
8713
|
+
reviewCommentFingerprint,
|
|
8121
8714
|
reviewRecordName,
|
|
8715
|
+
reviewReplyFingerprint,
|
|
8122
8716
|
reviewSlug,
|
|
8123
8717
|
reviewsDir,
|
|
8124
8718
|
runMiseGates,
|
|
8719
|
+
saveReviewPublicationState,
|
|
8720
|
+
sessionsDir,
|
|
8125
8721
|
setupPi,
|
|
8126
8722
|
severitySchema,
|
|
8127
8723
|
storeDir,
|
|
8128
8724
|
storeGlobalRoot,
|
|
8129
8725
|
templateRelativePath,
|
|
8726
|
+
toFindings,
|
|
8130
8727
|
toReviewComments,
|
|
8131
8728
|
tuicrAvailable,
|
|
8729
|
+
unpublishedReviewComments,
|
|
8730
|
+
upsertThreadReply,
|
|
8132
8731
|
withRemoteProvenance,
|
|
8133
8732
|
yymmdd,
|
|
8134
8733
|
zedKeymapPath,
|