@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/tools/index.js
CHANGED
|
@@ -503,6 +503,16 @@ function diffpiLaunchName(cwd, workflow) {
|
|
|
503
503
|
}
|
|
504
504
|
async function openFileAdjacent(path, opts) {
|
|
505
505
|
const env = opts.env ?? process.env;
|
|
506
|
+
const mux = detectMux(env);
|
|
507
|
+
if (mux !== "none") {
|
|
508
|
+
const editor = await resolveEditor(env);
|
|
509
|
+
if (editor) {
|
|
510
|
+
const command = persistentEditorArgs(editor, path, opts.cwd, env.SHELL);
|
|
511
|
+
const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", [editor, path].join(" "), command);
|
|
512
|
+
if (opened)
|
|
513
|
+
return opened;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
506
516
|
const ide = detectIde(env);
|
|
507
517
|
if (ide !== "unknown") {
|
|
508
518
|
const ideCommand = { zed: "zed", vscode: "code", cursor: "cursor", windsurf: "windsurf", jetbrains: "idea" }[ide];
|
|
@@ -512,16 +522,6 @@ async function openFileAdjacent(path, opts) {
|
|
|
512
522
|
return { launched: true, via: ide, command: `${ideCommand} ${path}` };
|
|
513
523
|
}
|
|
514
524
|
}
|
|
515
|
-
const mux = detectMux(env);
|
|
516
|
-
if (mux !== "none") {
|
|
517
|
-
const editor = await resolveEditor(env);
|
|
518
|
-
if (editor) {
|
|
519
|
-
const command = [editor, path];
|
|
520
|
-
const opened = await openMuxTab(mux, command, opts.cwd, opts.name ?? "plan", command.join(" "));
|
|
521
|
-
if (opened)
|
|
522
|
-
return opened;
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
525
|
return { launched: false, via: "print", command: path, reason: "No supported IDE or multiplexer was available." };
|
|
526
526
|
}
|
|
527
527
|
async function openInNewTab(command, opts) {
|
|
@@ -556,10 +556,15 @@ async function openInNewTab(command, opts) {
|
|
|
556
556
|
function screenWindowArgs(command, cwd, name) {
|
|
557
557
|
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
558
558
|
}
|
|
559
|
+
function persistentEditorArgs(editor, path, cwd, shell = "sh") {
|
|
560
|
+
return ["bash", "-lc", 'cd -- "$1" && shift && "$@"; exec "$0" -i', shell, cwd, editor, path];
|
|
561
|
+
}
|
|
559
562
|
async function resolveEditor(env) {
|
|
560
|
-
const configured
|
|
561
|
-
|
|
562
|
-
|
|
563
|
+
for (const configured of [env.EDITOR, env.VISUAL]) {
|
|
564
|
+
const editor = configured?.trim();
|
|
565
|
+
if (editor && await findExecutable(editor))
|
|
566
|
+
return editor;
|
|
567
|
+
}
|
|
563
568
|
for (const candidate of ["hx", "nvim", "vim"])
|
|
564
569
|
if (await findExecutable(candidate))
|
|
565
570
|
return candidate;
|
|
@@ -576,7 +581,7 @@ async function packageVersion() {
|
|
|
576
581
|
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
577
582
|
return value.version;
|
|
578
583
|
}
|
|
579
|
-
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
584
|
+
async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
|
|
580
585
|
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
581
586
|
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
582
587
|
if (result.code === 0)
|
|
@@ -586,7 +591,7 @@ async function openMuxTab(mux, command, cwd, name, printable) {
|
|
|
586
591
|
return { launched: true, via: "zellij-run", command: printable };
|
|
587
592
|
}
|
|
588
593
|
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
589
|
-
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
594
|
+
const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
590
595
|
if (result.code === 0)
|
|
591
596
|
return { launched: true, via: "tmux", command: printable };
|
|
592
597
|
}
|
|
@@ -1069,6 +1074,18 @@ async function reviewsDir(cwd, homeDir = homedir2()) {
|
|
|
1069
1074
|
await mkdir2(dir, { recursive: true });
|
|
1070
1075
|
return dir;
|
|
1071
1076
|
}
|
|
1077
|
+
async function completedReviewsDir(cwd, homeDir = homedir2()) {
|
|
1078
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1079
|
+
const dir = join6(store.link, "reviews");
|
|
1080
|
+
await mkdir2(dir, { recursive: true });
|
|
1081
|
+
return dir;
|
|
1082
|
+
}
|
|
1083
|
+
async function sessionsDir(cwd, homeDir = homedir2()) {
|
|
1084
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1085
|
+
const dir = join6(store.link, "sessions");
|
|
1086
|
+
await mkdir2(dir, { recursive: true });
|
|
1087
|
+
return dir;
|
|
1088
|
+
}
|
|
1072
1089
|
async function assertStoreLink(path, dest) {
|
|
1073
1090
|
const entry = await lstat(path);
|
|
1074
1091
|
if (!entry.isSymbolicLink())
|
|
@@ -1188,6 +1205,22 @@ var setParametersSchema = z5.object({
|
|
|
1188
1205
|
var setParameters = z5.toJSONSchema(setParametersSchema, { io: "input" });
|
|
1189
1206
|
function createModeTools(controller) {
|
|
1190
1207
|
return [
|
|
1208
|
+
defineTool4({
|
|
1209
|
+
name: "diffpi_modes_status",
|
|
1210
|
+
label: "diffpi modes status",
|
|
1211
|
+
description: "Report the actual runtime model, thinking level, and active tools for capability inspection.",
|
|
1212
|
+
promptSnippet: "Inspect actual post-compaction inline mode capabilities before claiming them.",
|
|
1213
|
+
parameters: emptyParameters,
|
|
1214
|
+
executionMode: "parallel",
|
|
1215
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
1216
|
+
emptyParametersSchema.parse(input);
|
|
1217
|
+
const status = controller.getStatus(ctx);
|
|
1218
|
+
return {
|
|
1219
|
+
content: [{ type: "text", text: JSON.stringify(status) }],
|
|
1220
|
+
details: status
|
|
1221
|
+
};
|
|
1222
|
+
}
|
|
1223
|
+
}),
|
|
1191
1224
|
defineTool4({
|
|
1192
1225
|
name: "diffpi_modes_list",
|
|
1193
1226
|
label: "diffpi modes list",
|
|
@@ -1510,6 +1543,9 @@ async function discoverMiseTasks(cwd) {
|
|
|
1510
1543
|
|
|
1511
1544
|
// src/plan/markdown.ts
|
|
1512
1545
|
var import_mdast_builder = __toESM(require_src(), 1);
|
|
1546
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1547
|
+
import { readFile as readFile5, readdir } from "node:fs/promises";
|
|
1548
|
+
import { join as join10 } from "node:path";
|
|
1513
1549
|
|
|
1514
1550
|
// ../../node_modules/zwitch/index.js
|
|
1515
1551
|
var own = {}.hasOwnProperty;
|
|
@@ -6149,6 +6185,8 @@ function isUint8Array2(value) {
|
|
|
6149
6185
|
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
6150
6186
|
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
6151
6187
|
var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
|
|
6188
|
+
var BRIEF_MARKER = /<!-- diffpi-implementation: (\{[^\n]+\}) -->/;
|
|
6189
|
+
var BRIEF_TASK_MARKER = /<!-- diffpi-brief-task: (\{[^\n]+\}) -->/g;
|
|
6152
6190
|
var SUPPORTED_SCHEMA = 1;
|
|
6153
6191
|
function countDesignWords(plan) {
|
|
6154
6192
|
return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
|
|
@@ -6177,11 +6215,13 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
6177
6215
|
if (!taskIds.has(dependency))
|
|
6178
6216
|
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
6179
6217
|
}
|
|
6180
|
-
if (options.strict && task.
|
|
6181
|
-
add("
|
|
6218
|
+
if (options.strict && isPlaceholder(task.title))
|
|
6219
|
+
add("placeholder", "error", `Task ${task.id} has placeholder content.`, task.id);
|
|
6182
6220
|
}
|
|
6183
6221
|
if (options.strict && phase.tasks.length === 0)
|
|
6184
6222
|
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
6223
|
+
if (options.strict && (isPlaceholder(phase.title) || isPlaceholder(phase.objective)))
|
|
6224
|
+
add("placeholder", "error", `Phase ${phase.id} has placeholder content.`, phase.id);
|
|
6185
6225
|
}
|
|
6186
6226
|
for (const cycle of dependencyCycles(plan))
|
|
6187
6227
|
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
@@ -6190,10 +6230,140 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
6190
6230
|
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
6191
6231
|
if (options.strict && designWords > 800)
|
|
6192
6232
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
6193
|
-
if (options.strict
|
|
6194
|
-
|
|
6233
|
+
if (options.strict) {
|
|
6234
|
+
if (plan.phases.length === 0)
|
|
6235
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
6236
|
+
if (isPlaceholder(plan.intent))
|
|
6237
|
+
add("placeholder", "error", "Intent must contain concrete content.", "intent");
|
|
6238
|
+
if (!plan.requirements.length || plan.requirements.some(isPlaceholder))
|
|
6239
|
+
add("requirements", "error", "Requirements must contain concrete content.", "requirements");
|
|
6240
|
+
for (const [key, value] of Object.entries(plan.design)) {
|
|
6241
|
+
if (isPlaceholder(value))
|
|
6242
|
+
add("design", "error", `Design/${key} must contain concrete content.`, `design.${key}`);
|
|
6243
|
+
}
|
|
6244
|
+
}
|
|
6245
|
+
return issues;
|
|
6246
|
+
}
|
|
6247
|
+
function validatePlanBriefs(plan, briefs) {
|
|
6248
|
+
const issues = [];
|
|
6249
|
+
const phaseIds = plan.phases.map((phase) => phase.id);
|
|
6250
|
+
const briefIds = briefs.map((brief) => brief.phaseId);
|
|
6251
|
+
if (!sameIds(phaseIds, briefIds))
|
|
6252
|
+
issues.push({
|
|
6253
|
+
code: "brief-phase-ids",
|
|
6254
|
+
severity: "error",
|
|
6255
|
+
message: `Brief phase IDs must exactly match plan phases in order: ${phaseIds.join(", ")}.`
|
|
6256
|
+
});
|
|
6257
|
+
for (const [index, phase] of plan.phases.entries()) {
|
|
6258
|
+
const brief = briefs[index];
|
|
6259
|
+
if (!brief || brief.phaseId !== phase.id)
|
|
6260
|
+
continue;
|
|
6261
|
+
if (isPlaceholder(brief.summary))
|
|
6262
|
+
issues.push({ code: "brief-placeholder", severity: "error", message: `Brief ${phase.id} needs a summary.` });
|
|
6263
|
+
const taskIds = phase.tasks.map((task) => task.id);
|
|
6264
|
+
const briefTaskIds = brief.tasks.map((task) => task.taskId);
|
|
6265
|
+
if (!sameIds(taskIds, briefTaskIds))
|
|
6266
|
+
issues.push({
|
|
6267
|
+
code: "brief-task-ids",
|
|
6268
|
+
severity: "error",
|
|
6269
|
+
message: `Brief ${phase.id} task IDs must exactly match phase tasks in order: ${taskIds.join(", ")}.`
|
|
6270
|
+
});
|
|
6271
|
+
for (const task of brief.tasks) {
|
|
6272
|
+
for (const [field, values] of [
|
|
6273
|
+
["ordered steps", task.steps],
|
|
6274
|
+
["file scopes", task.fileScopes],
|
|
6275
|
+
["acceptance criteria", task.acceptanceCriteria]
|
|
6276
|
+
]) {
|
|
6277
|
+
if (!values.length || values.some(isPlaceholder))
|
|
6278
|
+
issues.push({
|
|
6279
|
+
code: "brief-incomplete",
|
|
6280
|
+
severity: "error",
|
|
6281
|
+
message: `Brief task ${task.taskId} needs concrete ${field}.`,
|
|
6282
|
+
path: task.taskId
|
|
6283
|
+
});
|
|
6284
|
+
}
|
|
6285
|
+
}
|
|
6286
|
+
for (const [field, values] of [
|
|
6287
|
+
["API changes", brief.apiChanges],
|
|
6288
|
+
["libraries", brief.libraries],
|
|
6289
|
+
["constraints", brief.constraints]
|
|
6290
|
+
]) {
|
|
6291
|
+
if (values.some(isPlaceholder))
|
|
6292
|
+
issues.push({
|
|
6293
|
+
code: "brief-placeholder",
|
|
6294
|
+
severity: "error",
|
|
6295
|
+
message: `Brief ${phase.id} has placeholder ${field}.`,
|
|
6296
|
+
path: phase.id
|
|
6297
|
+
});
|
|
6298
|
+
}
|
|
6299
|
+
}
|
|
6300
|
+
return issues;
|
|
6301
|
+
}
|
|
6302
|
+
async function validatePlanRecord(record, options = {}) {
|
|
6303
|
+
const issues = validatePlanDocument(record.document, options);
|
|
6304
|
+
if (!options.strict)
|
|
6305
|
+
return issues;
|
|
6306
|
+
await validateImplementationDirectory(record.dir, record.document, issues, "latest");
|
|
6307
|
+
if (options.checkSnapshots !== false)
|
|
6308
|
+
await validateRevisionSnapshots(record, issues);
|
|
6195
6309
|
return issues;
|
|
6196
6310
|
}
|
|
6311
|
+
function renderImplementationBrief(planRevision, ordinal, phase, brief) {
|
|
6312
|
+
const marker = { schemaVersion: 1, planRevision, ordinal, phaseId: phase.id };
|
|
6313
|
+
const tasks = phase.tasks.map((task, index) => {
|
|
6314
|
+
const details = brief.tasks[index];
|
|
6315
|
+
if (!details || details.taskId !== task.id)
|
|
6316
|
+
throw new Error(`Missing implementation details for ${task.id}.`);
|
|
6317
|
+
return `<!-- diffpi-brief-task: ${json({ id: task.id })} -->
|
|
6318
|
+
### Task: ${task.title}
|
|
6319
|
+
|
|
6320
|
+
#### Ordered Steps
|
|
6321
|
+
|
|
6322
|
+
${bulletList(details.steps)}
|
|
6323
|
+
|
|
6324
|
+
#### File Scopes
|
|
6325
|
+
|
|
6326
|
+
${bulletList(details.fileScopes)}
|
|
6327
|
+
|
|
6328
|
+
#### Acceptance Criteria
|
|
6329
|
+
|
|
6330
|
+
${bulletList(details.acceptanceCriteria)}
|
|
6331
|
+
|
|
6332
|
+
<!-- /diffpi-brief-task -->`;
|
|
6333
|
+
}).join(`
|
|
6334
|
+
|
|
6335
|
+
`);
|
|
6336
|
+
return `<!-- diffpi-implementation: ${json(marker)} -->
|
|
6337
|
+
# Phase ${ordinal}: ${phase.title}
|
|
6338
|
+
|
|
6339
|
+
- **Phase ID:** ${phase.id}
|
|
6340
|
+
- **Plan Revision:** ${planRevision}
|
|
6341
|
+
|
|
6342
|
+
## Summary
|
|
6343
|
+
|
|
6344
|
+
${brief.summary}
|
|
6345
|
+
|
|
6346
|
+
## Objective
|
|
6347
|
+
|
|
6348
|
+
${phase.objective}
|
|
6349
|
+
|
|
6350
|
+
## Tasks
|
|
6351
|
+
|
|
6352
|
+
${tasks}
|
|
6353
|
+
|
|
6354
|
+
## API Changes
|
|
6355
|
+
|
|
6356
|
+
${bulletList(brief.apiChanges, "None.")}
|
|
6357
|
+
|
|
6358
|
+
## Libraries and Algorithms
|
|
6359
|
+
|
|
6360
|
+
${bulletList(brief.libraries, "None.")}
|
|
6361
|
+
|
|
6362
|
+
## Implementation Constraints
|
|
6363
|
+
|
|
6364
|
+
${bulletList(brief.constraints, "None.")}
|
|
6365
|
+
`;
|
|
6366
|
+
}
|
|
6197
6367
|
function renderPlanDocument(plan, previousSource) {
|
|
6198
6368
|
if (previousSource && canPatch(previousSource, plan))
|
|
6199
6369
|
return patchMarkers(previousSource, plan);
|
|
@@ -6202,6 +6372,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
6202
6372
|
id: plan.id,
|
|
6203
6373
|
revision: plan.revision,
|
|
6204
6374
|
branch: plan.branch,
|
|
6375
|
+
issueId: plan.issueId,
|
|
6376
|
+
issueUrl: plan.issueUrl,
|
|
6205
6377
|
status: plan.status,
|
|
6206
6378
|
execution: plan.execution,
|
|
6207
6379
|
createdAt: plan.createdAt,
|
|
@@ -6210,6 +6382,8 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
6210
6382
|
const metadata = import_mdast_builder.list("unordered", [
|
|
6211
6383
|
["Plan ID", plan.id],
|
|
6212
6384
|
["Branch", plan.branch],
|
|
6385
|
+
...plan.issueId ? [["Issue", plan.issueId]] : [],
|
|
6386
|
+
...plan.issueUrl ? [["Issue URL", plan.issueUrl]] : [],
|
|
6213
6387
|
["Status", plan.status],
|
|
6214
6388
|
["Revision", String(plan.revision)]
|
|
6215
6389
|
].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}`)]))));
|
|
@@ -6231,7 +6405,7 @@ function renderPlanDocument(plan, previousSource) {
|
|
|
6231
6405
|
import_mdast_builder.heading(3, import_mdast_builder.text("Consequences")),
|
|
6232
6406
|
import_mdast_builder.html(plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"),
|
|
6233
6407
|
import_mdast_builder.heading(2, import_mdast_builder.text("Implementation")),
|
|
6234
|
-
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with
|
|
6408
|
+
...plan.phases.length ? plan.phases.flatMap((phase) => [import_mdast_builder.html(renderPhase(phase))]) : [import_mdast_builder.html("<!-- Add phases with plan_apply_revision. -->")],
|
|
6235
6409
|
import_mdast_builder.heading(2, import_mdast_builder.text("References")),
|
|
6236
6410
|
references
|
|
6237
6411
|
]);
|
|
@@ -6305,10 +6479,7 @@ function renderTask(task) {
|
|
|
6305
6479
|
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
6306
6480
|
return `<!-- diffpi-task: ${json(marker)} -->
|
|
6307
6481
|
- [${checked}] **${task.title}**
|
|
6308
|
-
${taskList("Steps", task.steps ?? [])}
|
|
6309
6482
|
- Dependencies: ${list3(task.dependencies)}
|
|
6310
|
-
${taskList("File scopes", task.fileScopes)}
|
|
6311
|
-
${taskList("Acceptance criteria", task.acceptanceCriteria)}
|
|
6312
6483
|
<!-- /diffpi-task -->`;
|
|
6313
6484
|
}
|
|
6314
6485
|
function parsePhases(input, ref) {
|
|
@@ -6358,10 +6529,7 @@ function parseTasks(input, ref) {
|
|
|
6358
6529
|
return {
|
|
6359
6530
|
...marker,
|
|
6360
6531
|
title,
|
|
6361
|
-
|
|
6362
|
-
dependencies: parseListValue(body, "Dependencies"),
|
|
6363
|
-
fileScopes: parseListValue(body, "File scopes"),
|
|
6364
|
-
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
6532
|
+
dependencies: parseListValue(body, "Dependencies")
|
|
6365
6533
|
};
|
|
6366
6534
|
});
|
|
6367
6535
|
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
@@ -6388,6 +6556,8 @@ function patchMarkers(source, plan) {
|
|
|
6388
6556
|
id: plan.id,
|
|
6389
6557
|
revision: plan.revision,
|
|
6390
6558
|
branch: plan.branch,
|
|
6559
|
+
issueId: plan.issueId,
|
|
6560
|
+
issueUrl: plan.issueUrl,
|
|
6391
6561
|
status: plan.status,
|
|
6392
6562
|
execution: plan.execution,
|
|
6393
6563
|
createdAt: plan.createdAt,
|
|
@@ -6437,19 +6607,12 @@ function contentShape(plan) {
|
|
|
6437
6607
|
requirements: plan.requirements,
|
|
6438
6608
|
design: plan.design,
|
|
6439
6609
|
references: plan.references,
|
|
6440
|
-
phases: plan.phases.map((
|
|
6441
|
-
id,
|
|
6442
|
-
title,
|
|
6443
|
-
objective,
|
|
6444
|
-
dependencies,
|
|
6445
|
-
tasks: tasks.map(({ id
|
|
6446
|
-
id: taskId,
|
|
6447
|
-
title: taskTitle,
|
|
6448
|
-
steps,
|
|
6449
|
-
dependencies: taskDependencies,
|
|
6450
|
-
fileScopes,
|
|
6451
|
-
acceptanceCriteria
|
|
6452
|
-
}))
|
|
6610
|
+
phases: plan.phases.map((phase) => ({
|
|
6611
|
+
id: phase.id,
|
|
6612
|
+
title: phase.title,
|
|
6613
|
+
objective: phase.objective,
|
|
6614
|
+
dependencies: phase.dependencies,
|
|
6615
|
+
tasks: phase.tasks.map(({ id, title, dependencies }) => ({ id, title, dependencies }))
|
|
6453
6616
|
}))
|
|
6454
6617
|
};
|
|
6455
6618
|
}
|
|
@@ -6479,6 +6642,152 @@ function dependencyCycles(plan) {
|
|
|
6479
6642
|
walk(id, []);
|
|
6480
6643
|
return cycles;
|
|
6481
6644
|
}
|
|
6645
|
+
async function validateRevisionSnapshots(record, issues) {
|
|
6646
|
+
const revisionsDir = join10(record.dir, "revisions");
|
|
6647
|
+
let entries;
|
|
6648
|
+
try {
|
|
6649
|
+
entries = await readdir(revisionsDir);
|
|
6650
|
+
} catch {
|
|
6651
|
+
issues.push({ code: "snapshot-missing", severity: "error", message: "Missing revisions directory." });
|
|
6652
|
+
return;
|
|
6653
|
+
}
|
|
6654
|
+
const expected = Array.from({ length: record.document.revision + 1 }, (_, index) => String(index));
|
|
6655
|
+
if (!sameIds(expected, entries.filter((entry) => /^\d+$/.test(entry)).sort((a, b) => Number(a) - Number(b))))
|
|
6656
|
+
issues.push({
|
|
6657
|
+
code: "snapshot-sequence",
|
|
6658
|
+
severity: "error",
|
|
6659
|
+
message: `Revision snapshots must be exactly ${expected.join(", ")}.`,
|
|
6660
|
+
path: revisionsDir
|
|
6661
|
+
});
|
|
6662
|
+
for (const entry of entries) {
|
|
6663
|
+
if (!/^\d+$/.test(entry))
|
|
6664
|
+
issues.push({ code: "snapshot-entry", severity: "error", message: `Unexpected revisions entry: ${entry}.` });
|
|
6665
|
+
}
|
|
6666
|
+
for (const revision of expected) {
|
|
6667
|
+
const dir = join10(revisionsDir, revision);
|
|
6668
|
+
try {
|
|
6669
|
+
const [request, metadataSource, planSource] = await Promise.all([
|
|
6670
|
+
readFile5(join10(dir, "request.md"), "utf8"),
|
|
6671
|
+
readFile5(join10(dir, "metadata.json"), "utf8"),
|
|
6672
|
+
readFile5(join10(dir, "PLAN.md"), "utf8")
|
|
6673
|
+
]);
|
|
6674
|
+
if (isPlaceholder(request))
|
|
6675
|
+
issues.push({ code: "snapshot-request", severity: "error", message: `Revision ${revision} request is empty.` });
|
|
6676
|
+
const metadata = JSON.parse(metadataSource);
|
|
6677
|
+
const annotation = metadata.requestKind === "annotation" ? /^## Original annotation\n([\s\S]*?)\n\n## LLM response\n([\s\S]*)$/.exec(request) : undefined;
|
|
6678
|
+
const requestText = annotation?.[1];
|
|
6679
|
+
const responseText = annotation?.[2];
|
|
6680
|
+
if (metadata.schemaVersion !== 1 || metadata.id !== record.id || metadata.revision !== Number(revision) || !["user", "annotation", "blocker"].includes(metadata.requestKind) || (metadata.requestKind === "annotation" ? !annotation || !requestText?.trim() || !responseText?.trim() || metadata.requestSha256 !== sha256(requestText) || metadata.originalAnnotationSha256 !== sha256(requestText) || metadata.responseSha256 !== sha256(responseText) : metadata.requestSha256 !== sha256(request)))
|
|
6681
|
+
issues.push({
|
|
6682
|
+
code: "snapshot-metadata",
|
|
6683
|
+
severity: "error",
|
|
6684
|
+
message: `Revision ${revision} metadata does not match its immutable request.`
|
|
6685
|
+
});
|
|
6686
|
+
const snapshotPlan = parsePlanDocument(planSource, join10(dir, "PLAN.md"));
|
|
6687
|
+
if (snapshotPlan.id !== record.id || snapshotPlan.revision !== Number(revision))
|
|
6688
|
+
issues.push({
|
|
6689
|
+
code: "snapshot-plan",
|
|
6690
|
+
severity: "error",
|
|
6691
|
+
message: `Revision ${revision} PLAN.md has mismatched identity or revision.`
|
|
6692
|
+
});
|
|
6693
|
+
await validateImplementationDirectory(dir, snapshotPlan, issues, `revision ${revision}`);
|
|
6694
|
+
} catch (error) {
|
|
6695
|
+
issues.push({
|
|
6696
|
+
code: "snapshot-missing",
|
|
6697
|
+
severity: "error",
|
|
6698
|
+
message: `Revision ${revision} is incomplete: ${error.message}`,
|
|
6699
|
+
path: dir
|
|
6700
|
+
});
|
|
6701
|
+
}
|
|
6702
|
+
}
|
|
6703
|
+
}
|
|
6704
|
+
async function validateImplementationDirectory(baseDir, plan, issues, label) {
|
|
6705
|
+
const dir = join10(baseDir, "implementation");
|
|
6706
|
+
let entries;
|
|
6707
|
+
try {
|
|
6708
|
+
entries = await readdir(dir);
|
|
6709
|
+
} catch {
|
|
6710
|
+
issues.push({
|
|
6711
|
+
code: "brief-directory",
|
|
6712
|
+
severity: "error",
|
|
6713
|
+
message: `${label} is missing implementation/.`,
|
|
6714
|
+
path: dir
|
|
6715
|
+
});
|
|
6716
|
+
return;
|
|
6717
|
+
}
|
|
6718
|
+
const expected = plan.phases.map((_phase, index) => `phase-${index + 1}.md`);
|
|
6719
|
+
const actual = entries.filter((entry) => entry.endsWith(".md")).sort((left, right) => left.localeCompare(right));
|
|
6720
|
+
if (!sameIds([...expected].sort((left, right) => left.localeCompare(right)), actual))
|
|
6721
|
+
issues.push({
|
|
6722
|
+
code: "brief-filenames",
|
|
6723
|
+
severity: "error",
|
|
6724
|
+
message: `${label} briefs must use exactly: ${expected.join(", ") || "(none)"}.`,
|
|
6725
|
+
path: dir
|
|
6726
|
+
});
|
|
6727
|
+
for (const [index, phase] of plan.phases.entries()) {
|
|
6728
|
+
const path = join10(dir, `phase-${index + 1}.md`);
|
|
6729
|
+
try {
|
|
6730
|
+
validateBriefSource(await readFile5(path, "utf8"), plan.revision, index + 1, phase, path, issues);
|
|
6731
|
+
} catch (error) {
|
|
6732
|
+
issues.push({
|
|
6733
|
+
code: "brief-missing",
|
|
6734
|
+
severity: "error",
|
|
6735
|
+
message: `${label} brief phase-${index + 1}.md is missing: ${error.message}`,
|
|
6736
|
+
path
|
|
6737
|
+
});
|
|
6738
|
+
}
|
|
6739
|
+
}
|
|
6740
|
+
}
|
|
6741
|
+
function validateBriefSource(source, planRevision, ordinal, phase, path, issues) {
|
|
6742
|
+
const matches = source.match(new RegExp(BRIEF_MARKER.source, "g")) ?? [];
|
|
6743
|
+
const markerMatch = source.match(BRIEF_MARKER);
|
|
6744
|
+
if (matches.length !== 1 || !markerMatch) {
|
|
6745
|
+
issues.push({ code: "brief-marker", severity: "error", message: `${path} needs one implementation marker.`, path });
|
|
6746
|
+
return;
|
|
6747
|
+
}
|
|
6748
|
+
const marker = parseMarker(markerMatch[1], `${path} implementation`);
|
|
6749
|
+
if (marker.schemaVersion !== 1 || marker.planRevision !== planRevision || marker.ordinal !== ordinal || marker.phaseId !== phase.id)
|
|
6750
|
+
issues.push({
|
|
6751
|
+
code: "brief-marker",
|
|
6752
|
+
severity: "error",
|
|
6753
|
+
message: `${path} marker must identify phase ${ordinal} (${phase.id}) at revision ${planRevision}.`,
|
|
6754
|
+
path
|
|
6755
|
+
});
|
|
6756
|
+
const taskIds = [...source.matchAll(BRIEF_TASK_MARKER)].map((match) => parseMarker(match[1], `${path} task`).id);
|
|
6757
|
+
if (!sameIds(phase.tasks.map((task) => task.id), taskIds))
|
|
6758
|
+
issues.push({
|
|
6759
|
+
code: "brief-task-ids",
|
|
6760
|
+
severity: "error",
|
|
6761
|
+
message: `${path} task markers must exactly match phase task IDs in order.`,
|
|
6762
|
+
path
|
|
6763
|
+
});
|
|
6764
|
+
const prose = source.replace(BRIEF_MARKER, "").replace(BRIEF_TASK_MARKER, "").replace(/<!-- \/diffpi-brief-task -->/g, "");
|
|
6765
|
+
if (/<!--[^]*?-->|\b(?:TODO|TBD)\b|define during implementation/i.test(prose))
|
|
6766
|
+
issues.push({
|
|
6767
|
+
code: "brief-placeholder",
|
|
6768
|
+
severity: "error",
|
|
6769
|
+
message: `${path} contains placeholder content.`,
|
|
6770
|
+
path
|
|
6771
|
+
});
|
|
6772
|
+
try {
|
|
6773
|
+
if (isPlaceholder(section(source, "Summary")))
|
|
6774
|
+
throw new Error("Summary is empty.");
|
|
6775
|
+
for (const task of phase.tasks) {
|
|
6776
|
+
const taskStart = source.indexOf(`<!-- diffpi-brief-task: ${json({ id: task.id })} -->`);
|
|
6777
|
+
const taskEnd = source.indexOf("<!-- /diffpi-brief-task -->", taskStart);
|
|
6778
|
+
if (taskStart < 0 || taskEnd < 0)
|
|
6779
|
+
throw new Error(`Task ${task.id} body is missing.`);
|
|
6780
|
+
const body = source.slice(taskStart, taskEnd);
|
|
6781
|
+
for (const heading of ["Ordered Steps", "File Scopes", "Acceptance Criteria"]) {
|
|
6782
|
+
const content = briefSubsection(body, heading);
|
|
6783
|
+
if (!parseBullets(content).length || parseBullets(content).some(isPlaceholder))
|
|
6784
|
+
throw new Error(`Task ${task.id} ${heading} is incomplete.`);
|
|
6785
|
+
}
|
|
6786
|
+
}
|
|
6787
|
+
} catch (error) {
|
|
6788
|
+
issues.push({ code: "brief-incomplete", severity: "error", message: `${path}: ${error.message}`, path });
|
|
6789
|
+
}
|
|
6790
|
+
}
|
|
6482
6791
|
function section(source, heading) {
|
|
6483
6792
|
const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
6484
6793
|
if (!match?.index) {
|
|
@@ -6503,6 +6812,14 @@ function subsection(source, parent, heading) {
|
|
|
6503
6812
|
function parseBullets(input) {
|
|
6504
6813
|
return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
|
|
6505
6814
|
}
|
|
6815
|
+
function briefSubsection(source, heading) {
|
|
6816
|
+
const match = source.match(new RegExp(`^#### ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
6817
|
+
if (match?.index === undefined)
|
|
6818
|
+
throw new Error(`Missing required brief heading: ${heading}.`);
|
|
6819
|
+
const rest = source.slice(match.index + match[0].length);
|
|
6820
|
+
const end = rest.search(/^#### |^<!-- \/diffpi-brief-task -->/m);
|
|
6821
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
6822
|
+
}
|
|
6506
6823
|
function parseListValue(body, label) {
|
|
6507
6824
|
const match = new RegExp(`^ {2}- ${escapeRegExp(label)}:[ \\t]*(.*)$`, "m").exec(body);
|
|
6508
6825
|
if (!match)
|
|
@@ -6549,6 +6866,16 @@ function assertUniqueIds(ids, label) {
|
|
|
6549
6866
|
function cleanPlaceholder(value) {
|
|
6550
6867
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
6551
6868
|
}
|
|
6869
|
+
function isPlaceholder(value) {
|
|
6870
|
+
const normalized = cleanPlaceholder(value).trim();
|
|
6871
|
+
return normalized.length === 0 || /^(?:todo|tbd|none|n\/a)$/i.test(normalized) || /define during implementation|describe the|add requirements|add implementation/i.test(normalized);
|
|
6872
|
+
}
|
|
6873
|
+
function sameIds(expected, actual) {
|
|
6874
|
+
return expected.length === actual.length && expected.every((value, index) => actual[index] === value);
|
|
6875
|
+
}
|
|
6876
|
+
function sha256(value) {
|
|
6877
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
6878
|
+
}
|
|
6552
6879
|
function parseMarker(value, label) {
|
|
6553
6880
|
try {
|
|
6554
6881
|
return JSON.parse(value);
|
|
@@ -6556,15 +6883,14 @@ function parseMarker(value, label) {
|
|
|
6556
6883
|
throw new Error(`Malformed ${label} marker JSON.`);
|
|
6557
6884
|
}
|
|
6558
6885
|
}
|
|
6559
|
-
function
|
|
6886
|
+
function bulletList(values, empty = "") {
|
|
6560
6887
|
if (!values.length)
|
|
6561
|
-
return
|
|
6562
|
-
|
|
6888
|
+
return empty;
|
|
6889
|
+
return values.flatMap((value) => {
|
|
6563
6890
|
const [first = "", ...continuations] = value.split(`
|
|
6564
6891
|
`);
|
|
6565
|
-
return [
|
|
6566
|
-
})
|
|
6567
|
-
return [` - ${label}:`, ...items].join(`
|
|
6892
|
+
return [`- ${first}`, ...continuations.map((line) => ` ${line}`)];
|
|
6893
|
+
}).join(`
|
|
6568
6894
|
`);
|
|
6569
6895
|
}
|
|
6570
6896
|
function list3(values) {
|
|
@@ -6890,13 +7216,13 @@ function heartbeat(plan) {
|
|
|
6890
7216
|
}
|
|
6891
7217
|
|
|
6892
7218
|
// src/plan/reviews.ts
|
|
6893
|
-
import { mkdir as mkdir4, readFile as
|
|
6894
|
-
import { dirname as dirname5, join as
|
|
7219
|
+
import { mkdir as mkdir4, readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
|
|
7220
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
6895
7221
|
async function readPlanReview(record) {
|
|
6896
7222
|
const path = reviewPath(record);
|
|
6897
7223
|
let source;
|
|
6898
7224
|
try {
|
|
6899
|
-
source = await
|
|
7225
|
+
source = await readFile6(path, "utf8");
|
|
6900
7226
|
} catch (error) {
|
|
6901
7227
|
if (error.code === "ENOENT")
|
|
6902
7228
|
return;
|
|
@@ -6913,7 +7239,7 @@ async function readPlanReview(record) {
|
|
|
6913
7239
|
return { path, dump };
|
|
6914
7240
|
}
|
|
6915
7241
|
function reviewPath(record) {
|
|
6916
|
-
return
|
|
7242
|
+
return join11(record.dir, "reviews", `${record.document.revision}.json`);
|
|
6917
7243
|
}
|
|
6918
7244
|
function isPlanReviewDump(value) {
|
|
6919
7245
|
if (!value || typeof value !== "object")
|
|
@@ -6923,14 +7249,15 @@ function isPlanReviewDump(value) {
|
|
|
6923
7249
|
}
|
|
6924
7250
|
|
|
6925
7251
|
// src/plan/store.ts
|
|
6926
|
-
import {
|
|
7252
|
+
import { createHash as createHash3, randomUUID as randomUUID2 } from "node:crypto";
|
|
7253
|
+
import { mkdir as mkdir6, readdir as readdir2, readFile as readFile7, realpath as realpath3, rename as rename2, rm as rm2 } from "node:fs/promises";
|
|
6927
7254
|
import { basename as basename5, dirname as dirname7, join as join13, resolve as resolve3, sep } from "node:path";
|
|
6928
7255
|
|
|
6929
7256
|
// src/extensions/fsx.ts
|
|
6930
7257
|
import { mkdir as mkdir5, rename, rm, writeFile as writeFile4 } from "node:fs/promises";
|
|
6931
|
-
import { basename as basename4, dirname as dirname6, join as
|
|
7258
|
+
import { basename as basename4, dirname as dirname6, join as join12 } from "node:path";
|
|
6932
7259
|
async function atomicWrite(path, content) {
|
|
6933
|
-
const temp =
|
|
7260
|
+
const temp = join12(dirname6(path), `.${basename4(path)}.${crypto.randomUUID()}.tmp`);
|
|
6934
7261
|
await writeFile4(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
6935
7262
|
await rename(temp, path);
|
|
6936
7263
|
}
|
|
@@ -6960,42 +7287,6 @@ function sleep(ms) {
|
|
|
6960
7287
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
6961
7288
|
}
|
|
6962
7289
|
|
|
6963
|
-
// src/templates.ts
|
|
6964
|
-
import { readFile as readFile6 } from "node:fs/promises";
|
|
6965
|
-
import { homedir as homedir4 } from "node:os";
|
|
6966
|
-
import { join as join12, normalize } from "node:path";
|
|
6967
|
-
async function loadTemplate(name, options = {}) {
|
|
6968
|
-
const relative = templateRelativePath(name);
|
|
6969
|
-
const userPath = join12(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
|
|
6970
|
-
const bundledPath = join12(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
6971
|
-
const user = await readOptionalFile(userPath);
|
|
6972
|
-
if (user !== undefined)
|
|
6973
|
-
return { name, path: userPath, source: "user", content: user };
|
|
6974
|
-
const bundled = await readOptionalFile(bundledPath);
|
|
6975
|
-
if (bundled !== undefined)
|
|
6976
|
-
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
6977
|
-
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
6978
|
-
}
|
|
6979
|
-
function renderTemplate(content, variables) {
|
|
6980
|
-
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
6981
|
-
}
|
|
6982
|
-
function templateRelativePath(name) {
|
|
6983
|
-
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
6984
|
-
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
6985
|
-
throw new Error(`Invalid template name: ${name}`);
|
|
6986
|
-
}
|
|
6987
|
-
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
6988
|
-
}
|
|
6989
|
-
async function readOptionalFile(path) {
|
|
6990
|
-
try {
|
|
6991
|
-
return await readFile6(path, "utf8");
|
|
6992
|
-
} catch (error) {
|
|
6993
|
-
if (error.code === "ENOENT")
|
|
6994
|
-
return;
|
|
6995
|
-
throw error;
|
|
6996
|
-
}
|
|
6997
|
-
}
|
|
6998
|
-
|
|
6999
7290
|
// src/plan/store.ts
|
|
7000
7291
|
function planRecordName(shortSlug, date = new Date) {
|
|
7001
7292
|
const slug = normalizeSlug(shortSlug);
|
|
@@ -7003,7 +7294,7 @@ function planRecordName(shortSlug, date = new Date) {
|
|
|
7003
7294
|
}
|
|
7004
7295
|
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
7005
7296
|
const root = await plansDir(cwd, options.homeDir);
|
|
7006
|
-
const entries = await
|
|
7297
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
7007
7298
|
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
7008
7299
|
const records = [];
|
|
7009
7300
|
for (const entry of entries) {
|
|
@@ -7030,6 +7321,15 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7030
7321
|
}
|
|
7031
7322
|
function createPlanStore(options = {}) {
|
|
7032
7323
|
const now = options.now ?? (() => new Date);
|
|
7324
|
+
const read = async (cwd, query, filters) => {
|
|
7325
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
7326
|
+
if (!result.record) {
|
|
7327
|
+
if (result.ambiguous)
|
|
7328
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
7329
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
7330
|
+
}
|
|
7331
|
+
return result.record;
|
|
7332
|
+
};
|
|
7033
7333
|
return {
|
|
7034
7334
|
context(cwd, query, filters) {
|
|
7035
7335
|
return resolvePlan(cwd, query, filters, options);
|
|
@@ -7038,57 +7338,81 @@ function createPlanStore(options = {}) {
|
|
|
7038
7338
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7039
7339
|
const id = planRecordName(input.shortSlug, now());
|
|
7040
7340
|
const dir = join13(root, id);
|
|
7041
|
-
await
|
|
7042
|
-
try {
|
|
7043
|
-
await mkdir6(dir);
|
|
7044
|
-
} catch (error) {
|
|
7045
|
-
if (error.code === "EEXIST")
|
|
7046
|
-
throw new Error(`Plan ${id} already exists.`);
|
|
7047
|
-
throw error;
|
|
7048
|
-
}
|
|
7341
|
+
await createPlanDirectory(root, dir, id);
|
|
7049
7342
|
try {
|
|
7050
7343
|
const timestamp = now().toISOString();
|
|
7051
|
-
const
|
|
7052
|
-
|
|
7053
|
-
|
|
7054
|
-
});
|
|
7055
|
-
const source = renderTemplate(template.content, {
|
|
7344
|
+
const title = input.title?.trim() || titleFromSlug(input.shortSlug);
|
|
7345
|
+
const document = {
|
|
7346
|
+
schemaVersion: 1,
|
|
7056
7347
|
id,
|
|
7348
|
+
revision: 0,
|
|
7349
|
+
title,
|
|
7057
7350
|
branch: input.branch,
|
|
7058
|
-
|
|
7351
|
+
...input.issueId?.trim() ? { issueId: input.issueId.trim() } : {},
|
|
7352
|
+
...input.issueUrl?.trim() ? { issueUrl: input.issueUrl.trim() } : {},
|
|
7059
7353
|
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
7060
|
-
|
|
7061
|
-
|
|
7062
|
-
|
|
7063
|
-
|
|
7064
|
-
|
|
7065
|
-
|
|
7066
|
-
|
|
7067
|
-
return {
|
|
7068
|
-
id,
|
|
7069
|
-
dir,
|
|
7070
|
-
planPath: join13(dir, "PLAN.md"),
|
|
7071
|
-
logPath: join13(dir, "logs.txt"),
|
|
7072
|
-
implementationDir: join13(dir, "implementation"),
|
|
7073
|
-
document,
|
|
7074
|
-
source
|
|
7354
|
+
requirements: [],
|
|
7355
|
+
design: { bigIdeas: "", keyApiUpdates: "", consequences: "" },
|
|
7356
|
+
phases: [],
|
|
7357
|
+
references: [],
|
|
7358
|
+
status: "draft",
|
|
7359
|
+
createdAt: timestamp,
|
|
7360
|
+
updatedAt: timestamp
|
|
7075
7361
|
};
|
|
7362
|
+
const source = renderPlanDocument(document);
|
|
7363
|
+
parsePlanDocument(source, join13(dir, "PLAN.md"));
|
|
7364
|
+
const authoringRequest = input.request ?? { kind: "user", text: input.intent?.trim() || `Initialize phase-less plan ${title}.` };
|
|
7365
|
+
await writeRevisionSnapshot(dir, document, source, authoringRequest, []);
|
|
7366
|
+
await writeLatestView(dir, document, source, []);
|
|
7367
|
+
return await readRecord(dir);
|
|
7076
7368
|
} catch (error) {
|
|
7077
7369
|
await rm2(dir, { recursive: true, force: true });
|
|
7078
7370
|
throw error;
|
|
7079
7371
|
}
|
|
7080
7372
|
},
|
|
7081
|
-
|
|
7082
|
-
|
|
7083
|
-
if (
|
|
7084
|
-
|
|
7085
|
-
|
|
7086
|
-
|
|
7373
|
+
read,
|
|
7374
|
+
async applyRevision(cwd, request) {
|
|
7375
|
+
if (request.mode === "create") {
|
|
7376
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
7377
|
+
const id = planRecordName(request.shortSlug, now());
|
|
7378
|
+
const dir = join13(root, id);
|
|
7379
|
+
await createPlanDirectory(root, dir, id);
|
|
7380
|
+
try {
|
|
7381
|
+
const timestamp = now().toISOString();
|
|
7382
|
+
const document = normalizeRevision(undefined, request, id, 0, timestamp);
|
|
7383
|
+
assertCompleteRevision(document, request.briefs);
|
|
7384
|
+
const source = renderPlanDocument(document);
|
|
7385
|
+
parsePlanDocument(source, join13(dir, "PLAN.md"));
|
|
7386
|
+
await writeRevisionSnapshot(dir, document, source, request.request, request.briefs);
|
|
7387
|
+
await writeLatestView(dir, document, source, request.briefs);
|
|
7388
|
+
return await readRecord(dir);
|
|
7389
|
+
} catch (error) {
|
|
7390
|
+
await rm2(dir, { recursive: true, force: true });
|
|
7391
|
+
throw error;
|
|
7392
|
+
}
|
|
7087
7393
|
}
|
|
7088
|
-
|
|
7394
|
+
const initial = await read(cwd, request.plan);
|
|
7395
|
+
return withDirectoryLock(join13(initial.dir, ".lock"), async () => {
|
|
7396
|
+
const current = await readRecord(initial.dir);
|
|
7397
|
+
if (current.document.revision !== request.expectedPlanRevision)
|
|
7398
|
+
throw new Error(`Expected plan revision ${request.expectedPlanRevision}, found ${current.document.revision}.`);
|
|
7399
|
+
const revision = current.document.revision + 1;
|
|
7400
|
+
const document = normalizeRevision(current.document, request, current.id, revision, now().toISOString());
|
|
7401
|
+
assertCompleteRevision(document, request.briefs);
|
|
7402
|
+
const source = renderPlanDocument(document);
|
|
7403
|
+
parsePlanDocument(source, current.planPath);
|
|
7404
|
+
await writeRevisionSnapshot(current.dir, document, source, request.request, request.briefs);
|
|
7405
|
+
try {
|
|
7406
|
+
await writeLatestView(current.dir, document, source, request.briefs);
|
|
7407
|
+
} catch (error) {
|
|
7408
|
+
await rm2(join13(current.dir, "revisions", String(revision)), { recursive: true, force: true });
|
|
7409
|
+
throw error;
|
|
7410
|
+
}
|
|
7411
|
+
return await readRecord(current.dir);
|
|
7412
|
+
}, { operation: "apply revision" });
|
|
7089
7413
|
},
|
|
7090
7414
|
async mutate(cwd, query, operation, update) {
|
|
7091
|
-
const initial = await
|
|
7415
|
+
const initial = await read(cwd, query);
|
|
7092
7416
|
return withDirectoryLock(join13(initial.dir, ".lock"), async () => {
|
|
7093
7417
|
const current = await readRecord(initial.dir);
|
|
7094
7418
|
const next = await update(structuredClone(current.document));
|
|
@@ -7096,18 +7420,17 @@ function createPlanStore(options = {}) {
|
|
|
7096
7420
|
throw new Error("A plan mutation cannot change the plan ID.");
|
|
7097
7421
|
const document = {
|
|
7098
7422
|
...next,
|
|
7099
|
-
revision: current.document.revision
|
|
7423
|
+
revision: current.document.revision,
|
|
7100
7424
|
updatedAt: now().toISOString()
|
|
7101
7425
|
};
|
|
7102
7426
|
const source = renderPlanDocument(document, current.source);
|
|
7103
7427
|
parsePlanDocument(source, current.planPath);
|
|
7104
7428
|
await atomicWrite(current.planPath, source);
|
|
7105
|
-
|
|
7106
|
-
return { ...current, document, source, implementationDir: join13(current.dir, "implementation") };
|
|
7429
|
+
return { ...current, document, source };
|
|
7107
7430
|
}, { operation });
|
|
7108
7431
|
},
|
|
7109
7432
|
async log(cwd, query, event) {
|
|
7110
|
-
const record = await
|
|
7433
|
+
const record = await read(cwd, query);
|
|
7111
7434
|
return appendLogEntry(record.logPath, event);
|
|
7112
7435
|
}
|
|
7113
7436
|
};
|
|
@@ -7127,30 +7450,209 @@ async function readRecord(dir) {
|
|
|
7127
7450
|
source
|
|
7128
7451
|
};
|
|
7129
7452
|
}
|
|
7130
|
-
async function
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7453
|
+
async function createPlanDirectory(root, dir, id) {
|
|
7454
|
+
await assertContained(root, dirname7(dir));
|
|
7455
|
+
try {
|
|
7456
|
+
await mkdir6(dir);
|
|
7457
|
+
} catch (error) {
|
|
7458
|
+
if (error.code === "EEXIST")
|
|
7459
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
7460
|
+
throw error;
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
7463
|
+
async function writeRevisionSnapshot(planDir, document, source, request, briefs) {
|
|
7464
|
+
if (!request.text.trim())
|
|
7465
|
+
throw new Error("An exact non-empty authoring request is required.");
|
|
7466
|
+
if (request.kind === "annotation" && !request.response?.trim())
|
|
7467
|
+
throw new Error("Annotation requests require a non-empty response.");
|
|
7468
|
+
const revisionsDir = join13(planDir, "revisions");
|
|
7469
|
+
await mkdir6(revisionsDir, { recursive: true });
|
|
7470
|
+
const target = join13(revisionsDir, String(document.revision));
|
|
7471
|
+
try {
|
|
7472
|
+
await readFile7(join13(target, "metadata.json"), "utf8");
|
|
7473
|
+
throw new Error(`Revision ${document.revision} already exists and is immutable.`);
|
|
7474
|
+
} catch (error) {
|
|
7475
|
+
if (error.code !== "ENOENT")
|
|
7476
|
+
throw error;
|
|
7477
|
+
}
|
|
7478
|
+
const staging = join13(revisionsDir, `.tmp-${document.revision}-${randomUUID2()}`);
|
|
7479
|
+
await mkdir6(staging);
|
|
7480
|
+
try {
|
|
7481
|
+
const requestSource = request.kind === "annotation" ? `## Original annotation
|
|
7482
|
+
${request.text}
|
|
7483
|
+
|
|
7484
|
+
## LLM response
|
|
7485
|
+
${request.response}` : request.text;
|
|
7486
|
+
await atomicWrite(join13(staging, "request.md"), requestSource);
|
|
7487
|
+
await atomicWrite(join13(staging, "metadata.json"), `${JSON.stringify({
|
|
7488
|
+
schemaVersion: 1,
|
|
7489
|
+
id: document.id,
|
|
7490
|
+
revision: document.revision,
|
|
7491
|
+
requestKind: request.kind,
|
|
7492
|
+
requestSha256: sha2562(request.text),
|
|
7493
|
+
...request.kind === "annotation" ? { originalAnnotationSha256: sha2562(request.text), responseSha256: sha2562(request.response) } : {},
|
|
7494
|
+
createdAt: document.updatedAt
|
|
7495
|
+
}, null, 2)}
|
|
7496
|
+
`);
|
|
7497
|
+
await atomicWrite(join13(staging, "PLAN.md"), source);
|
|
7498
|
+
await writeImplementationDirectory(staging, document, briefs);
|
|
7499
|
+
await rename2(staging, target);
|
|
7500
|
+
} catch (error) {
|
|
7501
|
+
await rm2(staging, { recursive: true, force: true });
|
|
7502
|
+
throw error;
|
|
7503
|
+
}
|
|
7504
|
+
}
|
|
7505
|
+
async function writeLatestView(planDir, document, source, briefs) {
|
|
7506
|
+
const current = join13(planDir, "implementation");
|
|
7507
|
+
const stagingRoot = join13(planDir, `.latest-${randomUUID2()}`);
|
|
7508
|
+
const stagedImplementation = join13(stagingRoot, "implementation");
|
|
7509
|
+
const backup = join13(planDir, `.implementation-backup-${randomUUID2()}`);
|
|
7510
|
+
await mkdir6(stagingRoot);
|
|
7511
|
+
await writeImplementationDirectory(stagingRoot, document, briefs);
|
|
7512
|
+
let backedUp = false;
|
|
7513
|
+
try {
|
|
7139
7514
|
try {
|
|
7140
|
-
await
|
|
7515
|
+
await rename2(current, backup);
|
|
7516
|
+
backedUp = true;
|
|
7141
7517
|
} catch (error) {
|
|
7142
7518
|
if (error.code !== "ENOENT")
|
|
7143
7519
|
throw error;
|
|
7144
|
-
const tasks = phase.tasks.map((task) => `- ${task.id}: ${task.title}`).join("\\n") || "- Add implementation tasks.";
|
|
7145
|
-
await atomicWrite(path, renderTemplate(template.content, {
|
|
7146
|
-
phase_id: phase.id,
|
|
7147
|
-
phase_title: phase.title,
|
|
7148
|
-
phase_objective: phase.objective,
|
|
7149
|
-
phase_tasks: tasks
|
|
7150
|
-
}));
|
|
7151
7520
|
}
|
|
7521
|
+
await rename2(stagedImplementation, current);
|
|
7522
|
+
await atomicWrite(join13(planDir, "PLAN.md"), source);
|
|
7523
|
+
if (backedUp)
|
|
7524
|
+
await rm2(backup, { recursive: true, force: true });
|
|
7525
|
+
} catch (error) {
|
|
7526
|
+
await rm2(current, { recursive: true, force: true });
|
|
7527
|
+
if (backedUp)
|
|
7528
|
+
await rename2(backup, current);
|
|
7529
|
+
throw error;
|
|
7530
|
+
} finally {
|
|
7531
|
+
await rm2(stagingRoot, { recursive: true, force: true });
|
|
7152
7532
|
}
|
|
7153
7533
|
}
|
|
7534
|
+
async function writeImplementationDirectory(baseDir, document, briefs) {
|
|
7535
|
+
const implementationDir = join13(baseDir, "implementation");
|
|
7536
|
+
await mkdir6(implementationDir, { recursive: true });
|
|
7537
|
+
for (const [index, phase] of document.phases.entries()) {
|
|
7538
|
+
const brief = briefs[index];
|
|
7539
|
+
if (!brief)
|
|
7540
|
+
throw new Error(`Missing implementation brief for phase ${phase.id}.`);
|
|
7541
|
+
await atomicWrite(join13(implementationDir, `phase-${index + 1}.md`), renderImplementationBrief(document.revision, index + 1, phase, brief));
|
|
7542
|
+
}
|
|
7543
|
+
}
|
|
7544
|
+
function normalizeRevision(current, request, id, revision, updatedAt) {
|
|
7545
|
+
if (current?.status === "completed")
|
|
7546
|
+
throw new Error("Completed plans cannot be amended.");
|
|
7547
|
+
if (current?.execution?.active && request.request.kind !== "blocker")
|
|
7548
|
+
throw new Error("An active execution accepts only a blocker amendment.");
|
|
7549
|
+
const currentPhases = new Map(current?.phases.map((phase) => [phase.id, phase]) ?? []);
|
|
7550
|
+
for (const phase of current?.phases ?? []) {
|
|
7551
|
+
if (!request.phases.some((candidate) => candidate.id === phase.id) && (phase.status === "completed" || phase.status === "in_progress"))
|
|
7552
|
+
throw new Error(`${phase.status === "completed" ? "Completed" : "Active"} phase ${phase.id} cannot be removed.`);
|
|
7553
|
+
}
|
|
7554
|
+
const phases = request.phases.map((draft) => reconcilePhase(currentPhases.get(draft.id), draft, current));
|
|
7555
|
+
const createdAt = current?.createdAt ?? updatedAt;
|
|
7556
|
+
return {
|
|
7557
|
+
schemaVersion: 1,
|
|
7558
|
+
id,
|
|
7559
|
+
revision,
|
|
7560
|
+
title: request.title,
|
|
7561
|
+
branch: current?.branch ?? (request.mode === "create" ? request.branch : ""),
|
|
7562
|
+
issueId: current?.issueId ?? (request.mode === "create" ? request.issueId : undefined),
|
|
7563
|
+
issueUrl: current?.issueUrl ?? (request.mode === "create" ? request.issueUrl : undefined),
|
|
7564
|
+
intent: request.intent,
|
|
7565
|
+
requirements: request.requirements,
|
|
7566
|
+
design: request.design,
|
|
7567
|
+
references: request.references,
|
|
7568
|
+
phases,
|
|
7569
|
+
status: nextPlanStatus(current),
|
|
7570
|
+
execution: current?.execution,
|
|
7571
|
+
createdAt,
|
|
7572
|
+
updatedAt
|
|
7573
|
+
};
|
|
7574
|
+
}
|
|
7575
|
+
function reconcilePhase(current, draft, plan) {
|
|
7576
|
+
if (!current)
|
|
7577
|
+
return {
|
|
7578
|
+
...draft,
|
|
7579
|
+
revision: 0,
|
|
7580
|
+
status: "pending",
|
|
7581
|
+
gate: { phaseRevision: 0, status: "pending", results: [] },
|
|
7582
|
+
tasks: draft.tasks.map(newTask)
|
|
7583
|
+
};
|
|
7584
|
+
if (current.status === "completed") {
|
|
7585
|
+
if (JSON.stringify(phaseContent(current)) !== JSON.stringify(draft))
|
|
7586
|
+
throw new Error(`Completed phase ${current.id} cannot be changed.`);
|
|
7587
|
+
return current;
|
|
7588
|
+
}
|
|
7589
|
+
const byId = new Map(current.tasks.map((task) => [task.id, task]));
|
|
7590
|
+
for (const task of current.tasks) {
|
|
7591
|
+
if (!draft.tasks.some((candidate) => candidate.id === task.id) && (task.status === "completed" || task.status === "in_progress"))
|
|
7592
|
+
throw new Error(`${task.status === "completed" ? "Completed" : "Active"} task ${task.id} cannot be removed.`);
|
|
7593
|
+
}
|
|
7594
|
+
const tasks = draft.tasks.map((task) => reconcileTask(byId.get(task.id), task));
|
|
7595
|
+
const changed = JSON.stringify(phaseContent(current)) !== JSON.stringify(draft);
|
|
7596
|
+
const revision = changed ? current.revision + 1 : current.revision;
|
|
7597
|
+
const wasBlocked = current.status === "blocked";
|
|
7598
|
+
return {
|
|
7599
|
+
...current,
|
|
7600
|
+
...draft,
|
|
7601
|
+
revision,
|
|
7602
|
+
tasks,
|
|
7603
|
+
status: wasBlocked ? plan?.execution?.active ? "in_progress" : "pending" : current.status,
|
|
7604
|
+
gate: changed || wasBlocked ? { phaseRevision: revision, status: "stale", results: [] } : current.gate,
|
|
7605
|
+
blocker: wasBlocked ? undefined : current.blocker
|
|
7606
|
+
};
|
|
7607
|
+
}
|
|
7608
|
+
function reconcileTask(current, draft) {
|
|
7609
|
+
if (!current)
|
|
7610
|
+
return newTask(draft);
|
|
7611
|
+
const changed = JSON.stringify(taskContent(current)) !== JSON.stringify(draft);
|
|
7612
|
+
if ((current.status === "completed" || current.status === "in_progress") && changed)
|
|
7613
|
+
throw new Error(`${current.status === "completed" ? "Completed" : "Active"} task ${current.id} cannot be changed.`);
|
|
7614
|
+
if (current.status === "completed" || current.status === "in_progress")
|
|
7615
|
+
return current;
|
|
7616
|
+
return {
|
|
7617
|
+
...current,
|
|
7618
|
+
...draft,
|
|
7619
|
+
revision: changed ? current.revision + 1 : current.revision,
|
|
7620
|
+
status: current.status === "blocked" ? "pending" : current.status,
|
|
7621
|
+
owner: current.status === "blocked" ? undefined : current.owner,
|
|
7622
|
+
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
7623
|
+
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
7624
|
+
};
|
|
7625
|
+
}
|
|
7626
|
+
function newTask(draft) {
|
|
7627
|
+
return { ...draft, revision: 0, status: "pending" };
|
|
7628
|
+
}
|
|
7629
|
+
function phaseContent(phase) {
|
|
7630
|
+
return {
|
|
7631
|
+
id: phase.id,
|
|
7632
|
+
title: phase.title,
|
|
7633
|
+
objective: phase.objective,
|
|
7634
|
+
dependencies: phase.dependencies,
|
|
7635
|
+
tasks: phase.tasks.map(taskContent)
|
|
7636
|
+
};
|
|
7637
|
+
}
|
|
7638
|
+
function taskContent(task) {
|
|
7639
|
+
return { id: task.id, title: task.title, dependencies: task.dependencies };
|
|
7640
|
+
}
|
|
7641
|
+
function nextPlanStatus(current) {
|
|
7642
|
+
if (!current)
|
|
7643
|
+
return "draft";
|
|
7644
|
+
if (current.execution?.active)
|
|
7645
|
+
return current.status === "blocked" ? "in_progress" : current.status;
|
|
7646
|
+
return current.status === "ready" || current.status === "blocked" ? "draft" : current.status;
|
|
7647
|
+
}
|
|
7648
|
+
function assertCompleteRevision(document, briefs) {
|
|
7649
|
+
const errors = [...validatePlanDocument(document, { strict: true }), ...validatePlanBriefs(document, briefs)].filter((issue) => issue.severity === "error");
|
|
7650
|
+
if (errors.length)
|
|
7651
|
+
throw new Error(`Invalid plan revision: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
7652
|
+
}
|
|
7653
|
+
function sha2562(value) {
|
|
7654
|
+
return createHash3("sha256").update(value).digest("hex");
|
|
7655
|
+
}
|
|
7154
7656
|
async function assertContained(root, candidate) {
|
|
7155
7657
|
const canonicalRoot = await realpath3(root);
|
|
7156
7658
|
let canonicalCandidate;
|
|
@@ -7182,34 +7684,54 @@ function stripDate(value) {
|
|
|
7182
7684
|
return value.replace(/^\d{6}-/, "");
|
|
7183
7685
|
}
|
|
7184
7686
|
function titleFromSlug(value) {
|
|
7185
|
-
|
|
7687
|
+
const slug = normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "");
|
|
7688
|
+
return slug.replace(/(^|-)([a-z])/g, (_match, separator, letter) => {
|
|
7689
|
+
return `${separator ? " " : ""}${letter.toUpperCase()}`;
|
|
7690
|
+
});
|
|
7186
7691
|
}
|
|
7187
7692
|
|
|
7188
|
-
// src/
|
|
7693
|
+
// src/plan/schema.ts
|
|
7189
7694
|
import { z as z6 } from "zod";
|
|
7190
7695
|
var planStatusSchema = z6.enum(["draft", "ready", "in_progress", "blocked", "completed"]);
|
|
7191
7696
|
var planUnitStatusSchema = z6.enum(["draft", "ready", "in_progress", "blocked", "completed", "pending", "skipped"]);
|
|
7192
|
-
var planTaskDraftSchema = z6.object({
|
|
7193
|
-
id: zx.id,
|
|
7194
|
-
title: zx.text,
|
|
7195
|
-
steps: z6.array(zx.text).optional(),
|
|
7196
|
-
dependencies: z6.array(zx.id).optional(),
|
|
7197
|
-
fileScopes: z6.array(zx.text).optional(),
|
|
7198
|
-
acceptanceCriteria: z6.array(zx.text).optional()
|
|
7199
|
-
}).strict();
|
|
7697
|
+
var planTaskDraftSchema = z6.object({ id: zx.id, title: zx.text, dependencies: z6.array(zx.id) }).strict();
|
|
7200
7698
|
var planPhaseDraftSchema = z6.object({
|
|
7201
7699
|
id: zx.id,
|
|
7202
7700
|
title: zx.text,
|
|
7203
7701
|
objective: zx.text,
|
|
7204
|
-
dependencies: z6.array(zx.id)
|
|
7205
|
-
tasks: z6.array(planTaskDraftSchema).
|
|
7702
|
+
dependencies: z6.array(zx.id),
|
|
7703
|
+
tasks: z6.array(planTaskDraftSchema).min(1)
|
|
7206
7704
|
}).strict();
|
|
7207
|
-
var
|
|
7208
|
-
|
|
7209
|
-
|
|
7210
|
-
|
|
7211
|
-
|
|
7705
|
+
var planBriefSchema = z6.object({
|
|
7706
|
+
phaseId: zx.id,
|
|
7707
|
+
summary: zx.text,
|
|
7708
|
+
apiChanges: z6.array(zx.text),
|
|
7709
|
+
libraries: z6.array(zx.text),
|
|
7710
|
+
constraints: z6.array(zx.text),
|
|
7711
|
+
tasks: z6.array(z6.object({
|
|
7712
|
+
taskId: zx.id,
|
|
7713
|
+
steps: z6.array(zx.text).min(1),
|
|
7714
|
+
fileScopes: z6.array(zx.text).min(1),
|
|
7715
|
+
acceptanceCriteria: z6.array(zx.text).min(1)
|
|
7716
|
+
}).strict()).min(1)
|
|
7212
7717
|
}).strict();
|
|
7718
|
+
var planReferenceSchema = z6.object({ id: zx.id, value: zx.text }).strict();
|
|
7719
|
+
var planDesignSchema = z6.object({ bigIdeas: zx.text, keyApiUpdates: zx.text, consequences: zx.text }).strict();
|
|
7720
|
+
var planRequestSchema = z6.discriminatedUnion("kind", [
|
|
7721
|
+
z6.object({ kind: z6.literal("annotation"), text: zx.text, response: zx.text }).strict(),
|
|
7722
|
+
z6.object({ kind: z6.literal("user"), text: zx.text, response: zx.text.optional() }).strict(),
|
|
7723
|
+
z6.object({ kind: z6.literal("blocker"), text: zx.text, response: zx.text.optional() }).strict()
|
|
7724
|
+
]);
|
|
7725
|
+
var revisionContentFields = {
|
|
7726
|
+
request: planRequestSchema,
|
|
7727
|
+
title: zx.text,
|
|
7728
|
+
intent: zx.text,
|
|
7729
|
+
requirements: z6.array(zx.text).min(1),
|
|
7730
|
+
design: planDesignSchema,
|
|
7731
|
+
references: z6.array(planReferenceSchema),
|
|
7732
|
+
phases: z6.array(planPhaseDraftSchema).min(1),
|
|
7733
|
+
briefs: z6.array(planBriefSchema).min(1)
|
|
7734
|
+
};
|
|
7213
7735
|
var planContextParametersSchema = z6.object({
|
|
7214
7736
|
cwd: zx.cwd,
|
|
7215
7737
|
plan: z6.string().optional(),
|
|
@@ -7223,46 +7745,28 @@ var planInitParametersSchema = z6.object({
|
|
|
7223
7745
|
title: zx.text.optional(),
|
|
7224
7746
|
intent: zx.text.optional(),
|
|
7225
7747
|
issueId: zx.text.optional(),
|
|
7226
|
-
issueUrl: z6.
|
|
7748
|
+
issueUrl: z6.url().optional(),
|
|
7749
|
+
request: planRequestSchema.optional(),
|
|
7227
7750
|
open: z6.boolean().optional()
|
|
7228
7751
|
}).strict();
|
|
7229
|
-
var
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
|
|
7233
|
-
|
|
7234
|
-
|
|
7235
|
-
|
|
7236
|
-
|
|
7237
|
-
|
|
7238
|
-
|
|
7239
|
-
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7244
|
-
|
|
7245
|
-
phase: planPhaseDraftSchema
|
|
7246
|
-
}).strict();
|
|
7247
|
-
var planRemovePhaseParametersSchema = z6.object({
|
|
7248
|
-
cwd: zx.cwd,
|
|
7249
|
-
plan: zx.text,
|
|
7250
|
-
expectedPlanRevision: zx.revision,
|
|
7251
|
-
phaseId: zx.id,
|
|
7252
|
-
reason: zx.text
|
|
7253
|
-
}).strict();
|
|
7254
|
-
var planUpdatePhaseParametersSchema = z6.object({
|
|
7255
|
-
cwd: zx.cwd,
|
|
7256
|
-
plan: zx.text,
|
|
7257
|
-
expectedPlanRevision: zx.revision,
|
|
7258
|
-
phaseId: zx.id,
|
|
7259
|
-
patch: z6.object({
|
|
7260
|
-
title: zx.text.optional(),
|
|
7261
|
-
objective: zx.text.optional(),
|
|
7262
|
-
dependencies: z6.array(zx.id).optional(),
|
|
7263
|
-
tasks: z6.array(planTaskDraftSchema).optional()
|
|
7752
|
+
var planApplyRevisionParametersSchema = z6.discriminatedUnion("mode", [
|
|
7753
|
+
z6.object({
|
|
7754
|
+
mode: z6.literal("create"),
|
|
7755
|
+
cwd: zx.cwd,
|
|
7756
|
+
shortSlug: zx.id,
|
|
7757
|
+
branch: z6.string().min(1).optional(),
|
|
7758
|
+
issueId: zx.text.optional(),
|
|
7759
|
+
issueUrl: z6.url().optional(),
|
|
7760
|
+
...revisionContentFields
|
|
7761
|
+
}).strict(),
|
|
7762
|
+
z6.object({
|
|
7763
|
+
mode: z6.literal("amend"),
|
|
7764
|
+
cwd: zx.cwd,
|
|
7765
|
+
plan: zx.text,
|
|
7766
|
+
expectedPlanRevision: zx.revision,
|
|
7767
|
+
...revisionContentFields
|
|
7264
7768
|
}).strict()
|
|
7265
|
-
|
|
7769
|
+
]);
|
|
7266
7770
|
var planValidateParametersSchema = z6.object({ cwd: zx.cwd, plan: zx.text, strict: z6.boolean().optional() }).strict();
|
|
7267
7771
|
var planLogProgressParametersSchema = z6.object({
|
|
7268
7772
|
cwd: zx.cwd,
|
|
@@ -7362,116 +7866,17 @@ function createPlanTools(_pi, _modes, store = createPlanStore()) {
|
|
|
7362
7866
|
}
|
|
7363
7867
|
}),
|
|
7364
7868
|
defineTool5({
|
|
7365
|
-
name: "
|
|
7366
|
-
label: "plan
|
|
7367
|
-
description: "
|
|
7368
|
-
parameters: parameters(
|
|
7869
|
+
name: "plan_apply_revision",
|
|
7870
|
+
label: "plan apply revision",
|
|
7871
|
+
description: "Create mode writes a complete revision 0 without plan_init; amend mode atomically writes the next complete authoring revision.",
|
|
7872
|
+
parameters: parameters(planApplyRevisionParametersSchema),
|
|
7369
7873
|
executionMode: "sequential",
|
|
7370
7874
|
async execute(_id, input) {
|
|
7371
|
-
const params =
|
|
7372
|
-
const
|
|
7373
|
-
|
|
7374
|
-
|
|
7375
|
-
|
|
7376
|
-
...plan,
|
|
7377
|
-
intent: params.intent ?? plan.intent,
|
|
7378
|
-
requirements: params.requirements ?? plan.requirements,
|
|
7379
|
-
design: params.design ?? plan.design,
|
|
7380
|
-
references: params.references ?? plan.references
|
|
7381
|
-
});
|
|
7382
|
-
});
|
|
7383
|
-
return result2(`Updated ${record.id} to revision ${record.document.revision}.`, { record });
|
|
7384
|
-
}
|
|
7385
|
-
}),
|
|
7386
|
-
defineTool5({
|
|
7387
|
-
name: "plan_add_phase",
|
|
7388
|
-
label: "plan add phase",
|
|
7389
|
-
description: "Add an ordered phase with stable IDs.",
|
|
7390
|
-
parameters: parameters(planAddPhaseParametersSchema),
|
|
7391
|
-
executionMode: "sequential",
|
|
7392
|
-
async execute(_id, input) {
|
|
7393
|
-
const params = planAddPhaseParametersSchema.parse(input);
|
|
7394
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "add phase", (plan) => {
|
|
7395
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
7396
|
-
assertAuthorable(plan);
|
|
7397
|
-
if (allIds(plan).has(params.phase.id))
|
|
7398
|
-
throw new Error(`Duplicate stable ID: ${params.phase.id}.`);
|
|
7399
|
-
const phase = newPhase(params.phase);
|
|
7400
|
-
const phases = [...plan.phases];
|
|
7401
|
-
if (params.afterPhaseId) {
|
|
7402
|
-
const index = phases.findIndex((item) => item.id === params.afterPhaseId);
|
|
7403
|
-
if (index < 0)
|
|
7404
|
-
throw new Error(`Unknown phase: ${params.afterPhaseId}.`);
|
|
7405
|
-
phases.splice(index + 1, 0, phase);
|
|
7406
|
-
} else
|
|
7407
|
-
phases.push(phase);
|
|
7408
|
-
assertNewIds(plan, phase);
|
|
7409
|
-
return resetDraft({ ...plan, phases });
|
|
7410
|
-
});
|
|
7411
|
-
return result2(`Added phase ${params.phase.id} to ${record.id}.`, { record });
|
|
7412
|
-
}
|
|
7413
|
-
}),
|
|
7414
|
-
defineTool5({
|
|
7415
|
-
name: "plan_remove_phase",
|
|
7416
|
-
label: "plan remove phase",
|
|
7417
|
-
description: "Remove an unfinished phase that has no dependents.",
|
|
7418
|
-
parameters: parameters(planRemovePhaseParametersSchema),
|
|
7419
|
-
executionMode: "sequential",
|
|
7420
|
-
async execute(_id, input) {
|
|
7421
|
-
const params = planRemovePhaseParametersSchema.parse(input);
|
|
7422
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "remove phase", (plan) => {
|
|
7423
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
7424
|
-
assertAuthorable(plan);
|
|
7425
|
-
const phase = getPhase2(plan, params.phaseId);
|
|
7426
|
-
if (phase.status === "completed" || phase.tasks.some((task) => task.status === "completed"))
|
|
7427
|
-
throw new Error(`Completed phase ${phase.id} cannot be removed.`);
|
|
7428
|
-
if (plan.phases.some((item) => item.dependencies.includes(phase.id)))
|
|
7429
|
-
throw new Error(`Phase ${phase.id} has dependents and cannot be removed.`);
|
|
7430
|
-
return resetDraft({ ...plan, phases: plan.phases.filter((item) => item.id !== phase.id) });
|
|
7431
|
-
});
|
|
7432
|
-
await store.log(params.cwd ?? process.cwd(), record.id, {
|
|
7433
|
-
planRevision: record.document.revision,
|
|
7434
|
-
kind: "updated",
|
|
7435
|
-
actor: "planner",
|
|
7436
|
-
message: `Removed phase ${params.phaseId}: ${params.reason}`,
|
|
7437
|
-
phaseId: params.phaseId
|
|
7438
|
-
});
|
|
7439
|
-
return result2(`Removed phase ${params.phaseId}.`, { record });
|
|
7440
|
-
}
|
|
7441
|
-
}),
|
|
7442
|
-
defineTool5({
|
|
7443
|
-
name: "plan_update_phase",
|
|
7444
|
-
label: "plan update phase",
|
|
7445
|
-
description: "Update an unfinished phase and its ordered tasks.",
|
|
7446
|
-
parameters: parameters(planUpdatePhaseParametersSchema),
|
|
7447
|
-
executionMode: "sequential",
|
|
7448
|
-
async execute(_id, input) {
|
|
7449
|
-
const params = planUpdatePhaseParametersSchema.parse(input);
|
|
7450
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "update phase", (plan) => {
|
|
7451
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
7452
|
-
assertAuthorable(plan, params.phaseId);
|
|
7453
|
-
const existing = getPhase2(plan, params.phaseId);
|
|
7454
|
-
if (existing.status === "completed")
|
|
7455
|
-
throw new Error(`Completed phase ${existing.id} cannot be changed.`);
|
|
7456
|
-
const tasks = params.patch.tasks ? reconcileTasks(existing.tasks, params.patch.tasks) : existing.tasks;
|
|
7457
|
-
const updated = {
|
|
7458
|
-
...existing,
|
|
7459
|
-
title: params.patch.title ?? existing.title,
|
|
7460
|
-
objective: params.patch.objective ?? existing.objective,
|
|
7461
|
-
dependencies: params.patch.dependencies ?? existing.dependencies,
|
|
7462
|
-
tasks,
|
|
7463
|
-
revision: existing.revision + 1,
|
|
7464
|
-
status: existing.status === "blocked" ? "in_progress" : existing.status,
|
|
7465
|
-
gate: { ...existing.gate, status: "stale" },
|
|
7466
|
-
blocker: existing.status === "blocked" ? undefined : existing.blocker
|
|
7467
|
-
};
|
|
7468
|
-
assertNewIds({ ...plan, phases: plan.phases.filter((phase) => phase.id !== existing.id) }, updated);
|
|
7469
|
-
return resetDraft({
|
|
7470
|
-
...plan,
|
|
7471
|
-
phases: plan.phases.map((phase) => phase.id === existing.id ? updated : phase)
|
|
7472
|
-
});
|
|
7473
|
-
});
|
|
7474
|
-
return result2(`Updated phase ${params.phaseId}.`, { record });
|
|
7875
|
+
const params = planApplyRevisionParametersSchema.parse(input);
|
|
7876
|
+
const workingDirectory = params.cwd ?? process.cwd();
|
|
7877
|
+
const request = params.mode === "create" ? { ...params, branch: params.branch ?? await currentBranch(workingDirectory) } : params;
|
|
7878
|
+
const record = await store.applyRevision(workingDirectory, request);
|
|
7879
|
+
return result2(`Applied revision ${record.document.revision} to ${record.id}.`, { record });
|
|
7475
7880
|
}
|
|
7476
7881
|
}),
|
|
7477
7882
|
defineTool5({
|
|
@@ -7483,7 +7888,7 @@ function createPlanTools(_pi, _modes, store = createPlanStore()) {
|
|
|
7483
7888
|
async execute(_id, input) {
|
|
7484
7889
|
const params = planValidateParametersSchema.parse(input);
|
|
7485
7890
|
const record = await store.read(params.cwd ?? process.cwd(), params.plan);
|
|
7486
|
-
const issues =
|
|
7891
|
+
const issues = await validatePlanRecord(record, { strict: params.strict });
|
|
7487
7892
|
const words = countDesignWords(record.document);
|
|
7488
7893
|
return result2(issues.length ? issues.map((issue) => `- ${issue.severity}: ${issue.message}`).join(`
|
|
7489
7894
|
`) : "Plan is valid.", { record, issues, designWordCount: words, ready: !issues.some((issue) => issue.severity === "error") });
|
|
@@ -7714,8 +8119,8 @@ function renderExecutionPrompt(packet) {
|
|
|
7714
8119
|
return [
|
|
7715
8120
|
`Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
|
|
7716
8121
|
"Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
|
|
7717
|
-
"Use
|
|
7718
|
-
"
|
|
8122
|
+
"Use bounded Agent delegation for eligible tasks. Run dependent tasks in order; parallelize only tasks with non-overlapping declared file scopes. Collect and verify worker results before changing plan state.",
|
|
8123
|
+
"Use a multi-agent SubagentWorkflow only when the user explicitly opts into that orchestration.",
|
|
7719
8124
|
"Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
|
|
7720
8125
|
"In commit mode, create one local commit after each phase passes its gates and do not push. In push mode, commit and push each phase, record pending CI, and launch a bounded background Worker to call watch_ci for the exact SHA and then plan_record_ci for the phase while the next phase executes. Collect that monitor before the next push and collect all monitors before plan completion; failed or timed-out CI blocks execution."
|
|
7721
8126
|
].join(" ");
|
|
@@ -7779,81 +8184,6 @@ ${dirty.stdout.trim()}`);
|
|
|
7779
8184
|
prompt: renderExecutionPrompt(packet)
|
|
7780
8185
|
});
|
|
7781
8186
|
}
|
|
7782
|
-
function newPhase(input) {
|
|
7783
|
-
return {
|
|
7784
|
-
id: input.id,
|
|
7785
|
-
revision: 0,
|
|
7786
|
-
title: input.title,
|
|
7787
|
-
objective: input.objective,
|
|
7788
|
-
dependencies: input.dependencies ?? [],
|
|
7789
|
-
tasks: (input.tasks ?? []).map(newTask),
|
|
7790
|
-
status: "pending",
|
|
7791
|
-
gate: { phaseRevision: 0, status: "pending", results: [] }
|
|
7792
|
-
};
|
|
7793
|
-
}
|
|
7794
|
-
function newTask(input) {
|
|
7795
|
-
return {
|
|
7796
|
-
id: input.id,
|
|
7797
|
-
revision: 0,
|
|
7798
|
-
title: input.title,
|
|
7799
|
-
steps: input.steps,
|
|
7800
|
-
dependencies: input.dependencies ?? [],
|
|
7801
|
-
fileScopes: input.fileScopes ?? [],
|
|
7802
|
-
acceptanceCriteria: input.acceptanceCriteria ?? [],
|
|
7803
|
-
status: "pending"
|
|
7804
|
-
};
|
|
7805
|
-
}
|
|
7806
|
-
function reconcileTasks(existing, drafts) {
|
|
7807
|
-
const byId = new Map(existing.map((task) => [task.id, task]));
|
|
7808
|
-
const requested = new Set(drafts.map((task) => task.id));
|
|
7809
|
-
const protectedTask = existing.find((task) => (task.status === "completed" || task.status === "in_progress") && !requested.has(task.id));
|
|
7810
|
-
if (protectedTask)
|
|
7811
|
-
throw new Error(`${protectedTask.status === "completed" ? "Completed" : "Active"} task ${protectedTask.id} cannot be removed.`);
|
|
7812
|
-
return drafts.map((draft) => {
|
|
7813
|
-
const current = byId.get(draft.id);
|
|
7814
|
-
if (current?.status === "completed" || current?.status === "in_progress")
|
|
7815
|
-
return current;
|
|
7816
|
-
return current ? {
|
|
7817
|
-
...newTask(draft),
|
|
7818
|
-
revision: current.revision + 1,
|
|
7819
|
-
status: current.status === "blocked" ? "pending" : current.status,
|
|
7820
|
-
owner: current.status === "blocked" ? undefined : current.owner,
|
|
7821
|
-
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
7822
|
-
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
7823
|
-
} : newTask(draft);
|
|
7824
|
-
});
|
|
7825
|
-
}
|
|
7826
|
-
function assertAuthoringRevision(plan, expected) {
|
|
7827
|
-
if (plan.revision !== expected)
|
|
7828
|
-
throw new Error(`Stale plan revision: expected ${expected}, found ${plan.revision}.`);
|
|
7829
|
-
}
|
|
7830
|
-
function assertAuthorable(plan, blockedPhaseId) {
|
|
7831
|
-
if (plan.status === "completed")
|
|
7832
|
-
throw new Error("Completed plans cannot be structurally changed.");
|
|
7833
|
-
if (!plan.execution?.active)
|
|
7834
|
-
return;
|
|
7835
|
-
if (!blockedPhaseId)
|
|
7836
|
-
throw new Error("An active execution owns this plan.");
|
|
7837
|
-
const phase = getPhase2(plan, blockedPhaseId);
|
|
7838
|
-
if (phase.status !== "blocked" && !phase.tasks.some((task) => task.status === "blocked"))
|
|
7839
|
-
throw new Error("Planner may amend only blocked work during an active execution.");
|
|
7840
|
-
}
|
|
7841
|
-
function resetDraft(plan) {
|
|
7842
|
-
if (plan.status === "blocked" && plan.execution?.active)
|
|
7843
|
-
return { ...plan, status: "in_progress" };
|
|
7844
|
-
return plan.status === "ready" || plan.status === "blocked" ? { ...plan, status: "draft" } : plan;
|
|
7845
|
-
}
|
|
7846
|
-
function allIds(plan) {
|
|
7847
|
-
return new Set([plan.id, ...plan.phases.flatMap((phase) => [phase.id, ...phase.tasks.map((task) => task.id)])]);
|
|
7848
|
-
}
|
|
7849
|
-
function assertNewIds(plan, phase) {
|
|
7850
|
-
const known = allIds(plan);
|
|
7851
|
-
for (const candidate of [phase.id, ...phase.tasks.map((task) => task.id)]) {
|
|
7852
|
-
if (known.has(candidate))
|
|
7853
|
-
throw new Error(`Duplicate stable ID: ${candidate}.`);
|
|
7854
|
-
known.add(candidate);
|
|
7855
|
-
}
|
|
7856
|
-
}
|
|
7857
8187
|
function getPhase2(plan, phaseId) {
|
|
7858
8188
|
const phase = plan.phases.find((item) => item.id === phaseId);
|
|
7859
8189
|
if (!phase)
|
|
@@ -7891,10 +8221,10 @@ function result2(text, details = {}) {
|
|
|
7891
8221
|
|
|
7892
8222
|
// src/tools/review.ts
|
|
7893
8223
|
import { existsSync as existsSync2 } from "node:fs";
|
|
7894
|
-
import { mkdir as
|
|
7895
|
-
import { join as
|
|
8224
|
+
import { mkdir as mkdir7, readdir as readdir3, readFile as readFile12, rename as rename4, stat, unlink as unlink2, writeFile as writeFile7 } from "node:fs/promises";
|
|
8225
|
+
import { join as join17 } from "node:path";
|
|
7896
8226
|
import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
|
|
7897
|
-
import { z as
|
|
8227
|
+
import { z as z11 } from "zod";
|
|
7898
8228
|
|
|
7899
8229
|
// src/review/types.ts
|
|
7900
8230
|
import { z as z8 } from "zod";
|
|
@@ -7919,6 +8249,21 @@ function yymmdd(date = new Date) {
|
|
|
7919
8249
|
function reviewRecordName(target, date = new Date) {
|
|
7920
8250
|
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
7921
8251
|
}
|
|
8252
|
+
function localResponseMarker(threadId) {
|
|
8253
|
+
return `<!-- diffpi -->`;
|
|
8254
|
+
}
|
|
8255
|
+
function isLocalResponse(body) {
|
|
8256
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
8257
|
+
}
|
|
8258
|
+
function parseReviewThreadAction(body) {
|
|
8259
|
+
const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
|
|
8260
|
+
if (!match)
|
|
8261
|
+
return { body };
|
|
8262
|
+
return {
|
|
8263
|
+
action: match[1].toLowerCase(),
|
|
8264
|
+
body: body.slice(match[0].length)
|
|
8265
|
+
};
|
|
8266
|
+
}
|
|
7922
8267
|
function dedupeFindings(findings) {
|
|
7923
8268
|
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
7924
8269
|
const byKey = new Map;
|
|
@@ -7964,6 +8309,18 @@ function renderCommentBody(finding) {
|
|
|
7964
8309
|
return `${prefix}${finding.body}${reference}`;
|
|
7965
8310
|
}
|
|
7966
8311
|
// src/review/review-markdown.ts
|
|
8312
|
+
import { z as z9 } from "zod";
|
|
8313
|
+
var reviewThreadRecordSchema = z9.object({
|
|
8314
|
+
id: z9.string().min(1),
|
|
8315
|
+
file: z9.string().nullable().optional().transform((value) => value ?? undefined),
|
|
8316
|
+
line: z9.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
|
|
8317
|
+
body: z9.string(),
|
|
8318
|
+
author: z9.string().optional(),
|
|
8319
|
+
resolved: z9.boolean(),
|
|
8320
|
+
addressed: z9.boolean().optional(),
|
|
8321
|
+
question: z9.boolean(),
|
|
8322
|
+
replies: z9.array(z9.string()).optional()
|
|
8323
|
+
});
|
|
7967
8324
|
function renderReviewDoc(input) {
|
|
7968
8325
|
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
7969
8326
|
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
@@ -8005,6 +8362,88 @@ function renderReviewDoc(input) {
|
|
|
8005
8362
|
`).trimEnd()}
|
|
8006
8363
|
`;
|
|
8007
8364
|
}
|
|
8365
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
8366
|
+
const records = threads.map((thread) => ({
|
|
8367
|
+
id: thread.id,
|
|
8368
|
+
file: thread.file,
|
|
8369
|
+
line: thread.line,
|
|
8370
|
+
body: thread.body,
|
|
8371
|
+
author: thread.author,
|
|
8372
|
+
resolved: thread.resolved,
|
|
8373
|
+
addressed: thread.addressed,
|
|
8374
|
+
question: thread.question,
|
|
8375
|
+
replies: thread.replies
|
|
8376
|
+
}));
|
|
8377
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
8378
|
+
const lines = [
|
|
8379
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
8380
|
+
`# Review threads: ${title}`,
|
|
8381
|
+
"",
|
|
8382
|
+
"## Metadata",
|
|
8383
|
+
"",
|
|
8384
|
+
`- Target: ${target}`,
|
|
8385
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
8386
|
+
];
|
|
8387
|
+
if (options.number !== undefined)
|
|
8388
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
8389
|
+
lines.push("", "## Replies", "");
|
|
8390
|
+
for (const thread of threads) {
|
|
8391
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
8392
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
8393
|
+
}
|
|
8394
|
+
lines.push("## Source comments", "");
|
|
8395
|
+
for (const thread of threads) {
|
|
8396
|
+
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._", "", "---", "");
|
|
8397
|
+
}
|
|
8398
|
+
return `${lines.join(`
|
|
8399
|
+
`).trimEnd()}
|
|
8400
|
+
`;
|
|
8401
|
+
}
|
|
8402
|
+
function parseThreadArtifact(content) {
|
|
8403
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
8404
|
+
if (!payload)
|
|
8405
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
8406
|
+
let threads;
|
|
8407
|
+
try {
|
|
8408
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
8409
|
+
threads = z9.array(reviewThreadRecordSchema).parse(decoded);
|
|
8410
|
+
} catch {
|
|
8411
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
8412
|
+
}
|
|
8413
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
8414
|
+
return threads.map((thread) => {
|
|
8415
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
8416
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
8417
|
+
`;
|
|
8418
|
+
const endMarker = `
|
|
8419
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
8420
|
+
const start = replies.indexOf(startMarker);
|
|
8421
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
8422
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
8423
|
+
return reply ? { ...thread, reply } : thread;
|
|
8424
|
+
});
|
|
8425
|
+
}
|
|
8426
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
8427
|
+
const threads = parseThreadArtifact(content);
|
|
8428
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
8429
|
+
if (!thread)
|
|
8430
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
8431
|
+
thread.reply = body;
|
|
8432
|
+
thread.addressed = true;
|
|
8433
|
+
if (question !== undefined)
|
|
8434
|
+
thread.question = question;
|
|
8435
|
+
if (resolved !== undefined)
|
|
8436
|
+
thread.resolved = resolved;
|
|
8437
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
8438
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
8439
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
8440
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
8441
|
+
return renderThreadArtifact(title, target, threads, {
|
|
8442
|
+
timestamp,
|
|
8443
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
8444
|
+
url: pr?.[2]
|
|
8445
|
+
});
|
|
8446
|
+
}
|
|
8008
8447
|
// src/review/github-review-backend.ts
|
|
8009
8448
|
function GitHubReviewBackend(vcs, number) {
|
|
8010
8449
|
const pendingReview = async () => {
|
|
@@ -8066,6 +8505,9 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8066
8505
|
id: thread.id,
|
|
8067
8506
|
file: thread.path ?? undefined,
|
|
8068
8507
|
line: thread.line ?? undefined,
|
|
8508
|
+
rootCommentId: comment?.id,
|
|
8509
|
+
commentIds: nodes.flatMap((node) => [node.id, node.databaseId?.toString()].filter((id) => Boolean(id))),
|
|
8510
|
+
commentNodeIds: nodes.flatMap((node) => node.id ? [node.id] : []),
|
|
8069
8511
|
body,
|
|
8070
8512
|
author: comment?.author?.login,
|
|
8071
8513
|
resolved: thread.isResolved,
|
|
@@ -8075,6 +8517,7 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8075
8517
|
});
|
|
8076
8518
|
};
|
|
8077
8519
|
return {
|
|
8520
|
+
kind: "remote",
|
|
8078
8521
|
async stage(draft) {
|
|
8079
8522
|
const pending = await pendingReview();
|
|
8080
8523
|
if (!pending) {
|
|
@@ -8132,6 +8575,33 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8132
8575
|
await ghChecked(args);
|
|
8133
8576
|
}
|
|
8134
8577
|
},
|
|
8578
|
+
async readDraft() {
|
|
8579
|
+
const pending = await pendingReview();
|
|
8580
|
+
if (!pending)
|
|
8581
|
+
return { comments: [], body: "" };
|
|
8582
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
8583
|
+
const comments = await ghChecked([
|
|
8584
|
+
"api",
|
|
8585
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
8586
|
+
]);
|
|
8587
|
+
let reviewData;
|
|
8588
|
+
let commentData;
|
|
8589
|
+
try {
|
|
8590
|
+
reviewData = JSON.parse(review.stdout);
|
|
8591
|
+
commentData = JSON.parse(comments.stdout);
|
|
8592
|
+
} catch {
|
|
8593
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
8594
|
+
}
|
|
8595
|
+
return {
|
|
8596
|
+
body: reviewData.body ?? "",
|
|
8597
|
+
comments: commentData.map((comment) => ({
|
|
8598
|
+
file: comment.path,
|
|
8599
|
+
line: comment.line ?? comment.original_line ?? undefined,
|
|
8600
|
+
side: comment.side ?? undefined,
|
|
8601
|
+
body: comment.body
|
|
8602
|
+
}))
|
|
8603
|
+
};
|
|
8604
|
+
},
|
|
8135
8605
|
listThreads,
|
|
8136
8606
|
async reply(input) {
|
|
8137
8607
|
const threads = await listThreads();
|
|
@@ -8148,14 +8618,32 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8148
8618
|
`body=${input.body}`
|
|
8149
8619
|
]);
|
|
8150
8620
|
}
|
|
8151
|
-
if (input.resolve)
|
|
8621
|
+
if (input.resolve)
|
|
8622
|
+
await this.setResolved?.(input.threadId, true);
|
|
8623
|
+
},
|
|
8624
|
+
async setResolved(threadId, resolved) {
|
|
8625
|
+
await ghChecked([
|
|
8626
|
+
"api",
|
|
8627
|
+
"graphql",
|
|
8628
|
+
"-f",
|
|
8629
|
+
`query=${resolved ? GITHUB_RESOLVE_MUTATION : GITHUB_UNRESOLVE_MUTATION}`,
|
|
8630
|
+
"-f",
|
|
8631
|
+
`threadId=${threadId}`
|
|
8632
|
+
]);
|
|
8633
|
+
},
|
|
8634
|
+
async deleteThread(threadId) {
|
|
8635
|
+
const thread = (await listThreads()).find((candidate) => candidate.id === threadId);
|
|
8636
|
+
const commentNodeIds = thread?.commentNodeIds ?? (thread?.rootCommentId ? [thread.rootCommentId] : []);
|
|
8637
|
+
if (!commentNodeIds.length)
|
|
8638
|
+
throw new Error(`Cannot delete GitHub thread ${threadId}: comment node IDs are missing.`);
|
|
8639
|
+
for (const commentId of commentNodeIds) {
|
|
8152
8640
|
await ghChecked([
|
|
8153
8641
|
"api",
|
|
8154
8642
|
"graphql",
|
|
8155
8643
|
"-f",
|
|
8156
|
-
`query=${
|
|
8644
|
+
`query=${GITHUB_DELETE_COMMENT_MUTATION}`,
|
|
8157
8645
|
"-f",
|
|
8158
|
-
`
|
|
8646
|
+
`commentId=${commentId}`
|
|
8159
8647
|
]);
|
|
8160
8648
|
}
|
|
8161
8649
|
},
|
|
@@ -8174,11 +8662,13 @@ function GitHubReviewBackend(vcs, number) {
|
|
|
8174
8662
|
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
8175
8663
|
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
8176
8664
|
}
|
|
8177
|
-
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}}}}}`;
|
|
8665
|
+
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}}}}}`;
|
|
8178
8666
|
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}}}`;
|
|
8179
8667
|
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}}}`;
|
|
8180
8668
|
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
8181
8669
|
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
8670
|
+
var GITHUB_UNRESOLVE_MUTATION = `mutation($threadId:ID!){unresolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
8671
|
+
var GITHUB_DELETE_COMMENT_MUTATION = `mutation($commentId:ID!){deletePullRequestReviewComment(input:{id:$commentId}){clientMutationId}}`;
|
|
8182
8672
|
|
|
8183
8673
|
// src/review/gitlab-review-backend.ts
|
|
8184
8674
|
function GitLabReviewBackend(vcs, number) {
|
|
@@ -8215,6 +8705,7 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8215
8705
|
});
|
|
8216
8706
|
};
|
|
8217
8707
|
return {
|
|
8708
|
+
kind: "remote",
|
|
8218
8709
|
async stage(draft) {
|
|
8219
8710
|
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
8220
8711
|
if (draft.body.trim()) {
|
|
@@ -8243,6 +8734,36 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8243
8734
|
});
|
|
8244
8735
|
}
|
|
8245
8736
|
},
|
|
8737
|
+
async readDraft() {
|
|
8738
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
8739
|
+
let notes;
|
|
8740
|
+
try {
|
|
8741
|
+
notes = JSON.parse(result.stdout);
|
|
8742
|
+
} catch {
|
|
8743
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
8744
|
+
}
|
|
8745
|
+
const comments = [];
|
|
8746
|
+
const body = [];
|
|
8747
|
+
for (const note of notes) {
|
|
8748
|
+
if (!note.note)
|
|
8749
|
+
continue;
|
|
8750
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
8751
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
8752
|
+
if (file && line) {
|
|
8753
|
+
comments.push({
|
|
8754
|
+
file,
|
|
8755
|
+
line,
|
|
8756
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
8757
|
+
body: note.note
|
|
8758
|
+
});
|
|
8759
|
+
} else {
|
|
8760
|
+
body.push(note.note);
|
|
8761
|
+
}
|
|
8762
|
+
}
|
|
8763
|
+
return { comments, body: body.join(`
|
|
8764
|
+
|
|
8765
|
+
`) };
|
|
8766
|
+
},
|
|
8246
8767
|
listThreads,
|
|
8247
8768
|
async reply(input) {
|
|
8248
8769
|
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
@@ -8254,7 +8775,15 @@ function GitLabReviewBackend(vcs, number) {
|
|
|
8254
8775
|
});
|
|
8255
8776
|
}
|
|
8256
8777
|
if (input.resolve)
|
|
8257
|
-
await
|
|
8778
|
+
await this.setResolved?.(input.threadId, true);
|
|
8779
|
+
},
|
|
8780
|
+
async setResolved(threadId, resolved) {
|
|
8781
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
8782
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=${resolved}`]);
|
|
8783
|
+
},
|
|
8784
|
+
async deleteThread(threadId) {
|
|
8785
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(threadId)}`;
|
|
8786
|
+
await glabChecked(["api", "--method", "DELETE", endpoint]);
|
|
8258
8787
|
},
|
|
8259
8788
|
async publish(event) {
|
|
8260
8789
|
assertReviewEventSupported(vcs.provider, event);
|
|
@@ -8294,17 +8823,8 @@ function hasGitlabDraftNotes(input) {
|
|
|
8294
8823
|
}
|
|
8295
8824
|
}
|
|
8296
8825
|
|
|
8297
|
-
// src/review/review-backend.ts
|
|
8298
|
-
|
|
8299
|
-
if (vcs.provider === "github")
|
|
8300
|
-
return GitHubReviewBackend(vcs, number);
|
|
8301
|
-
if (vcs.provider === "gitlab")
|
|
8302
|
-
return GitLabReviewBackend(vcs, number);
|
|
8303
|
-
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
8304
|
-
}
|
|
8305
|
-
// src/review/local-reviews.ts
|
|
8306
|
-
import { mkdir as mkdir7, readdir as readdir2, readFile as readFile9, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
|
|
8307
|
-
import { join as join15 } from "node:path";
|
|
8826
|
+
// src/review/local-review-backend.ts
|
|
8827
|
+
import { readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
|
|
8308
8828
|
|
|
8309
8829
|
// src/extensions/tuicrx.ts
|
|
8310
8830
|
import { readFile as readFile8, realpath as realpath4 } from "node:fs/promises";
|
|
@@ -8375,6 +8895,11 @@ async function resolveReviewSession(cwd, target) {
|
|
|
8375
8895
|
}
|
|
8376
8896
|
return resolveSession(cwd, target.branch);
|
|
8377
8897
|
}
|
|
8898
|
+
async function resolvePublishSession(cwd, target) {
|
|
8899
|
+
if (target.owner && target.repo && target.number !== undefined)
|
|
8900
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
8901
|
+
return resolveSession(cwd, target.branch);
|
|
8902
|
+
}
|
|
8378
8903
|
async function resolvePrSession(cwd, owner, repo, number) {
|
|
8379
8904
|
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
8380
8905
|
const sessions = await listSessions(cwd);
|
|
@@ -8409,6 +8934,8 @@ async function readSession(path) {
|
|
|
8409
8934
|
function toLocalReviewThreads(session) {
|
|
8410
8935
|
const threads = [];
|
|
8411
8936
|
const add = (comment, location = {}) => {
|
|
8937
|
+
if (isLocalResponse(comment.content))
|
|
8938
|
+
return;
|
|
8412
8939
|
const author = commentAuthor(comment);
|
|
8413
8940
|
threads.push({
|
|
8414
8941
|
id: comment.id ?? `local-${threads.length + 1}`,
|
|
@@ -8434,6 +8961,54 @@ function toLocalReviewThreads(session) {
|
|
|
8434
8961
|
}
|
|
8435
8962
|
return threads;
|
|
8436
8963
|
}
|
|
8964
|
+
function toFindings(session, options = {}) {
|
|
8965
|
+
const comments = [];
|
|
8966
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
8967
|
+
const publishableBody = (comment) => {
|
|
8968
|
+
const author = commentAuthor(comment);
|
|
8969
|
+
const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
8970
|
+
return route ? withRemoteProvenance(comment.content, route) : comment.content;
|
|
8971
|
+
};
|
|
8972
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
|
|
8973
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
8974
|
+
for (const fileComment of entry.file_comments ?? []) {
|
|
8975
|
+
if (!include(fileComment))
|
|
8976
|
+
continue;
|
|
8977
|
+
const comment = {
|
|
8978
|
+
file,
|
|
8979
|
+
body: fileComment.content,
|
|
8980
|
+
...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
|
|
8981
|
+
};
|
|
8982
|
+
const author = commentAuthor(fileComment);
|
|
8983
|
+
if (author)
|
|
8984
|
+
comment.author = author;
|
|
8985
|
+
comments.push(comment);
|
|
8986
|
+
}
|
|
8987
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
8988
|
+
const line = Number.parseInt(lineKey, 10);
|
|
8989
|
+
if (!Number.isFinite(line))
|
|
8990
|
+
continue;
|
|
8991
|
+
for (const lineComment of lineComments) {
|
|
8992
|
+
if (!include(lineComment))
|
|
8993
|
+
continue;
|
|
8994
|
+
const comment = {
|
|
8995
|
+
file,
|
|
8996
|
+
line,
|
|
8997
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
8998
|
+
body: lineComment.content,
|
|
8999
|
+
...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
|
|
9000
|
+
};
|
|
9001
|
+
const author = commentAuthor(lineComment);
|
|
9002
|
+
if (author)
|
|
9003
|
+
comment.author = author;
|
|
9004
|
+
comments.push(comment);
|
|
9005
|
+
}
|
|
9006
|
+
}
|
|
9007
|
+
}
|
|
9008
|
+
return { comments, body: bodyParts.join(`
|
|
9009
|
+
|
|
9010
|
+
`) };
|
|
9011
|
+
}
|
|
8437
9012
|
async function requireTuicr() {
|
|
8438
9013
|
if (!await tuicrAvailable())
|
|
8439
9014
|
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
@@ -8449,83 +9024,231 @@ async function canonicalPath3(path) {
|
|
|
8449
9024
|
}
|
|
8450
9025
|
}
|
|
8451
9026
|
|
|
8452
|
-
// src/review/local-
|
|
8453
|
-
|
|
8454
|
-
|
|
8455
|
-
|
|
8456
|
-
|
|
8457
|
-
|
|
8458
|
-
|
|
8459
|
-
|
|
8460
|
-
|
|
8461
|
-
|
|
8462
|
-
|
|
8463
|
-
|
|
8464
|
-
|
|
8465
|
-
|
|
8466
|
-
|
|
8467
|
-
|
|
8468
|
-
|
|
8469
|
-
|
|
9027
|
+
// src/review/local-review-backend.ts
|
|
9028
|
+
function LocalReviewBackend(options) {
|
|
9029
|
+
return {
|
|
9030
|
+
kind: "local",
|
|
9031
|
+
async stage(draft) {
|
|
9032
|
+
for (const comment of draft.comments) {
|
|
9033
|
+
await addComment(options.session, comment.body, {
|
|
9034
|
+
targetFile: comment.file,
|
|
9035
|
+
line: comment.line,
|
|
9036
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
9037
|
+
username: options.author
|
|
9038
|
+
});
|
|
9039
|
+
}
|
|
9040
|
+
if (draft.body.trim())
|
|
9041
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
9042
|
+
},
|
|
9043
|
+
async readDraft() {
|
|
9044
|
+
return toFindings(await readSession(options.session), { excludeLocalResponses: true });
|
|
9045
|
+
},
|
|
9046
|
+
async listThreads() {
|
|
9047
|
+
try {
|
|
9048
|
+
return parseThreadArtifact(await readFile9(options.artifactPath, "utf8"));
|
|
9049
|
+
} catch (error) {
|
|
9050
|
+
if (error.code === "ENOENT")
|
|
9051
|
+
return [];
|
|
9052
|
+
throw error;
|
|
9053
|
+
}
|
|
9054
|
+
},
|
|
9055
|
+
async reply(input) {
|
|
9056
|
+
const content = await readFile9(options.artifactPath, "utf8");
|
|
9057
|
+
await writeFile5(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
9058
|
+
},
|
|
9059
|
+
async publish() {
|
|
9060
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
9061
|
+
}
|
|
8470
9062
|
};
|
|
8471
|
-
const path = join15(dir, `${revision}.json`);
|
|
8472
|
-
await writeFile5(path, `${JSON.stringify(dump, null, 2)}
|
|
8473
|
-
`, { encoding: "utf8", flag: "wx" });
|
|
8474
|
-
await unlink2(input.session.path);
|
|
8475
|
-
return { path, dump };
|
|
8476
9063
|
}
|
|
8477
|
-
|
|
8478
|
-
|
|
9064
|
+
|
|
9065
|
+
// src/review/review-backend.ts
|
|
9066
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
9067
|
+
if (vcs.provider === "github")
|
|
9068
|
+
return GitHubReviewBackend(vcs, number);
|
|
9069
|
+
if (vcs.provider === "gitlab")
|
|
9070
|
+
return GitLabReviewBackend(vcs, number);
|
|
9071
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
9072
|
+
}
|
|
9073
|
+
function createLocalReviewBackend(options) {
|
|
9074
|
+
return LocalReviewBackend(options);
|
|
9075
|
+
}
|
|
9076
|
+
// src/review/review-state.ts
|
|
9077
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
9078
|
+
import { readFile as readFile10, rename as rename3, writeFile as writeFile6 } from "node:fs/promises";
|
|
9079
|
+
import { join as join15 } from "node:path";
|
|
9080
|
+
import { z as z10 } from "zod";
|
|
9081
|
+
var reviewPublicationStateSchema = z10.object({
|
|
9082
|
+
target: z10.string().optional(),
|
|
9083
|
+
bodies: z10.array(z10.string()).default([]),
|
|
9084
|
+
comments: z10.array(z10.string()).default([]),
|
|
9085
|
+
stagedComments: z10.array(z10.string()).default([]),
|
|
9086
|
+
replies: z10.array(z10.string()).default([]),
|
|
9087
|
+
overlayPath: z10.string().optional()
|
|
9088
|
+
});
|
|
9089
|
+
function reviewBodyFingerprint(body) {
|
|
9090
|
+
return digest(body);
|
|
9091
|
+
}
|
|
9092
|
+
function reviewCommentFingerprint(comment) {
|
|
9093
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
9094
|
+
}
|
|
9095
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
9096
|
+
return digest(`${threadId}\x00${body}`);
|
|
9097
|
+
}
|
|
9098
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
9099
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
8479
9100
|
}
|
|
8480
|
-
async function
|
|
9101
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
9102
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
9103
|
+
const path = join15(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
8481
9104
|
try {
|
|
8482
|
-
|
|
8483
|
-
|
|
8484
|
-
|
|
8485
|
-
|
|
9105
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile10(path, "utf8")));
|
|
9106
|
+
return { path, state: { ...parsed, target } };
|
|
9107
|
+
} catch (error) {
|
|
9108
|
+
if (error.code === "ENOENT") {
|
|
9109
|
+
return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
|
|
9110
|
+
}
|
|
9111
|
+
if (error instanceof SyntaxError || error instanceof z10.ZodError) {
|
|
9112
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
9113
|
+
}
|
|
9114
|
+
throw error;
|
|
9115
|
+
}
|
|
9116
|
+
}
|
|
9117
|
+
async function saveReviewPublicationState(path, state) {
|
|
9118
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
9119
|
+
await writeFile6(temp, `${JSON.stringify(state, null, 2)}
|
|
9120
|
+
`, "utf8");
|
|
9121
|
+
await rename3(temp, path);
|
|
9122
|
+
}
|
|
9123
|
+
function digest(value) {
|
|
9124
|
+
return createHash4("sha256").update(value).digest("hex");
|
|
9125
|
+
}
|
|
9126
|
+
// src/templates.ts
|
|
9127
|
+
import { readFile as readFile11 } from "node:fs/promises";
|
|
9128
|
+
import { homedir as homedir4 } from "node:os";
|
|
9129
|
+
import { join as join16, normalize } from "node:path";
|
|
9130
|
+
async function loadTemplate(name, options = {}) {
|
|
9131
|
+
const relative = templateRelativePath(name);
|
|
9132
|
+
const userPath = join16(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
|
|
9133
|
+
const bundledPath = join16(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
9134
|
+
const user = await readOptionalFile(userPath);
|
|
9135
|
+
if (user !== undefined)
|
|
9136
|
+
return { name, path: userPath, source: "user", content: user };
|
|
9137
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
9138
|
+
if (bundled !== undefined)
|
|
9139
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
9140
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
9141
|
+
}
|
|
9142
|
+
function renderTemplate(content, variables) {
|
|
9143
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
9144
|
+
}
|
|
9145
|
+
function templateRelativePath(name) {
|
|
9146
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
9147
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
9148
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
9149
|
+
}
|
|
9150
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
9151
|
+
}
|
|
9152
|
+
async function readOptionalFile(path) {
|
|
9153
|
+
try {
|
|
9154
|
+
return await readFile11(path, "utf8");
|
|
8486
9155
|
} catch (error) {
|
|
8487
9156
|
if (error.code === "ENOENT")
|
|
8488
|
-
return
|
|
9157
|
+
return;
|
|
8489
9158
|
throw error;
|
|
8490
9159
|
}
|
|
8491
9160
|
}
|
|
9161
|
+
|
|
8492
9162
|
// src/tools/review.ts
|
|
8493
|
-
var contextSchema =
|
|
8494
|
-
cwd:
|
|
8495
|
-
target:
|
|
8496
|
-
workingTree:
|
|
9163
|
+
var contextSchema = z11.object({
|
|
9164
|
+
cwd: z11.string().optional(),
|
|
9165
|
+
target: z11.string().optional(),
|
|
9166
|
+
workingTree: z11.boolean().optional()
|
|
8497
9167
|
});
|
|
8498
|
-
var localSchema = contextSchema.extend({ local:
|
|
9168
|
+
var localSchema = contextSchema.extend({ local: z11.boolean().optional() });
|
|
8499
9169
|
var openSchema = localSchema.extend({
|
|
8500
|
-
title:
|
|
8501
|
-
intent:
|
|
8502
|
-
issueUrl:
|
|
8503
|
-
base:
|
|
9170
|
+
title: z11.string().optional(),
|
|
9171
|
+
intent: z11.string().optional(),
|
|
9172
|
+
issueUrl: z11.string().url().optional(),
|
|
9173
|
+
base: z11.string().optional()
|
|
8504
9174
|
});
|
|
8505
9175
|
var submitSchema = localSchema.extend({
|
|
8506
9176
|
findings: findingsSchema,
|
|
8507
|
-
overallIssues:
|
|
8508
|
-
notVerified:
|
|
8509
|
-
title:
|
|
9177
|
+
overallIssues: z11.array(z11.string()).optional(),
|
|
9178
|
+
notVerified: z11.array(z11.string()).optional(),
|
|
9179
|
+
title: z11.string().optional()
|
|
8510
9180
|
});
|
|
8511
9181
|
var addCommentSchema = localSchema.extend({
|
|
8512
|
-
body:
|
|
8513
|
-
file:
|
|
8514
|
-
line:
|
|
8515
|
-
side:
|
|
9182
|
+
body: z11.string().min(1),
|
|
9183
|
+
file: z11.string().min(1),
|
|
9184
|
+
line: z11.number().int().positive(),
|
|
9185
|
+
side: z11.enum(["LEFT", "RIGHT"]).optional()
|
|
8516
9186
|
});
|
|
8517
9187
|
var respondSchema = localSchema.extend({
|
|
8518
|
-
threadId:
|
|
8519
|
-
body:
|
|
8520
|
-
question:
|
|
8521
|
-
resolve:
|
|
9188
|
+
threadId: z11.string().min(1),
|
|
9189
|
+
body: z11.string().min(1),
|
|
9190
|
+
question: z11.boolean().optional(),
|
|
9191
|
+
resolve: z11.boolean().optional()
|
|
8522
9192
|
});
|
|
8523
9193
|
var publishSchema = localSchema.extend({
|
|
8524
|
-
status:
|
|
9194
|
+
status: z11.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
8525
9195
|
});
|
|
8526
9196
|
var completeSchema = localSchema.extend({
|
|
8527
|
-
action:
|
|
9197
|
+
action: z11.enum(["approve", "reject", "abandon"]).optional()
|
|
8528
9198
|
});
|
|
9199
|
+
function hasReviewDraft(comments, body) {
|
|
9200
|
+
return comments.length > 0 || body.trim().length > 0;
|
|
9201
|
+
}
|
|
9202
|
+
function reviewBrowserCommand(url, platform = process.platform) {
|
|
9203
|
+
return platform === "darwin" ? { command: "open", args: [url] } : platform === "win32" ? { command: "cmd", args: ["/c", "start", "", url] } : { command: "xdg-open", args: [url] };
|
|
9204
|
+
}
|
|
9205
|
+
async function openReviewUrl(url, execute = run, platform = process.platform) {
|
|
9206
|
+
const launch = reviewBrowserCommand(url, platform);
|
|
9207
|
+
try {
|
|
9208
|
+
return (await execute(launch.command, launch.args)).code === 0;
|
|
9209
|
+
} catch {
|
|
9210
|
+
return false;
|
|
9211
|
+
}
|
|
9212
|
+
}
|
|
9213
|
+
function partitionReviewComments(comments, knownFingerprints, model) {
|
|
9214
|
+
const normalized = comments.map((comment) => {
|
|
9215
|
+
const published = { ...comment, body: withCommentProvenance(comment, model) };
|
|
9216
|
+
return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
|
|
9217
|
+
});
|
|
9218
|
+
return {
|
|
9219
|
+
checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
|
|
9220
|
+
candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
|
|
9221
|
+
};
|
|
9222
|
+
}
|
|
9223
|
+
function reviewResponseResolution(question, requested) {
|
|
9224
|
+
return question ? false : requested ?? false;
|
|
9225
|
+
}
|
|
9226
|
+
function workingTreeReplyDraft(thread, body) {
|
|
9227
|
+
if (!thread.file)
|
|
9228
|
+
return { comments: [], body };
|
|
9229
|
+
return {
|
|
9230
|
+
comments: [
|
|
9231
|
+
{
|
|
9232
|
+
file: thread.file,
|
|
9233
|
+
...thread.line === undefined ? {} : { line: thread.line, side: "RIGHT" },
|
|
9234
|
+
body
|
|
9235
|
+
}
|
|
9236
|
+
],
|
|
9237
|
+
body: ""
|
|
9238
|
+
};
|
|
9239
|
+
}
|
|
9240
|
+
function resolveReviewThread(comment, threads) {
|
|
9241
|
+
const sourceCommentId = comment.sourceCommentId;
|
|
9242
|
+
if (sourceCommentId) {
|
|
9243
|
+
const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
|
|
9244
|
+
if (identified)
|
|
9245
|
+
return identified;
|
|
9246
|
+
}
|
|
9247
|
+
const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
|
|
9248
|
+
if (matches.length > 1)
|
|
9249
|
+
throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
|
|
9250
|
+
return matches[0];
|
|
9251
|
+
}
|
|
8529
9252
|
async function workingTreeDiff(cwd) {
|
|
8530
9253
|
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
8531
9254
|
if (tracked.code !== 0)
|
|
@@ -8558,7 +9281,9 @@ function createReviewTools() {
|
|
|
8558
9281
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8559
9282
|
const store = await ensureStore(review.cwd);
|
|
8560
9283
|
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
8561
|
-
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree))
|
|
9284
|
+
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree)).catch(() => {
|
|
9285
|
+
return;
|
|
9286
|
+
});
|
|
8562
9287
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
8563
9288
|
const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
|
|
8564
9289
|
return result3([
|
|
@@ -8582,6 +9307,21 @@ function createReviewTools() {
|
|
|
8582
9307
|
});
|
|
8583
9308
|
}
|
|
8584
9309
|
}),
|
|
9310
|
+
defineTool6({
|
|
9311
|
+
name: "review_status",
|
|
9312
|
+
label: "review status",
|
|
9313
|
+
description: "Report local and remote review lifecycle status without requiring a forge or tuicr.",
|
|
9314
|
+
promptSnippet: "Call review_status for review lifecycle state",
|
|
9315
|
+
promptGuidelines: ["Use this for status even when no forge or tuicr binary is available."],
|
|
9316
|
+
parameters: parameters2(localSchema),
|
|
9317
|
+
executionMode: "parallel",
|
|
9318
|
+
async execute(_id, input) {
|
|
9319
|
+
const params = localSchema.parse(input);
|
|
9320
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9321
|
+
const status = await collectReviewStatus(review);
|
|
9322
|
+
return result3(status.text, status.details);
|
|
9323
|
+
}
|
|
9324
|
+
}),
|
|
8585
9325
|
defineTool6({
|
|
8586
9326
|
name: "review_new",
|
|
8587
9327
|
label: "review new",
|
|
@@ -8625,6 +9365,31 @@ ${openMessage}`, {
|
|
|
8625
9365
|
});
|
|
8626
9366
|
}
|
|
8627
9367
|
}),
|
|
9368
|
+
defineTool6({
|
|
9369
|
+
name: "review_open",
|
|
9370
|
+
label: "review open",
|
|
9371
|
+
description: "Open an existing remote PR/MR in the system browser; never creates one.",
|
|
9372
|
+
promptSnippet: "Call review_open to open an existing PR/MR",
|
|
9373
|
+
promptGuidelines: ["Direct users to review_new when no remote review exists."],
|
|
9374
|
+
parameters: parameters2(localSchema),
|
|
9375
|
+
executionMode: "sequential",
|
|
9376
|
+
async execute(_id, input) {
|
|
9377
|
+
const params = localSchema.parse(input);
|
|
9378
|
+
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9379
|
+
if (params.local || params.workingTree)
|
|
9380
|
+
return launchLocalReview(review, true);
|
|
9381
|
+
if (!review.forge)
|
|
9382
|
+
return result3(`${unsupportedForgeMessage()} Use review_new with local=true for an offline review.`);
|
|
9383
|
+
if (!review.pr)
|
|
9384
|
+
return result3("No remote PR/MR exists. Use review_new to create a draft review first.");
|
|
9385
|
+
const opened = await openReviewUrl(review.pr.url);
|
|
9386
|
+
return result3(`${opened ? "Opened" : "Open this URL"} PR/MR #${review.pr.number}: ${review.pr.url}`, {
|
|
9387
|
+
pr: review.pr,
|
|
9388
|
+
opened,
|
|
9389
|
+
url: review.pr.url
|
|
9390
|
+
});
|
|
9391
|
+
}
|
|
9392
|
+
}),
|
|
8628
9393
|
defineTool6({
|
|
8629
9394
|
name: "review_edit",
|
|
8630
9395
|
label: "review edit",
|
|
@@ -8721,7 +9486,7 @@ ${openMessage}`, {
|
|
|
8721
9486
|
const gates = await runMiseGates(review.cwd);
|
|
8722
9487
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
8723
9488
|
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
8724
|
-
await
|
|
9489
|
+
await writeFile7(artifact, renderReviewDoc({
|
|
8725
9490
|
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
8726
9491
|
number: useLocalBackend ? undefined : review.pr?.number,
|
|
8727
9492
|
url: useLocalBackend ? undefined : review.pr?.url,
|
|
@@ -8744,21 +9509,17 @@ ${openMessage}`, {
|
|
|
8744
9509
|
count: findings.length
|
|
8745
9510
|
});
|
|
8746
9511
|
}
|
|
8747
|
-
|
|
8748
|
-
|
|
8749
|
-
|
|
8750
|
-
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
});
|
|
8754
|
-
}
|
|
8755
|
-
if (body.trim())
|
|
8756
|
-
await addComment(session.path, body, { username: localReviewAuthor(model) });
|
|
9512
|
+
const backend = createLocalReviewBackend({
|
|
9513
|
+
session: session.path,
|
|
9514
|
+
artifactPath: artifact,
|
|
9515
|
+
author: localReviewAuthor(model)
|
|
9516
|
+
});
|
|
9517
|
+
await backend.stage({ comments, body });
|
|
8757
9518
|
return result3(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
8758
9519
|
}
|
|
8759
9520
|
if (!review.pr)
|
|
8760
9521
|
return result3(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
8761
|
-
const hasDraft = comments
|
|
9522
|
+
const hasDraft = hasReviewDraft(comments, body);
|
|
8762
9523
|
if (hasDraft) {
|
|
8763
9524
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
8764
9525
|
}
|
|
@@ -8794,12 +9555,12 @@ ${openMessage}`, {
|
|
|
8794
9555
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
8795
9556
|
if (!session)
|
|
8796
9557
|
return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
username: localReviewAuthor(model)
|
|
9558
|
+
const backend = createLocalReviewBackend({
|
|
9559
|
+
session: session.path,
|
|
9560
|
+
artifactPath: "",
|
|
9561
|
+
author: localReviewAuthor(model)
|
|
8802
9562
|
});
|
|
9563
|
+
await backend.stage({ comments: [comment], body: "" });
|
|
8803
9564
|
return result3(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
8804
9565
|
}
|
|
8805
9566
|
if (!review.pr)
|
|
@@ -8811,70 +9572,88 @@ ${openMessage}`, {
|
|
|
8811
9572
|
defineTool6({
|
|
8812
9573
|
name: "review_comments",
|
|
8813
9574
|
label: "review comments",
|
|
8814
|
-
description: "
|
|
8815
|
-
promptSnippet: "Call review_comments before addressing
|
|
8816
|
-
promptGuidelines: ["
|
|
9575
|
+
description: "Pull review threads or local tuicr comments and write a target-named artifact.",
|
|
9576
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
9577
|
+
promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
|
|
8817
9578
|
parameters: parameters2(localSchema),
|
|
8818
|
-
executionMode: "
|
|
9579
|
+
executionMode: "sequential",
|
|
8819
9580
|
async execute(_id, input) {
|
|
8820
9581
|
const params = localSchema.parse(input);
|
|
8821
|
-
if (params.local || params.workingTree)
|
|
8822
|
-
return result3("Use review_dump for a local tuicr review.");
|
|
8823
9582
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8824
|
-
|
|
9583
|
+
let threads;
|
|
9584
|
+
let artifact;
|
|
9585
|
+
if (params.local || params.workingTree) {
|
|
9586
|
+
const session = await resolveTuicrSession(review, true);
|
|
9587
|
+
if (!session)
|
|
9588
|
+
return result3("No matching tuicr session found.");
|
|
9589
|
+
artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
9590
|
+
threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
|
|
9591
|
+
} else if (review.pr && review.forge) {
|
|
9592
|
+
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
9593
|
+
const target = await reviewTargetId(review);
|
|
9594
|
+
artifact = await newReviewArtifactPath(review.cwd, target);
|
|
9595
|
+
await writeFile7(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
|
|
9596
|
+
} else {
|
|
8825
9597
|
return result3(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
8826
|
-
|
|
9598
|
+
}
|
|
8827
9599
|
return result3(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
8828
|
-
`) || "No comments.", {
|
|
8829
|
-
}
|
|
8830
|
-
}),
|
|
8831
|
-
defineTool6({
|
|
8832
|
-
name: "review_dump",
|
|
8833
|
-
label: "review dump",
|
|
8834
|
-
description: "Save one immutable local tuicr review revision and remove its completed session.",
|
|
8835
|
-
promptSnippet: "Call review_dump before applying local review feedback",
|
|
8836
|
-
promptGuidelines: ["Each dump advances the local review revision."],
|
|
8837
|
-
parameters: parameters2(localSchema),
|
|
8838
|
-
executionMode: "sequential",
|
|
8839
|
-
async execute(_id, input) {
|
|
8840
|
-
const params = localSchema.parse(input);
|
|
8841
|
-
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8842
|
-
const session = await resolveTuicrSession(review, true);
|
|
8843
|
-
if (!session)
|
|
8844
|
-
return result3("No matching local tuicr session found.");
|
|
8845
|
-
const base = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "HEAD");
|
|
8846
|
-
const captured = await captureLocalReview({
|
|
8847
|
-
cwd: review.cwd,
|
|
8848
|
-
branch: review.vcs.branch,
|
|
8849
|
-
base,
|
|
8850
|
-
diff: await workingTreeDiff(review.cwd),
|
|
8851
|
-
session
|
|
8852
|
-
});
|
|
8853
|
-
return result3(captured.dump.comments.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
8854
|
-
`) || "No comments.", { artifact: captured.path, review: captured.dump, threads: captured.dump.comments });
|
|
9600
|
+
`) || "No comments.", { artifact, threads });
|
|
8855
9601
|
}
|
|
8856
9602
|
}),
|
|
8857
9603
|
defineTool6({
|
|
8858
9604
|
name: "review_respond",
|
|
8859
9605
|
label: "review respond",
|
|
8860
|
-
description: "
|
|
8861
|
-
promptSnippet: "Call review_respond after addressing a
|
|
8862
|
-
promptGuidelines: ["
|
|
9606
|
+
description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
|
|
9607
|
+
promptSnippet: "Call review_respond after addressing a comment",
|
|
9608
|
+
promptGuidelines: ["Question replies remain unresolved."],
|
|
8863
9609
|
parameters: parameters2(respondSchema),
|
|
8864
9610
|
executionMode: "sequential",
|
|
8865
9611
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8866
9612
|
const params = respondSchema.parse(input);
|
|
8867
|
-
if (params.local)
|
|
8868
|
-
return result3("Local reviews use immutable revision dumps, not thread replies.");
|
|
8869
9613
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9614
|
+
const model = modelRoute(ctx);
|
|
9615
|
+
if (params.local) {
|
|
9616
|
+
const tuicrSession = await resolveTuicrSession(review, true);
|
|
9617
|
+
if (!tuicrSession)
|
|
9618
|
+
return result3("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
9619
|
+
const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
|
|
9620
|
+
if (!existsSync2(artifact))
|
|
9621
|
+
return result3("No local review artifact. Run review_comments with local=true first.");
|
|
9622
|
+
const threads = await readThreadArtifact(artifact);
|
|
9623
|
+
const known = threads.find((thread) => thread.id === params.threadId);
|
|
9624
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
9625
|
+
const awaitUserDeletion = !question && params.resolve === true;
|
|
9626
|
+
await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
|
|
9627
|
+
${params.body}`, {
|
|
9628
|
+
targetFile: known?.file,
|
|
9629
|
+
line: known?.line,
|
|
9630
|
+
side: "new",
|
|
9631
|
+
username: localReviewAuthor(model)
|
|
9632
|
+
});
|
|
9633
|
+
await createLocalReviewBackend({
|
|
9634
|
+
session: tuicrSession.path,
|
|
9635
|
+
artifactPath: artifact,
|
|
9636
|
+
author: localReviewAuthor(model)
|
|
9637
|
+
}).reply({
|
|
9638
|
+
threadId: params.threadId,
|
|
9639
|
+
body: params.body,
|
|
9640
|
+
resolve: false,
|
|
9641
|
+
question
|
|
9642
|
+
});
|
|
9643
|
+
return result3(`Local ${question ? "answer" : "response"} posted to tuicr and recorded in ${artifact}${awaitUserDeletion ? "; delete the source comment in tuicr to resolve it" : ""}.`, { artifact, question, awaitUserDeletion, session: tuicrSession });
|
|
9644
|
+
}
|
|
8870
9645
|
if (!review.pr || !review.forge)
|
|
8871
9646
|
return result3("No remote PR/MR for this reply.");
|
|
8872
|
-
const
|
|
8873
|
-
await
|
|
9647
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
9648
|
+
const remoteThreads = await remote.listThreads();
|
|
9649
|
+
const known = remoteThreads.find((thread) => thread.id === params.threadId);
|
|
9650
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
9651
|
+
const resolve = reviewResponseResolution(question, params.resolve);
|
|
9652
|
+
await remote.reply({
|
|
8874
9653
|
threadId: params.threadId,
|
|
8875
|
-
body: withRemoteProvenance(params.body,
|
|
9654
|
+
body: withRemoteProvenance(params.body, model),
|
|
8876
9655
|
resolve,
|
|
8877
|
-
question
|
|
9656
|
+
question
|
|
8878
9657
|
});
|
|
8879
9658
|
return result3(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
8880
9659
|
pr: review.pr,
|
|
@@ -8885,52 +9664,66 @@ ${openMessage}`, {
|
|
|
8885
9664
|
defineTool6({
|
|
8886
9665
|
name: "review_publish",
|
|
8887
9666
|
label: "review publish",
|
|
8888
|
-
description: "Publish pending remote review comments and status.",
|
|
9667
|
+
description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
|
|
8889
9668
|
promptSnippet: "Call review_publish to make remote review work public",
|
|
8890
|
-
promptGuidelines: ["
|
|
9669
|
+
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
8891
9670
|
parameters: parameters2(publishSchema),
|
|
8892
9671
|
executionMode: "sequential",
|
|
8893
|
-
async execute(_id, input) {
|
|
9672
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8894
9673
|
const params = publishSchema.parse(input);
|
|
8895
|
-
if (params.local)
|
|
8896
|
-
return result3("Local reviews use immutable revision dumps and have no publish step.");
|
|
8897
9674
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8898
9675
|
if (!review.pr || !review.forge)
|
|
8899
9676
|
return result3("No remote PR/MR to publish.");
|
|
8900
|
-
return publishResolvedReview(review, params);
|
|
9677
|
+
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
8901
9678
|
}
|
|
8902
9679
|
}),
|
|
8903
9680
|
defineTool6({
|
|
8904
9681
|
name: "review_complete",
|
|
8905
9682
|
label: "review complete",
|
|
8906
|
-
description: "Approve, reject, or abandon a remote review.",
|
|
8907
|
-
promptSnippet: "Call review_complete to finish a
|
|
8908
|
-
promptGuidelines: ["
|
|
9683
|
+
description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
|
|
9684
|
+
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
9685
|
+
promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
|
|
8909
9686
|
parameters: parameters2(completeSchema),
|
|
8910
9687
|
executionMode: "sequential",
|
|
8911
|
-
async execute(_id, input) {
|
|
9688
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
8912
9689
|
const params = completeSchema.parse(input);
|
|
8913
|
-
if (params.local)
|
|
8914
|
-
return result3("Use review_dump to finish a local review revision.");
|
|
8915
9690
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
9691
|
+
if (params.local) {
|
|
9692
|
+
const session = await resolveTuicrSession(review, true);
|
|
9693
|
+
if (!session)
|
|
9694
|
+
return result3("No matching local tuicr session to close.");
|
|
9695
|
+
const artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
9696
|
+
if (!existsSync2(artifact))
|
|
9697
|
+
return result3("No local review artifact. Run review_comments with local=true first.");
|
|
9698
|
+
const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
|
|
9699
|
+
await rename4(artifact, archived);
|
|
9700
|
+
await unlink2(session.path);
|
|
9701
|
+
return result3(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
|
|
9702
|
+
artifact: archived,
|
|
9703
|
+
session
|
|
9704
|
+
});
|
|
9705
|
+
}
|
|
8916
9706
|
if (!review.pr || !review.forge)
|
|
8917
9707
|
return result3("No remote PR/MR to complete.");
|
|
8918
9708
|
if (!params.action)
|
|
8919
9709
|
return result3("Choose a complete action: approve, reject, or abandon.");
|
|
8920
9710
|
const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
|
|
8921
|
-
return publishResolvedReview(review, { ...params, status });
|
|
9711
|
+
return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
|
|
8922
9712
|
}
|
|
8923
9713
|
}),
|
|
8924
9714
|
defineTool6({
|
|
8925
9715
|
name: "review_merge",
|
|
8926
9716
|
label: "review merge",
|
|
8927
|
-
description: "Squash-merge an open, ready GitHub PR after checking its conventional subject.",
|
|
8928
|
-
promptSnippet: "Call review_merge
|
|
8929
|
-
promptGuidelines: [
|
|
8930
|
-
|
|
9717
|
+
description: "Squash-merge an open, ready GitHub PR through gh after checking its conventional subject.",
|
|
9718
|
+
promptSnippet: "Call review_merge directly; it uses gh to confirm the PR is open, ready, and CI is settled before merging",
|
|
9719
|
+
promptGuidelines: [
|
|
9720
|
+
"Do not query GitHub through MCP or duplicate readiness checks before calling review_merge.",
|
|
9721
|
+
"This is intentionally GitHub-only until GitLab merge support is added."
|
|
9722
|
+
],
|
|
9723
|
+
parameters: parameters2(contextSchema.extend({ subject: z11.string().optional() })),
|
|
8931
9724
|
executionMode: "sequential",
|
|
8932
9725
|
async execute(_id, input) {
|
|
8933
|
-
const params = contextSchema.extend({ subject:
|
|
9726
|
+
const params = contextSchema.extend({ subject: z11.string().optional() }).parse(input);
|
|
8934
9727
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
8935
9728
|
if (review.vcs.provider !== "github" || !review.forge)
|
|
8936
9729
|
return result3("review_merge currently supports GitHub only.");
|
|
@@ -8963,8 +9756,32 @@ ${openMessage}`, {
|
|
|
8963
9756
|
})
|
|
8964
9757
|
];
|
|
8965
9758
|
}
|
|
9759
|
+
async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
9760
|
+
let previous = [];
|
|
9761
|
+
try {
|
|
9762
|
+
previous = parseThreadArtifact(await readFile12(path, "utf8"));
|
|
9763
|
+
} catch (error) {
|
|
9764
|
+
if (error.code !== "ENOENT")
|
|
9765
|
+
throw error;
|
|
9766
|
+
}
|
|
9767
|
+
const previousById = new Map(previous.map((thread) => [thread.id, thread]));
|
|
9768
|
+
const currentIds = new Set(threads.map((thread) => thread.id));
|
|
9769
|
+
const current = threads.map((thread) => {
|
|
9770
|
+
const existing = previousById.get(thread.id);
|
|
9771
|
+
return {
|
|
9772
|
+
...thread,
|
|
9773
|
+
resolved: false,
|
|
9774
|
+
addressed: existing?.addressed ?? Boolean(existing?.reply),
|
|
9775
|
+
reply: existing?.reply
|
|
9776
|
+
};
|
|
9777
|
+
});
|
|
9778
|
+
const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
|
|
9779
|
+
const merged = [...current, ...removed];
|
|
9780
|
+
await writeFile7(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
|
|
9781
|
+
return merged;
|
|
9782
|
+
}
|
|
8966
9783
|
function parameters2(schema) {
|
|
8967
|
-
return
|
|
9784
|
+
return z11.toJSONSchema(schema, { io: "input" });
|
|
8968
9785
|
}
|
|
8969
9786
|
function resolveWorkingDirectory(params) {
|
|
8970
9787
|
return params.cwd ?? process.cwd();
|
|
@@ -8975,22 +9792,216 @@ function result3(text, details = {}) {
|
|
|
8975
9792
|
function unsupportedForgeMessage() {
|
|
8976
9793
|
return "No supported GitHub or GitLab remote was detected. Use local=true for an offline tuicr review.";
|
|
8977
9794
|
}
|
|
9795
|
+
async function collectReviewStatus(review) {
|
|
9796
|
+
const [porcelain, branch, localSession, remoteSession] = await Promise.all([
|
|
9797
|
+
runChecked("git", ["-C", review.cwd, "status", "--porcelain"]),
|
|
9798
|
+
runChecked("git", ["-C", review.cwd, "branch", "--show-current"]),
|
|
9799
|
+
resolveTuicrSession(review, true).catch(() => {
|
|
9800
|
+
return;
|
|
9801
|
+
}),
|
|
9802
|
+
resolveTuicrSession(review, false).catch(() => {
|
|
9803
|
+
return;
|
|
9804
|
+
})
|
|
9805
|
+
]);
|
|
9806
|
+
const entries = porcelain.stdout.split("\\n").filter(Boolean);
|
|
9807
|
+
const staged = entries.filter((line) => !line.startsWith("??") && line[0] !== " ").length;
|
|
9808
|
+
const unstaged = entries.filter((line) => !line.startsWith("??") && line[1] !== " ").length;
|
|
9809
|
+
const untracked = entries.filter((line) => line.startsWith("??")).length;
|
|
9810
|
+
const remote = review.pr ? `yes (#${review.pr.number})` : "no";
|
|
9811
|
+
const url = review.pr?.url ?? "none";
|
|
9812
|
+
const text = [
|
|
9813
|
+
`Branch: ${branch.stdout.trim() || review.vcs.branch}`,
|
|
9814
|
+
`Worktree: ${entries.length ? "dirty" : "clean"} (staged=${staged}, unstaged=${unstaged}, untracked=${untracked})`,
|
|
9815
|
+
`Local working-tree tuicr review: ${localSession ? `yes (${localSession.slug}, ${localSession.commentCount} comments)` : "no"}`,
|
|
9816
|
+
`Remote PR/MR: ${remote}`,
|
|
9817
|
+
`Remote URL: ${url}`,
|
|
9818
|
+
`Remote tuicr session: ${remoteSession ? `yes (${remoteSession.slug}, ${remoteSession.commentCount} comments)` : "no"}`
|
|
9819
|
+
].join("\\n");
|
|
9820
|
+
return {
|
|
9821
|
+
text,
|
|
9822
|
+
details: {
|
|
9823
|
+
branch: branch.stdout.trim(),
|
|
9824
|
+
staged,
|
|
9825
|
+
unstaged,
|
|
9826
|
+
untracked,
|
|
9827
|
+
localSession,
|
|
9828
|
+
remoteSession,
|
|
9829
|
+
pr: review.pr,
|
|
9830
|
+
url
|
|
9831
|
+
}
|
|
9832
|
+
};
|
|
9833
|
+
}
|
|
8978
9834
|
function modelRoute(ctx) {
|
|
8979
9835
|
if (!ctx.model)
|
|
8980
9836
|
throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
|
|
8981
9837
|
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
8982
9838
|
}
|
|
8983
|
-
async function publishResolvedReview(review, params) {
|
|
9839
|
+
async function publishResolvedReview(review, params, model) {
|
|
8984
9840
|
const status = params.status ?? "COMMENT";
|
|
8985
9841
|
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
8986
9842
|
assertReviewEventSupported(review.vcs.provider, event);
|
|
9843
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
9844
|
+
const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
|
|
8987
9845
|
if (status !== "CLOSE" && review.pr.isDraft)
|
|
8988
9846
|
await review.forge.markReady(review.pr.number);
|
|
8989
|
-
await
|
|
9847
|
+
await remote.publish(event);
|
|
9848
|
+
if (promotion) {
|
|
9849
|
+
promotion.publication.state.bodies = [
|
|
9850
|
+
...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
|
|
9851
|
+
];
|
|
9852
|
+
promotion.publication.state.comments = [
|
|
9853
|
+
...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
|
|
9854
|
+
];
|
|
9855
|
+
const published = new Set(promotion.commentFingerprints);
|
|
9856
|
+
promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
|
|
9857
|
+
await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
|
|
9858
|
+
}
|
|
8990
9859
|
if (status === "CLOSE")
|
|
8991
9860
|
await review.forge.closePr(review.pr.number);
|
|
8992
9861
|
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
8993
|
-
|
|
9862
|
+
const promotedBodies = promotion?.promotedBodies ?? 0;
|
|
9863
|
+
const promotedComments = promotion?.promotedComments ?? 0;
|
|
9864
|
+
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
9865
|
+
return result3(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
|
|
9866
|
+
}
|
|
9867
|
+
async function promoteLocalReview(review, remote, model) {
|
|
9868
|
+
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
9869
|
+
const session = await resolvePublishSession(review.cwd, {
|
|
9870
|
+
branch: review.vcs.branch,
|
|
9871
|
+
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
9872
|
+
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
9873
|
+
number: review.pr?.number
|
|
9874
|
+
});
|
|
9875
|
+
if (!session)
|
|
9876
|
+
throw new Error("No matching tuicr session to publish.");
|
|
9877
|
+
const sessionIsWorkingTree = session.kind === "local";
|
|
9878
|
+
const local = createLocalReviewBackend({
|
|
9879
|
+
session: session.path,
|
|
9880
|
+
artifactPath: "",
|
|
9881
|
+
author: localReviewAuthor(model)
|
|
9882
|
+
});
|
|
9883
|
+
const draft = await local.readDraft();
|
|
9884
|
+
const remoteDraft = await remote.readDraft();
|
|
9885
|
+
const knownRootComments = new Set([
|
|
9886
|
+
...publication.state.comments,
|
|
9887
|
+
...publication.state.stagedComments,
|
|
9888
|
+
...remoteDraft.comments.map(reviewCommentFingerprint)
|
|
9889
|
+
]);
|
|
9890
|
+
const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
|
|
9891
|
+
const remoteThreads = await remote.listThreads();
|
|
9892
|
+
const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
|
|
9893
|
+
const thread = resolveReviewThread(comment, remoteThreads);
|
|
9894
|
+
return thread ? [{ comment, thread }] : [];
|
|
9895
|
+
});
|
|
9896
|
+
const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
|
|
9897
|
+
if (unmatchedActions.length > 0) {
|
|
9898
|
+
const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
|
|
9899
|
+
throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
|
|
9900
|
+
}
|
|
9901
|
+
const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
|
|
9902
|
+
const body = draft.body.trim();
|
|
9903
|
+
const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
|
|
9904
|
+
const commentFingerprints = [
|
|
9905
|
+
...checkpointed.map(({ fingerprint }) => fingerprint),
|
|
9906
|
+
...comments.map(reviewCommentFingerprint)
|
|
9907
|
+
];
|
|
9908
|
+
const knownBodies = new Set(publication.state.bodies);
|
|
9909
|
+
if (remoteDraft.body.trim())
|
|
9910
|
+
knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
|
|
9911
|
+
const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
|
|
9912
|
+
const unpublished = unpublishedReviewComments(comments, knownRootComments);
|
|
9913
|
+
if (unpublishedBody)
|
|
9914
|
+
await remote.stage({ comments: [], body: unpublishedBody });
|
|
9915
|
+
let promotedComments = 0;
|
|
9916
|
+
for (const comment of unpublished) {
|
|
9917
|
+
await remote.stage({ comments: [comment], body: "" });
|
|
9918
|
+
publication.state.stagedComments.push(reviewCommentFingerprint(comment));
|
|
9919
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9920
|
+
promotedComments += 1;
|
|
9921
|
+
}
|
|
9922
|
+
const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
|
|
9923
|
+
return {
|
|
9924
|
+
publication,
|
|
9925
|
+
bodyFingerprints,
|
|
9926
|
+
commentFingerprints,
|
|
9927
|
+
promotedBodies: unpublishedBody ? 1 : 0,
|
|
9928
|
+
promotedComments,
|
|
9929
|
+
promotedReplies
|
|
9930
|
+
};
|
|
9931
|
+
}
|
|
9932
|
+
async function promoteLocationReplies(remote, publication, replies, model) {
|
|
9933
|
+
let count = 0;
|
|
9934
|
+
for (const { comment, thread } of replies) {
|
|
9935
|
+
const parsed = parseReviewThreadAction(comment.body);
|
|
9936
|
+
const body = parsed.body.trim();
|
|
9937
|
+
const action = parsed.action;
|
|
9938
|
+
const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
|
|
9939
|
+
if (publication.state.replies.includes(fingerprint))
|
|
9940
|
+
continue;
|
|
9941
|
+
if (action === "delete") {
|
|
9942
|
+
if (!remote.deleteThread)
|
|
9943
|
+
throw new Error(`The review backend cannot delete thread ${thread.id}.`);
|
|
9944
|
+
await remote.deleteThread(thread.id);
|
|
9945
|
+
publication.state.replies.push(fingerprint);
|
|
9946
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9947
|
+
count += 1;
|
|
9948
|
+
continue;
|
|
9949
|
+
}
|
|
9950
|
+
const remoteBody = withCommentProvenance({ ...comment, body }, model);
|
|
9951
|
+
if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
|
|
9952
|
+
await remote.reply({
|
|
9953
|
+
threadId: thread.id,
|
|
9954
|
+
body: remoteBody,
|
|
9955
|
+
resolve: false
|
|
9956
|
+
});
|
|
9957
|
+
}
|
|
9958
|
+
if (action && remote.setResolved)
|
|
9959
|
+
await remote.setResolved(thread.id, action === "resolve");
|
|
9960
|
+
publication.state.replies.push(fingerprint);
|
|
9961
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
9962
|
+
count += 1;
|
|
9963
|
+
}
|
|
9964
|
+
return count;
|
|
9965
|
+
}
|
|
9966
|
+
async function promoteLocalReplies(review, remote, publication, model, workingTree) {
|
|
9967
|
+
const target = workingTree ? "uncommitted" : await reviewTargetId(review);
|
|
9968
|
+
const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
9969
|
+
if (!artifact)
|
|
9970
|
+
return 0;
|
|
9971
|
+
if (!workingTree)
|
|
9972
|
+
publication.state.overlayPath = artifact;
|
|
9973
|
+
let count = 0;
|
|
9974
|
+
for (const thread of await readThreadArtifact(artifact)) {
|
|
9975
|
+
if (!thread.reply)
|
|
9976
|
+
continue;
|
|
9977
|
+
const body = withRemoteProvenance(thread.reply, model);
|
|
9978
|
+
const fingerprint = reviewReplyFingerprint(thread.id, body);
|
|
9979
|
+
if (publication.state.replies.includes(fingerprint))
|
|
9980
|
+
continue;
|
|
9981
|
+
if (workingTree) {
|
|
9982
|
+
const draft = workingTreeReplyDraft(thread, body);
|
|
9983
|
+
const comment = draft.comments[0];
|
|
9984
|
+
if (comment) {
|
|
9985
|
+
const commentFingerprint = reviewCommentFingerprint(comment);
|
|
9986
|
+
if (!publication.state.stagedComments.includes(commentFingerprint)) {
|
|
9987
|
+
await remote.stage(draft);
|
|
9988
|
+
publication.state.stagedComments.push(commentFingerprint);
|
|
9989
|
+
}
|
|
9990
|
+
} else {
|
|
9991
|
+
const bodyFingerprint = reviewBodyFingerprint(draft.body);
|
|
9992
|
+
if (!publication.state.bodies.includes(bodyFingerprint)) {
|
|
9993
|
+
await remote.stage(draft);
|
|
9994
|
+
publication.state.bodies.push(bodyFingerprint);
|
|
9995
|
+
}
|
|
9996
|
+
}
|
|
9997
|
+
} else {
|
|
9998
|
+
await remote.reply({ threadId: thread.id, body, resolve: false, question: thread.question });
|
|
9999
|
+
}
|
|
10000
|
+
publication.state.replies.push(fingerprint);
|
|
10001
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
10002
|
+
count += 1;
|
|
10003
|
+
}
|
|
10004
|
+
return count;
|
|
8994
10005
|
}
|
|
8995
10006
|
async function resolveReviewContext(cwd, target) {
|
|
8996
10007
|
const vcs = await detectVcs(cwd);
|
|
@@ -9031,6 +10042,10 @@ async function assertRemoteBranchReady(cwd) {
|
|
|
9031
10042
|
if (head.stdout.trim() !== pushed.stdout.trim())
|
|
9032
10043
|
throw new Error("Push the current branch before opening a remote review.");
|
|
9033
10044
|
}
|
|
10045
|
+
function withCommentProvenance(comment, fallbackModel) {
|
|
10046
|
+
const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
10047
|
+
return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
|
|
10048
|
+
}
|
|
9034
10049
|
async function reviewTargetId(review) {
|
|
9035
10050
|
if (!review.pr)
|
|
9036
10051
|
return "uncommitted";
|
|
@@ -9047,41 +10062,103 @@ async function headSha(cwd) {
|
|
|
9047
10062
|
}
|
|
9048
10063
|
async function newReviewArtifactPath(cwd, target) {
|
|
9049
10064
|
const dir = await reviewsDir(cwd);
|
|
9050
|
-
await
|
|
10065
|
+
await mkdir7(dir, { recursive: true });
|
|
9051
10066
|
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
9052
10067
|
}
|
|
10068
|
+
async function localThreadArtifactPath(cwd, sessionSlug) {
|
|
10069
|
+
const dir = await reviewsDir(cwd);
|
|
10070
|
+
return join17(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
|
|
10071
|
+
}
|
|
10072
|
+
async function readThreadArtifact(path) {
|
|
10073
|
+
try {
|
|
10074
|
+
return parseThreadArtifact(await readFile12(path, "utf8"));
|
|
10075
|
+
} catch (error) {
|
|
10076
|
+
if (error.code === "ENOENT") {
|
|
10077
|
+
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
10078
|
+
}
|
|
10079
|
+
throw error;
|
|
10080
|
+
}
|
|
10081
|
+
}
|
|
10082
|
+
async function latestThreadArtifact(cwd, review, target) {
|
|
10083
|
+
try {
|
|
10084
|
+
return await findLatestThreadArtifact(cwd, review, target);
|
|
10085
|
+
} catch (error) {
|
|
10086
|
+
throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
|
|
10087
|
+
}
|
|
10088
|
+
}
|
|
10089
|
+
async function findLatestThreadArtifact(cwd, review, target) {
|
|
10090
|
+
const dir = await reviewsDir(cwd);
|
|
10091
|
+
const suffix = reviewSlug(target) || "uncommitted";
|
|
10092
|
+
const names = await listReviewArtifactNames(dir);
|
|
10093
|
+
const candidates = await Promise.all(names.flatMap((name) => {
|
|
10094
|
+
if (!name.endsWith(".md"))
|
|
10095
|
+
return [];
|
|
10096
|
+
return [
|
|
10097
|
+
(async () => {
|
|
10098
|
+
const path = join17(dir, name);
|
|
10099
|
+
const info = await stat(path);
|
|
10100
|
+
return { path, name, content: await readFile12(path, "utf8"), modified: info.mtimeMs };
|
|
10101
|
+
})()
|
|
10102
|
+
];
|
|
10103
|
+
}));
|
|
10104
|
+
return candidates.filter(({ name, content }) => {
|
|
10105
|
+
if (!content.startsWith("<!-- diffpi-threads:"))
|
|
10106
|
+
return false;
|
|
10107
|
+
if (review.pr && target !== "local" && target !== "uncommitted")
|
|
10108
|
+
return content.includes(`- PR/MR: #${review.pr.number}`);
|
|
10109
|
+
return artifactNameMatches(name, suffix);
|
|
10110
|
+
}).sort((a, b) => b.modified - a.modified)[0]?.path;
|
|
10111
|
+
}
|
|
10112
|
+
async function listReviewArtifactNames(dir) {
|
|
10113
|
+
try {
|
|
10114
|
+
return await readdir3(dir);
|
|
10115
|
+
} catch (error) {
|
|
10116
|
+
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
10117
|
+
}
|
|
10118
|
+
}
|
|
9053
10119
|
function deriveTitle(branch) {
|
|
9054
10120
|
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
9055
10121
|
}
|
|
9056
10122
|
function uniqueRecordPath(dir, base) {
|
|
9057
|
-
let path =
|
|
10123
|
+
let path = join17(dir, `${base}.md`);
|
|
9058
10124
|
let count = 2;
|
|
9059
10125
|
while (existsSync2(path))
|
|
9060
|
-
path =
|
|
10126
|
+
path = join17(dir, `${base}-${count++}.md`);
|
|
9061
10127
|
return path;
|
|
9062
10128
|
}
|
|
10129
|
+
function artifactNameMatches(name, suffix) {
|
|
10130
|
+
if (!name.endsWith(".md"))
|
|
10131
|
+
return false;
|
|
10132
|
+
const stem = name.slice(0, -3);
|
|
10133
|
+
const marker = `-${suffix}`;
|
|
10134
|
+
const markerIndex = stem.lastIndexOf(marker);
|
|
10135
|
+
if (markerIndex < 0)
|
|
10136
|
+
return false;
|
|
10137
|
+
const tail = stem.slice(markerIndex + marker.length);
|
|
10138
|
+
return tail === "" || /^-\d+$/.test(tail);
|
|
10139
|
+
}
|
|
9063
10140
|
|
|
9064
10141
|
// src/tools/setup.ts
|
|
9065
10142
|
import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
|
|
9066
|
-
import { z as
|
|
10143
|
+
import { z as z13 } from "zod";
|
|
9067
10144
|
|
|
9068
10145
|
// src/setup.ts
|
|
9069
10146
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
9070
|
-
import { readdir as
|
|
10147
|
+
import { readdir as readdir5, readFile as readFile15 } from "node:fs/promises";
|
|
9071
10148
|
import { homedir as homedir8 } from "node:os";
|
|
9072
|
-
import { basename as basename6, join as
|
|
10149
|
+
import { basename as basename6, join as join21 } from "node:path";
|
|
9073
10150
|
|
|
9074
10151
|
// src/config.ts
|
|
9075
10152
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
9076
|
-
import { z as
|
|
10153
|
+
import { z as z12 } from "zod";
|
|
9077
10154
|
import { homedir as homedir5 } from "node:os";
|
|
9078
|
-
import { join as
|
|
10155
|
+
import { join as join18 } from "node:path";
|
|
9079
10156
|
|
|
9080
10157
|
// src/fsx.ts
|
|
9081
|
-
import { readdir as
|
|
10158
|
+
import { readdir as readdir4, readFile as readFile13 } from "node:fs/promises";
|
|
9082
10159
|
async function readTextIfExists(path) {
|
|
9083
10160
|
try {
|
|
9084
|
-
return await
|
|
10161
|
+
return await readFile13(path, "utf8");
|
|
9085
10162
|
} catch (error) {
|
|
9086
10163
|
if (isMissingPath(error))
|
|
9087
10164
|
return;
|
|
@@ -9094,17 +10171,17 @@ function isMissingPath(error) {
|
|
|
9094
10171
|
|
|
9095
10172
|
// src/config.ts
|
|
9096
10173
|
var modelReferenceSchema = zx.text;
|
|
9097
|
-
var agentConfigSchema =
|
|
9098
|
-
models:
|
|
10174
|
+
var agentConfigSchema = z12.object({
|
|
10175
|
+
models: z12.array(modelReferenceSchema).optional()
|
|
9099
10176
|
}).strict();
|
|
9100
|
-
var diffpiConfigSchema =
|
|
9101
|
-
agents:
|
|
10177
|
+
var diffpiConfigSchema = z12.object({
|
|
10178
|
+
agents: z12.record(z12.string(), agentConfigSchema).optional()
|
|
9102
10179
|
}).strict();
|
|
9103
10180
|
function diffpiConfigPaths(homeDir = homedir5()) {
|
|
9104
|
-
const directory =
|
|
10181
|
+
const directory = join18(homeDir, ".difflab", "diffpi");
|
|
9105
10182
|
return {
|
|
9106
|
-
yaml:
|
|
9107
|
-
json:
|
|
10183
|
+
yaml: join18(directory, "config.yaml"),
|
|
10184
|
+
json: join18(directory, "config.json")
|
|
9108
10185
|
};
|
|
9109
10186
|
}
|
|
9110
10187
|
async function loadDiffpiConfig(options = {}) {
|
|
@@ -9160,12 +10237,12 @@ function normalizeModelReference(value) {
|
|
|
9160
10237
|
}
|
|
9161
10238
|
|
|
9162
10239
|
// src/mcp.ts
|
|
9163
|
-
import { mkdir as
|
|
10240
|
+
import { mkdir as mkdir8, readFile as readFile14, writeFile as writeFile8 } from "node:fs/promises";
|
|
9164
10241
|
import { homedir as homedir6 } from "node:os";
|
|
9165
|
-
import { dirname as dirname8, join as
|
|
10242
|
+
import { dirname as dirname8, join as join19 } from "node:path";
|
|
9166
10243
|
var mcp = {
|
|
9167
10244
|
globalConfigPath(homeDir = homedir6()) {
|
|
9168
|
-
return
|
|
10245
|
+
return join19(homeDir, ".config", "mcp", "mcp.json");
|
|
9169
10246
|
},
|
|
9170
10247
|
async serversEnsure(servers, options = {}) {
|
|
9171
10248
|
const path = options.path ?? mcp.globalConfigPath();
|
|
@@ -9178,8 +10255,8 @@ var mcp = {
|
|
|
9178
10255
|
const next = { ...current, mcpServers: nextServers };
|
|
9179
10256
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9180
10257
|
if (changed && !options.dryRun) {
|
|
9181
|
-
await
|
|
9182
|
-
await
|
|
10258
|
+
await mkdir8(dirname8(path), { recursive: true });
|
|
10259
|
+
await writeFile8(path, `${JSON.stringify(next, null, 2)}
|
|
9183
10260
|
`, "utf8");
|
|
9184
10261
|
}
|
|
9185
10262
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -9208,7 +10285,7 @@ function getParsedConfig(content, path) {
|
|
|
9208
10285
|
}
|
|
9209
10286
|
async function getOptionalFile2(path) {
|
|
9210
10287
|
try {
|
|
9211
|
-
return await
|
|
10288
|
+
return await readFile14(path, "utf8");
|
|
9212
10289
|
} catch (error) {
|
|
9213
10290
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
9214
10291
|
return;
|
|
@@ -9220,9 +10297,9 @@ function isRecord(value) {
|
|
|
9220
10297
|
}
|
|
9221
10298
|
|
|
9222
10299
|
// src/pi.ts
|
|
9223
|
-
import { mkdir as
|
|
10300
|
+
import { mkdir as mkdir9, writeFile as writeFile9 } from "node:fs/promises";
|
|
9224
10301
|
import { homedir as homedir7 } from "node:os";
|
|
9225
|
-
import { dirname as dirname9, join as
|
|
10302
|
+
import { dirname as dirname9, join as join20 } from "node:path";
|
|
9226
10303
|
var pi = {
|
|
9227
10304
|
executableCheck: findPiExecutable,
|
|
9228
10305
|
packageList: listPiPackages,
|
|
@@ -9235,19 +10312,19 @@ var pi = {
|
|
|
9235
10312
|
configEnsure: ensurePiConfig
|
|
9236
10313
|
};
|
|
9237
10314
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
9238
|
-
const path =
|
|
10315
|
+
const path = join20(agentDir, "agents", filename);
|
|
9239
10316
|
const currentText = await readTextIfExists(path);
|
|
9240
10317
|
const changed = currentText !== content;
|
|
9241
10318
|
if (changed && !dryRun) {
|
|
9242
|
-
await
|
|
9243
|
-
await
|
|
10319
|
+
await mkdir9(dirname9(path), { recursive: true });
|
|
10320
|
+
await writeFile9(path, content, "utf8");
|
|
9244
10321
|
}
|
|
9245
10322
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
9246
10323
|
}
|
|
9247
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
9248
|
-
const roots = [
|
|
10324
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join20(homedir7(), ".agents", "skills")) {
|
|
10325
|
+
const roots = [join20(agentDir, "skills"), sharedSkillsDir];
|
|
9249
10326
|
for (const root of roots) {
|
|
9250
|
-
if (await readTextIfExists(
|
|
10327
|
+
if (await readTextIfExists(join20(root, name, "SKILL.md")) !== undefined)
|
|
9251
10328
|
return true;
|
|
9252
10329
|
}
|
|
9253
10330
|
return false;
|
|
@@ -9276,8 +10353,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
9276
10353
|
const next = update(current);
|
|
9277
10354
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
9278
10355
|
if (changed && !dryRun) {
|
|
9279
|
-
await
|
|
9280
|
-
await
|
|
10356
|
+
await mkdir9(dirname9(path), { recursive: true });
|
|
10357
|
+
await writeFile9(path, `${JSON.stringify(next, null, 2)}
|
|
9281
10358
|
`, "utf8");
|
|
9282
10359
|
}
|
|
9283
10360
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -9297,7 +10374,7 @@ async function installPiPackage(executable, source) {
|
|
|
9297
10374
|
await runChecked(executable, ["install", source]);
|
|
9298
10375
|
}
|
|
9299
10376
|
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
9300
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
10377
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join20(process.env.XDG_CONFIG_HOME, "pi") : join20(homeDir, ".pi", "agent"));
|
|
9301
10378
|
}
|
|
9302
10379
|
function parseJsonObject(content, path) {
|
|
9303
10380
|
if (!content?.trim())
|
|
@@ -9349,7 +10426,7 @@ var FORGE_DEPENDENCIES = {
|
|
|
9349
10426
|
};
|
|
9350
10427
|
async function ensureMise(options = {}) {
|
|
9351
10428
|
const homeDir = options.homeDir ?? homedir8();
|
|
9352
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
10429
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join21(homeDir, ".local", "bin", "mise"));
|
|
9353
10430
|
if (current)
|
|
9354
10431
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
9355
10432
|
reportProgress(options, "Installing mise");
|
|
@@ -9398,9 +10475,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
9398
10475
|
async function ensurePiPlugins(options = {}) {
|
|
9399
10476
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
9400
10477
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
9401
|
-
const webSearch = await pi.configEnsure(
|
|
10478
|
+
const webSearch = await pi.configEnsure(join21(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
9402
10479
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
9403
|
-
const lsp = await pi.configEnsure(
|
|
10480
|
+
const lsp = await pi.configEnsure(join21(agentDir, "pi-lsp.json"), (config) => ({
|
|
9404
10481
|
...config,
|
|
9405
10482
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
9406
10483
|
}), options.dryRun);
|
|
@@ -9411,11 +10488,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
9411
10488
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
9412
10489
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
|
|
9413
10490
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
9414
|
-
const entries = (await
|
|
10491
|
+
const entries = (await readdir5(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
9415
10492
|
const actions = [];
|
|
9416
10493
|
for (const entry of entries) {
|
|
9417
10494
|
const id = basename6(entry.name, ".md").replace(/^diffpi-/, "");
|
|
9418
|
-
const source = await
|
|
10495
|
+
const source = await readFile15(join21(bundledAgentsDir, entry.name), "utf8");
|
|
9419
10496
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
9420
10497
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
9421
10498
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -9424,7 +10501,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
9424
10501
|
}
|
|
9425
10502
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
9426
10503
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
9427
|
-
const sharedSkillsDir =
|
|
10504
|
+
const sharedSkillsDir = join21(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
9428
10505
|
const actions = [];
|
|
9429
10506
|
for (const source of PI_SKILL_SOURCES) {
|
|
9430
10507
|
const missing = [];
|
|
@@ -9620,7 +10697,7 @@ function reportProgress(options, message) {
|
|
|
9620
10697
|
options.onProgress?.(message);
|
|
9621
10698
|
}
|
|
9622
10699
|
async function packageVersion2() {
|
|
9623
|
-
const source = await
|
|
10700
|
+
const source = await readFile15(join21(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
9624
10701
|
let value;
|
|
9625
10702
|
try {
|
|
9626
10703
|
value = JSON.parse(source);
|
|
@@ -9636,13 +10713,13 @@ function getRecord(value) {
|
|
|
9636
10713
|
}
|
|
9637
10714
|
|
|
9638
10715
|
// src/tools/setup.ts
|
|
9639
|
-
var setupParametersSchema =
|
|
9640
|
-
issueTracker:
|
|
9641
|
-
forges:
|
|
9642
|
-
forge:
|
|
9643
|
-
bindZedKey:
|
|
10716
|
+
var setupParametersSchema = z13.object({
|
|
10717
|
+
issueTracker: z13.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
10718
|
+
forges: z13.array(z13.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
10719
|
+
forge: z13.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
|
|
10720
|
+
bindZedKey: z13.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
9644
10721
|
});
|
|
9645
|
-
var setupParameters =
|
|
10722
|
+
var setupParameters = z13.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
9646
10723
|
var diffpiSetupTool = defineTool7({
|
|
9647
10724
|
name: "diffpi_setup",
|
|
9648
10725
|
label: "diffpi setup",
|
|
@@ -9715,11 +10792,11 @@ ${lines.join(`
|
|
|
9715
10792
|
|
|
9716
10793
|
// src/tools/templates.ts
|
|
9717
10794
|
import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
|
|
9718
|
-
import { z as
|
|
9719
|
-
var templateSchema =
|
|
9720
|
-
name:
|
|
9721
|
-
variables:
|
|
9722
|
-
homeDir:
|
|
10795
|
+
import { z as z14 } from "zod";
|
|
10796
|
+
var templateSchema = z14.object({
|
|
10797
|
+
name: z14.string().min(1),
|
|
10798
|
+
variables: z14.record(z14.string(), z14.string()).optional(),
|
|
10799
|
+
homeDir: z14.string().optional()
|
|
9723
10800
|
});
|
|
9724
10801
|
var diffpiTemplateTool = defineTool8({
|
|
9725
10802
|
name: "diffpi_template",
|
|
@@ -9727,7 +10804,7 @@ var diffpiTemplateTool = defineTool8({
|
|
|
9727
10804
|
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
9728
10805
|
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
9729
10806
|
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
9730
|
-
parameters:
|
|
10807
|
+
parameters: z14.toJSONSchema(templateSchema, { io: "input" }),
|
|
9731
10808
|
executionMode: "parallel",
|
|
9732
10809
|
async execute(_id, input) {
|
|
9733
10810
|
const params = templateSchema.parse(input);
|