@difflab/pi 0.6.0 → 0.7.0-rc.202609271854.6af8600
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-plan-reviewer.md +27 -0
- package/agents/diffpi-planner.md +5 -5
- 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 +446 -160
- 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 +1841 -3858
- 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 +951 -349
- 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 +1668 -607
- 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 +30 -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 +17 -11
- package/templates/plan/implementation.md +34 -12
- 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 {
|
|
2382
|
-
import {
|
|
2637
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
2638
|
+
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile11, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
|
|
2639
|
+
import { basename as basename6, dirname as dirname8, join as join15, 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,
|
|
@@ -2430,42 +2687,6 @@ async function appendLogEntry(logPath, event) {
|
|
|
2430
2687
|
return entry;
|
|
2431
2688
|
}
|
|
2432
2689
|
|
|
2433
|
-
// src/templates.ts
|
|
2434
|
-
import { readFile as readFile10 } from "node:fs/promises";
|
|
2435
|
-
import { homedir as homedir8 } from "node:os";
|
|
2436
|
-
import { join as join14, normalize } from "node:path";
|
|
2437
|
-
async function loadTemplate(name, options = {}) {
|
|
2438
|
-
const relative = templateRelativePath(name);
|
|
2439
|
-
const userPath = join14(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
|
|
2440
|
-
const bundledPath = join14(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2441
|
-
const user = await readOptionalFile(userPath);
|
|
2442
|
-
if (user !== undefined)
|
|
2443
|
-
return { name, path: userPath, source: "user", content: user };
|
|
2444
|
-
const bundled = await readOptionalFile(bundledPath);
|
|
2445
|
-
if (bundled !== undefined)
|
|
2446
|
-
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
2447
|
-
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
2448
|
-
}
|
|
2449
|
-
function renderTemplate(content, variables) {
|
|
2450
|
-
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
2451
|
-
}
|
|
2452
|
-
function templateRelativePath(name) {
|
|
2453
|
-
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
2454
|
-
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
2455
|
-
throw new Error(`Invalid template name: ${name}`);
|
|
2456
|
-
}
|
|
2457
|
-
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
2458
|
-
}
|
|
2459
|
-
async function readOptionalFile(path) {
|
|
2460
|
-
try {
|
|
2461
|
-
return await readFile10(path, "utf8");
|
|
2462
|
-
} catch (error) {
|
|
2463
|
-
if (error.code === "ENOENT")
|
|
2464
|
-
return;
|
|
2465
|
-
throw error;
|
|
2466
|
-
}
|
|
2467
|
-
}
|
|
2468
|
-
|
|
2469
2690
|
// src/plan/markdown.ts
|
|
2470
2691
|
var import_mdast_builder = __toESM(require_src(), 1);
|
|
2471
2692
|
|
|
@@ -3812,7 +4033,7 @@ var handle = {
|
|
|
3812
4033
|
};
|
|
3813
4034
|
|
|
3814
4035
|
// ../../node_modules/mdast-util-to-markdown/lib/join.js
|
|
3815
|
-
var
|
|
4036
|
+
var join14 = [joinDefaults];
|
|
3816
4037
|
function joinDefaults(left, right, parent, state) {
|
|
3817
4038
|
if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
|
|
3818
4039
|
return false;
|
|
@@ -6358,7 +6579,7 @@ function toMarkdown(tree, options) {
|
|
|
6358
6579
|
handle: undefined,
|
|
6359
6580
|
indentLines,
|
|
6360
6581
|
indexStack: [],
|
|
6361
|
-
join: [...
|
|
6582
|
+
join: [...join14],
|
|
6362
6583
|
options: {},
|
|
6363
6584
|
safe: safeBound,
|
|
6364
6585
|
stack: [],
|
|
@@ -7135,11 +7356,13 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7135
7356
|
if (!taskIds.has(dependency))
|
|
7136
7357
|
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
7137
7358
|
}
|
|
7138
|
-
if (options.strict && task.
|
|
7139
|
-
add("
|
|
7359
|
+
if (options.strict && isPlaceholder(task.title))
|
|
7360
|
+
add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
|
|
7140
7361
|
}
|
|
7141
7362
|
if (options.strict && phase.tasks.length === 0)
|
|
7142
7363
|
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
7364
|
+
if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
|
|
7365
|
+
add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
|
|
7143
7366
|
}
|
|
7144
7367
|
for (const cycle of dependencyCycles(plan))
|
|
7145
7368
|
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
@@ -7148,10 +7371,131 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7148
7371
|
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
7149
7372
|
if (options.strict && designWords > 800)
|
|
7150
7373
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
7151
|
-
if (options.strict
|
|
7152
|
-
|
|
7374
|
+
if (options.strict) {
|
|
7375
|
+
if (plan.phases.length === 0)
|
|
7376
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
7377
|
+
if (isPlaceholder(plan.intent))
|
|
7378
|
+
add("placeholder", "error", "Intent must contain concrete content.", "intent");
|
|
7379
|
+
if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
|
|
7380
|
+
add("requirements", "error", "Requirements must contain concrete content.", "requirements");
|
|
7381
|
+
for (const [key, value] of Object.entries(plan.design)) {
|
|
7382
|
+
if (isPlaceholder(value))
|
|
7383
|
+
add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
|
|
7384
|
+
}
|
|
7385
|
+
}
|
|
7386
|
+
return issues;
|
|
7387
|
+
}
|
|
7388
|
+
function validatePlanBriefs(plan, briefs) {
|
|
7389
|
+
const issues = [];
|
|
7390
|
+
const phaseIds = plan.phases.map((phase) => phase.id);
|
|
7391
|
+
const briefIds = briefs.map((brief) => brief.phaseId);
|
|
7392
|
+
if (!sameIds(phaseIds, briefIds))
|
|
7393
|
+
issues.push({
|
|
7394
|
+
code: "brief-phase-ids",
|
|
7395
|
+
severity: "error",
|
|
7396
|
+
message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
|
|
7397
|
+
});
|
|
7398
|
+
for (const [index, phase] of plan.phases.entries()) {
|
|
7399
|
+
const brief = briefs[index];
|
|
7400
|
+
if (!brief || brief.phaseId !== phase.id)
|
|
7401
|
+
continue;
|
|
7402
|
+
if (isPlaceholder(brief.summary))
|
|
7403
|
+
issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
|
|
7404
|
+
const taskIds = phase.tasks.map((task) => task.id);
|
|
7405
|
+
const briefTaskIds = brief.tasks.map((task) => task.taskId);
|
|
7406
|
+
if (!sameIds(taskIds, briefTaskIds))
|
|
7407
|
+
issues.push({
|
|
7408
|
+
code: "brief-task-ids",
|
|
7409
|
+
severity: "error",
|
|
7410
|
+
message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
|
|
7411
|
+
});
|
|
7412
|
+
for (const task of brief.tasks) {
|
|
7413
|
+
for (const [field, values] of [
|
|
7414
|
+
["ordered steps", task.steps],
|
|
7415
|
+
["file scopes", task.fileScopes],
|
|
7416
|
+
["acceptance criteria", task.acceptanceCriteria]
|
|
7417
|
+
]) {
|
|
7418
|
+
if (!values.length || values.some(isPlaceholder))
|
|
7419
|
+
issues.push({
|
|
7420
|
+
code: "brief-incomplete",
|
|
7421
|
+
severity: "error",
|
|
7422
|
+
message: `Brief task ${task.taskId} needs concrete ${field}.`,
|
|
7423
|
+
path: task.taskId
|
|
7424
|
+
});
|
|
7425
|
+
}
|
|
7426
|
+
}
|
|
7427
|
+
for (const [field, values] of [
|
|
7428
|
+
["API changes", brief.apiChanges],
|
|
7429
|
+
["libraries", brief.libraries],
|
|
7430
|
+
["constraints", brief.constraints]
|
|
7431
|
+
]) {
|
|
7432
|
+
if (values.some(isPlaceholder))
|
|
7433
|
+
issues.push({
|
|
7434
|
+
code: "brief-placeholder",
|
|
7435
|
+
severity: "error",
|
|
7436
|
+
message: `Brief ${phase.id} has placeholder ${field}.`,
|
|
7437
|
+
path: phase.id
|
|
7438
|
+
});
|
|
7439
|
+
}
|
|
7440
|
+
}
|
|
7153
7441
|
return issues;
|
|
7154
7442
|
}
|
|
7443
|
+
function renderImplementationBrief(planRevision, ordinal, phase, brief) {
|
|
7444
|
+
const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
|
|
7445
|
+
const tasks = phase.tasks.map((task, index) => {
|
|
7446
|
+
const details = brief.tasks[index];
|
|
7447
|
+
if (!details || details.taskId !== task.id)
|
|
7448
|
+
throw new Error(`Missing implementation details for ${task.id}.`);
|
|
7449
|
+
return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
|
|
7450
|
+
### Task: ${task.title}
|
|
7451
|
+
|
|
7452
|
+
#### Ordered Steps
|
|
7453
|
+
|
|
7454
|
+
${bulletList(details.steps)}
|
|
7455
|
+
|
|
7456
|
+
#### File Scopes
|
|
7457
|
+
|
|
7458
|
+
${bulletList(details.fileScopes)}
|
|
7459
|
+
|
|
7460
|
+
#### Acceptance Criteria
|
|
7461
|
+
|
|
7462
|
+
${bulletList(details.acceptanceCriteria)}
|
|
7463
|
+
|
|
7464
|
+
<!-- /diffpi-brief-task -->`;
|
|
7465
|
+
}).join(`
|
|
7466
|
+
|
|
7467
|
+
`);
|
|
7468
|
+
return `<!-- diffpi-implementation: ${json(marker)} -->
|
|
7469
|
+
# Phase ${ordinal}: ${phase.title}
|
|
7470
|
+
|
|
7471
|
+
- **Phase ID:** ${phase.id}
|
|
7472
|
+
- **Plan Revision:** ${planRevision}
|
|
7473
|
+
|
|
7474
|
+
## Summary
|
|
7475
|
+
|
|
7476
|
+
${brief.summary}
|
|
7477
|
+
|
|
7478
|
+
## Objective
|
|
7479
|
+
|
|
7480
|
+
${phase.objective}
|
|
7481
|
+
|
|
7482
|
+
## Tasks
|
|
7483
|
+
|
|
7484
|
+
${tasks}
|
|
7485
|
+
|
|
7486
|
+
## API Changes
|
|
7487
|
+
|
|
7488
|
+
${bulletList(brief.apiChanges, "None.")}
|
|
7489
|
+
|
|
7490
|
+
## Libraries and Algorithms
|
|
7491
|
+
|
|
7492
|
+
${bulletList(brief.libraries, "None.")}
|
|
7493
|
+
|
|
7494
|
+
## Implementation Constraints
|
|
7495
|
+
|
|
7496
|
+
${bulletList(brief.constraints, "None.")}
|
|
7497
|
+
`;
|
|
7498
|
+
}
|
|
7155
7499
|
function renderPlanDocument(plan, previousSource) {
|
|
7156
7500
|
if (previousSource && canPatch(previousSource, plan))
|
|
7157
7501
|
return patchMarkers(previousSource, plan);
|
|
@@ -7160,6 +7504,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7160
7504
|
id: plan.id,
|
|
7161
7505
|
revision: plan.revision,
|
|
7162
7506
|
branch: plan.branch,
|
|
7507
|
+
issueId: plan.issueId,
|
|
7508
|
+
issueUrl: plan.issueUrl,
|
|
7163
7509
|
status: plan.status,
|
|
7164
7510
|
execution: plan.execution,
|
|
7165
7511
|
createdAt: plan.createdAt,
|
|
@@ -7168,6 +7514,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7168
7514
|
const metadata = import_mdast_builder.list("unordered", [
|
|
7169
7515
|
["Plan ID", plan.id],
|
|
7170
7516
|
["Branch", plan.branch],
|
|
7517
|
+
...plan.issueId ? [["Issue", plan.issueId]] : [],
|
|
7518
|
+
...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
|
|
7171
7519
|
["Status", plan.status],
|
|
7172
7520
|
["Revision", String(plan.revision)]
|
|
7173
7521
|
].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 +7537,7 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7189
7537
|
import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
|
|
7190
7538
|
import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
|
|
7191
7539
|
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
|
|
7540
|
+
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
|
|
7193
7541
|
import_mdast_builder.heading(2, import_mdast_builder.text("References")),
|
|
7194
7542
|
references
|
|
7195
7543
|
]);
|
|
@@ -7263,10 +7611,7 @@ function renderTask(task) {
|
|
|
7263
7611
|
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
7264
7612
|
return `<!-- diffpi-task: ${json(marker)} -->
|
|
7265
7613
|
- [${checked}] **${task.title}**
|
|
7266
|
-
${taskList("Steps", task.steps ?? [])}
|
|
7267
7614
|
- Dependencies: ${list3(task.dependencies)}
|
|
7268
|
-
${taskList("File scopes", task.fileScopes)}
|
|
7269
|
-
${taskList("Acceptance criteria", task.acceptanceCriteria)}
|
|
7270
7615
|
<!-- /diffpi-task -->`;
|
|
7271
7616
|
}
|
|
7272
7617
|
function parsePhases(input, ref) {
|
|
@@ -7316,10 +7661,7 @@ function parseTasks(input, ref) {
|
|
|
7316
7661
|
return {
|
|
7317
7662
|
...marker,
|
|
7318
7663
|
title,
|
|
7319
|
-
|
|
7320
|
-
dependencies: parseListValue(body, "Dependencies"),
|
|
7321
|
-
fileScopes: parseListValue(body, "File scopes"),
|
|
7322
|
-
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
7664
|
+
dependencies: parseListValue(body, "Dependencies")
|
|
7323
7665
|
};
|
|
7324
7666
|
});
|
|
7325
7667
|
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
@@ -7346,6 +7688,8 @@ function patchMarkers(source, plan) {
|
|
|
7346
7688
|
id: plan.id,
|
|
7347
7689
|
revision: plan.revision,
|
|
7348
7690
|
branch: plan.branch,
|
|
7691
|
+
issueId: plan.issueId,
|
|
7692
|
+
issueUrl: plan.issueUrl,
|
|
7349
7693
|
status: plan.status,
|
|
7350
7694
|
execution: plan.execution,
|
|
7351
7695
|
createdAt: plan.createdAt,
|
|
@@ -7395,19 +7739,12 @@ function contentShape(plan) {
|
|
|
7395
7739
|
requirements: plan.requirements,
|
|
7396
7740
|
design: plan.design,
|
|
7397
7741
|
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
|
-
}))
|
|
7742
|
+
phases: plan.phases.map((phase) => ({
|
|
7743
|
+
id: phase.id,
|
|
7744
|
+
title: phase.title,
|
|
7745
|
+
objective: phase.objective,
|
|
7746
|
+
dependencies: phase.dependencies,
|
|
7747
|
+
tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
|
|
7411
7748
|
}))
|
|
7412
7749
|
};
|
|
7413
7750
|
}
|
|
@@ -7507,6 +7844,13 @@ function assertUniqueIds(ids, label) {
|
|
|
7507
7844
|
function cleanPlaceholder(value) {
|
|
7508
7845
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
7509
7846
|
}
|
|
7847
|
+
function isPlaceholder(value) {
|
|
7848
|
+
const normalized = cleanPlaceholder(value).trim();
|
|
7849
|
+
return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
|
|
7850
|
+
}
|
|
7851
|
+
function sameIds(expected, actual) {
|
|
7852
|
+
return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
|
|
7853
|
+
}
|
|
7510
7854
|
function parseMarker(value, label) {
|
|
7511
7855
|
try {
|
|
7512
7856
|
return JSON.parse(value);
|
|
@@ -7514,15 +7858,14 @@ function parseMarker(value, label) {
|
|
|
7514
7858
|
throw new Error(`Malformed ${label} marker JSON.`);
|
|
7515
7859
|
}
|
|
7516
7860
|
}
|
|
7517
|
-
function
|
|
7861
|
+
function bulletList(values, empty = "") {
|
|
7518
7862
|
if (!values.length)
|
|
7519
|
-
return
|
|
7520
|
-
|
|
7863
|
+
return empty;
|
|
7864
|
+
return values.flatMap((value) => {
|
|
7521
7865
|
const [first = "", ...continuations] = value.split(`
|
|
7522
7866
|
`);
|
|
7523
|
-
return [
|
|
7524
|
-
})
|
|
7525
|
-
return [` - ${label}:`, ...items].join(`
|
|
7867
|
+
return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
|
|
7868
|
+
}).join(`
|
|
7526
7869
|
`);
|
|
7527
7870
|
}
|
|
7528
7871
|
function list3(values) {
|
|
@@ -7542,7 +7885,7 @@ function planRecordName(shortSlug, date = new Date) {
|
|
|
7542
7885
|
}
|
|
7543
7886
|
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
7544
7887
|
const root = await plansDir(cwd, options.homeDir);
|
|
7545
|
-
const entries = await
|
|
7888
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
7546
7889
|
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
7547
7890
|
const records = [];
|
|
7548
7891
|
for (const entry of entries) {
|
|
@@ -7550,7 +7893,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7550
7893
|
continue;
|
|
7551
7894
|
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
7552
7895
|
continue;
|
|
7553
|
-
const dir =
|
|
7896
|
+
const dir = join15(root, entry.name);
|
|
7554
7897
|
await assertContained(root, dir);
|
|
7555
7898
|
try {
|
|
7556
7899
|
const record = await readRecord(dir);
|
|
@@ -7569,6 +7912,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7569
7912
|
}
|
|
7570
7913
|
function createPlanStore(options = {}) {
|
|
7571
7914
|
const now = options.now ?? (() => new Date);
|
|
7915
|
+
const read = async (cwd, query, filters) => {
|
|
7916
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
7917
|
+
if (!result.record) {
|
|
7918
|
+
if (result.ambiguous)
|
|
7919
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
7920
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
7921
|
+
}
|
|
7922
|
+
return result.record;
|
|
7923
|
+
};
|
|
7572
7924
|
return {
|
|
7573
7925
|
context(cwd, query, filters) {
|
|
7574
7926
|
return resolvePlan(cwd, query, filters, options);
|
|
@@ -7576,84 +7928,107 @@ function createPlanStore(options = {}) {
|
|
|
7576
7928
|
async init(input) {
|
|
7577
7929
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7578
7930
|
const id = planRecordName(input.shortSlug, now());
|
|
7579
|
-
const dir =
|
|
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
|
-
}
|
|
7931
|
+
const dir = join15(root, id);
|
|
7932
|
+
await createPlanDirectory(root, dir, id);
|
|
7588
7933
|
try {
|
|
7589
7934
|
const timestamp = now().toISOString();
|
|
7590
|
-
const
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
});
|
|
7594
|
-
const source = renderTemplate(template.content, {
|
|
7935
|
+
const title = input.title?.trim() || titleFromSlug(input.shortSlug);
|
|
7936
|
+
const document = {
|
|
7937
|
+
schemaVersion: 1,
|
|
7595
7938
|
id,
|
|
7939
|
+
revision: 0,
|
|
7940
|
+
title,
|
|
7596
7941
|
branch: input.branch,
|
|
7597
|
-
|
|
7942
|
+
...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
|
|
7943
|
+
...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
|
|
7598
7944
|
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
7599
|
-
|
|
7600
|
-
|
|
7601
|
-
|
|
7602
|
-
|
|
7603
|
-
|
|
7604
|
-
|
|
7605
|
-
|
|
7606
|
-
return {
|
|
7607
|
-
id,
|
|
7608
|
-
dir,
|
|
7609
|
-
planPath: join16(dir, "PLAN.md"),
|
|
7610
|
-
logPath: join16(dir, "logs.txt"),
|
|
7611
|
-
implementationDir: join16(dir, "implementation"),
|
|
7612
|
-
document,
|
|
7613
|
-
source
|
|
7945
|
+
requirements: [],
|
|
7946
|
+
design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
|
|
7947
|
+
phases: [],
|
|
7948
|
+
references: [],
|
|
7949
|
+
status: "draft",
|
|
7950
|
+
createdAt: timestamp,
|
|
7951
|
+
updatedAt: timestamp
|
|
7614
7952
|
};
|
|
7953
|
+
const source = renderPlanDocument(document);
|
|
7954
|
+
parsePlanDocument(source, join15(dir, "PLAN.md"));
|
|
7955
|
+
const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
|
|
7956
|
+
await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
|
|
7957
|
+
await writeLatestView(dir, document, source, []);
|
|
7958
|
+
return await readRecord(dir);
|
|
7615
7959
|
} catch (error) {
|
|
7616
7960
|
await rm2(dir, { recursive: true, force: true });
|
|
7617
7961
|
throw error;
|
|
7618
7962
|
}
|
|
7619
7963
|
},
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
if (
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
7964
|
+
read,
|
|
7965
|
+
async applyRevision(cwd, request) {
|
|
7966
|
+
if (request.mode === "create") {
|
|
7967
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
7968
|
+
const id = planRecordName(request.shortSlug, now());
|
|
7969
|
+
const dir = join15(root, id);
|
|
7970
|
+
await createPlanDirectory(root, dir, id);
|
|
7971
|
+
try {
|
|
7972
|
+
const timestamp = now().toISOString();
|
|
7973
|
+
const document = normalizeRevision(undefined, request, id, 0, timestamp);
|
|
7974
|
+
assertCompleteRevision(document, request.briefs);
|
|
7975
|
+
const source = renderPlanDocument(document);
|
|
7976
|
+
parsePlanDocument(source, join15(dir, "PLAN.md"));
|
|
7977
|
+
await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
|
|
7978
|
+
await writeLatestView(dir, document, source, request.briefs);
|
|
7979
|
+
return await readRecord(dir);
|
|
7980
|
+
} catch (error) {
|
|
7981
|
+
await rm2(dir, { recursive: true, force: true });
|
|
7982
|
+
throw error;
|
|
7983
|
+
}
|
|
7626
7984
|
}
|
|
7627
|
-
|
|
7985
|
+
const initial = await read(cwd, request.plan);
|
|
7986
|
+
return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
|
|
7987
|
+
const current = await readRecord(initial.dir);
|
|
7988
|
+
if (current.document.revision !== request.expectedPlanRevision)
|
|
7989
|
+
throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
|
|
7990
|
+
const revision = current.document.revision + 1;
|
|
7991
|
+
const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
|
|
7992
|
+
assertCompleteRevision(document, request.briefs);
|
|
7993
|
+
const source = renderPlanDocument(document);
|
|
7994
|
+
parsePlanDocument(source, current.planPath);
|
|
7995
|
+
await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
|
|
7996
|
+
try {
|
|
7997
|
+
await writeLatestView(current.dir, document, source, request.briefs);
|
|
7998
|
+
} catch (error) {
|
|
7999
|
+
await rm2(join15(current.dir, "revisions", String(revision)), { recursive: true, force: true });
|
|
8000
|
+
throw error;
|
|
8001
|
+
}
|
|
8002
|
+
return await readRecord(current.dir);
|
|
8003
|
+
}, { operation: "apply revision" });
|
|
7628
8004
|
},
|
|
7629
8005
|
async mutate(cwd, query, operation, update) {
|
|
7630
|
-
const initial = await
|
|
7631
|
-
return withDirectoryLock(
|
|
8006
|
+
const initial = await read(cwd, query);
|
|
8007
|
+
return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
|
|
7632
8008
|
const current = await readRecord(initial.dir);
|
|
7633
8009
|
const next = await update(structuredClone(current.document));
|
|
7634
8010
|
if (next.id !== current.id)
|
|
7635
8011
|
throw new Error("A plan mutation cannot change the plan ID.");
|
|
7636
8012
|
const document = {
|
|
7637
8013
|
...next,
|
|
7638
|
-
revision: current.document.revision
|
|
8014
|
+
revision: current.document.revision,
|
|
7639
8015
|
updatedAt: now().toISOString()
|
|
7640
8016
|
};
|
|
7641
8017
|
const source = renderPlanDocument(document, current.source);
|
|
7642
8018
|
parsePlanDocument(source, current.planPath);
|
|
7643
8019
|
await atomicWrite(current.planPath, source);
|
|
7644
|
-
|
|
7645
|
-
return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
|
|
8020
|
+
return { ...current, document, source };
|
|
7646
8021
|
}, { operation });
|
|
7647
8022
|
},
|
|
7648
8023
|
async log(cwd, query, event) {
|
|
7649
|
-
const record = await
|
|
8024
|
+
const record = await read(cwd, query);
|
|
7650
8025
|
return appendLogEntry(record.logPath, event);
|
|
7651
8026
|
}
|
|
7652
8027
|
};
|
|
7653
8028
|
}
|
|
7654
8029
|
async function readRecord(dir) {
|
|
7655
|
-
const planPath =
|
|
7656
|
-
const logPath =
|
|
8030
|
+
const planPath = join15(dir, "PLAN.md");
|
|
8031
|
+
const logPath = join15(dir, "logs.txt");
|
|
7657
8032
|
const source = await readFile11(planPath, "utf8");
|
|
7658
8033
|
const document = parsePlanDocument(source, planPath);
|
|
7659
8034
|
return {
|
|
@@ -7661,34 +8036,213 @@ async function readRecord(dir) {
|
|
|
7661
8036
|
dir,
|
|
7662
8037
|
planPath,
|
|
7663
8038
|
logPath,
|
|
7664
|
-
implementationDir:
|
|
8039
|
+
implementationDir: join15(dir, "implementation"),
|
|
7665
8040
|
document,
|
|
7666
8041
|
source
|
|
7667
8042
|
};
|
|
7668
8043
|
}
|
|
7669
|
-
async function
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
8044
|
+
async function createPlanDirectory(root, dir, id) {
|
|
8045
|
+
await assertContained(root, dirname8(dir));
|
|
8046
|
+
try {
|
|
8047
|
+
await mkdir7(dir);
|
|
8048
|
+
} catch (error) {
|
|
8049
|
+
if (error.code === "EEXIST")
|
|
8050
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
8051
|
+
throw error;
|
|
8052
|
+
}
|
|
8053
|
+
}
|
|
8054
|
+
async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
|
|
8055
|
+
if (!request.text.trim())
|
|
8056
|
+
throw new Error("An exact non-empty authoring request is required.");
|
|
8057
|
+
if (request.kind === "annotation" && !request.response?.trim())
|
|
8058
|
+
throw new Error("Annotation requests require a non-empty response.");
|
|
8059
|
+
const revisionsDir = join15(planDir, "revisions");
|
|
8060
|
+
await mkdir7(revisionsDir, { recursive: true });
|
|
8061
|
+
const target = join15(revisionsDir, String(document.revision));
|
|
8062
|
+
try {
|
|
8063
|
+
await readFile11(join15(target, "metadata.json"), "utf8");
|
|
8064
|
+
throw new Error(`Revision ${document.revision} already exists and is immutable.`);
|
|
8065
|
+
} catch (error) {
|
|
8066
|
+
if (error.code !== "ENOENT")
|
|
8067
|
+
throw error;
|
|
8068
|
+
}
|
|
8069
|
+
const staging = join15(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
|
|
8070
|
+
await mkdir7(staging);
|
|
8071
|
+
try {
|
|
8072
|
+
const requestSource = request.kind === "annotation" ? `## Original annotation
|
|
8073
|
+
${request.text}
|
|
8074
|
+
|
|
8075
|
+
## LLM response
|
|
8076
|
+
${request.response}` : request.text;
|
|
8077
|
+
await atomicWrite(join15(staging, "request.md"), requestSource);
|
|
8078
|
+
await atomicWrite(join15(staging, "metadata.json"), `${JSON.stringify({
|
|
8079
|
+
schemaVersion: 1,
|
|
8080
|
+
id: document.id,
|
|
8081
|
+
revision: document.revision,
|
|
8082
|
+
requestKind: request.kind,
|
|
8083
|
+
requestSha256: sha256(request.text),
|
|
8084
|
+
...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
|
|
8085
|
+
createdAt: document.updatedAt
|
|
8086
|
+
}, null, 2)}
|
|
8087
|
+
`);
|
|
8088
|
+
await atomicWrite(join15(staging, "PLAN.md"), source);
|
|
8089
|
+
await writeImplementationDirectory(staging, document, briefs);
|
|
8090
|
+
await rename3(staging, target);
|
|
8091
|
+
} catch (error) {
|
|
8092
|
+
await rm2(staging, { recursive: true, force: true });
|
|
8093
|
+
throw error;
|
|
8094
|
+
}
|
|
8095
|
+
}
|
|
8096
|
+
async function writeLatestView(planDir, document, source, briefs) {
|
|
8097
|
+
const current = join15(planDir, "implementation");
|
|
8098
|
+
const stagingRoot = join15(planDir, `.latest-${randomUUID2()}`);
|
|
8099
|
+
const stagedImplementation = join15(stagingRoot, "implementation");
|
|
8100
|
+
const backup = join15(planDir, `.implementation-backup-${randomUUID2()}`);
|
|
8101
|
+
await mkdir7(stagingRoot);
|
|
8102
|
+
await writeImplementationDirectory(stagingRoot, document, briefs);
|
|
8103
|
+
let backedUp = false;
|
|
8104
|
+
try {
|
|
7678
8105
|
try {
|
|
7679
|
-
await
|
|
8106
|
+
await rename3(current, backup);
|
|
8107
|
+
backedUp = true;
|
|
7680
8108
|
} catch (error) {
|
|
7681
8109
|
if (error.code !== "ENOENT")
|
|
7682
8110
|
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
8111
|
}
|
|
7691
|
-
|
|
8112
|
+
await rename3(stagedImplementation, current);
|
|
8113
|
+
await atomicWrite(join15(planDir, "PLAN.md"), source);
|
|
8114
|
+
if (backedUp)
|
|
8115
|
+
await rm2(backup, { recursive: true, force: true });
|
|
8116
|
+
} catch (error) {
|
|
8117
|
+
await rm2(current, { recursive: true, force: true });
|
|
8118
|
+
if (backedUp)
|
|
8119
|
+
await rename3(backup, current);
|
|
8120
|
+
throw error;
|
|
8121
|
+
} finally {
|
|
8122
|
+
await rm2(stagingRoot, { recursive: true, force: true });
|
|
8123
|
+
}
|
|
8124
|
+
}
|
|
8125
|
+
async function writeImplementationDirectory(baseDir, document, briefs) {
|
|
8126
|
+
const implementationDir = join15(baseDir, "implementation");
|
|
8127
|
+
await mkdir7(implementationDir, { recursive: true });
|
|
8128
|
+
for (const [index, phase] of document.phases.entries()) {
|
|
8129
|
+
const brief = briefs[index];
|
|
8130
|
+
if (!brief)
|
|
8131
|
+
throw new Error(`Missing implementation brief for phase ${phase.id}.`);
|
|
8132
|
+
await atomicWrite(join15(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
|
|
8133
|
+
}
|
|
8134
|
+
}
|
|
8135
|
+
function normalizeRevision(current, request, id, revision, updatedAt) {
|
|
8136
|
+
if (current?.status === "completed")
|
|
8137
|
+
throw new Error("Completed plans cannot be amended.");
|
|
8138
|
+
if (current?.execution?.active && request.request.kind !== "blocker")
|
|
8139
|
+
throw new Error("An active execution accepts only a blocker amendment.");
|
|
8140
|
+
const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
|
|
8141
|
+
for (const phase of current?.phases ?? []) {
|
|
8142
|
+
if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
|
|
8143
|
+
throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
|
|
8144
|
+
}
|
|
8145
|
+
const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
|
|
8146
|
+
const createdAt = current?.createdAt ?? updatedAt;
|
|
8147
|
+
return {
|
|
8148
|
+
schemaVersion: 1,
|
|
8149
|
+
id,
|
|
8150
|
+
revision,
|
|
8151
|
+
title: request.title,
|
|
8152
|
+
branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
|
|
8153
|
+
issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
|
|
8154
|
+
issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
|
|
8155
|
+
intent: request.intent,
|
|
8156
|
+
requirements: request.requirements,
|
|
8157
|
+
design: request.design,
|
|
8158
|
+
references: request.references,
|
|
8159
|
+
phases,
|
|
8160
|
+
status: nextPlanStatus(current),
|
|
8161
|
+
execution: current?.execution,
|
|
8162
|
+
createdAt,
|
|
8163
|
+
updatedAt
|
|
8164
|
+
};
|
|
8165
|
+
}
|
|
8166
|
+
function reconcilePhase(current, draft, plan) {
|
|
8167
|
+
if (!current)
|
|
8168
|
+
return {
|
|
8169
|
+
...draft,
|
|
8170
|
+
revision: 0,
|
|
8171
|
+
status: "pending",
|
|
8172
|
+
gate: { phaseRevision: 0, status: "pending", results: [] },
|
|
8173
|
+
tasks: draft.tasks.map(newTask)
|
|
8174
|
+
};
|
|
8175
|
+
if (current.status === "completed") {
|
|
8176
|
+
if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
|
|
8177
|
+
throw new Error(`Completed phase ${current.id} cannot be changed.`);
|
|
8178
|
+
return current;
|
|
8179
|
+
}
|
|
8180
|
+
const byId = new Map(current.tasks.map((task) => [task.id, task]));
|
|
8181
|
+
for (const task of current.tasks) {
|
|
8182
|
+
if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
|
|
8183
|
+
throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
|
|
8184
|
+
}
|
|
8185
|
+
const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
|
|
8186
|
+
const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
|
|
8187
|
+
const revision = changed ? current.revision + 1 : current.revision;
|
|
8188
|
+
const wasBlocked = current.status === "blocked";
|
|
8189
|
+
return {
|
|
8190
|
+
...current,
|
|
8191
|
+
...draft,
|
|
8192
|
+
revision,
|
|
8193
|
+
tasks,
|
|
8194
|
+
status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
|
|
8195
|
+
gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
|
|
8196
|
+
blocker: wasBlocked ? undefined : current.blocker
|
|
8197
|
+
};
|
|
8198
|
+
}
|
|
8199
|
+
function reconcileTask(current, draft) {
|
|
8200
|
+
if (!current)
|
|
8201
|
+
return newTask(draft);
|
|
8202
|
+
const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
|
|
8203
|
+
if ((current.status === "completed" || current.status === "in_progress") && changed)
|
|
8204
|
+
throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
|
|
8205
|
+
if (current.status === "completed" || current.status === "in_progress")
|
|
8206
|
+
return current;
|
|
8207
|
+
return {
|
|
8208
|
+
...current,
|
|
8209
|
+
...draft,
|
|
8210
|
+
revision: changed ? current.revision + 1 : current.revision,
|
|
8211
|
+
status: current.status === "blocked" ? "pending" : current.status,
|
|
8212
|
+
owner: current.status === "blocked" ? undefined : current.owner,
|
|
8213
|
+
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
8214
|
+
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
8215
|
+
};
|
|
8216
|
+
}
|
|
8217
|
+
function newTask(draft) {
|
|
8218
|
+
return { ...draft, revision: 0, status: "pending" };
|
|
8219
|
+
}
|
|
8220
|
+
function phaseContent(phase) {
|
|
8221
|
+
return {
|
|
8222
|
+
id: phase.id,
|
|
8223
|
+
title: phase.title,
|
|
8224
|
+
objective: phase.objective,
|
|
8225
|
+
dependencies: phase.dependencies,
|
|
8226
|
+
tasks: phase.tasks.map(taskContent)
|
|
8227
|
+
};
|
|
8228
|
+
}
|
|
8229
|
+
function taskContent(task) {
|
|
8230
|
+
return { id: task.id, title: task.title, dependencies: task.dependencies };
|
|
8231
|
+
}
|
|
8232
|
+
function nextPlanStatus(current) {
|
|
8233
|
+
if (!current)
|
|
8234
|
+
return "draft";
|
|
8235
|
+
if (current.execution?.active)
|
|
8236
|
+
return current.status === "blocked" ? "in_progress" : current.status;
|
|
8237
|
+
return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
|
|
8238
|
+
}
|
|
8239
|
+
function assertCompleteRevision(document, briefs) {
|
|
8240
|
+
const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
|
|
8241
|
+
if (errors.length)
|
|
8242
|
+
throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
8243
|
+
}
|
|
8244
|
+
function sha256(value) {
|
|
8245
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
7692
8246
|
}
|
|
7693
8247
|
async function assertContained(root, candidate) {
|
|
7694
8248
|
const canonicalRoot = await realpath4(root);
|
|
@@ -7721,13 +8275,16 @@ function stripDate(value) {
|
|
|
7721
8275
|
return value.replace(/^\d{6}-/, "");
|
|
7722
8276
|
}
|
|
7723
8277
|
function titleFromSlug(value) {
|
|
7724
|
-
|
|
8278
|
+
const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
|
|
8279
|
+
return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
|
|
8280
|
+
return `${separator ? " " : ""}${letter.toUpperCase()}`;
|
|
8281
|
+
});
|
|
7725
8282
|
}
|
|
7726
8283
|
// src/setup.ts
|
|
7727
8284
|
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
7728
|
-
import { readdir as
|
|
7729
|
-
import { homedir as
|
|
7730
|
-
import { basename as basename7, join as
|
|
8285
|
+
import { readdir as readdir3, readFile as readFile12 } from "node:fs/promises";
|
|
8286
|
+
import { homedir as homedir8 } from "node:os";
|
|
8287
|
+
import { basename as basename7, join as join16 } from "node:path";
|
|
7731
8288
|
var MISE_DEPENDENCIES = [
|
|
7732
8289
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
7733
8290
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -7765,8 +8322,8 @@ var FORGE_DEPENDENCIES = {
|
|
|
7765
8322
|
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
7766
8323
|
};
|
|
7767
8324
|
async function ensureMise(options = {}) {
|
|
7768
|
-
const homeDir = options.homeDir ??
|
|
7769
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
8325
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
8326
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join16(homeDir, ".local", "bin", "mise"));
|
|
7770
8327
|
if (current)
|
|
7771
8328
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
7772
8329
|
reportProgress(options, "Installing mise");
|
|
@@ -7815,9 +8372,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
7815
8372
|
async function ensurePiPlugins(options = {}) {
|
|
7816
8373
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
7817
8374
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7818
|
-
const webSearch = await pi.configEnsure(
|
|
8375
|
+
const webSearch = await pi.configEnsure(join16(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
7819
8376
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
7820
|
-
const lsp = await pi.configEnsure(
|
|
8377
|
+
const lsp = await pi.configEnsure(join16(agentDir, "pi-lsp.json"), (config) => ({
|
|
7821
8378
|
...config,
|
|
7822
8379
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
7823
8380
|
}), options.dryRun);
|
|
@@ -7828,11 +8385,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
7828
8385
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7829
8386
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
7830
8387
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
7831
|
-
const entries = (await
|
|
8388
|
+
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
8389
|
const actions = [];
|
|
7833
8390
|
for (const entry of entries) {
|
|
7834
8391
|
const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
|
|
7835
|
-
const source = await readFile12(
|
|
8392
|
+
const source = await readFile12(join16(bundledAgentsDir, entry.name), "utf8");
|
|
7836
8393
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
7837
8394
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
7838
8395
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -7841,7 +8398,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
7841
8398
|
}
|
|
7842
8399
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
7843
8400
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7844
|
-
const sharedSkillsDir =
|
|
8401
|
+
const sharedSkillsDir = join16(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
7845
8402
|
const actions = [];
|
|
7846
8403
|
for (const source of PI_SKILL_SOURCES) {
|
|
7847
8404
|
const missing = [];
|
|
@@ -8037,7 +8594,7 @@ function reportProgress(options, message) {
|
|
|
8037
8594
|
options.onProgress?.(message);
|
|
8038
8595
|
}
|
|
8039
8596
|
async function packageVersion2() {
|
|
8040
|
-
const source = await readFile12(
|
|
8597
|
+
const source = await readFile12(join16(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
8041
8598
|
let value;
|
|
8042
8599
|
try {
|
|
8043
8600
|
value = JSON.parse(source);
|
|
@@ -8051,6 +8608,41 @@ async function packageVersion2() {
|
|
|
8051
8608
|
function getRecord(value) {
|
|
8052
8609
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
8053
8610
|
}
|
|
8611
|
+
// src/templates.ts
|
|
8612
|
+
import { readFile as readFile13 } from "node:fs/promises";
|
|
8613
|
+
import { homedir as homedir9 } from "node:os";
|
|
8614
|
+
import { join as join17, normalize } from "node:path";
|
|
8615
|
+
async function loadTemplate(name, options = {}) {
|
|
8616
|
+
const relative = templateRelativePath(name);
|
|
8617
|
+
const userPath = join17(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
|
|
8618
|
+
const bundledPath = join17(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
8619
|
+
const user = await readOptionalFile(userPath);
|
|
8620
|
+
if (user !== undefined)
|
|
8621
|
+
return { name, path: userPath, source: "user", content: user };
|
|
8622
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
8623
|
+
if (bundled !== undefined)
|
|
8624
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
8625
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
8626
|
+
}
|
|
8627
|
+
function renderTemplate(content, variables) {
|
|
8628
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
8629
|
+
}
|
|
8630
|
+
function templateRelativePath(name) {
|
|
8631
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
8632
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
8633
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
8634
|
+
}
|
|
8635
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
8636
|
+
}
|
|
8637
|
+
async function readOptionalFile(path) {
|
|
8638
|
+
try {
|
|
8639
|
+
return await readFile13(path, "utf8");
|
|
8640
|
+
} catch (error) {
|
|
8641
|
+
if (error.code === "ENOENT")
|
|
8642
|
+
return;
|
|
8643
|
+
throw error;
|
|
8644
|
+
}
|
|
8645
|
+
}
|
|
8054
8646
|
export {
|
|
8055
8647
|
CONVENTIONAL_COMMIT,
|
|
8056
8648
|
GitHubVcsBackend,
|
|
@@ -8062,11 +8654,11 @@ export {
|
|
|
8062
8654
|
addComment,
|
|
8063
8655
|
assertGitHubMergeReady,
|
|
8064
8656
|
assertReviewEventSupported,
|
|
8065
|
-
captureLocalReview,
|
|
8066
8657
|
checkConventionalSubject,
|
|
8067
8658
|
computeProjectSlug,
|
|
8068
8659
|
createForge,
|
|
8069
8660
|
createForgeBackend,
|
|
8661
|
+
createLocalReviewBackend,
|
|
8070
8662
|
createModeController,
|
|
8071
8663
|
createPlanStore,
|
|
8072
8664
|
createRemoteReviewBackend,
|
|
@@ -8099,6 +8691,7 @@ export {
|
|
|
8099
8691
|
launch,
|
|
8100
8692
|
listSessions,
|
|
8101
8693
|
loadDiffpiConfig,
|
|
8694
|
+
loadReviewPublicationState,
|
|
8102
8695
|
loadTemplate,
|
|
8103
8696
|
localReviewAuthor,
|
|
8104
8697
|
mcp,
|
|
@@ -8107,28 +8700,37 @@ export {
|
|
|
8107
8700
|
openInNewTab,
|
|
8108
8701
|
parseGitlabDiffRefs,
|
|
8109
8702
|
parseRemote,
|
|
8703
|
+
parseThreadArtifact,
|
|
8110
8704
|
pi,
|
|
8111
8705
|
plansDir,
|
|
8112
|
-
readLocalReview,
|
|
8113
8706
|
readSession,
|
|
8114
8707
|
renderReviewDoc,
|
|
8115
8708
|
renderTemplate,
|
|
8709
|
+
renderThreadArtifact,
|
|
8116
8710
|
resolveAgentMode,
|
|
8117
8711
|
resolveAgentModelPreferences,
|
|
8118
8712
|
resolvePrSession,
|
|
8119
8713
|
resolveReviewSession,
|
|
8120
8714
|
resolveSession,
|
|
8715
|
+
reviewBodyFingerprint,
|
|
8716
|
+
reviewCommentFingerprint,
|
|
8121
8717
|
reviewRecordName,
|
|
8718
|
+
reviewReplyFingerprint,
|
|
8122
8719
|
reviewSlug,
|
|
8123
8720
|
reviewsDir,
|
|
8124
8721
|
runMiseGates,
|
|
8722
|
+
saveReviewPublicationState,
|
|
8723
|
+
sessionsDir,
|
|
8125
8724
|
setupPi,
|
|
8126
8725
|
severitySchema,
|
|
8127
8726
|
storeDir,
|
|
8128
8727
|
storeGlobalRoot,
|
|
8129
8728
|
templateRelativePath,
|
|
8729
|
+
toFindings,
|
|
8130
8730
|
toReviewComments,
|
|
8131
8731
|
tuicrAvailable,
|
|
8732
|
+
unpublishedReviewComments,
|
|
8733
|
+
upsertThreadReply,
|
|
8132
8734
|
withRemoteProvenance,
|
|
8133
8735
|
yymmdd,
|
|
8134
8736
|
zedKeymapPath,
|