@difflab/pi 0.6.0 → 0.7.0-rc.202609271932.42cb72b
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 +8 -4
- package/agents/diffpi-plan-reviewer.md +33 -0
- package/agents/diffpi-planner.md +9 -6
- package/agents/diffpi-reviewer.md +6 -4
- package/agents/diffpi-worker.md +4 -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 +1940 -3849
- 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 +1051 -357
- package/dist/modes.d.ts +15 -0
- package/dist/modes.d.ts.map +1 -1
- 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 +1674 -597
- package/dist/tools/modes.d.ts.map +1 -1
- 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) {
|
|
@@ -2111,14 +2367,36 @@ function createModeController(pi, options = {}) {
|
|
|
2111
2367
|
const result = resolveAgentMode(catalog.modes, agent);
|
|
2112
2368
|
if (!result.ok || !result.active)
|
|
2113
2369
|
return result;
|
|
2370
|
+
const previousActive = active;
|
|
2371
|
+
const previousBaseline = baseline;
|
|
2114
2372
|
baseline ??= captureRuntime(pi, ctx);
|
|
2115
2373
|
if (active && baseline)
|
|
2116
2374
|
await restoreRuntime(pi, baseline, ctx);
|
|
2117
2375
|
active = result.active;
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2376
|
+
try {
|
|
2377
|
+
const runtimeMessage = await applyModeRuntime(pi, active, ctx);
|
|
2378
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
|
|
2379
|
+
updateStatus(ctx);
|
|
2380
|
+
return { ...result, message: `${result.message} ${runtimeMessage}` };
|
|
2381
|
+
} catch (error) {
|
|
2382
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
2383
|
+
active = undefined;
|
|
2384
|
+
baseline = undefined;
|
|
2385
|
+
if (previousActive && previousBaseline) {
|
|
2386
|
+
try {
|
|
2387
|
+
active = previousActive;
|
|
2388
|
+
baseline = previousBaseline;
|
|
2389
|
+
await applyModeRuntime(pi, active, ctx);
|
|
2390
|
+
} catch {
|
|
2391
|
+
active = undefined;
|
|
2392
|
+
baseline = undefined;
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
if (!active)
|
|
2396
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active: null });
|
|
2397
|
+
updateStatus(ctx);
|
|
2398
|
+
return { ok: false, message: error instanceof Error ? error.message : String(error) };
|
|
2399
|
+
}
|
|
2122
2400
|
},
|
|
2123
2401
|
async unset(ctx) {
|
|
2124
2402
|
if (!active)
|
|
@@ -2138,9 +2416,20 @@ function createModeController(pi, options = {}) {
|
|
|
2138
2416
|
const restored = entry?.data?.active;
|
|
2139
2417
|
const restoredBaseline = entry?.data?.baseline;
|
|
2140
2418
|
if (isAgentModeSnapshot(restored)) {
|
|
2141
|
-
|
|
2419
|
+
const catalog = await list(ctx, { includeSkills: restored.id.includes(":") });
|
|
2420
|
+
const current = catalog.modes.find((mode) => mode.id === restored.id);
|
|
2421
|
+
active = current ?? normalizeAgentModeSnapshot(restored);
|
|
2142
2422
|
baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
|
|
2143
|
-
|
|
2423
|
+
try {
|
|
2424
|
+
await applyModeRuntime(pi, active, ctx);
|
|
2425
|
+
} catch (error) {
|
|
2426
|
+
const fallbackBaseline = previousBaseline ?? (isModeBaseline(restoredBaseline) ? restoredBaseline : undefined);
|
|
2427
|
+
if (fallbackBaseline)
|
|
2428
|
+
await restoreRuntime(pi, fallbackBaseline, ctx);
|
|
2429
|
+
active = undefined;
|
|
2430
|
+
baseline = undefined;
|
|
2431
|
+
throw error;
|
|
2432
|
+
}
|
|
2144
2433
|
} else {
|
|
2145
2434
|
if (previousActive && previousBaseline)
|
|
2146
2435
|
pi.setActiveTools(previousBaseline.tools);
|
|
@@ -2165,13 +2454,28 @@ ${active.systemPrompt}`;
|
|
|
2165
2454
|
},
|
|
2166
2455
|
getActive() {
|
|
2167
2456
|
return active;
|
|
2457
|
+
},
|
|
2458
|
+
getStatus(ctx) {
|
|
2459
|
+
return {
|
|
2460
|
+
active: active?.id,
|
|
2461
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
2462
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
2463
|
+
activeTools: pi.getActiveTools(),
|
|
2464
|
+
capabilityError: active ? validateModeRuntime(pi, active, ctx) : undefined
|
|
2465
|
+
};
|
|
2168
2466
|
}
|
|
2169
2467
|
};
|
|
2170
2468
|
}
|
|
2171
2469
|
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
2172
2470
|
var MODE_STATUS_KEY = "diffpi-mode";
|
|
2173
2471
|
var MODE_WIDGET_KEY = "diffpi-mode-badge";
|
|
2174
|
-
var MODE_CONTROL_TOOLS = [
|
|
2472
|
+
var MODE_CONTROL_TOOLS = [
|
|
2473
|
+
"ask_user_question",
|
|
2474
|
+
"diffpi_modes_list",
|
|
2475
|
+
"diffpi_modes_set",
|
|
2476
|
+
"diffpi_modes_unset",
|
|
2477
|
+
"diffpi_modes_status"
|
|
2478
|
+
];
|
|
2175
2479
|
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
2176
2480
|
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
2177
2481
|
async function applyModeRuntime(pi, mode, ctx) {
|
|
@@ -2195,6 +2499,17 @@ async function applyModeRuntime(pi, mode, ctx) {
|
|
|
2195
2499
|
if (selectedTools.length > 0)
|
|
2196
2500
|
pi.setActiveTools(selectedTools);
|
|
2197
2501
|
}
|
|
2502
|
+
const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
2503
|
+
const unavailableTools = mode.requiredTools.filter((tool) => !availableTools.has(tool));
|
|
2504
|
+
const activeTools = new Set(pi.getActiveTools());
|
|
2505
|
+
const missingTools = mode.requiredTools.filter((tool) => !activeTools.has(tool));
|
|
2506
|
+
const forbiddenTools = mode.forbiddenTools.filter((tool) => activeTools.has(tool));
|
|
2507
|
+
if (unavailableTools.length > 0 || missingTools.length > 0 || forbiddenTools.length > 0)
|
|
2508
|
+
throw new Error(`Profile preflight failed: missing required tools [${[...new Set([...unavailableTools, ...missingTools])].join(", ")}]${forbiddenTools.length > 0 ? `; forbidden tools active [${forbiddenTools.join(", ")}]` : ""}`);
|
|
2509
|
+
if (mode.requireModel && !selectedModel)
|
|
2510
|
+
throw new Error("Profile preflight failed: requested model was not selected.");
|
|
2511
|
+
if (mode.requireThinking && (!mode.thinkingLevel || pi.getThinkingLevel() !== mode.thinkingLevel))
|
|
2512
|
+
throw new Error(`Profile preflight failed: thinking level ${mode.thinkingLevel ?? "unset"} was not applied.`);
|
|
2198
2513
|
const parts = [];
|
|
2199
2514
|
if (mode.modelPreferences.length > 0) {
|
|
2200
2515
|
parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
|
|
@@ -2205,6 +2520,23 @@ async function applyModeRuntime(pi, mode, ctx) {
|
|
|
2205
2520
|
parts.push("Applied the profile tool set.");
|
|
2206
2521
|
return parts.join(" ") || "The profile changes the prompt only.";
|
|
2207
2522
|
}
|
|
2523
|
+
function validateModeRuntime(pi, mode, ctx) {
|
|
2524
|
+
const activeTools = new Set(pi.getActiveTools());
|
|
2525
|
+
const missingTools = mode.requiredTools.filter((tool) => !activeTools.has(tool));
|
|
2526
|
+
const forbiddenTools = mode.forbiddenTools.filter((tool) => activeTools.has(tool));
|
|
2527
|
+
if (missingTools.length > 0 || forbiddenTools.length > 0)
|
|
2528
|
+
return `Profile capability check failed: missing required tools [${missingTools.join(", ")}]${forbiddenTools.length > 0 ? `; forbidden tools active [${forbiddenTools.join(", ")}]` : ""}`;
|
|
2529
|
+
if (mode.requireModel && mode.modelPreferences.length > 0) {
|
|
2530
|
+
const actual = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "unset";
|
|
2531
|
+
const available = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : ctx.modelRegistry.getAvailable();
|
|
2532
|
+
const expected = mode.modelPreferences.map((preference) => findPreferredModel(available, preference)).find(Boolean);
|
|
2533
|
+
if (!expected || `${expected.provider}/${expected.id}` !== actual)
|
|
2534
|
+
return `Profile capability check failed: requested model was not active (actual ${actual}).`;
|
|
2535
|
+
}
|
|
2536
|
+
if (mode.requireThinking && (!mode.thinkingLevel || pi.getThinkingLevel() !== mode.thinkingLevel))
|
|
2537
|
+
return `Profile capability check failed: thinking level ${mode.thinkingLevel ?? "unset"} was not applied.`;
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2208
2540
|
async function restoreRuntime(pi, state, ctx) {
|
|
2209
2541
|
if (state.model) {
|
|
2210
2542
|
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
@@ -2213,6 +2545,9 @@ async function restoreRuntime(pi, state, ctx) {
|
|
|
2213
2545
|
}
|
|
2214
2546
|
pi.setThinkingLevel(state.thinkingLevel);
|
|
2215
2547
|
pi.setActiveTools(state.tools);
|
|
2548
|
+
const restoredTools = pi.getActiveTools();
|
|
2549
|
+
if (state.tools.some((tool) => !restoredTools.includes(tool)))
|
|
2550
|
+
throw new Error("Failed to restore the previous active tool set.");
|
|
2216
2551
|
}
|
|
2217
2552
|
function captureRuntime(pi, ctx) {
|
|
2218
2553
|
return {
|
|
@@ -2232,7 +2567,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
2232
2567
|
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2233
2568
|
const path = join11(directory, entry.name);
|
|
2234
2569
|
try {
|
|
2235
|
-
const content = await
|
|
2570
|
+
const content = await readFile9(path, "utf8");
|
|
2236
2571
|
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
2237
2572
|
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
2238
2573
|
continue;
|
|
@@ -2254,6 +2589,10 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
2254
2589
|
...getFrontmatterList(frontmatter.model_fallbacks)
|
|
2255
2590
|
],
|
|
2256
2591
|
thinkingLevel: getThinkingLevel(frontmatter.thinking),
|
|
2592
|
+
requiredTools: getFrontmatterList(frontmatter.required_tools),
|
|
2593
|
+
forbiddenTools: getFrontmatterList(frontmatter.forbidden_tools),
|
|
2594
|
+
requireModel: frontmatter.required_model === true,
|
|
2595
|
+
requireThinking: frontmatter.required_thinking === true,
|
|
2257
2596
|
tools: getFrontmatterList(frontmatter.tools),
|
|
2258
2597
|
source,
|
|
2259
2598
|
sourcePath: path
|
|
@@ -2274,11 +2613,20 @@ function getThinkingLevel(value) {
|
|
|
2274
2613
|
const level = getFrontmatterText(value);
|
|
2275
2614
|
return level && THINKING_LEVELS.has(level) ? level : undefined;
|
|
2276
2615
|
}
|
|
2616
|
+
function normalizeAgentModeSnapshot(value) {
|
|
2617
|
+
return {
|
|
2618
|
+
...value,
|
|
2619
|
+
requiredTools: Array.isArray(value.requiredTools) ? value.requiredTools : [],
|
|
2620
|
+
forbiddenTools: Array.isArray(value.forbiddenTools) ? value.forbiddenTools : [],
|
|
2621
|
+
requireModel: value.requireModel === true,
|
|
2622
|
+
requireThinking: value.requireThinking === true
|
|
2623
|
+
};
|
|
2624
|
+
}
|
|
2277
2625
|
function isAgentModeSnapshot(value) {
|
|
2278
2626
|
if (!value || typeof value !== "object")
|
|
2279
2627
|
return false;
|
|
2280
2628
|
const candidate = value;
|
|
2281
|
-
return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
|
|
2629
|
+
return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && (candidate.requiredTools === undefined || Array.isArray(candidate.requiredTools)) && (candidate.forbiddenTools === undefined || Array.isArray(candidate.forbiddenTools)) && (candidate.requireModel === undefined || typeof candidate.requireModel === "boolean") && (candidate.requireThinking === undefined || typeof candidate.requireThinking === "boolean") && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
|
|
2282
2630
|
}
|
|
2283
2631
|
function isModeBaseline(value) {
|
|
2284
2632
|
if (!value || typeof value !== "object")
|
|
@@ -2288,7 +2636,7 @@ function isModeBaseline(value) {
|
|
|
2288
2636
|
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
2637
|
}
|
|
2290
2638
|
// src/pi.ts
|
|
2291
|
-
import { mkdir as
|
|
2639
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2292
2640
|
import { homedir as homedir7 } from "node:os";
|
|
2293
2641
|
import { dirname as dirname6, join as join12 } from "node:path";
|
|
2294
2642
|
var pi = {
|
|
@@ -2307,8 +2655,8 @@ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(),
|
|
|
2307
2655
|
const currentText = await readTextIfExists(path);
|
|
2308
2656
|
const changed = currentText !== content;
|
|
2309
2657
|
if (changed && !dryRun) {
|
|
2310
|
-
await
|
|
2311
|
-
await
|
|
2658
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2659
|
+
await writeFile6(path, content, "utf8");
|
|
2312
2660
|
}
|
|
2313
2661
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
2314
2662
|
}
|
|
@@ -2344,8 +2692,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
2344
2692
|
const next = update(current);
|
|
2345
2693
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
2346
2694
|
if (changed && !dryRun) {
|
|
2347
|
-
await
|
|
2348
|
-
await
|
|
2695
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2696
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
2349
2697
|
`, "utf8");
|
|
2350
2698
|
}
|
|
2351
2699
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -2378,16 +2726,17 @@ function parseJsonObject(content, path) {
|
|
|
2378
2726
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
2379
2727
|
}
|
|
2380
2728
|
// src/plan/store.ts
|
|
2381
|
-
import {
|
|
2382
|
-
import {
|
|
2729
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
2730
|
+
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile11, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
|
|
2731
|
+
import { basename as basename6, dirname as dirname8, join as join15, resolve as resolve4, sep } from "node:path";
|
|
2383
2732
|
|
|
2384
2733
|
// src/extensions/fsx.ts
|
|
2385
|
-
import { mkdir as
|
|
2734
|
+
import { mkdir as mkdir6, rename as rename2, rm, writeFile as writeFile7 } from "node:fs/promises";
|
|
2386
2735
|
import { basename as basename5, dirname as dirname7, join as join13 } from "node:path";
|
|
2387
2736
|
async function atomicWrite(path, content) {
|
|
2388
2737
|
const temp = join13(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
|
|
2389
|
-
await
|
|
2390
|
-
await
|
|
2738
|
+
await writeFile7(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
2739
|
+
await rename2(temp, path);
|
|
2391
2740
|
}
|
|
2392
2741
|
async function withDirectoryLock(lockDir, operation, options = {}) {
|
|
2393
2742
|
const waitMs = options.waitMs ?? 5000;
|
|
@@ -2395,7 +2744,7 @@ async function withDirectoryLock(lockDir, operation, options = {}) {
|
|
|
2395
2744
|
const started = Date.now();
|
|
2396
2745
|
while (true) {
|
|
2397
2746
|
try {
|
|
2398
|
-
await
|
|
2747
|
+
await mkdir6(lockDir);
|
|
2399
2748
|
break;
|
|
2400
2749
|
} catch (error) {
|
|
2401
2750
|
if (error.code !== "EEXIST")
|
|
@@ -2417,7 +2766,7 @@ function sleep(ms) {
|
|
|
2417
2766
|
|
|
2418
2767
|
// src/log.ts
|
|
2419
2768
|
import { randomUUID } from "node:crypto";
|
|
2420
|
-
import { appendFile, readFile as
|
|
2769
|
+
import { appendFile, readFile as readFile10 } from "node:fs/promises";
|
|
2421
2770
|
async function appendLogEntry(logPath, event) {
|
|
2422
2771
|
const entry = {
|
|
2423
2772
|
...event,
|
|
@@ -2430,42 +2779,6 @@ async function appendLogEntry(logPath, event) {
|
|
|
2430
2779
|
return entry;
|
|
2431
2780
|
}
|
|
2432
2781
|
|
|
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
2782
|
// src/plan/markdown.ts
|
|
2470
2783
|
var import_mdast_builder = __toESM(require_src(), 1);
|
|
2471
2784
|
|
|
@@ -3812,7 +4125,7 @@ var handle = {
|
|
|
3812
4125
|
};
|
|
3813
4126
|
|
|
3814
4127
|
// ../../node_modules/mdast-util-to-markdown/lib/join.js
|
|
3815
|
-
var
|
|
4128
|
+
var join14 = [joinDefaults];
|
|
3816
4129
|
function joinDefaults(left, right, parent, state) {
|
|
3817
4130
|
if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
|
|
3818
4131
|
return false;
|
|
@@ -6358,7 +6671,7 @@ function toMarkdown(tree, options) {
|
|
|
6358
6671
|
handle: undefined,
|
|
6359
6672
|
indentLines,
|
|
6360
6673
|
indexStack: [],
|
|
6361
|
-
join: [...
|
|
6674
|
+
join: [...join14],
|
|
6362
6675
|
options: {},
|
|
6363
6676
|
safe: safeBound,
|
|
6364
6677
|
stack: [],
|
|
@@ -7135,11 +7448,13 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7135
7448
|
if (!taskIds.has(dependency))
|
|
7136
7449
|
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
7137
7450
|
}
|
|
7138
|
-
if (options.strict && task.
|
|
7139
|
-
add("
|
|
7451
|
+
if (options.strict && isPlaceholder(task.title))
|
|
7452
|
+
add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
|
|
7140
7453
|
}
|
|
7141
7454
|
if (options.strict && phase.tasks.length === 0)
|
|
7142
7455
|
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
7456
|
+
if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
|
|
7457
|
+
add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
|
|
7143
7458
|
}
|
|
7144
7459
|
for (const cycle of dependencyCycles(plan))
|
|
7145
7460
|
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
@@ -7148,10 +7463,131 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7148
7463
|
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
7149
7464
|
if (options.strict && designWords > 800)
|
|
7150
7465
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
7151
|
-
if (options.strict
|
|
7152
|
-
|
|
7466
|
+
if (options.strict) {
|
|
7467
|
+
if (plan.phases.length === 0)
|
|
7468
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
7469
|
+
if (isPlaceholder(plan.intent))
|
|
7470
|
+
add("placeholder", "error", "Intent must contain concrete content.", "intent");
|
|
7471
|
+
if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
|
|
7472
|
+
add("requirements", "error", "Requirements must contain concrete content.", "requirements");
|
|
7473
|
+
for (const [key, value] of Object.entries(plan.design)) {
|
|
7474
|
+
if (isPlaceholder(value))
|
|
7475
|
+
add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
|
|
7476
|
+
}
|
|
7477
|
+
}
|
|
7478
|
+
return issues;
|
|
7479
|
+
}
|
|
7480
|
+
function validatePlanBriefs(plan, briefs) {
|
|
7481
|
+
const issues = [];
|
|
7482
|
+
const phaseIds = plan.phases.map((phase) => phase.id);
|
|
7483
|
+
const briefIds = briefs.map((brief) => brief.phaseId);
|
|
7484
|
+
if (!sameIds(phaseIds, briefIds))
|
|
7485
|
+
issues.push({
|
|
7486
|
+
code: "brief-phase-ids",
|
|
7487
|
+
severity: "error",
|
|
7488
|
+
message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
|
|
7489
|
+
});
|
|
7490
|
+
for (const [index, phase] of plan.phases.entries()) {
|
|
7491
|
+
const brief = briefs[index];
|
|
7492
|
+
if (!brief || brief.phaseId !== phase.id)
|
|
7493
|
+
continue;
|
|
7494
|
+
if (isPlaceholder(brief.summary))
|
|
7495
|
+
issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
|
|
7496
|
+
const taskIds = phase.tasks.map((task) => task.id);
|
|
7497
|
+
const briefTaskIds = brief.tasks.map((task) => task.taskId);
|
|
7498
|
+
if (!sameIds(taskIds, briefTaskIds))
|
|
7499
|
+
issues.push({
|
|
7500
|
+
code: "brief-task-ids",
|
|
7501
|
+
severity: "error",
|
|
7502
|
+
message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
|
|
7503
|
+
});
|
|
7504
|
+
for (const task of brief.tasks) {
|
|
7505
|
+
for (const [field, values] of [
|
|
7506
|
+
["ordered steps", task.steps],
|
|
7507
|
+
["file scopes", task.fileScopes],
|
|
7508
|
+
["acceptance criteria", task.acceptanceCriteria]
|
|
7509
|
+
]) {
|
|
7510
|
+
if (!values.length || values.some(isPlaceholder))
|
|
7511
|
+
issues.push({
|
|
7512
|
+
code: "brief-incomplete",
|
|
7513
|
+
severity: "error",
|
|
7514
|
+
message: `Brief task ${task.taskId} needs concrete ${field}.`,
|
|
7515
|
+
path: task.taskId
|
|
7516
|
+
});
|
|
7517
|
+
}
|
|
7518
|
+
}
|
|
7519
|
+
for (const [field, values] of [
|
|
7520
|
+
["API changes", brief.apiChanges],
|
|
7521
|
+
["libraries", brief.libraries],
|
|
7522
|
+
["constraints", brief.constraints]
|
|
7523
|
+
]) {
|
|
7524
|
+
if (values.some(isPlaceholder))
|
|
7525
|
+
issues.push({
|
|
7526
|
+
code: "brief-placeholder",
|
|
7527
|
+
severity: "error",
|
|
7528
|
+
message: `Brief ${phase.id} has placeholder ${field}.`,
|
|
7529
|
+
path: phase.id
|
|
7530
|
+
});
|
|
7531
|
+
}
|
|
7532
|
+
}
|
|
7153
7533
|
return issues;
|
|
7154
7534
|
}
|
|
7535
|
+
function renderImplementationBrief(planRevision, ordinal, phase, brief) {
|
|
7536
|
+
const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
|
|
7537
|
+
const tasks = phase.tasks.map((task, index) => {
|
|
7538
|
+
const details = brief.tasks[index];
|
|
7539
|
+
if (!details || details.taskId !== task.id)
|
|
7540
|
+
throw new Error(`Missing implementation details for ${task.id}.`);
|
|
7541
|
+
return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
|
|
7542
|
+
### Task: ${task.title}
|
|
7543
|
+
|
|
7544
|
+
#### Ordered Steps
|
|
7545
|
+
|
|
7546
|
+
${bulletList(details.steps)}
|
|
7547
|
+
|
|
7548
|
+
#### File Scopes
|
|
7549
|
+
|
|
7550
|
+
${bulletList(details.fileScopes)}
|
|
7551
|
+
|
|
7552
|
+
#### Acceptance Criteria
|
|
7553
|
+
|
|
7554
|
+
${bulletList(details.acceptanceCriteria)}
|
|
7555
|
+
|
|
7556
|
+
<!-- /diffpi-brief-task -->`;
|
|
7557
|
+
}).join(`
|
|
7558
|
+
|
|
7559
|
+
`);
|
|
7560
|
+
return `<!-- diffpi-implementation: ${json(marker)} -->
|
|
7561
|
+
# Phase ${ordinal}: ${phase.title}
|
|
7562
|
+
|
|
7563
|
+
- **Phase ID:** ${phase.id}
|
|
7564
|
+
- **Plan Revision:** ${planRevision}
|
|
7565
|
+
|
|
7566
|
+
## Summary
|
|
7567
|
+
|
|
7568
|
+
${brief.summary}
|
|
7569
|
+
|
|
7570
|
+
## Objective
|
|
7571
|
+
|
|
7572
|
+
${phase.objective}
|
|
7573
|
+
|
|
7574
|
+
## Tasks
|
|
7575
|
+
|
|
7576
|
+
${tasks}
|
|
7577
|
+
|
|
7578
|
+
## API Changes
|
|
7579
|
+
|
|
7580
|
+
${bulletList(brief.apiChanges, "None.")}
|
|
7581
|
+
|
|
7582
|
+
## Libraries and Algorithms
|
|
7583
|
+
|
|
7584
|
+
${bulletList(brief.libraries, "None.")}
|
|
7585
|
+
|
|
7586
|
+
## Implementation Constraints
|
|
7587
|
+
|
|
7588
|
+
${bulletList(brief.constraints, "None.")}
|
|
7589
|
+
`;
|
|
7590
|
+
}
|
|
7155
7591
|
function renderPlanDocument(plan, previousSource) {
|
|
7156
7592
|
if (previousSource && canPatch(previousSource, plan))
|
|
7157
7593
|
return patchMarkers(previousSource, plan);
|
|
@@ -7160,6 +7596,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7160
7596
|
id: plan.id,
|
|
7161
7597
|
revision: plan.revision,
|
|
7162
7598
|
branch: plan.branch,
|
|
7599
|
+
issueId: plan.issueId,
|
|
7600
|
+
issueUrl: plan.issueUrl,
|
|
7163
7601
|
status: plan.status,
|
|
7164
7602
|
execution: plan.execution,
|
|
7165
7603
|
createdAt: plan.createdAt,
|
|
@@ -7168,6 +7606,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7168
7606
|
const metadata = import_mdast_builder.list("unordered", [
|
|
7169
7607
|
["Plan ID", plan.id],
|
|
7170
7608
|
["Branch", plan.branch],
|
|
7609
|
+
...plan.issueId ? [["Issue", plan.issueId]] : [],
|
|
7610
|
+
...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
|
|
7171
7611
|
["Status", plan.status],
|
|
7172
7612
|
["Revision", String(plan.revision)]
|
|
7173
7613
|
].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 +7629,7 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
7189
7629
|
import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
|
|
7190
7630
|
import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
|
|
7191
7631
|
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
|
|
7632
|
+
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
|
|
7193
7633
|
import_mdast_builder.heading(2, import_mdast_builder.text("References")),
|
|
7194
7634
|
references
|
|
7195
7635
|
]);
|
|
@@ -7263,10 +7703,7 @@ function renderTask(task) {
|
|
|
7263
7703
|
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
7264
7704
|
return `<!-- diffpi-task: ${json(marker)} -->
|
|
7265
7705
|
- [${checked}] **${task.title}**
|
|
7266
|
-
${taskList("Steps", task.steps ?? [])}
|
|
7267
7706
|
- Dependencies: ${list3(task.dependencies)}
|
|
7268
|
-
${taskList("File scopes", task.fileScopes)}
|
|
7269
|
-
${taskList("Acceptance criteria", task.acceptanceCriteria)}
|
|
7270
7707
|
<!-- /diffpi-task -->`;
|
|
7271
7708
|
}
|
|
7272
7709
|
function parsePhases(input, ref) {
|
|
@@ -7316,10 +7753,7 @@ function parseTasks(input, ref) {
|
|
|
7316
7753
|
return {
|
|
7317
7754
|
...marker,
|
|
7318
7755
|
title,
|
|
7319
|
-
|
|
7320
|
-
dependencies: parseListValue(body, "Dependencies"),
|
|
7321
|
-
fileScopes: parseListValue(body, "File scopes"),
|
|
7322
|
-
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
7756
|
+
dependencies: parseListValue(body, "Dependencies")
|
|
7323
7757
|
};
|
|
7324
7758
|
});
|
|
7325
7759
|
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
@@ -7346,6 +7780,8 @@ function patchMarkers(source, plan) {
|
|
|
7346
7780
|
id: plan.id,
|
|
7347
7781
|
revision: plan.revision,
|
|
7348
7782
|
branch: plan.branch,
|
|
7783
|
+
issueId: plan.issueId,
|
|
7784
|
+
issueUrl: plan.issueUrl,
|
|
7349
7785
|
status: plan.status,
|
|
7350
7786
|
execution: plan.execution,
|
|
7351
7787
|
createdAt: plan.createdAt,
|
|
@@ -7395,19 +7831,12 @@ function contentShape(plan) {
|
|
|
7395
7831
|
requirements: plan.requirements,
|
|
7396
7832
|
design: plan.design,
|
|
7397
7833
|
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
|
-
}))
|
|
7834
|
+
phases: plan.phases.map((phase) => ({
|
|
7835
|
+
id: phase.id,
|
|
7836
|
+
title: phase.title,
|
|
7837
|
+
objective: phase.objective,
|
|
7838
|
+
dependencies: phase.dependencies,
|
|
7839
|
+
tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
|
|
7411
7840
|
}))
|
|
7412
7841
|
};
|
|
7413
7842
|
}
|
|
@@ -7507,6 +7936,13 @@ function assertUniqueIds(ids, label) {
|
|
|
7507
7936
|
function cleanPlaceholder(value) {
|
|
7508
7937
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
7509
7938
|
}
|
|
7939
|
+
function isPlaceholder(value) {
|
|
7940
|
+
const normalized = cleanPlaceholder(value).trim();
|
|
7941
|
+
return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
|
|
7942
|
+
}
|
|
7943
|
+
function sameIds(expected, actual) {
|
|
7944
|
+
return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
|
|
7945
|
+
}
|
|
7510
7946
|
function parseMarker(value, label) {
|
|
7511
7947
|
try {
|
|
7512
7948
|
return JSON.parse(value);
|
|
@@ -7514,15 +7950,14 @@ function parseMarker(value, label) {
|
|
|
7514
7950
|
throw new Error(`Malformed ${label} marker JSON.`);
|
|
7515
7951
|
}
|
|
7516
7952
|
}
|
|
7517
|
-
function
|
|
7953
|
+
function bulletList(values, empty = "") {
|
|
7518
7954
|
if (!values.length)
|
|
7519
|
-
return
|
|
7520
|
-
|
|
7955
|
+
return empty;
|
|
7956
|
+
return values.flatMap((value) => {
|
|
7521
7957
|
const [first = "", ...continuations] = value.split(`
|
|
7522
7958
|
`);
|
|
7523
|
-
return [
|
|
7524
|
-
})
|
|
7525
|
-
return [` - ${label}:`, ...items].join(`
|
|
7959
|
+
return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
|
|
7960
|
+
}).join(`
|
|
7526
7961
|
`);
|
|
7527
7962
|
}
|
|
7528
7963
|
function list3(values) {
|
|
@@ -7542,7 +7977,7 @@ function planRecordName(shortSlug, date = new Date) {
|
|
|
7542
7977
|
}
|
|
7543
7978
|
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
7544
7979
|
const root = await plansDir(cwd, options.homeDir);
|
|
7545
|
-
const entries = await
|
|
7980
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
7546
7981
|
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
7547
7982
|
const records = [];
|
|
7548
7983
|
for (const entry of entries) {
|
|
@@ -7550,7 +7985,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7550
7985
|
continue;
|
|
7551
7986
|
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
7552
7987
|
continue;
|
|
7553
|
-
const dir =
|
|
7988
|
+
const dir = join15(root, entry.name);
|
|
7554
7989
|
await assertContained(root, dir);
|
|
7555
7990
|
try {
|
|
7556
7991
|
const record = await readRecord(dir);
|
|
@@ -7569,6 +8004,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7569
8004
|
}
|
|
7570
8005
|
function createPlanStore(options = {}) {
|
|
7571
8006
|
const now = options.now ?? (() => new Date);
|
|
8007
|
+
const read = async (cwd, query, filters) => {
|
|
8008
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
8009
|
+
if (!result.record) {
|
|
8010
|
+
if (result.ambiguous)
|
|
8011
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
8012
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
8013
|
+
}
|
|
8014
|
+
return result.record;
|
|
8015
|
+
};
|
|
7572
8016
|
return {
|
|
7573
8017
|
context(cwd, query, filters) {
|
|
7574
8018
|
return resolvePlan(cwd, query, filters, options);
|
|
@@ -7576,84 +8020,107 @@ function createPlanStore(options = {}) {
|
|
|
7576
8020
|
async init(input) {
|
|
7577
8021
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7578
8022
|
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
|
-
}
|
|
8023
|
+
const dir = join15(root, id);
|
|
8024
|
+
await createPlanDirectory(root, dir, id);
|
|
7588
8025
|
try {
|
|
7589
8026
|
const timestamp = now().toISOString();
|
|
7590
|
-
const
|
|
7591
|
-
|
|
7592
|
-
|
|
7593
|
-
});
|
|
7594
|
-
const source = renderTemplate(template.content, {
|
|
8027
|
+
const title = input.title?.trim() || titleFromSlug(input.shortSlug);
|
|
8028
|
+
const document = {
|
|
8029
|
+
schemaVersion: 1,
|
|
7595
8030
|
id,
|
|
8031
|
+
revision: 0,
|
|
8032
|
+
title,
|
|
7596
8033
|
branch: input.branch,
|
|
7597
|
-
|
|
8034
|
+
...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
|
|
8035
|
+
...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
|
|
7598
8036
|
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
|
|
8037
|
+
requirements: [],
|
|
8038
|
+
design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
|
|
8039
|
+
phases: [],
|
|
8040
|
+
references: [],
|
|
8041
|
+
status: "draft",
|
|
8042
|
+
createdAt: timestamp,
|
|
8043
|
+
updatedAt: timestamp
|
|
7614
8044
|
};
|
|
8045
|
+
const source = renderPlanDocument(document);
|
|
8046
|
+
parsePlanDocument(source, join15(dir, "PLAN.md"));
|
|
8047
|
+
const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
|
|
8048
|
+
await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
|
|
8049
|
+
await writeLatestView(dir, document, source, []);
|
|
8050
|
+
return await readRecord(dir);
|
|
7615
8051
|
} catch (error) {
|
|
7616
8052
|
await rm2(dir, { recursive: true, force: true });
|
|
7617
8053
|
throw error;
|
|
7618
8054
|
}
|
|
7619
8055
|
},
|
|
7620
|
-
|
|
7621
|
-
|
|
7622
|
-
if (
|
|
7623
|
-
|
|
7624
|
-
|
|
7625
|
-
|
|
8056
|
+
read,
|
|
8057
|
+
async applyRevision(cwd, request) {
|
|
8058
|
+
if (request.mode === "create") {
|
|
8059
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
8060
|
+
const id = planRecordName(request.shortSlug, now());
|
|
8061
|
+
const dir = join15(root, id);
|
|
8062
|
+
await createPlanDirectory(root, dir, id);
|
|
8063
|
+
try {
|
|
8064
|
+
const timestamp = now().toISOString();
|
|
8065
|
+
const document = normalizeRevision(undefined, request, id, 0, timestamp);
|
|
8066
|
+
assertCompleteRevision(document, request.briefs);
|
|
8067
|
+
const source = renderPlanDocument(document);
|
|
8068
|
+
parsePlanDocument(source, join15(dir, "PLAN.md"));
|
|
8069
|
+
await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
|
|
8070
|
+
await writeLatestView(dir, document, source, request.briefs);
|
|
8071
|
+
return await readRecord(dir);
|
|
8072
|
+
} catch (error) {
|
|
8073
|
+
await rm2(dir, { recursive: true, force: true });
|
|
8074
|
+
throw error;
|
|
8075
|
+
}
|
|
7626
8076
|
}
|
|
7627
|
-
|
|
8077
|
+
const initial = await read(cwd, request.plan);
|
|
8078
|
+
return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
|
|
8079
|
+
const current = await readRecord(initial.dir);
|
|
8080
|
+
if (current.document.revision !== request.expectedPlanRevision)
|
|
8081
|
+
throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
|
|
8082
|
+
const revision = current.document.revision + 1;
|
|
8083
|
+
const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
|
|
8084
|
+
assertCompleteRevision(document, request.briefs);
|
|
8085
|
+
const source = renderPlanDocument(document);
|
|
8086
|
+
parsePlanDocument(source, current.planPath);
|
|
8087
|
+
await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
|
|
8088
|
+
try {
|
|
8089
|
+
await writeLatestView(current.dir, document, source, request.briefs);
|
|
8090
|
+
} catch (error) {
|
|
8091
|
+
await rm2(join15(current.dir, "revisions", String(revision)), { recursive: true, force: true });
|
|
8092
|
+
throw error;
|
|
8093
|
+
}
|
|
8094
|
+
return await readRecord(current.dir);
|
|
8095
|
+
}, { operation: "apply revision" });
|
|
7628
8096
|
},
|
|
7629
8097
|
async mutate(cwd, query, operation, update) {
|
|
7630
|
-
const initial = await
|
|
7631
|
-
return withDirectoryLock(
|
|
8098
|
+
const initial = await read(cwd, query);
|
|
8099
|
+
return withDirectoryLock(join15(initial.dir, ".lock"), async () => {
|
|
7632
8100
|
const current = await readRecord(initial.dir);
|
|
7633
8101
|
const next = await update(structuredClone(current.document));
|
|
7634
8102
|
if (next.id !== current.id)
|
|
7635
8103
|
throw new Error("A plan mutation cannot change the plan ID.");
|
|
7636
8104
|
const document = {
|
|
7637
8105
|
...next,
|
|
7638
|
-
revision: current.document.revision
|
|
8106
|
+
revision: current.document.revision,
|
|
7639
8107
|
updatedAt: now().toISOString()
|
|
7640
8108
|
};
|
|
7641
8109
|
const source = renderPlanDocument(document, current.source);
|
|
7642
8110
|
parsePlanDocument(source, current.planPath);
|
|
7643
8111
|
await atomicWrite(current.planPath, source);
|
|
7644
|
-
|
|
7645
|
-
return { ...current, document, source, implementationDir: join16(current.dir, "implementation") };
|
|
8112
|
+
return { ...current, document, source };
|
|
7646
8113
|
}, { operation });
|
|
7647
8114
|
},
|
|
7648
8115
|
async log(cwd, query, event) {
|
|
7649
|
-
const record = await
|
|
8116
|
+
const record = await read(cwd, query);
|
|
7650
8117
|
return appendLogEntry(record.logPath, event);
|
|
7651
8118
|
}
|
|
7652
8119
|
};
|
|
7653
8120
|
}
|
|
7654
8121
|
async function readRecord(dir) {
|
|
7655
|
-
const planPath =
|
|
7656
|
-
const logPath =
|
|
8122
|
+
const planPath = join15(dir, "PLAN.md");
|
|
8123
|
+
const logPath = join15(dir, "logs.txt");
|
|
7657
8124
|
const source = await readFile11(planPath, "utf8");
|
|
7658
8125
|
const document = parsePlanDocument(source, planPath);
|
|
7659
8126
|
return {
|
|
@@ -7661,34 +8128,213 @@ async function readRecord(dir) {
|
|
|
7661
8128
|
dir,
|
|
7662
8129
|
planPath,
|
|
7663
8130
|
logPath,
|
|
7664
|
-
implementationDir:
|
|
8131
|
+
implementationDir: join15(dir, "implementation"),
|
|
7665
8132
|
document,
|
|
7666
8133
|
source
|
|
7667
8134
|
};
|
|
7668
8135
|
}
|
|
7669
|
-
async function
|
|
7670
|
-
|
|
7671
|
-
|
|
7672
|
-
|
|
7673
|
-
|
|
7674
|
-
|
|
7675
|
-
|
|
7676
|
-
|
|
7677
|
-
|
|
8136
|
+
async function createPlanDirectory(root, dir, id) {
|
|
8137
|
+
await assertContained(root, dirname8(dir));
|
|
8138
|
+
try {
|
|
8139
|
+
await mkdir7(dir);
|
|
8140
|
+
} catch (error) {
|
|
8141
|
+
if (error.code === "EEXIST")
|
|
8142
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
8143
|
+
throw error;
|
|
8144
|
+
}
|
|
8145
|
+
}
|
|
8146
|
+
async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
|
|
8147
|
+
if (!request.text.trim())
|
|
8148
|
+
throw new Error("An exact non-empty authoring request is required.");
|
|
8149
|
+
if (request.kind === "annotation" && !request.response?.trim())
|
|
8150
|
+
throw new Error("Annotation requests require a non-empty response.");
|
|
8151
|
+
const revisionsDir = join15(planDir, "revisions");
|
|
8152
|
+
await mkdir7(revisionsDir, { recursive: true });
|
|
8153
|
+
const target = join15(revisionsDir, String(document.revision));
|
|
8154
|
+
try {
|
|
8155
|
+
await readFile11(join15(target, "metadata.json"), "utf8");
|
|
8156
|
+
throw new Error(`Revision ${document.revision} already exists and is immutable.`);
|
|
8157
|
+
} catch (error) {
|
|
8158
|
+
if (error.code !== "ENOENT")
|
|
8159
|
+
throw error;
|
|
8160
|
+
}
|
|
8161
|
+
const staging = join15(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
|
|
8162
|
+
await mkdir7(staging);
|
|
8163
|
+
try {
|
|
8164
|
+
const requestSource = request.kind === "annotation" ? `## Original annotation
|
|
8165
|
+
${request.text}
|
|
8166
|
+
|
|
8167
|
+
## LLM response
|
|
8168
|
+
${request.response}` : request.text;
|
|
8169
|
+
await atomicWrite(join15(staging, "request.md"), requestSource);
|
|
8170
|
+
await atomicWrite(join15(staging, "metadata.json"), `${JSON.stringify({
|
|
8171
|
+
schemaVersion: 1,
|
|
8172
|
+
id: document.id,
|
|
8173
|
+
revision: document.revision,
|
|
8174
|
+
requestKind: request.kind,
|
|
8175
|
+
requestSha256: sha256(request.text),
|
|
8176
|
+
...request.kind === "annotation" ? { originalAnnotationSha256: sha256(request.text), responseSha256: sha256(request.response) } : {},
|
|
8177
|
+
createdAt: document.updatedAt
|
|
8178
|
+
}, null, 2)}
|
|
8179
|
+
`);
|
|
8180
|
+
await atomicWrite(join15(staging, "PLAN.md"), source);
|
|
8181
|
+
await writeImplementationDirectory(staging, document, briefs);
|
|
8182
|
+
await rename3(staging, target);
|
|
8183
|
+
} catch (error) {
|
|
8184
|
+
await rm2(staging, { recursive: true, force: true });
|
|
8185
|
+
throw error;
|
|
8186
|
+
}
|
|
8187
|
+
}
|
|
8188
|
+
async function writeLatestView(planDir, document, source, briefs) {
|
|
8189
|
+
const current = join15(planDir, "implementation");
|
|
8190
|
+
const stagingRoot = join15(planDir, `.latest-${randomUUID2()}`);
|
|
8191
|
+
const stagedImplementation = join15(stagingRoot, "implementation");
|
|
8192
|
+
const backup = join15(planDir, `.implementation-backup-${randomUUID2()}`);
|
|
8193
|
+
await mkdir7(stagingRoot);
|
|
8194
|
+
await writeImplementationDirectory(stagingRoot, document, briefs);
|
|
8195
|
+
let backedUp = false;
|
|
8196
|
+
try {
|
|
7678
8197
|
try {
|
|
7679
|
-
await
|
|
8198
|
+
await rename3(current, backup);
|
|
8199
|
+
backedUp = true;
|
|
7680
8200
|
} catch (error) {
|
|
7681
8201
|
if (error.code !== "ENOENT")
|
|
7682
8202
|
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
8203
|
}
|
|
7691
|
-
|
|
8204
|
+
await rename3(stagedImplementation, current);
|
|
8205
|
+
await atomicWrite(join15(planDir, "PLAN.md"), source);
|
|
8206
|
+
if (backedUp)
|
|
8207
|
+
await rm2(backup, { recursive: true, force: true });
|
|
8208
|
+
} catch (error) {
|
|
8209
|
+
await rm2(current, { recursive: true, force: true });
|
|
8210
|
+
if (backedUp)
|
|
8211
|
+
await rename3(backup, current);
|
|
8212
|
+
throw error;
|
|
8213
|
+
} finally {
|
|
8214
|
+
await rm2(stagingRoot, { recursive: true, force: true });
|
|
8215
|
+
}
|
|
8216
|
+
}
|
|
8217
|
+
async function writeImplementationDirectory(baseDir, document, briefs) {
|
|
8218
|
+
const implementationDir = join15(baseDir, "implementation");
|
|
8219
|
+
await mkdir7(implementationDir, { recursive: true });
|
|
8220
|
+
for (const [index, phase] of document.phases.entries()) {
|
|
8221
|
+
const brief = briefs[index];
|
|
8222
|
+
if (!brief)
|
|
8223
|
+
throw new Error(`Missing implementation brief for phase ${phase.id}.`);
|
|
8224
|
+
await atomicWrite(join15(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
|
|
8225
|
+
}
|
|
8226
|
+
}
|
|
8227
|
+
function normalizeRevision(current, request, id, revision, updatedAt) {
|
|
8228
|
+
if (current?.status === "completed")
|
|
8229
|
+
throw new Error("Completed plans cannot be amended.");
|
|
8230
|
+
if (current?.execution?.active && request.request.kind !== "blocker")
|
|
8231
|
+
throw new Error("An active execution accepts only a blocker amendment.");
|
|
8232
|
+
const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
|
|
8233
|
+
for (const phase of current?.phases ?? []) {
|
|
8234
|
+
if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
|
|
8235
|
+
throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
|
|
8236
|
+
}
|
|
8237
|
+
const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
|
|
8238
|
+
const createdAt = current?.createdAt ?? updatedAt;
|
|
8239
|
+
return {
|
|
8240
|
+
schemaVersion: 1,
|
|
8241
|
+
id,
|
|
8242
|
+
revision,
|
|
8243
|
+
title: request.title,
|
|
8244
|
+
branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
|
|
8245
|
+
issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
|
|
8246
|
+
issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
|
|
8247
|
+
intent: request.intent,
|
|
8248
|
+
requirements: request.requirements,
|
|
8249
|
+
design: request.design,
|
|
8250
|
+
references: request.references,
|
|
8251
|
+
phases,
|
|
8252
|
+
status: nextPlanStatus(current),
|
|
8253
|
+
execution: current?.execution,
|
|
8254
|
+
createdAt,
|
|
8255
|
+
updatedAt
|
|
8256
|
+
};
|
|
8257
|
+
}
|
|
8258
|
+
function reconcilePhase(current, draft, plan) {
|
|
8259
|
+
if (!current)
|
|
8260
|
+
return {
|
|
8261
|
+
...draft,
|
|
8262
|
+
revision: 0,
|
|
8263
|
+
status: "pending",
|
|
8264
|
+
gate: { phaseRevision: 0, status: "pending", results: [] },
|
|
8265
|
+
tasks: draft.tasks.map(newTask)
|
|
8266
|
+
};
|
|
8267
|
+
if (current.status === "completed") {
|
|
8268
|
+
if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
|
|
8269
|
+
throw new Error(`Completed phase ${current.id} cannot be changed.`);
|
|
8270
|
+
return current;
|
|
8271
|
+
}
|
|
8272
|
+
const byId = new Map(current.tasks.map((task) => [task.id, task]));
|
|
8273
|
+
for (const task of current.tasks) {
|
|
8274
|
+
if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
|
|
8275
|
+
throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
|
|
8276
|
+
}
|
|
8277
|
+
const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
|
|
8278
|
+
const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
|
|
8279
|
+
const revision = changed ? current.revision + 1 : current.revision;
|
|
8280
|
+
const wasBlocked = current.status === "blocked";
|
|
8281
|
+
return {
|
|
8282
|
+
...current,
|
|
8283
|
+
...draft,
|
|
8284
|
+
revision,
|
|
8285
|
+
tasks,
|
|
8286
|
+
status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
|
|
8287
|
+
gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
|
|
8288
|
+
blocker: wasBlocked ? undefined : current.blocker
|
|
8289
|
+
};
|
|
8290
|
+
}
|
|
8291
|
+
function reconcileTask(current, draft) {
|
|
8292
|
+
if (!current)
|
|
8293
|
+
return newTask(draft);
|
|
8294
|
+
const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
|
|
8295
|
+
if ((current.status === "completed" || current.status === "in_progress") && changed)
|
|
8296
|
+
throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
|
|
8297
|
+
if (current.status === "completed" || current.status === "in_progress")
|
|
8298
|
+
return current;
|
|
8299
|
+
return {
|
|
8300
|
+
...current,
|
|
8301
|
+
...draft,
|
|
8302
|
+
revision: changed ? current.revision + 1 : current.revision,
|
|
8303
|
+
status: current.status === "blocked" ? "pending" : current.status,
|
|
8304
|
+
owner: current.status === "blocked" ? undefined : current.owner,
|
|
8305
|
+
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
8306
|
+
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
8307
|
+
};
|
|
8308
|
+
}
|
|
8309
|
+
function newTask(draft) {
|
|
8310
|
+
return { ...draft, revision: 0, status: "pending" };
|
|
8311
|
+
}
|
|
8312
|
+
function phaseContent(phase) {
|
|
8313
|
+
return {
|
|
8314
|
+
id: phase.id,
|
|
8315
|
+
title: phase.title,
|
|
8316
|
+
objective: phase.objective,
|
|
8317
|
+
dependencies: phase.dependencies,
|
|
8318
|
+
tasks: phase.tasks.map(taskContent)
|
|
8319
|
+
};
|
|
8320
|
+
}
|
|
8321
|
+
function taskContent(task) {
|
|
8322
|
+
return { id: task.id, title: task.title, dependencies: task.dependencies };
|
|
8323
|
+
}
|
|
8324
|
+
function nextPlanStatus(current) {
|
|
8325
|
+
if (!current)
|
|
8326
|
+
return "draft";
|
|
8327
|
+
if (current.execution?.active)
|
|
8328
|
+
return current.status === "blocked" ? "in_progress" : current.status;
|
|
8329
|
+
return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
|
|
8330
|
+
}
|
|
8331
|
+
function assertCompleteRevision(document, briefs) {
|
|
8332
|
+
const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
|
|
8333
|
+
if (errors.length)
|
|
8334
|
+
throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
8335
|
+
}
|
|
8336
|
+
function sha256(value) {
|
|
8337
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
7692
8338
|
}
|
|
7693
8339
|
async function assertContained(root, candidate) {
|
|
7694
8340
|
const canonicalRoot = await realpath4(root);
|
|
@@ -7721,13 +8367,16 @@ function stripDate(value) {
|
|
|
7721
8367
|
return value.replace(/^\d{6}-/, "");
|
|
7722
8368
|
}
|
|
7723
8369
|
function titleFromSlug(value) {
|
|
7724
|
-
|
|
8370
|
+
const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
|
|
8371
|
+
return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
|
|
8372
|
+
return `${separator ? " " : ""}${letter.toUpperCase()}`;
|
|
8373
|
+
});
|
|
7725
8374
|
}
|
|
7726
8375
|
// src/setup.ts
|
|
7727
8376
|
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
|
|
8377
|
+
import { readdir as readdir3, readFile as readFile12 } from "node:fs/promises";
|
|
8378
|
+
import { homedir as homedir8 } from "node:os";
|
|
8379
|
+
import { basename as basename7, join as join16 } from "node:path";
|
|
7731
8380
|
var MISE_DEPENDENCIES = [
|
|
7732
8381
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
7733
8382
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -7765,8 +8414,8 @@ var FORGE_DEPENDENCIES = {
|
|
|
7765
8414
|
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
7766
8415
|
};
|
|
7767
8416
|
async function ensureMise(options = {}) {
|
|
7768
|
-
const homeDir = options.homeDir ??
|
|
7769
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
8417
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
8418
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join16(homeDir, ".local", "bin", "mise"));
|
|
7770
8419
|
if (current)
|
|
7771
8420
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
7772
8421
|
reportProgress(options, "Installing mise");
|
|
@@ -7815,9 +8464,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
7815
8464
|
async function ensurePiPlugins(options = {}) {
|
|
7816
8465
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
7817
8466
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7818
|
-
const webSearch = await pi.configEnsure(
|
|
8467
|
+
const webSearch = await pi.configEnsure(join16(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
7819
8468
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
7820
|
-
const lsp = await pi.configEnsure(
|
|
8469
|
+
const lsp = await pi.configEnsure(join16(agentDir, "pi-lsp.json"), (config) => ({
|
|
7821
8470
|
...config,
|
|
7822
8471
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
7823
8472
|
}), options.dryRun);
|
|
@@ -7828,11 +8477,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
7828
8477
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7829
8478
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
7830
8479
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
7831
|
-
const entries = (await
|
|
8480
|
+
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
8481
|
const actions = [];
|
|
7833
8482
|
for (const entry of entries) {
|
|
7834
8483
|
const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
|
|
7835
|
-
const source = await readFile12(
|
|
8484
|
+
const source = await readFile12(join16(bundledAgentsDir, entry.name), "utf8");
|
|
7836
8485
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
7837
8486
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
7838
8487
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -7841,7 +8490,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
7841
8490
|
}
|
|
7842
8491
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
7843
8492
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
7844
|
-
const sharedSkillsDir =
|
|
8493
|
+
const sharedSkillsDir = join16(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
7845
8494
|
const actions = [];
|
|
7846
8495
|
for (const source of PI_SKILL_SOURCES) {
|
|
7847
8496
|
const missing = [];
|
|
@@ -8037,7 +8686,7 @@ function reportProgress(options, message) {
|
|
|
8037
8686
|
options.onProgress?.(message);
|
|
8038
8687
|
}
|
|
8039
8688
|
async function packageVersion2() {
|
|
8040
|
-
const source = await readFile12(
|
|
8689
|
+
const source = await readFile12(join16(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
8041
8690
|
let value;
|
|
8042
8691
|
try {
|
|
8043
8692
|
value = JSON.parse(source);
|
|
@@ -8051,6 +8700,41 @@ async function packageVersion2() {
|
|
|
8051
8700
|
function getRecord(value) {
|
|
8052
8701
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
8053
8702
|
}
|
|
8703
|
+
// src/templates.ts
|
|
8704
|
+
import { readFile as readFile13 } from "node:fs/promises";
|
|
8705
|
+
import { homedir as homedir9 } from "node:os";
|
|
8706
|
+
import { join as join17, normalize } from "node:path";
|
|
8707
|
+
async function loadTemplate(name, options = {}) {
|
|
8708
|
+
const relative = templateRelativePath(name);
|
|
8709
|
+
const userPath = join17(options.homeDir ?? homedir9(), ".difflab", "diffpi", "templates", relative);
|
|
8710
|
+
const bundledPath = join17(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
8711
|
+
const user = await readOptionalFile(userPath);
|
|
8712
|
+
if (user !== undefined)
|
|
8713
|
+
return { name, path: userPath, source: "user", content: user };
|
|
8714
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
8715
|
+
if (bundled !== undefined)
|
|
8716
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
8717
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
8718
|
+
}
|
|
8719
|
+
function renderTemplate(content, variables) {
|
|
8720
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
8721
|
+
}
|
|
8722
|
+
function templateRelativePath(name) {
|
|
8723
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
8724
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
8725
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
8726
|
+
}
|
|
8727
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
8728
|
+
}
|
|
8729
|
+
async function readOptionalFile(path) {
|
|
8730
|
+
try {
|
|
8731
|
+
return await readFile13(path, "utf8");
|
|
8732
|
+
} catch (error) {
|
|
8733
|
+
if (error.code === "ENOENT")
|
|
8734
|
+
return;
|
|
8735
|
+
throw error;
|
|
8736
|
+
}
|
|
8737
|
+
}
|
|
8054
8738
|
export {
|
|
8055
8739
|
CONVENTIONAL_COMMIT,
|
|
8056
8740
|
GitHubVcsBackend,
|
|
@@ -8062,11 +8746,11 @@ export {
|
|
|
8062
8746
|
addComment,
|
|
8063
8747
|
assertGitHubMergeReady,
|
|
8064
8748
|
assertReviewEventSupported,
|
|
8065
|
-
captureLocalReview,
|
|
8066
8749
|
checkConventionalSubject,
|
|
8067
8750
|
computeProjectSlug,
|
|
8068
8751
|
createForge,
|
|
8069
8752
|
createForgeBackend,
|
|
8753
|
+
createLocalReviewBackend,
|
|
8070
8754
|
createModeController,
|
|
8071
8755
|
createPlanStore,
|
|
8072
8756
|
createRemoteReviewBackend,
|
|
@@ -8099,6 +8783,7 @@ export {
|
|
|
8099
8783
|
launch,
|
|
8100
8784
|
listSessions,
|
|
8101
8785
|
loadDiffpiConfig,
|
|
8786
|
+
loadReviewPublicationState,
|
|
8102
8787
|
loadTemplate,
|
|
8103
8788
|
localReviewAuthor,
|
|
8104
8789
|
mcp,
|
|
@@ -8107,28 +8792,37 @@ export {
|
|
|
8107
8792
|
openInNewTab,
|
|
8108
8793
|
parseGitlabDiffRefs,
|
|
8109
8794
|
parseRemote,
|
|
8795
|
+
parseThreadArtifact,
|
|
8110
8796
|
pi,
|
|
8111
8797
|
plansDir,
|
|
8112
|
-
readLocalReview,
|
|
8113
8798
|
readSession,
|
|
8114
8799
|
renderReviewDoc,
|
|
8115
8800
|
renderTemplate,
|
|
8801
|
+
renderThreadArtifact,
|
|
8116
8802
|
resolveAgentMode,
|
|
8117
8803
|
resolveAgentModelPreferences,
|
|
8118
8804
|
resolvePrSession,
|
|
8119
8805
|
resolveReviewSession,
|
|
8120
8806
|
resolveSession,
|
|
8807
|
+
reviewBodyFingerprint,
|
|
8808
|
+
reviewCommentFingerprint,
|
|
8121
8809
|
reviewRecordName,
|
|
8810
|
+
reviewReplyFingerprint,
|
|
8122
8811
|
reviewSlug,
|
|
8123
8812
|
reviewsDir,
|
|
8124
8813
|
runMiseGates,
|
|
8814
|
+
saveReviewPublicationState,
|
|
8815
|
+
sessionsDir,
|
|
8125
8816
|
setupPi,
|
|
8126
8817
|
severitySchema,
|
|
8127
8818
|
storeDir,
|
|
8128
8819
|
storeGlobalRoot,
|
|
8129
8820
|
templateRelativePath,
|
|
8821
|
+
toFindings,
|
|
8130
8822
|
toReviewComments,
|
|
8131
8823
|
tuicrAvailable,
|
|
8824
|
+
unpublishedReviewComments,
|
|
8825
|
+
upsertThreadReply,
|
|
8132
8826
|
withRemoteProvenance,
|
|
8133
8827
|
yymmdd,
|
|
8134
8828
|
zedKeymapPath,
|