@difflab/pi 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -10
- package/agents/diffpi-orchestrator.md +1 -1
- package/agents/diffpi-planner.md +2 -2
- package/agents/diffpi-reviewer.md +2 -2
- package/agents/diffpi-worker.md +6 -6
- package/dist/environment.d.ts +0 -1
- package/dist/environment.d.ts.map +1 -1
- package/dist/extensions/index.js +319 -1495
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +140 -759
- package/dist/log.d.ts +6 -11
- package/dist/log.d.ts.map +1 -1
- package/dist/plan/verify.d.ts.map +1 -1
- package/dist/review/index.d.ts +2 -3
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/review-backend.d.ts +2 -3
- package/dist/review/review-backend.d.ts.map +1 -1
- package/dist/setup.d.ts +0 -2
- package/dist/setup.d.ts.map +1 -1
- package/dist/tools/index.js +319 -1495
- package/dist/tools/log.d.ts.map +1 -1
- package/dist/tools/review.d.ts +1 -7
- package/dist/tools/review.d.ts.map +1 -1
- package/dist/tools/setup.d.ts.map +1 -1
- package/package.json +1 -5
- package/skills/diffpi-setup/SKILL.md +3 -3
- package/skills/plan/SKILL.md +3 -4
- package/skills/plan/references/design-quality.md +1 -1
- package/skills/plan/references/workflows/finalize.md +2 -2
- package/skills/plan/references/workflows/go.md +7 -5
- package/skills/plan/references/workflows/help.md +1 -2
- package/skills/plan/references/workflows/init.md +2 -2
- package/skills/plan/references/workflows/inline-fallback.md +3 -3
- package/skills/plan/references/workflows/new.md +3 -3
- package/skills/plan/references/workflows/update.md +6 -4
- package/skills/plan/references/workflows/validate.md +5 -5
- package/skills/review/SKILL.md +5 -5
- package/skills/review/references/review-standards.md +9 -1
- package/skills/review/references/workflows/address.md +9 -15
- package/skills/review/references/workflows/auto.md +9 -17
- package/skills/review/references/workflows/complete.md +2 -2
- package/skills/review/references/workflows/edit.md +7 -5
- package/skills/review/references/workflows/help.md +12 -20
- package/skills/review/references/workflows/new.md +14 -13
- package/skills/review/references/workflows/publish.md +2 -2
- package/skills/review/references/workflows/status.md +1 -1
- package/skills/review/templates/REVIEW.md +39 -0
- package/templates/plan/PLAN.md +9 -1
- package/templates/review/draft-pr.md +3 -9
- package/dist/cli/plan.d.ts +0 -17
- package/dist/cli/plan.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -4
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -2258
- package/dist/extensions/tuicrx.d.ts +0 -66
- package/dist/extensions/tuicrx.d.ts.map +0 -1
- package/dist/extensions/zedx.d.ts +0 -18
- package/dist/extensions/zedx.d.ts.map +0 -1
- package/dist/review/local-review-backend.d.ts +0 -3
- package/dist/review/local-review-backend.d.ts.map +0 -1
- package/dist/review/local-reviews.d.ts +0 -31
- package/dist/review/local-reviews.d.ts.map +0 -1
- package/dist/review/review-markdown.d.ts +0 -6
- package/dist/review/review-markdown.d.ts.map +0 -1
- package/skills/plan/references/workflows/annotate.md +0 -13
- package/skills/review/references/workflows/open.md +0 -11
package/dist/tools/index.js
CHANGED
|
@@ -3,30 +3,13 @@ import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
|
3
3
|
import { z as z2 } from "zod";
|
|
4
4
|
|
|
5
5
|
// src/environment.ts
|
|
6
|
-
import {
|
|
7
|
-
import { basename, join as join4 } from "node:path";
|
|
8
|
-
|
|
9
|
-
// src/assets.ts
|
|
10
|
-
import { existsSync } from "node:fs";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
|
-
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
14
|
-
const moduleDir = dirname(fileURLToPath(moduleUrl));
|
|
15
|
-
const candidates = [join(moduleDir, name), join(moduleDir, "..", name), join(moduleDir, "..", "..", name)];
|
|
16
|
-
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
17
|
-
}
|
|
18
|
-
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
19
|
-
return resolveBundledAssetDir("agents", moduleUrl);
|
|
20
|
-
}
|
|
21
|
-
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
22
|
-
return resolveBundledAssetDir("templates", moduleUrl);
|
|
23
|
-
}
|
|
6
|
+
import { basename } from "node:path";
|
|
24
7
|
|
|
25
8
|
// src/extensions/processx.ts
|
|
26
9
|
import { spawn } from "node:child_process";
|
|
27
10
|
import { constants } from "node:fs";
|
|
28
11
|
import { access } from "node:fs/promises";
|
|
29
|
-
import { delimiter, join
|
|
12
|
+
import { delimiter, join } from "node:path";
|
|
30
13
|
async function findExecutable(name) {
|
|
31
14
|
if (name.includes("/")) {
|
|
32
15
|
try {
|
|
@@ -39,7 +22,7 @@ async function findExecutable(name) {
|
|
|
39
22
|
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
40
23
|
if (!directory)
|
|
41
24
|
continue;
|
|
42
|
-
const candidate =
|
|
25
|
+
const candidate = join(directory, name);
|
|
43
26
|
try {
|
|
44
27
|
await access(candidate, constants.X_OK);
|
|
45
28
|
return candidate;
|
|
@@ -103,174 +86,6 @@ function appendBounded(current, next) {
|
|
|
103
86
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
104
87
|
}
|
|
105
88
|
|
|
106
|
-
// src/extensions/zedx.ts
|
|
107
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
108
|
-
import { homedir } from "node:os";
|
|
109
|
-
import { dirname as dirname2, join as join3 } from "node:path";
|
|
110
|
-
var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
|
|
111
|
-
var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
|
|
112
|
-
var ZED_PLAN_ANNOTATE_TASK_NAME = "diffpi: annotate plan";
|
|
113
|
-
var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
114
|
-
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
115
|
-
function zedTasksPath(homeDir = homedir()) {
|
|
116
|
-
return join3(homeDir, ".config", "zed", "tasks.json");
|
|
117
|
-
}
|
|
118
|
-
function zedKeymapPath(homeDir = homedir()) {
|
|
119
|
-
return join3(homeDir, ".config", "zed", "keymap.json");
|
|
120
|
-
}
|
|
121
|
-
async function ensureZedReviewTask(homeDir = homedir(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
|
|
122
|
-
const path = zedTasksPath(homeDir);
|
|
123
|
-
const currentText = await readOptional(path);
|
|
124
|
-
const tasks = parseJsonArray(currentText, path);
|
|
125
|
-
const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
|
|
126
|
-
const next = [...migrated];
|
|
127
|
-
for (const task of [localReviewTask(), prReviewTask()]) {
|
|
128
|
-
const index = next.findIndex((existing) => existing.label === task.label);
|
|
129
|
-
if (index >= 0)
|
|
130
|
-
next[index] = { ...next[index], ...task };
|
|
131
|
-
else
|
|
132
|
-
next.push(task);
|
|
133
|
-
}
|
|
134
|
-
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
135
|
-
if (changed)
|
|
136
|
-
await writeJson(path, next);
|
|
137
|
-
return { path, changed, existed: currentText !== undefined };
|
|
138
|
-
}
|
|
139
|
-
async function ensureZedPlanTask(packageVersion, homeDir = homedir()) {
|
|
140
|
-
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(packageVersion)) {
|
|
141
|
-
throw new Error(`Invalid @difflab/pi package version: ${packageVersion}.`);
|
|
142
|
-
}
|
|
143
|
-
const path = zedTasksPath(homeDir);
|
|
144
|
-
const currentText = await readOptional(path);
|
|
145
|
-
const tasks = parseJsonArray(currentText, path);
|
|
146
|
-
const task = {
|
|
147
|
-
label: ZED_PLAN_ANNOTATE_TASK_NAME,
|
|
148
|
-
command: "npx",
|
|
149
|
-
args: ["--yes", `@difflab/pi@${packageVersion}`, "plan", "annotate", "$ZED_FILE", "--cwd", "$ZED_WORKTREE_ROOT"],
|
|
150
|
-
cwd: "$ZED_WORKTREE_ROOT",
|
|
151
|
-
use_new_terminal: true,
|
|
152
|
-
reveal: "always",
|
|
153
|
-
reveal_target: "center"
|
|
154
|
-
};
|
|
155
|
-
const next = [...tasks];
|
|
156
|
-
const index = next.findIndex((candidate) => candidate.label === task.label);
|
|
157
|
-
if (index >= 0)
|
|
158
|
-
next[index] = { ...next[index], ...task };
|
|
159
|
-
else
|
|
160
|
-
next.push(task);
|
|
161
|
-
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
162
|
-
if (changed)
|
|
163
|
-
await writeJson(path, next);
|
|
164
|
-
return { path, changed, existed: currentText !== undefined };
|
|
165
|
-
}
|
|
166
|
-
async function ensureZedReviewKeybinding(homeDir = homedir()) {
|
|
167
|
-
const path = zedKeymapPath(homeDir);
|
|
168
|
-
const currentText = await readOptional(path);
|
|
169
|
-
const entries = parseJsonArray(currentText, path);
|
|
170
|
-
const migrated = entries.map(migrateReviewKeymapEntry);
|
|
171
|
-
const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
172
|
-
const next = alreadyBound ? migrated : [
|
|
173
|
-
...migrated,
|
|
174
|
-
{
|
|
175
|
-
context: "Workspace",
|
|
176
|
-
bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
|
|
177
|
-
}
|
|
178
|
-
];
|
|
179
|
-
const changed = JSON.stringify(entries) !== JSON.stringify(next);
|
|
180
|
-
if (changed)
|
|
181
|
-
await writeJson(path, next);
|
|
182
|
-
return { path, changed, existed: currentText !== undefined };
|
|
183
|
-
}
|
|
184
|
-
function zedReviewTaskName(command) {
|
|
185
|
-
if (command.some((value, index) => value === "plan" && command[index + 1] === "annotate")) {
|
|
186
|
-
return ZED_PLAN_ANNOTATE_TASK_NAME;
|
|
187
|
-
}
|
|
188
|
-
return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
|
|
189
|
-
}
|
|
190
|
-
var LOCAL_REVIEW_SCRIPT = `set -eu
|
|
191
|
-
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
192
|
-
branch=$(git branch --show-current)
|
|
193
|
-
base=
|
|
194
|
-
case "$remote" in
|
|
195
|
-
*github.com*)
|
|
196
|
-
base=$(gh pr list --head "$branch" --state open --limit 100 --json baseRefName,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].baseRefName // empty' 2>/dev/null || true)
|
|
197
|
-
[ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
|
|
198
|
-
;;
|
|
199
|
-
*gitlab*)
|
|
200
|
-
mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
|
|
201
|
-
base=$(printf '%s' "$mr" | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(a[0]?.target_branch||"")}catch{}})')
|
|
202
|
-
[ -n "$base" ] || base=$(glab repo view --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).default_branch||"")}catch{}})' || true)
|
|
203
|
-
;;
|
|
204
|
-
esac
|
|
205
|
-
[ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
|
|
206
|
-
exec tuicr -w -r "$base..HEAD"`;
|
|
207
|
-
var PR_REVIEW_SCRIPT = `set -eu
|
|
208
|
-
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
209
|
-
branch=$(git branch --show-current)
|
|
210
|
-
case "$remote" in
|
|
211
|
-
*github.com*) number=$(gh pr list --head "$branch" --state open --limit 100 --json number,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].number // empty' 2>/dev/null || true) ;;
|
|
212
|
-
*gitlab*) number=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(String(a[0]?.iid||a[0]?.number||""))}catch{}})' || true) ;;
|
|
213
|
-
*) number= ;;
|
|
214
|
-
esac
|
|
215
|
-
[ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
|
|
216
|
-
exec tuicr pr "$number"`;
|
|
217
|
-
function staticReviewTask(label, script) {
|
|
218
|
-
return {
|
|
219
|
-
label,
|
|
220
|
-
command: "sh",
|
|
221
|
-
args: ["-lc", script],
|
|
222
|
-
cwd: "$ZED_WORKTREE_ROOT",
|
|
223
|
-
use_new_terminal: true,
|
|
224
|
-
reveal: "always",
|
|
225
|
-
reveal_target: "center"
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
function localReviewTask() {
|
|
229
|
-
return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
|
|
230
|
-
}
|
|
231
|
-
function prReviewTask() {
|
|
232
|
-
return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
|
|
233
|
-
}
|
|
234
|
-
function bindsReviewTask(payload) {
|
|
235
|
-
return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
|
|
236
|
-
}
|
|
237
|
-
function migrateReviewKeymapEntry(entry) {
|
|
238
|
-
const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
|
|
239
|
-
if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
|
|
240
|
-
return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
|
|
241
|
-
}
|
|
242
|
-
return [key, action];
|
|
243
|
-
}));
|
|
244
|
-
return entry.bindings ? { ...entry, bindings } : entry;
|
|
245
|
-
}
|
|
246
|
-
async function readOptional(path) {
|
|
247
|
-
try {
|
|
248
|
-
return await readFile(path, "utf8");
|
|
249
|
-
} catch (error) {
|
|
250
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
251
|
-
return;
|
|
252
|
-
throw error;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
function parseJsonArray(content, path) {
|
|
256
|
-
if (!content?.trim())
|
|
257
|
-
return [];
|
|
258
|
-
let value;
|
|
259
|
-
try {
|
|
260
|
-
value = JSON.parse(content);
|
|
261
|
-
} catch {
|
|
262
|
-
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
263
|
-
}
|
|
264
|
-
if (!Array.isArray(value))
|
|
265
|
-
throw new Error(`Expected a JSON array in ${path}.`);
|
|
266
|
-
return value;
|
|
267
|
-
}
|
|
268
|
-
async function writeJson(path, value) {
|
|
269
|
-
await mkdir(dirname2(path), { recursive: true });
|
|
270
|
-
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
271
|
-
`, "utf8");
|
|
272
|
-
}
|
|
273
|
-
|
|
274
89
|
// src/environment.ts
|
|
275
90
|
function detectIde(env = process.env) {
|
|
276
91
|
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
@@ -322,73 +137,6 @@ function parseRemote(remote) {
|
|
|
322
137
|
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
323
138
|
return { provider, host, owner, repo };
|
|
324
139
|
}
|
|
325
|
-
function diffpiLaunchName(cwd, workflow) {
|
|
326
|
-
return `diffpi: ${basename(cwd)} / ${workflow}`;
|
|
327
|
-
}
|
|
328
|
-
async function openInNewTab(command, opts) {
|
|
329
|
-
const env = opts.env ?? process.env;
|
|
330
|
-
const name = opts.name ?? "review";
|
|
331
|
-
const printable = command.join(" ");
|
|
332
|
-
const mux = detectMux(env);
|
|
333
|
-
if (mux !== "none") {
|
|
334
|
-
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
335
|
-
if (opened)
|
|
336
|
-
return opened;
|
|
337
|
-
}
|
|
338
|
-
if (detectIde(env) === "zed") {
|
|
339
|
-
try {
|
|
340
|
-
const taskName = zedReviewTaskName(command);
|
|
341
|
-
if (taskName === ZED_PLAN_ANNOTATE_TASK_NAME)
|
|
342
|
-
await ensureZedPlanTask(await packageVersion(), opts.homeDir);
|
|
343
|
-
else
|
|
344
|
-
await ensureZedReviewTask(opts.homeDir, command);
|
|
345
|
-
return {
|
|
346
|
-
launched: false,
|
|
347
|
-
configured: true,
|
|
348
|
-
via: "zed-task",
|
|
349
|
-
command: printable,
|
|
350
|
-
taskName,
|
|
351
|
-
instruction: `Run the Zed task "${taskName}".`
|
|
352
|
-
};
|
|
353
|
-
} catch {}
|
|
354
|
-
}
|
|
355
|
-
return { launched: false, via: "print", command: printable };
|
|
356
|
-
}
|
|
357
|
-
function screenWindowArgs(command, cwd, name) {
|
|
358
|
-
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
359
|
-
}
|
|
360
|
-
async function packageVersion() {
|
|
361
|
-
let value;
|
|
362
|
-
try {
|
|
363
|
-
value = JSON.parse(await readFile2(join4(resolveBundledAgentsDir(), "..", "package.json"), "utf8"));
|
|
364
|
-
} catch (error) {
|
|
365
|
-
throw new Error("Cannot parse the installed @difflab/pi package metadata.", { cause: error });
|
|
366
|
-
}
|
|
367
|
-
if (typeof value.version !== "string")
|
|
368
|
-
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
369
|
-
return value.version;
|
|
370
|
-
}
|
|
371
|
-
async function openMuxTab(mux, command, cwd, name, printable, tmuxCommand) {
|
|
372
|
-
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
373
|
-
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
374
|
-
if (result.code === 0)
|
|
375
|
-
return { launched: true, via: "zellij", command: printable };
|
|
376
|
-
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
377
|
-
if (fallback.code === 0)
|
|
378
|
-
return { launched: true, via: "zellij-run", command: printable };
|
|
379
|
-
}
|
|
380
|
-
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
381
|
-
const result = tmuxCommand ? await run("tmux", ["new-window", "-c", cwd, "-n", name, ...tmuxCommand]) : await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
382
|
-
if (result.code === 0)
|
|
383
|
-
return { launched: true, via: "tmux", command: printable };
|
|
384
|
-
}
|
|
385
|
-
if (mux === "screen" && await findExecutable("screen")) {
|
|
386
|
-
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
387
|
-
if (result.code === 0)
|
|
388
|
-
return { launched: true, via: "screen", command: printable };
|
|
389
|
-
}
|
|
390
|
-
return;
|
|
391
|
-
}
|
|
392
140
|
|
|
393
141
|
// src/extensions/zodx.ts
|
|
394
142
|
import { z } from "zod";
|
|
@@ -757,33 +505,21 @@ function result(text, status, detail = text) {
|
|
|
757
505
|
|
|
758
506
|
// src/tools/log.ts
|
|
759
507
|
import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
|
|
760
|
-
import { join as join7 } from "node:path";
|
|
761
508
|
import { z as z3 } from "zod";
|
|
762
509
|
|
|
763
510
|
// src/log.ts
|
|
764
|
-
import {
|
|
765
|
-
import {
|
|
766
|
-
async function appendLogEntry(logPath, event) {
|
|
767
|
-
const entry = {
|
|
768
|
-
...event,
|
|
769
|
-
version: 1,
|
|
770
|
-
eventId: event.eventId ?? randomUUID(),
|
|
771
|
-
timestamp: event.timestamp ?? new Date().toISOString()
|
|
772
|
-
};
|
|
773
|
-
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
774
|
-
`, { encoding: "utf8", mode: 384 });
|
|
775
|
-
return entry;
|
|
776
|
-
}
|
|
511
|
+
import { appendFile, lstat as lstat2, mkdir as mkdir2, readFile, realpath as realpath3 } from "node:fs/promises";
|
|
512
|
+
import { dirname as dirname2, isAbsolute as isAbsolute3, join as join4, relative, resolve as resolve3, sep } from "node:path";
|
|
777
513
|
|
|
778
514
|
// src/store.ts
|
|
779
515
|
import { createHash } from "node:crypto";
|
|
780
|
-
import { lstat, mkdir
|
|
781
|
-
import { homedir
|
|
782
|
-
import { dirname
|
|
516
|
+
import { lstat, mkdir, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
|
|
517
|
+
import { homedir } from "node:os";
|
|
518
|
+
import { dirname, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
|
|
783
519
|
|
|
784
520
|
// src/extensions/gitx.ts
|
|
785
521
|
import { realpath } from "node:fs/promises";
|
|
786
|
-
import { basename as basename2, isAbsolute, join as
|
|
522
|
+
import { basename as basename2, isAbsolute, join as join2, resolve } from "node:path";
|
|
787
523
|
async function gitToplevel(cwd) {
|
|
788
524
|
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
789
525
|
const top = result.stdout.trim();
|
|
@@ -795,7 +531,7 @@ async function inspectGitRepository(cwd) {
|
|
|
795
531
|
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
796
532
|
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
797
533
|
const common = commonResult.stdout.trim();
|
|
798
|
-
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common :
|
|
534
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join2(root, common)) : root;
|
|
799
535
|
const commonDir = await canonicalPath(commonPath);
|
|
800
536
|
return {
|
|
801
537
|
root,
|
|
@@ -822,17 +558,17 @@ async function canonicalPath(path) {
|
|
|
822
558
|
|
|
823
559
|
// src/store.ts
|
|
824
560
|
var STORE_LINK = ".diffpi";
|
|
825
|
-
var LEGACY_STORE_LINK =
|
|
826
|
-
function storeGlobalRoot(homeDir =
|
|
827
|
-
return
|
|
561
|
+
var LEGACY_STORE_LINK = join3(".pi", "diffpi");
|
|
562
|
+
function storeGlobalRoot(homeDir = homedir()) {
|
|
563
|
+
return join3(homeDir, ".difflab", "diffpi", "projects");
|
|
828
564
|
}
|
|
829
|
-
async function ensureStore(cwd, homeDir =
|
|
565
|
+
async function ensureStore(cwd, homeDir = homedir()) {
|
|
830
566
|
const repository = await inspectGitRepository(cwd);
|
|
831
567
|
const root = repository.root;
|
|
832
568
|
const slug = projectSlug(repository);
|
|
833
|
-
const dest =
|
|
834
|
-
const link =
|
|
835
|
-
await
|
|
569
|
+
const dest = join3(storeGlobalRoot(homeDir), slug);
|
|
570
|
+
const link = join3(root, STORE_LINK);
|
|
571
|
+
await mkdir(dest, { recursive: true });
|
|
836
572
|
try {
|
|
837
573
|
await assertStoreLink(link, dest);
|
|
838
574
|
} catch (error) {
|
|
@@ -840,32 +576,12 @@ async function ensureStore(cwd, homeDir = homedir2()) {
|
|
|
840
576
|
throw error;
|
|
841
577
|
await symlink(dest, link);
|
|
842
578
|
}
|
|
843
|
-
await removeLegacyStoreLink(
|
|
579
|
+
await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
|
|
844
580
|
return { slug, root, dest, link, linked: true };
|
|
845
581
|
}
|
|
846
|
-
async function
|
|
582
|
+
async function storeDir(cwd, homeDir = homedir()) {
|
|
847
583
|
const store = await ensureStore(cwd, homeDir);
|
|
848
|
-
|
|
849
|
-
await mkdir2(dir, { recursive: true });
|
|
850
|
-
return dir;
|
|
851
|
-
}
|
|
852
|
-
async function reviewsDir(cwd, homeDir = homedir2()) {
|
|
853
|
-
const store = await ensureStore(cwd, homeDir);
|
|
854
|
-
const dir = join6(store.link, "review");
|
|
855
|
-
await mkdir2(dir, { recursive: true });
|
|
856
|
-
return dir;
|
|
857
|
-
}
|
|
858
|
-
async function completedReviewsDir(cwd, homeDir = homedir2()) {
|
|
859
|
-
const store = await ensureStore(cwd, homeDir);
|
|
860
|
-
const dir = join6(store.link, "reviews");
|
|
861
|
-
await mkdir2(dir, { recursive: true });
|
|
862
|
-
return dir;
|
|
863
|
-
}
|
|
864
|
-
async function sessionsDir(cwd, homeDir = homedir2()) {
|
|
865
|
-
const store = await ensureStore(cwd, homeDir);
|
|
866
|
-
const dir = join6(store.link, "sessions");
|
|
867
|
-
await mkdir2(dir, { recursive: true });
|
|
868
|
-
return dir;
|
|
584
|
+
return store.dest;
|
|
869
585
|
}
|
|
870
586
|
async function assertStoreLink(path, dest) {
|
|
871
587
|
const entry = await lstat(path);
|
|
@@ -890,7 +606,7 @@ async function removeLegacyStoreLink(path, dest) {
|
|
|
890
606
|
}
|
|
891
607
|
async function symlinkTarget(path) {
|
|
892
608
|
const target = await readlink(path);
|
|
893
|
-
return canonicalPath2(isAbsolute2(target) ? target : resolve2(
|
|
609
|
+
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname(path), target));
|
|
894
610
|
}
|
|
895
611
|
async function canonicalPath2(path) {
|
|
896
612
|
try {
|
|
@@ -905,41 +621,94 @@ function projectSlug(repository) {
|
|
|
905
621
|
return `${readable}-${digest}`;
|
|
906
622
|
}
|
|
907
623
|
|
|
624
|
+
// src/log.ts
|
|
625
|
+
async function appendLogEntry(logPath, entry) {
|
|
626
|
+
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
627
|
+
`, { encoding: "utf8", mode: 384 });
|
|
628
|
+
return entry;
|
|
629
|
+
}
|
|
630
|
+
async function appendLog(cwd, filename, message, label) {
|
|
631
|
+
const entry = label === undefined ? { message } : { message, label };
|
|
632
|
+
return appendLogEntry(await resolveLogPath(cwd, filename), entry);
|
|
633
|
+
}
|
|
634
|
+
async function resolveLogPath(cwd, filename) {
|
|
635
|
+
if (!filename || isAbsolute3(filename))
|
|
636
|
+
throw new Error("Log filename must be a non-empty relative path.");
|
|
637
|
+
const root = await realpath3(cwd);
|
|
638
|
+
const lexicalTarget = resolve3(root, filename);
|
|
639
|
+
assertContained(root, lexicalTarget);
|
|
640
|
+
let safeRoot = root;
|
|
641
|
+
let target = lexicalTarget;
|
|
642
|
+
if (filename.startsWith(`.diffpi${sep}`)) {
|
|
643
|
+
const insideStore = filename.slice(`.diffpi${sep}`.length);
|
|
644
|
+
if (insideStore.split(sep).includes(".."))
|
|
645
|
+
throw new Error("Log filename must stay within cwd.");
|
|
646
|
+
safeRoot = await realpath3(await storeDir(root));
|
|
647
|
+
target = resolve3(safeRoot, insideStore);
|
|
648
|
+
assertContained(safeRoot, target);
|
|
649
|
+
if (target === safeRoot)
|
|
650
|
+
throw new Error("Log filename must name a file within the store.");
|
|
651
|
+
}
|
|
652
|
+
await ensureSafeParent(safeRoot, dirname2(target));
|
|
653
|
+
try {
|
|
654
|
+
const entry = await lstat2(target);
|
|
655
|
+
if (entry.isSymbolicLink())
|
|
656
|
+
throw new Error("Log filename must not point through a symlink.");
|
|
657
|
+
} catch (error) {
|
|
658
|
+
if (error.code !== "ENOENT")
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
return target;
|
|
662
|
+
}
|
|
663
|
+
async function ensureSafeParent(root, parent) {
|
|
664
|
+
assertContained(root, parent);
|
|
665
|
+
let current = root;
|
|
666
|
+
const suffix = relative(root, parent);
|
|
667
|
+
if (!suffix)
|
|
668
|
+
return;
|
|
669
|
+
for (const part of suffix.split(sep)) {
|
|
670
|
+
current = join4(current, part);
|
|
671
|
+
try {
|
|
672
|
+
await mkdir2(current);
|
|
673
|
+
} catch (error) {
|
|
674
|
+
if (error.code !== "EEXIST")
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
const entry = await lstat2(current);
|
|
678
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
679
|
+
throw new Error("Log filename must not pass through a symlink or non-directory.");
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
function assertContained(root, target) {
|
|
683
|
+
const targetRelative = relative(root, target);
|
|
684
|
+
if (targetRelative === ".." || targetRelative.startsWith(`..${sep}`) || isAbsolute3(targetRelative))
|
|
685
|
+
throw new Error("Log filename must stay within cwd.");
|
|
686
|
+
}
|
|
687
|
+
|
|
908
688
|
// src/tools/log.ts
|
|
909
689
|
var schema = z3.object({
|
|
910
690
|
cwd: zx.cwd,
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
message: zx.text,
|
|
915
|
-
correlation: z3.record(z3.string(), z3.string()).optional(),
|
|
916
|
-
evidence: z3.array(zx.text).optional(),
|
|
917
|
-
data: z3.record(z3.string(), z3.unknown()).optional()
|
|
691
|
+
filename: z3.string().trim().min(1),
|
|
692
|
+
label: z3.string().trim().min(1).optional(),
|
|
693
|
+
message: zx.text
|
|
918
694
|
}).strict();
|
|
919
695
|
var diffpiLogTool = defineTool2({
|
|
920
696
|
name: "diffpi_log",
|
|
921
697
|
label: "diffpi log",
|
|
922
|
-
description: "Append a
|
|
923
|
-
promptSnippet: "Use diffpi_log
|
|
698
|
+
description: "Append a message to a caller-selected relative project JSONL file.",
|
|
699
|
+
promptSnippet: "Use diffpi_log to append durable progress messages to the supplied relative JSONL filename.",
|
|
924
700
|
promptGuidelines: [
|
|
925
|
-
"
|
|
926
|
-
"
|
|
701
|
+
"For /plan go, use cwd at the canonical Git root and filename .diffpi/plan/<plan-id>/logs.jsonl, directly beside PLAN.md.",
|
|
702
|
+
"Use label only when a reusable category helps readers filter messages."
|
|
927
703
|
],
|
|
928
704
|
parameters: z3.toJSONSchema(schema, { io: "input" }),
|
|
929
705
|
executionMode: "sequential",
|
|
930
706
|
async execute(_id, input) {
|
|
931
707
|
const params = schema.parse(input);
|
|
932
|
-
const entry = await
|
|
933
|
-
kind: params.kind,
|
|
934
|
-
actor: params.actor,
|
|
935
|
-
message: params.message,
|
|
936
|
-
correlation: params.correlation,
|
|
937
|
-
evidence: params.evidence,
|
|
938
|
-
data: params.data
|
|
939
|
-
});
|
|
708
|
+
const entry = await appendLog(params.cwd ?? process.cwd(), params.filename, params.message, params.label);
|
|
940
709
|
return {
|
|
941
|
-
content: [{ type: "text", text: `Logged
|
|
942
|
-
details: { entry,
|
|
710
|
+
content: [{ type: "text", text: `Logged message to ${params.filename}.` }],
|
|
711
|
+
details: { entry, filename: params.filename }
|
|
943
712
|
};
|
|
944
713
|
}
|
|
945
714
|
});
|
|
@@ -950,19 +719,35 @@ import { z as z5 } from "zod";
|
|
|
950
719
|
|
|
951
720
|
// src/doctor.ts
|
|
952
721
|
import { createHash as createHash2 } from "node:crypto";
|
|
953
|
-
import { lstat as
|
|
954
|
-
import { basename as basename5, join as
|
|
722
|
+
import { lstat as lstat3, mkdir as mkdir6, readFile as readFile6, readdir as readdir3, writeFile as writeFile4 } from "node:fs/promises";
|
|
723
|
+
import { basename as basename5, join as join11 } from "node:path";
|
|
724
|
+
|
|
725
|
+
// src/assets.ts
|
|
726
|
+
import { existsSync } from "node:fs";
|
|
727
|
+
import { dirname as dirname3, join as join5 } from "node:path";
|
|
728
|
+
import { fileURLToPath } from "node:url";
|
|
729
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
730
|
+
const moduleDir = dirname3(fileURLToPath(moduleUrl));
|
|
731
|
+
const candidates = [join5(moduleDir, name), join5(moduleDir, "..", name), join5(moduleDir, "..", "..", name)];
|
|
732
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
733
|
+
}
|
|
734
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
735
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
736
|
+
}
|
|
737
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
738
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
739
|
+
}
|
|
955
740
|
|
|
956
741
|
// src/pi.ts
|
|
957
|
-
import { mkdir as mkdir3, writeFile
|
|
958
|
-
import { homedir as
|
|
959
|
-
import { dirname as dirname4, join as
|
|
742
|
+
import { mkdir as mkdir3, writeFile } from "node:fs/promises";
|
|
743
|
+
import { homedir as homedir2 } from "node:os";
|
|
744
|
+
import { dirname as dirname4, join as join6 } from "node:path";
|
|
960
745
|
|
|
961
746
|
// src/fsx.ts
|
|
962
|
-
import { readdir, readFile as
|
|
747
|
+
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
963
748
|
async function readTextIfExists(path) {
|
|
964
749
|
try {
|
|
965
|
-
return await
|
|
750
|
+
return await readFile2(path, "utf8");
|
|
966
751
|
} catch (error) {
|
|
967
752
|
if (isMissingPath(error))
|
|
968
753
|
return;
|
|
@@ -986,19 +771,19 @@ var pi = {
|
|
|
986
771
|
configEnsure: ensurePiConfig
|
|
987
772
|
};
|
|
988
773
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
989
|
-
const path =
|
|
774
|
+
const path = join6(agentDir, "agents", filename);
|
|
990
775
|
const currentText = await readTextIfExists(path);
|
|
991
776
|
const changed = currentText !== content;
|
|
992
777
|
if (changed && !dryRun) {
|
|
993
778
|
await mkdir3(dirname4(path), { recursive: true });
|
|
994
|
-
await
|
|
779
|
+
await writeFile(path, content, "utf8");
|
|
995
780
|
}
|
|
996
781
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
997
782
|
}
|
|
998
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
999
|
-
const roots = [
|
|
783
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join6(homedir2(), ".agents", "skills")) {
|
|
784
|
+
const roots = [join6(agentDir, "skills"), sharedSkillsDir];
|
|
1000
785
|
for (const root of roots) {
|
|
1001
|
-
if (await readTextIfExists(
|
|
786
|
+
if (await readTextIfExists(join6(root, name, "SKILL.md")) !== undefined)
|
|
1002
787
|
return true;
|
|
1003
788
|
}
|
|
1004
789
|
return false;
|
|
@@ -1028,7 +813,7 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
1028
813
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1029
814
|
if (changed && !dryRun) {
|
|
1030
815
|
await mkdir3(dirname4(path), { recursive: true });
|
|
1031
|
-
await
|
|
816
|
+
await writeFile(path, `${JSON.stringify(next, null, 2)}
|
|
1032
817
|
`, "utf8");
|
|
1033
818
|
}
|
|
1034
819
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -1047,8 +832,8 @@ function hasPiPackage(listOutput, source) {
|
|
|
1047
832
|
async function installPiPackage(executable, source) {
|
|
1048
833
|
await runChecked(executable, ["install", source]);
|
|
1049
834
|
}
|
|
1050
|
-
function resolvePiAgentDir(homeDir =
|
|
1051
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
835
|
+
function resolvePiAgentDir(homeDir = homedir2()) {
|
|
836
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join6(process.env.XDG_CONFIG_HOME, "pi") : join6(homeDir, ".pi", "agent"));
|
|
1052
837
|
}
|
|
1053
838
|
function parseJsonObject(content, path) {
|
|
1054
839
|
if (!content?.trim())
|
|
@@ -1063,15 +848,15 @@ function parseJsonObject(content, path) {
|
|
|
1063
848
|
|
|
1064
849
|
// src/setup.ts
|
|
1065
850
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
1066
|
-
import { readdir as readdir2, readFile as
|
|
1067
|
-
import { homedir as
|
|
1068
|
-
import { basename as basename4, join as
|
|
851
|
+
import { readdir as readdir2, readFile as readFile5 } from "node:fs/promises";
|
|
852
|
+
import { homedir as homedir6 } from "node:os";
|
|
853
|
+
import { basename as basename4, join as join10 } from "node:path";
|
|
1069
854
|
|
|
1070
855
|
// src/config.ts
|
|
1071
856
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
1072
857
|
import { z as z4 } from "zod";
|
|
1073
|
-
import { homedir as
|
|
1074
|
-
import { join as
|
|
858
|
+
import { homedir as homedir3 } from "node:os";
|
|
859
|
+
import { join as join7 } from "node:path";
|
|
1075
860
|
var modelReferenceSchema = zx.text;
|
|
1076
861
|
var agentConfigSchema = z4.object({
|
|
1077
862
|
models: z4.array(modelReferenceSchema).optional()
|
|
@@ -1079,11 +864,11 @@ var agentConfigSchema = z4.object({
|
|
|
1079
864
|
var diffpiConfigSchema = z4.object({
|
|
1080
865
|
agents: z4.record(z4.string(), agentConfigSchema).optional()
|
|
1081
866
|
}).strict();
|
|
1082
|
-
function diffpiConfigPaths(homeDir =
|
|
1083
|
-
const directory =
|
|
867
|
+
function diffpiConfigPaths(homeDir = homedir3()) {
|
|
868
|
+
const directory = join7(homeDir, ".difflab", "diffpi");
|
|
1084
869
|
return {
|
|
1085
|
-
yaml:
|
|
1086
|
-
json:
|
|
870
|
+
yaml: join7(directory, "config.yaml"),
|
|
871
|
+
json: join7(directory, "config.json")
|
|
1087
872
|
};
|
|
1088
873
|
}
|
|
1089
874
|
async function loadDiffpiConfig(options = {}) {
|
|
@@ -1139,12 +924,12 @@ function normalizeModelReference(value) {
|
|
|
1139
924
|
}
|
|
1140
925
|
|
|
1141
926
|
// src/mcp.ts
|
|
1142
|
-
import { mkdir as mkdir4, readFile as
|
|
1143
|
-
import { homedir as
|
|
1144
|
-
import { dirname as dirname5, join as
|
|
927
|
+
import { mkdir as mkdir4, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
928
|
+
import { homedir as homedir4 } from "node:os";
|
|
929
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
1145
930
|
var mcp = {
|
|
1146
|
-
globalConfigPath(homeDir =
|
|
1147
|
-
return
|
|
931
|
+
globalConfigPath(homeDir = homedir4()) {
|
|
932
|
+
return join8(homeDir, ".config", "mcp", "mcp.json");
|
|
1148
933
|
},
|
|
1149
934
|
async serversEnsure(servers, options = {}) {
|
|
1150
935
|
const path = options.path ?? mcp.globalConfigPath();
|
|
@@ -1158,7 +943,7 @@ var mcp = {
|
|
|
1158
943
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1159
944
|
if (changed && !options.dryRun) {
|
|
1160
945
|
await mkdir4(dirname5(path), { recursive: true });
|
|
1161
|
-
await
|
|
946
|
+
await writeFile2(path, `${JSON.stringify(next, null, 2)}
|
|
1162
947
|
`, "utf8");
|
|
1163
948
|
}
|
|
1164
949
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -1187,7 +972,7 @@ function getParsedConfig(content, path) {
|
|
|
1187
972
|
}
|
|
1188
973
|
async function getOptionalFile(path) {
|
|
1189
974
|
try {
|
|
1190
|
-
return await
|
|
975
|
+
return await readFile3(path, "utf8");
|
|
1191
976
|
} catch (error) {
|
|
1192
977
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1193
978
|
return;
|
|
@@ -1199,9 +984,9 @@ function isRecord(value) {
|
|
|
1199
984
|
}
|
|
1200
985
|
|
|
1201
986
|
// src/extensions/misex.ts
|
|
1202
|
-
import { mkdir as mkdir5, readFile as
|
|
1203
|
-
import { homedir as
|
|
1204
|
-
import { basename as basename3, dirname as dirname6, join as
|
|
987
|
+
import { mkdir as mkdir5, readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
988
|
+
import { homedir as homedir5 } from "node:os";
|
|
989
|
+
import { basename as basename3, dirname as dirname6, join as join9 } from "node:path";
|
|
1205
990
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
1206
991
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
1207
992
|
var mise = {
|
|
@@ -1212,11 +997,11 @@ var mise = {
|
|
|
1212
997
|
return run("mise", args, options);
|
|
1213
998
|
},
|
|
1214
999
|
async install(options = {}) {
|
|
1215
|
-
const homeDir = options.homeDir ??
|
|
1000
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
1216
1001
|
const platform = options.platform ?? process.platform;
|
|
1217
1002
|
if (platform === "win32")
|
|
1218
1003
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
1219
|
-
const installedPath =
|
|
1004
|
+
const installedPath = join9(homeDir, ".local", "bin", "mise");
|
|
1220
1005
|
if (options.dryRun)
|
|
1221
1006
|
return installedPath;
|
|
1222
1007
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -1226,7 +1011,7 @@ var mise = {
|
|
|
1226
1011
|
return executable;
|
|
1227
1012
|
},
|
|
1228
1013
|
async hookEnsure(executable, options = {}) {
|
|
1229
|
-
const homeDir = options.homeDir ??
|
|
1014
|
+
const homeDir = options.homeDir ?? homedir5();
|
|
1230
1015
|
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
1231
1016
|
const current = await getOptionalFile2(hook.path);
|
|
1232
1017
|
if (current.includes(MISE_HOOK_START))
|
|
@@ -1237,7 +1022,7 @@ var mise = {
|
|
|
1237
1022
|
`) ? "" : `
|
|
1238
1023
|
`;
|
|
1239
1024
|
await mkdir5(dirname6(hook.path), { recursive: true });
|
|
1240
|
-
await
|
|
1025
|
+
await writeFile3(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
1241
1026
|
return { path: hook.path, changed: true, planned: false };
|
|
1242
1027
|
},
|
|
1243
1028
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -1254,7 +1039,7 @@ var mise = {
|
|
|
1254
1039
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
1255
1040
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
1256
1041
|
},
|
|
1257
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
1042
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir5()) {
|
|
1258
1043
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
1259
1044
|
}
|
|
1260
1045
|
};
|
|
@@ -1263,7 +1048,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
1263
1048
|
switch (shell.toLowerCase()) {
|
|
1264
1049
|
case "zsh":
|
|
1265
1050
|
return {
|
|
1266
|
-
path:
|
|
1051
|
+
path: join9(homeDir, ".zshrc"),
|
|
1267
1052
|
content: `${MISE_HOOK_START}
|
|
1268
1053
|
eval "$(${command} activate zsh)"
|
|
1269
1054
|
${MISE_HOOK_END}
|
|
@@ -1271,7 +1056,7 @@ ${MISE_HOOK_END}
|
|
|
1271
1056
|
};
|
|
1272
1057
|
case "fish":
|
|
1273
1058
|
return {
|
|
1274
|
-
path:
|
|
1059
|
+
path: join9(homeDir, ".config", "fish", "config.fish"),
|
|
1275
1060
|
content: `${MISE_HOOK_START}
|
|
1276
1061
|
${command} activate fish | source
|
|
1277
1062
|
${MISE_HOOK_END}
|
|
@@ -1280,7 +1065,7 @@ ${MISE_HOOK_END}
|
|
|
1280
1065
|
case "nu":
|
|
1281
1066
|
case "nushell":
|
|
1282
1067
|
return {
|
|
1283
|
-
path:
|
|
1068
|
+
path: join9(homeDir, ".config", "nushell", "config.nu"),
|
|
1284
1069
|
content: `${MISE_HOOK_START}
|
|
1285
1070
|
let mise_bin = ${command}
|
|
1286
1071
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -1291,7 +1076,7 @@ ${MISE_HOOK_END}
|
|
|
1291
1076
|
};
|
|
1292
1077
|
case "xonsh":
|
|
1293
1078
|
return {
|
|
1294
|
-
path:
|
|
1079
|
+
path: join9(homeDir, ".xonshrc"),
|
|
1295
1080
|
content: `${MISE_HOOK_START}
|
|
1296
1081
|
execx($(${command} activate xonsh))
|
|
1297
1082
|
${MISE_HOOK_END}
|
|
@@ -1299,7 +1084,7 @@ ${MISE_HOOK_END}
|
|
|
1299
1084
|
};
|
|
1300
1085
|
case "elvish":
|
|
1301
1086
|
return {
|
|
1302
|
-
path:
|
|
1087
|
+
path: join9(homeDir, ".config", "elvish", "rc.elv"),
|
|
1303
1088
|
content: `${MISE_HOOK_START}
|
|
1304
1089
|
var mise: = (ns [&])
|
|
1305
1090
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -1310,7 +1095,7 @@ ${MISE_HOOK_END}
|
|
|
1310
1095
|
case "pwsh":
|
|
1311
1096
|
case "powershell":
|
|
1312
1097
|
return {
|
|
1313
|
-
path:
|
|
1098
|
+
path: join9(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
1314
1099
|
content: `${MISE_HOOK_START}
|
|
1315
1100
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
1316
1101
|
${MISE_HOOK_END}
|
|
@@ -1319,7 +1104,7 @@ ${MISE_HOOK_END}
|
|
|
1319
1104
|
case "bash":
|
|
1320
1105
|
default:
|
|
1321
1106
|
return {
|
|
1322
|
-
path:
|
|
1107
|
+
path: join9(homeDir, ".bashrc"),
|
|
1323
1108
|
content: `${MISE_HOOK_START}
|
|
1324
1109
|
eval "$(${command} activate bash)"
|
|
1325
1110
|
${MISE_HOOK_END}
|
|
@@ -1358,7 +1143,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
1358
1143
|
}
|
|
1359
1144
|
async function getOptionalFile2(path) {
|
|
1360
1145
|
try {
|
|
1361
|
-
return await
|
|
1146
|
+
return await readFile4(path, "utf8");
|
|
1362
1147
|
} catch (error) {
|
|
1363
1148
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1364
1149
|
return "";
|
|
@@ -1374,12 +1159,6 @@ var MISE_DEPENDENCIES = [
|
|
|
1374
1159
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
1375
1160
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
1376
1161
|
{ name: "helix", tool: "helix", spec: "helix@latest", minimumVersion: undefined },
|
|
1377
|
-
{
|
|
1378
|
-
name: "tuicr",
|
|
1379
|
-
tool: "github:agavra/tuicr",
|
|
1380
|
-
spec: "github:agavra/tuicr@latest",
|
|
1381
|
-
minimumVersion: undefined
|
|
1382
|
-
},
|
|
1383
1162
|
{
|
|
1384
1163
|
name: "context-mode",
|
|
1385
1164
|
tool: "npm:context-mode",
|
|
@@ -1408,8 +1187,8 @@ var FORGE_DEPENDENCIES = {
|
|
|
1408
1187
|
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
1409
1188
|
};
|
|
1410
1189
|
async function ensureMise(options = {}) {
|
|
1411
|
-
const homeDir = options.homeDir ??
|
|
1412
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
1190
|
+
const homeDir = options.homeDir ?? homedir6();
|
|
1191
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join10(homeDir, ".local", "bin", "mise"));
|
|
1413
1192
|
if (current)
|
|
1414
1193
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
1415
1194
|
reportProgress(options, "Installing mise");
|
|
@@ -1458,9 +1237,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
1458
1237
|
async function ensurePiPlugins(options = {}) {
|
|
1459
1238
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
1460
1239
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
1461
|
-
const webSearch = await pi.configEnsure(
|
|
1240
|
+
const webSearch = await pi.configEnsure(join10(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
1462
1241
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
1463
|
-
const lsp = await pi.configEnsure(
|
|
1242
|
+
const lsp = await pi.configEnsure(join10(agentDir, "pi-lsp.json"), (config) => ({
|
|
1464
1243
|
...config,
|
|
1465
1244
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
1466
1245
|
}), options.dryRun);
|
|
@@ -1475,7 +1254,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
1475
1254
|
const actions = [];
|
|
1476
1255
|
for (const entry of entries) {
|
|
1477
1256
|
const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
|
|
1478
|
-
const source = await
|
|
1257
|
+
const source = await readFile5(join10(bundledAgentsDir, entry.name), "utf8");
|
|
1479
1258
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
1480
1259
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
1481
1260
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -1484,7 +1263,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
1484
1263
|
}
|
|
1485
1264
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
1486
1265
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
1487
|
-
const sharedSkillsDir =
|
|
1266
|
+
const sharedSkillsDir = join10(options.homeDir ?? homedir6(), ".agents", "skills");
|
|
1488
1267
|
const actions = [];
|
|
1489
1268
|
for (const source of PI_SKILL_SOURCES) {
|
|
1490
1269
|
const missing = [];
|
|
@@ -1552,8 +1331,6 @@ async function setupPi(options = {}) {
|
|
|
1552
1331
|
actions.push(...await ensurePiAgents(options));
|
|
1553
1332
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
1554
1333
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
1555
|
-
if (options.bindZedKey)
|
|
1556
|
-
actions.push(...await ensureZedIntegration(options));
|
|
1557
1334
|
return {
|
|
1558
1335
|
actions,
|
|
1559
1336
|
restartPi: setupRequiresRestart(actions)
|
|
@@ -1562,35 +1339,6 @@ async function setupPi(options = {}) {
|
|
|
1562
1339
|
function setupRequiresRestart(actions) {
|
|
1563
1340
|
return actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi agent ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"));
|
|
1564
1341
|
}
|
|
1565
|
-
async function ensureZedIntegration(options = {}) {
|
|
1566
|
-
if (options.dryRun) {
|
|
1567
|
-
const actions = [
|
|
1568
|
-
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json"),
|
|
1569
|
-
createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
|
|
1570
|
-
];
|
|
1571
|
-
if (options.bindZedKey)
|
|
1572
|
-
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
1573
|
-
return actions;
|
|
1574
|
-
}
|
|
1575
|
-
const actions = [];
|
|
1576
|
-
try {
|
|
1577
|
-
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
1578
|
-
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
1579
|
-
const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
|
|
1580
|
-
actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
|
|
1581
|
-
} catch (error) {
|
|
1582
|
-
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
1583
|
-
}
|
|
1584
|
-
if (options.bindZedKey) {
|
|
1585
|
-
try {
|
|
1586
|
-
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
1587
|
-
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
1588
|
-
} catch (error) {
|
|
1589
|
-
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
1590
|
-
}
|
|
1591
|
-
}
|
|
1592
|
-
return actions;
|
|
1593
|
-
}
|
|
1594
1342
|
async function gitlabMcpHost(projectDir) {
|
|
1595
1343
|
try {
|
|
1596
1344
|
const vcs = await detectVcs(projectDir);
|
|
@@ -1679,18 +1427,6 @@ function createSetupAction(name, status, detail) {
|
|
|
1679
1427
|
function reportProgress(options, message) {
|
|
1680
1428
|
options.onProgress?.(message);
|
|
1681
1429
|
}
|
|
1682
|
-
async function packageVersion2() {
|
|
1683
|
-
const source = await readFile7(join12(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
1684
|
-
let value;
|
|
1685
|
-
try {
|
|
1686
|
-
value = JSON.parse(source);
|
|
1687
|
-
} catch (error) {
|
|
1688
|
-
throw new Error("Cannot parse the installed @difflab/pi package metadata.", { cause: error });
|
|
1689
|
-
}
|
|
1690
|
-
if (typeof value.version !== "string")
|
|
1691
|
-
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
1692
|
-
return value.version;
|
|
1693
|
-
}
|
|
1694
1430
|
function getRecord(value) {
|
|
1695
1431
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
1696
1432
|
}
|
|
@@ -1706,13 +1442,13 @@ var REQUIRED_AGENTS = [
|
|
|
1706
1442
|
async function doctorAgents(options = {}) {
|
|
1707
1443
|
const bundledDir = options.bundledAgentsDir ?? resolveBundledAgentsDir();
|
|
1708
1444
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
1709
|
-
const targetDir =
|
|
1445
|
+
const targetDir = join11(agentDir, "agents");
|
|
1710
1446
|
const bundled = (await readdir3(bundledDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).map((entry) => entry.name);
|
|
1711
1447
|
for (const name of REQUIRED_AGENTS)
|
|
1712
1448
|
if (!bundled.includes(name))
|
|
1713
1449
|
throw new Error(`Installed @difflab/pi is missing bundled agent ${name}`);
|
|
1714
1450
|
for (const name of bundled) {
|
|
1715
|
-
const content = await
|
|
1451
|
+
const content = await readFile6(join11(bundledDir, name), "utf8");
|
|
1716
1452
|
if (content.includes("diffpi_modes_"))
|
|
1717
1453
|
throw new Error(`Installed agent ${name} still requires removed mode tools`);
|
|
1718
1454
|
}
|
|
@@ -1726,10 +1462,10 @@ async function doctorAgents(options = {}) {
|
|
|
1726
1462
|
if (!bundled.includes(basename5(path)))
|
|
1727
1463
|
throw new Error(`Unexpected agent destination: ${path}`);
|
|
1728
1464
|
try {
|
|
1729
|
-
const stat = await
|
|
1465
|
+
const stat = await lstat3(path);
|
|
1730
1466
|
if (!stat.isFile())
|
|
1731
1467
|
throw new Error(`Refusing to overwrite non-file agent: ${path}`);
|
|
1732
|
-
originals.push({ path, content: await
|
|
1468
|
+
originals.push({ path, content: await readFile6(path) });
|
|
1733
1469
|
} catch (error) {
|
|
1734
1470
|
if (error.code !== "ENOENT")
|
|
1735
1471
|
throw error;
|
|
@@ -1737,17 +1473,17 @@ async function doctorAgents(options = {}) {
|
|
|
1737
1473
|
}
|
|
1738
1474
|
const backups = [];
|
|
1739
1475
|
if (originals.length > 0) {
|
|
1740
|
-
const backupDir =
|
|
1476
|
+
const backupDir = join11(targetDir, ".diffpi-doctor-backups");
|
|
1741
1477
|
await mkdir6(backupDir, { recursive: true, mode: 448 });
|
|
1742
1478
|
for (const original of originals) {
|
|
1743
1479
|
const digest = createHash2("sha256").update(original.content).digest("hex").slice(0, 16);
|
|
1744
|
-
const backupPath =
|
|
1480
|
+
const backupPath = join11(backupDir, `${basename5(original.path)}.${digest}.bak`);
|
|
1745
1481
|
try {
|
|
1746
|
-
await
|
|
1482
|
+
await writeFile4(backupPath, original.content, { flag: "wx", mode: 384 });
|
|
1747
1483
|
} catch (error) {
|
|
1748
1484
|
if (error.code !== "EEXIST")
|
|
1749
1485
|
throw error;
|
|
1750
|
-
const existing = await
|
|
1486
|
+
const existing = await readFile6(backupPath);
|
|
1751
1487
|
if (!existing.equals(original.content))
|
|
1752
1488
|
throw new Error(`Backup hash collision: ${backupPath}`);
|
|
1753
1489
|
}
|
|
@@ -1799,14 +1535,14 @@ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
|
1799
1535
|
import { z as z6 } from "zod";
|
|
1800
1536
|
|
|
1801
1537
|
// src/plan/verify.ts
|
|
1802
|
-
import { readFile as
|
|
1803
|
-
import { basename as basename6, dirname as dirname7, join as
|
|
1538
|
+
import { lstat as lstat4, readFile as readFile7, readdir as readdir4, stat } from "node:fs/promises";
|
|
1539
|
+
import { basename as basename6, dirname as dirname7, join as join12, resolve as resolve4 } from "node:path";
|
|
1804
1540
|
async function verifyLivePlan(requested, cwd = process.cwd()) {
|
|
1805
|
-
const path =
|
|
1541
|
+
const path = resolve4(cwd, requested);
|
|
1806
1542
|
let planPath = path;
|
|
1807
1543
|
try {
|
|
1808
1544
|
if ((await stat(path)).isDirectory())
|
|
1809
|
-
planPath =
|
|
1545
|
+
planPath = join12(path, "PLAN.md");
|
|
1810
1546
|
} catch {}
|
|
1811
1547
|
const issues = [];
|
|
1812
1548
|
const briefPaths = [];
|
|
@@ -1819,7 +1555,7 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
|
|
|
1819
1555
|
addIssue(issues, planPath, 0, "layout", "Place PLAN.md under .diffpi/plan/<YYMMDD[-ticket]-short-slug>/.");
|
|
1820
1556
|
let source;
|
|
1821
1557
|
try {
|
|
1822
|
-
source = await
|
|
1558
|
+
source = await readFile7(planPath, "utf8");
|
|
1823
1559
|
} catch {
|
|
1824
1560
|
addIssue(issues, planPath, 0, "missing-plan", "PLAN.md is not readable.");
|
|
1825
1561
|
return { planPath, briefPaths, ok: false, issues };
|
|
@@ -1833,7 +1569,7 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
|
|
|
1833
1569
|
if (!planLines[line]?.startsWith(`### Phase ${index + 1}: `))
|
|
1834
1570
|
addIssue(issues, planPath, line + 1, "phase-order", "Number phase headings from 1 without gaps.");
|
|
1835
1571
|
}
|
|
1836
|
-
const implementation =
|
|
1572
|
+
const implementation = join12(planDir, "implementation");
|
|
1837
1573
|
let rootEntries = [];
|
|
1838
1574
|
let briefEntries = [];
|
|
1839
1575
|
try {
|
|
@@ -1841,19 +1577,28 @@ async function verifyLivePlan(requested, cwd = process.cwd()) {
|
|
|
1841
1577
|
briefEntries = await readdir4(implementation);
|
|
1842
1578
|
} catch {}
|
|
1843
1579
|
for (const entry of rootEntries) {
|
|
1844
|
-
|
|
1845
|
-
|
|
1580
|
+
const directory = ["implementation", "revisions", "logs"].includes(entry);
|
|
1581
|
+
const logFile = entry === "logs.jsonl";
|
|
1582
|
+
let valid = entry === "PLAN.md";
|
|
1583
|
+
if (directory || logFile) {
|
|
1584
|
+
try {
|
|
1585
|
+
const details = await lstat4(join12(planDir, entry));
|
|
1586
|
+
valid = !details.isSymbolicLink() && (directory ? details.isDirectory() : details.isFile());
|
|
1587
|
+
} catch {}
|
|
1588
|
+
}
|
|
1589
|
+
if (!valid)
|
|
1590
|
+
addIssue(issues, join12(planDir, entry), 0, "layout", "Only PLAN.md, implementation/, revisions/, logs.jsonl, and historical logs/ belong in the plan directory.");
|
|
1846
1591
|
}
|
|
1847
1592
|
for (const entry of briefEntries) {
|
|
1848
1593
|
const ordinal = /^phase-(\d+)\.md$/.exec(entry)?.[1];
|
|
1849
1594
|
if (!ordinal || !Number(ordinal) || Number(ordinal) > phases.length || entry !== `phase-${Number(ordinal)}.md`)
|
|
1850
|
-
addIssue(issues,
|
|
1595
|
+
addIssue(issues, join12(implementation, entry), 0, "extra-brief", "Only matching numbered phase briefs belong in implementation/.");
|
|
1851
1596
|
}
|
|
1852
1597
|
for (let index = 0;index < phases.length; index++) {
|
|
1853
|
-
const brief =
|
|
1598
|
+
const brief = join12(implementation, `phase-${index + 1}.md`);
|
|
1854
1599
|
briefPaths.push(brief);
|
|
1855
1600
|
try {
|
|
1856
|
-
const lines = visibleLines(await
|
|
1601
|
+
const lines = visibleLines(await readFile7(brief, "utf8"));
|
|
1857
1602
|
if (!/^# Phase \d+:\s*\S/.test(lines[0] ?? "") || !lines[0]?.startsWith(`# Phase ${index + 1}: `))
|
|
1858
1603
|
addIssue(issues, brief, 1, "phase-title", `Start with # Phase ${index + 1}: <title>.`);
|
|
1859
1604
|
checkHeadings(lines, brief, ["Objective", "Files Affected", "Tasks", "Implementation Constraints"], issues);
|
|
@@ -1934,11 +1679,8 @@ function createDiffpiReloadTool(pi) {
|
|
|
1934
1679
|
}
|
|
1935
1680
|
|
|
1936
1681
|
// src/tools/review.ts
|
|
1937
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
1938
|
-
import { mkdir as mkdir7, readdir as readdir5, readFile as readFile14, rename as rename2, stat as stat2, unlink as unlink2, writeFile as writeFile8 } from "node:fs/promises";
|
|
1939
|
-
import { join as join17 } from "node:path";
|
|
1940
1682
|
import { defineTool as defineTool6 } from "@earendil-works/pi-coding-agent";
|
|
1941
|
-
import { z as
|
|
1683
|
+
import { z as z10 } from "zod";
|
|
1942
1684
|
|
|
1943
1685
|
// src/gates.ts
|
|
1944
1686
|
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
@@ -2010,33 +1752,6 @@ var findingSchema = z8.object({
|
|
|
2010
1752
|
reference: z8.string().optional().default("")
|
|
2011
1753
|
});
|
|
2012
1754
|
var findingsSchema = z8.array(findingSchema);
|
|
2013
|
-
function reviewSlug(input) {
|
|
2014
|
-
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
2015
|
-
}
|
|
2016
|
-
function yymmdd(date = new Date) {
|
|
2017
|
-
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
2018
|
-
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
2019
|
-
const dd = String(date.getDate()).padStart(2, "0");
|
|
2020
|
-
return `${yy}${mm}${dd}`;
|
|
2021
|
-
}
|
|
2022
|
-
function reviewRecordName(target, date = new Date) {
|
|
2023
|
-
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
2024
|
-
}
|
|
2025
|
-
function localResponseMarker(threadId) {
|
|
2026
|
-
return `<!-- diffpi -->`;
|
|
2027
|
-
}
|
|
2028
|
-
function isLocalResponse(body) {
|
|
2029
|
-
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
2030
|
-
}
|
|
2031
|
-
function parseReviewThreadAction(body) {
|
|
2032
|
-
const match = body.match(/^\[(REOPEN|RESOLVE|DELETE)\]\s*/i);
|
|
2033
|
-
if (!match)
|
|
2034
|
-
return { body };
|
|
2035
|
-
return {
|
|
2036
|
-
action: match[1].toLowerCase(),
|
|
2037
|
-
body: body.slice(match[0].length)
|
|
2038
|
-
};
|
|
2039
|
-
}
|
|
2040
1755
|
function dedupeFindings(findings) {
|
|
2041
1756
|
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
2042
1757
|
const byKey = new Map;
|
|
@@ -2071,9 +1786,6 @@ function withRemoteProvenance(body, model) {
|
|
|
2071
1786
|
|
|
2072
1787
|
Generated review by Diffpi using \`${model}\`.`;
|
|
2073
1788
|
}
|
|
2074
|
-
function localReviewAuthor(model) {
|
|
2075
|
-
return `Agent: ${model}`;
|
|
2076
|
-
}
|
|
2077
1789
|
function renderCommentBody(finding) {
|
|
2078
1790
|
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
2079
1791
|
const reference = finding.reference ? `
|
|
@@ -2081,142 +1793,6 @@ function renderCommentBody(finding) {
|
|
|
2081
1793
|
> **Reference:** ${finding.reference}` : "";
|
|
2082
1794
|
return `${prefix}${finding.body}${reference}`;
|
|
2083
1795
|
}
|
|
2084
|
-
// src/review/review-markdown.ts
|
|
2085
|
-
import { z as z9 } from "zod";
|
|
2086
|
-
var reviewThreadRecordSchema = z9.object({
|
|
2087
|
-
id: z9.string().min(1),
|
|
2088
|
-
file: z9.string().nullable().optional().transform((value) => value ?? undefined),
|
|
2089
|
-
line: z9.number().int().positive().nullable().optional().transform((value) => value ?? undefined),
|
|
2090
|
-
body: z9.string(),
|
|
2091
|
-
author: z9.string().optional(),
|
|
2092
|
-
resolved: z9.boolean(),
|
|
2093
|
-
addressed: z9.boolean().optional(),
|
|
2094
|
-
question: z9.boolean(),
|
|
2095
|
-
replies: z9.array(z9.string()).optional()
|
|
2096
|
-
});
|
|
2097
|
-
function renderReviewDoc(input) {
|
|
2098
|
-
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
2099
|
-
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
2100
|
-
if (input.number !== undefined)
|
|
2101
|
-
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
2102
|
-
if (input.author)
|
|
2103
|
-
lines.push(`- **Author**: ${input.author}`);
|
|
2104
|
-
if (input.model)
|
|
2105
|
-
lines.push(`- **Review agent**: ${input.model}`);
|
|
2106
|
-
if (input.headRef && input.baseRef)
|
|
2107
|
-
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
2108
|
-
if (input.additions !== undefined)
|
|
2109
|
-
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
2110
|
-
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
2111
|
-
if (input.overallIssues.length > 0) {
|
|
2112
|
-
lines.push("## Overall issues", "");
|
|
2113
|
-
for (const issue of input.overallIssues)
|
|
2114
|
-
lines.push(`- ${issue}`);
|
|
2115
|
-
lines.push("");
|
|
2116
|
-
}
|
|
2117
|
-
lines.push("## Verification", "");
|
|
2118
|
-
for (const gate of input.gates)
|
|
2119
|
-
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
2120
|
-
lines.push("");
|
|
2121
|
-
if (input.notVerified.length > 0) {
|
|
2122
|
-
lines.push("## What was NOT verified", "");
|
|
2123
|
-
for (const item of input.notVerified)
|
|
2124
|
-
lines.push(`- ${item}`);
|
|
2125
|
-
lines.push("");
|
|
2126
|
-
}
|
|
2127
|
-
lines.push("## Inline Comments", "");
|
|
2128
|
-
for (const finding of anchored) {
|
|
2129
|
-
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
2130
|
-
if (finding.reference)
|
|
2131
|
-
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
2132
|
-
lines.push("---", "");
|
|
2133
|
-
}
|
|
2134
|
-
return `${lines.join(`
|
|
2135
|
-
`).trimEnd()}
|
|
2136
|
-
`;
|
|
2137
|
-
}
|
|
2138
|
-
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
2139
|
-
const records = threads.map((thread) => ({
|
|
2140
|
-
id: thread.id,
|
|
2141
|
-
file: thread.file,
|
|
2142
|
-
line: thread.line,
|
|
2143
|
-
body: thread.body,
|
|
2144
|
-
author: thread.author,
|
|
2145
|
-
resolved: thread.resolved,
|
|
2146
|
-
addressed: thread.addressed,
|
|
2147
|
-
question: thread.question,
|
|
2148
|
-
replies: thread.replies
|
|
2149
|
-
}));
|
|
2150
|
-
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
2151
|
-
const lines = [
|
|
2152
|
-
`<!-- diffpi-threads:${payload} -->`,
|
|
2153
|
-
`# Review threads: ${title}`,
|
|
2154
|
-
"",
|
|
2155
|
-
"## Metadata",
|
|
2156
|
-
"",
|
|
2157
|
-
`- Target: ${target}`,
|
|
2158
|
-
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
2159
|
-
];
|
|
2160
|
-
if (options.number !== undefined)
|
|
2161
|
-
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
2162
|
-
lines.push("", "## Replies", "");
|
|
2163
|
-
for (const thread of threads) {
|
|
2164
|
-
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
2165
|
-
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
2166
|
-
}
|
|
2167
|
-
lines.push("## Source comments", "");
|
|
2168
|
-
for (const thread of threads) {
|
|
2169
|
-
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._", "", "---", "");
|
|
2170
|
-
}
|
|
2171
|
-
return `${lines.join(`
|
|
2172
|
-
`).trimEnd()}
|
|
2173
|
-
`;
|
|
2174
|
-
}
|
|
2175
|
-
function parseThreadArtifact(content) {
|
|
2176
|
-
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
2177
|
-
if (!payload)
|
|
2178
|
-
throw new Error("This file is not a Diffpi thread artifact.");
|
|
2179
|
-
let threads;
|
|
2180
|
-
try {
|
|
2181
|
-
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
2182
|
-
threads = z9.array(reviewThreadRecordSchema).parse(decoded);
|
|
2183
|
-
} catch {
|
|
2184
|
-
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
2185
|
-
}
|
|
2186
|
-
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
2187
|
-
return threads.map((thread) => {
|
|
2188
|
-
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
2189
|
-
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
2190
|
-
`;
|
|
2191
|
-
const endMarker = `
|
|
2192
|
-
<!-- diffpi-reply-end:${id} -->`;
|
|
2193
|
-
const start = replies.indexOf(startMarker);
|
|
2194
|
-
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
2195
|
-
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
2196
|
-
return reply ? { ...thread, reply } : thread;
|
|
2197
|
-
});
|
|
2198
|
-
}
|
|
2199
|
-
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
2200
|
-
const threads = parseThreadArtifact(content);
|
|
2201
|
-
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
2202
|
-
if (!thread)
|
|
2203
|
-
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
2204
|
-
thread.reply = body;
|
|
2205
|
-
thread.addressed = true;
|
|
2206
|
-
if (question !== undefined)
|
|
2207
|
-
thread.question = question;
|
|
2208
|
-
if (resolved !== undefined)
|
|
2209
|
-
thread.resolved = resolved;
|
|
2210
|
-
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
2211
|
-
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
2212
|
-
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
2213
|
-
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
2214
|
-
return renderThreadArtifact(title, target, threads, {
|
|
2215
|
-
timestamp,
|
|
2216
|
-
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
2217
|
-
url: pr?.[2]
|
|
2218
|
-
});
|
|
2219
|
-
}
|
|
2220
1796
|
// src/review/github-review-backend.ts
|
|
2221
1797
|
function GitHubReviewBackend(vcs, number) {
|
|
2222
1798
|
const pendingReview = async () => {
|
|
@@ -2596,245 +2172,6 @@ function hasGitlabDraftNotes(input) {
|
|
|
2596
2172
|
}
|
|
2597
2173
|
}
|
|
2598
2174
|
|
|
2599
|
-
// src/review/local-review-backend.ts
|
|
2600
|
-
import { readFile as readFile11, writeFile as writeFile6 } from "node:fs/promises";
|
|
2601
|
-
|
|
2602
|
-
// src/extensions/tuicrx.ts
|
|
2603
|
-
import { readFile as readFile10, realpath as realpath3 } from "node:fs/promises";
|
|
2604
|
-
import { resolve as resolve4 } from "node:path";
|
|
2605
|
-
async function tuicrAvailable() {
|
|
2606
|
-
return Boolean(await findExecutable("tuicr"));
|
|
2607
|
-
}
|
|
2608
|
-
async function listSessions(repo = ".") {
|
|
2609
|
-
await requireTuicr();
|
|
2610
|
-
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
2611
|
-
if (result.code !== 0 || !result.stdout.trim())
|
|
2612
|
-
return [];
|
|
2613
|
-
try {
|
|
2614
|
-
const raw = JSON.parse(result.stdout);
|
|
2615
|
-
return raw.map((entry) => ({
|
|
2616
|
-
slug: entry.slug,
|
|
2617
|
-
kind: entry.kind,
|
|
2618
|
-
path: entry.path,
|
|
2619
|
-
updatedAt: entry.updated_at,
|
|
2620
|
-
commentCount: entry.comment_count,
|
|
2621
|
-
anchor: entry.anchor,
|
|
2622
|
-
active: entry.active
|
|
2623
|
-
}));
|
|
2624
|
-
} catch {
|
|
2625
|
-
throw new Error("Cannot parse tuicr review list output.");
|
|
2626
|
-
}
|
|
2627
|
-
}
|
|
2628
|
-
async function addComment(session, body, opts = {}) {
|
|
2629
|
-
await requireTuicr();
|
|
2630
|
-
const args = ["review", "add", "--session", session, body];
|
|
2631
|
-
if (opts.targetFile)
|
|
2632
|
-
args.push("--target-file", opts.targetFile);
|
|
2633
|
-
if (opts.line !== undefined)
|
|
2634
|
-
args.push("--line", String(opts.line));
|
|
2635
|
-
if (opts.side)
|
|
2636
|
-
args.push("--side", opts.side);
|
|
2637
|
-
if (opts.username)
|
|
2638
|
-
args.push("--username", opts.username);
|
|
2639
|
-
await runChecked("tuicr", args);
|
|
2640
|
-
}
|
|
2641
|
-
async function launch(cwd, pr, localBase) {
|
|
2642
|
-
let command;
|
|
2643
|
-
if (pr !== undefined)
|
|
2644
|
-
command = ["tuicr", "pr", String(pr)];
|
|
2645
|
-
else if (localBase)
|
|
2646
|
-
command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
|
|
2647
|
-
else
|
|
2648
|
-
throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
|
|
2649
|
-
if (!await tuicrAvailable()) {
|
|
2650
|
-
return {
|
|
2651
|
-
launched: false,
|
|
2652
|
-
via: "print",
|
|
2653
|
-
command: command.join(" "),
|
|
2654
|
-
reason: "tuicr is not installed or is not available on PATH.",
|
|
2655
|
-
instruction: `Install tuicr, then run: ${command.join(" ")}`
|
|
2656
|
-
};
|
|
2657
|
-
}
|
|
2658
|
-
const name = diffpiLaunchName(cwd, pr === undefined ? "local review" : `PR #${pr}`);
|
|
2659
|
-
return openInNewTab(command, { cwd, name });
|
|
2660
|
-
}
|
|
2661
|
-
async function resolveSession(cwd, branch) {
|
|
2662
|
-
const sessions = await listSessions(cwd);
|
|
2663
|
-
return findMatchingSession(sessions, cwd, branch);
|
|
2664
|
-
}
|
|
2665
|
-
async function resolveReviewSession(cwd, target) {
|
|
2666
|
-
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
2667
|
-
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
2668
|
-
}
|
|
2669
|
-
return resolveSession(cwd, target.branch);
|
|
2670
|
-
}
|
|
2671
|
-
async function resolvePublishSession(cwd, target) {
|
|
2672
|
-
if (target.owner && target.repo && target.number !== undefined)
|
|
2673
|
-
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
2674
|
-
return resolveSession(cwd, target.branch);
|
|
2675
|
-
}
|
|
2676
|
-
async function resolvePrSession(cwd, owner, repo, number) {
|
|
2677
|
-
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
2678
|
-
const sessions = await listSessions(cwd);
|
|
2679
|
-
return sessions.find((session) => {
|
|
2680
|
-
const slug = session.slug.toLowerCase();
|
|
2681
|
-
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
2682
|
-
});
|
|
2683
|
-
}
|
|
2684
|
-
async function findMatchingSession(sessions, cwd, branch) {
|
|
2685
|
-
const repository = await canonicalPath3(await gitToplevel(cwd));
|
|
2686
|
-
for (const session of sessions) {
|
|
2687
|
-
if (session.kind !== "local")
|
|
2688
|
-
continue;
|
|
2689
|
-
try {
|
|
2690
|
-
const data = await readSession(session.path);
|
|
2691
|
-
if (data.branch_name !== branch || !data.repo_path)
|
|
2692
|
-
continue;
|
|
2693
|
-
if (await canonicalPath3(data.repo_path) === repository)
|
|
2694
|
-
return session;
|
|
2695
|
-
} catch {}
|
|
2696
|
-
}
|
|
2697
|
-
return;
|
|
2698
|
-
}
|
|
2699
|
-
async function readSession(path) {
|
|
2700
|
-
const content = await readFile10(path, "utf8");
|
|
2701
|
-
try {
|
|
2702
|
-
return JSON.parse(content);
|
|
2703
|
-
} catch {
|
|
2704
|
-
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
function toLocalReviewThreads(session) {
|
|
2708
|
-
const threads = [];
|
|
2709
|
-
const add = (comment, location = {}) => {
|
|
2710
|
-
if (isLocalResponse(comment.content))
|
|
2711
|
-
return;
|
|
2712
|
-
const author = commentAuthor(comment);
|
|
2713
|
-
threads.push({
|
|
2714
|
-
id: comment.id ?? `local-${threads.length + 1}`,
|
|
2715
|
-
...location,
|
|
2716
|
-
body: comment.content,
|
|
2717
|
-
...author ? { author } : {},
|
|
2718
|
-
resolved: false,
|
|
2719
|
-
question: /\?\s*$/.test(comment.content.trim())
|
|
2720
|
-
});
|
|
2721
|
-
};
|
|
2722
|
-
for (const comment of session.review_comments ?? [])
|
|
2723
|
-
add(comment);
|
|
2724
|
-
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
2725
|
-
for (const comment of entry.file_comments ?? [])
|
|
2726
|
-
add(comment, { file });
|
|
2727
|
-
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
2728
|
-
const line = Number.parseInt(lineKey, 10);
|
|
2729
|
-
if (!Number.isFinite(line))
|
|
2730
|
-
continue;
|
|
2731
|
-
for (const comment of lineComments)
|
|
2732
|
-
add(comment, { file, line });
|
|
2733
|
-
}
|
|
2734
|
-
}
|
|
2735
|
-
return threads;
|
|
2736
|
-
}
|
|
2737
|
-
function toFindings(session, options = {}) {
|
|
2738
|
-
const comments = [];
|
|
2739
|
-
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
2740
|
-
const publishableBody = (comment) => {
|
|
2741
|
-
const author = commentAuthor(comment);
|
|
2742
|
-
const route = author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
2743
|
-
return route ? withRemoteProvenance(comment.content, route) : comment.content;
|
|
2744
|
-
};
|
|
2745
|
-
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [publishableBody(comment)] : []);
|
|
2746
|
-
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
2747
|
-
for (const fileComment of entry.file_comments ?? []) {
|
|
2748
|
-
if (!include(fileComment))
|
|
2749
|
-
continue;
|
|
2750
|
-
const comment = {
|
|
2751
|
-
file,
|
|
2752
|
-
body: fileComment.content,
|
|
2753
|
-
...fileComment.id ? { sourceCommentId: String(fileComment.id) } : {}
|
|
2754
|
-
};
|
|
2755
|
-
const author = commentAuthor(fileComment);
|
|
2756
|
-
if (author)
|
|
2757
|
-
comment.author = author;
|
|
2758
|
-
comments.push(comment);
|
|
2759
|
-
}
|
|
2760
|
-
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
2761
|
-
const line = Number.parseInt(lineKey, 10);
|
|
2762
|
-
if (!Number.isFinite(line))
|
|
2763
|
-
continue;
|
|
2764
|
-
for (const lineComment of lineComments) {
|
|
2765
|
-
if (!include(lineComment))
|
|
2766
|
-
continue;
|
|
2767
|
-
const comment = {
|
|
2768
|
-
file,
|
|
2769
|
-
line,
|
|
2770
|
-
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
2771
|
-
body: lineComment.content,
|
|
2772
|
-
...lineComment.id ? { sourceCommentId: String(lineComment.id) } : {}
|
|
2773
|
-
};
|
|
2774
|
-
const author = commentAuthor(lineComment);
|
|
2775
|
-
if (author)
|
|
2776
|
-
comment.author = author;
|
|
2777
|
-
comments.push(comment);
|
|
2778
|
-
}
|
|
2779
|
-
}
|
|
2780
|
-
}
|
|
2781
|
-
return { comments, body: bodyParts.join(`
|
|
2782
|
-
|
|
2783
|
-
`) };
|
|
2784
|
-
}
|
|
2785
|
-
async function requireTuicr() {
|
|
2786
|
-
if (!await tuicrAvailable())
|
|
2787
|
-
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
2788
|
-
}
|
|
2789
|
-
function commentAuthor(comment) {
|
|
2790
|
-
return comment.username ?? comment.author;
|
|
2791
|
-
}
|
|
2792
|
-
async function canonicalPath3(path) {
|
|
2793
|
-
try {
|
|
2794
|
-
return await realpath3(path);
|
|
2795
|
-
} catch {
|
|
2796
|
-
return resolve4(path);
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
2799
|
-
|
|
2800
|
-
// src/review/local-review-backend.ts
|
|
2801
|
-
function LocalReviewBackend(options) {
|
|
2802
|
-
return {
|
|
2803
|
-
kind: "local",
|
|
2804
|
-
async stage(draft) {
|
|
2805
|
-
for (const comment of draft.comments) {
|
|
2806
|
-
await addComment(options.session, comment.body, {
|
|
2807
|
-
targetFile: comment.file,
|
|
2808
|
-
line: comment.line,
|
|
2809
|
-
side: comment.side === "LEFT" ? "old" : "new",
|
|
2810
|
-
username: options.author
|
|
2811
|
-
});
|
|
2812
|
-
}
|
|
2813
|
-
if (draft.body.trim())
|
|
2814
|
-
await addComment(options.session, draft.body, { username: options.author });
|
|
2815
|
-
},
|
|
2816
|
-
async readDraft() {
|
|
2817
|
-
return toFindings(await readSession(options.session), { excludeLocalResponses: true });
|
|
2818
|
-
},
|
|
2819
|
-
async listThreads() {
|
|
2820
|
-
try {
|
|
2821
|
-
return parseThreadArtifact(await readFile11(options.artifactPath, "utf8"));
|
|
2822
|
-
} catch (error) {
|
|
2823
|
-
if (error.code === "ENOENT")
|
|
2824
|
-
return [];
|
|
2825
|
-
throw error;
|
|
2826
|
-
}
|
|
2827
|
-
},
|
|
2828
|
-
async reply(input) {
|
|
2829
|
-
const content = await readFile11(options.artifactPath, "utf8");
|
|
2830
|
-
await writeFile6(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
2831
|
-
},
|
|
2832
|
-
async publish() {
|
|
2833
|
-
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
2834
|
-
}
|
|
2835
|
-
};
|
|
2836
|
-
}
|
|
2837
|
-
|
|
2838
2175
|
// src/review/review-backend.ts
|
|
2839
2176
|
function createRemoteReviewBackend(vcs, number) {
|
|
2840
2177
|
if (vcs.provider === "github")
|
|
@@ -2843,67 +2180,24 @@ function createRemoteReviewBackend(vcs, number) {
|
|
|
2843
2180
|
return GitLabReviewBackend(vcs, number);
|
|
2844
2181
|
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
2845
2182
|
}
|
|
2846
|
-
function createLocalReviewBackend(options) {
|
|
2847
|
-
return LocalReviewBackend(options);
|
|
2848
|
-
}
|
|
2849
2183
|
// src/review/review-state.ts
|
|
2850
|
-
import {
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2858
|
-
stagedComments: z10.array(z10.string()).default([]),
|
|
2859
|
-
replies: z10.array(z10.string()).default([]),
|
|
2860
|
-
overlayPath: z10.string().optional()
|
|
2184
|
+
import { z as z9 } from "zod";
|
|
2185
|
+
var reviewPublicationStateSchema = z9.object({
|
|
2186
|
+
target: z9.string().optional(),
|
|
2187
|
+
bodies: z9.array(z9.string()).default([]),
|
|
2188
|
+
comments: z9.array(z9.string()).default([]),
|
|
2189
|
+
stagedComments: z9.array(z9.string()).default([]),
|
|
2190
|
+
replies: z9.array(z9.string()).default([]),
|
|
2191
|
+
overlayPath: z9.string().optional()
|
|
2861
2192
|
});
|
|
2862
|
-
function reviewBodyFingerprint(body) {
|
|
2863
|
-
return digest(body);
|
|
2864
|
-
}
|
|
2865
|
-
function reviewCommentFingerprint(comment) {
|
|
2866
|
-
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
2867
|
-
}
|
|
2868
|
-
function reviewReplyFingerprint(threadId, body) {
|
|
2869
|
-
return digest(`${threadId}\x00${body}`);
|
|
2870
|
-
}
|
|
2871
|
-
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
2872
|
-
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
2873
|
-
}
|
|
2874
|
-
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
2875
|
-
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
2876
|
-
const path = join15(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
2877
|
-
try {
|
|
2878
|
-
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile12(path, "utf8")));
|
|
2879
|
-
return { path, state: { ...parsed, target } };
|
|
2880
|
-
} catch (error) {
|
|
2881
|
-
if (error.code === "ENOENT") {
|
|
2882
|
-
return { path, state: { target, bodies: [], comments: [], stagedComments: [], replies: [] } };
|
|
2883
|
-
}
|
|
2884
|
-
if (error instanceof SyntaxError || error instanceof z10.ZodError) {
|
|
2885
|
-
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
2886
|
-
}
|
|
2887
|
-
throw error;
|
|
2888
|
-
}
|
|
2889
|
-
}
|
|
2890
|
-
async function saveReviewPublicationState(path, state) {
|
|
2891
|
-
const temp = `${path}.${process.pid}.tmp`;
|
|
2892
|
-
await writeFile7(temp, `${JSON.stringify(state, null, 2)}
|
|
2893
|
-
`, "utf8");
|
|
2894
|
-
await rename(temp, path);
|
|
2895
|
-
}
|
|
2896
|
-
function digest(value) {
|
|
2897
|
-
return createHash3("sha256").update(value).digest("hex");
|
|
2898
|
-
}
|
|
2899
2193
|
// src/templates.ts
|
|
2900
|
-
import { readFile as
|
|
2901
|
-
import { homedir as
|
|
2902
|
-
import { join as
|
|
2194
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
2195
|
+
import { homedir as homedir7 } from "node:os";
|
|
2196
|
+
import { join as join13, normalize } from "node:path";
|
|
2903
2197
|
async function loadTemplate(name, options = {}) {
|
|
2904
2198
|
const relative = templateRelativePath(name);
|
|
2905
|
-
const userPath =
|
|
2906
|
-
const bundledPath =
|
|
2199
|
+
const userPath = join13(options.homeDir ?? homedir7(), ".difflab", "diffpi", "templates", relative);
|
|
2200
|
+
const bundledPath = join13(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2907
2201
|
const user = await readOptionalFile(userPath);
|
|
2908
2202
|
if (user !== undefined)
|
|
2909
2203
|
return { name, path: userPath, source: "user", content: user };
|
|
@@ -2924,7 +2218,7 @@ function templateRelativePath(name) {
|
|
|
2924
2218
|
}
|
|
2925
2219
|
async function readOptionalFile(path) {
|
|
2926
2220
|
try {
|
|
2927
|
-
return await
|
|
2221
|
+
return await readFile8(path, "utf8");
|
|
2928
2222
|
} catch (error) {
|
|
2929
2223
|
if (error.code === "ENOENT")
|
|
2930
2224
|
return;
|
|
@@ -2933,94 +2227,64 @@ async function readOptionalFile(path) {
|
|
|
2933
2227
|
}
|
|
2934
2228
|
|
|
2935
2229
|
// src/tools/review.ts
|
|
2936
|
-
var contextSchema =
|
|
2937
|
-
cwd:
|
|
2938
|
-
target:
|
|
2939
|
-
workingTree:
|
|
2230
|
+
var contextSchema = z10.object({
|
|
2231
|
+
cwd: z10.string().optional(),
|
|
2232
|
+
target: z10.string().optional(),
|
|
2233
|
+
workingTree: z10.boolean().optional()
|
|
2940
2234
|
});
|
|
2941
|
-
var localSchema = contextSchema.extend({ local:
|
|
2235
|
+
var localSchema = contextSchema.extend({ local: z10.boolean().optional() });
|
|
2942
2236
|
var openSchema = localSchema.extend({
|
|
2943
|
-
title:
|
|
2944
|
-
intent:
|
|
2945
|
-
issueUrl:
|
|
2946
|
-
base:
|
|
2237
|
+
title: z10.string().optional(),
|
|
2238
|
+
intent: z10.string().optional(),
|
|
2239
|
+
issueUrl: z10.string().url().optional(),
|
|
2240
|
+
base: z10.string().optional(),
|
|
2241
|
+
body: z10.string().optional()
|
|
2947
2242
|
});
|
|
2948
2243
|
var submitSchema = localSchema.extend({
|
|
2949
2244
|
findings: findingsSchema,
|
|
2950
|
-
overallIssues:
|
|
2951
|
-
notVerified:
|
|
2952
|
-
title:
|
|
2245
|
+
overallIssues: z10.array(z10.string()).optional(),
|
|
2246
|
+
notVerified: z10.array(z10.string()).optional(),
|
|
2247
|
+
title: z10.string().optional()
|
|
2953
2248
|
});
|
|
2954
2249
|
var addCommentSchema = localSchema.extend({
|
|
2955
|
-
body:
|
|
2956
|
-
file:
|
|
2957
|
-
line:
|
|
2958
|
-
side:
|
|
2250
|
+
body: z10.string().min(1),
|
|
2251
|
+
file: z10.string().min(1),
|
|
2252
|
+
line: z10.number().int().positive(),
|
|
2253
|
+
side: z10.enum(["LEFT", "RIGHT"]).optional()
|
|
2959
2254
|
});
|
|
2960
2255
|
var respondSchema = localSchema.extend({
|
|
2961
|
-
threadId:
|
|
2962
|
-
body:
|
|
2963
|
-
question:
|
|
2964
|
-
resolve:
|
|
2256
|
+
threadId: z10.string().min(1),
|
|
2257
|
+
body: z10.string().min(1),
|
|
2258
|
+
question: z10.boolean().optional(),
|
|
2259
|
+
resolve: z10.boolean().optional()
|
|
2965
2260
|
});
|
|
2966
2261
|
var publishSchema = localSchema.extend({
|
|
2967
|
-
status:
|
|
2262
|
+
status: z10.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
2968
2263
|
});
|
|
2969
2264
|
var completeSchema = localSchema.extend({
|
|
2970
|
-
action:
|
|
2265
|
+
action: z10.enum(["approve", "reject", "close"]).optional()
|
|
2971
2266
|
});
|
|
2972
2267
|
function hasReviewDraft(comments, body) {
|
|
2973
2268
|
return comments.length > 0 || body.trim().length > 0;
|
|
2974
2269
|
}
|
|
2975
|
-
function reviewBrowserCommand(url, platform = process.platform) {
|
|
2976
|
-
return platform === "darwin" ? { command: "open", args: [url] } : platform === "win32" ? { command: "cmd", args: ["/c", "start", "", url] } : { command: "xdg-open", args: [url] };
|
|
2977
|
-
}
|
|
2978
|
-
async function openReviewUrl(url, execute = run, platform = process.platform) {
|
|
2979
|
-
const launch = reviewBrowserCommand(url, platform);
|
|
2980
|
-
try {
|
|
2981
|
-
return (await execute(launch.command, launch.args)).code === 0;
|
|
2982
|
-
} catch {
|
|
2983
|
-
return false;
|
|
2984
|
-
}
|
|
2985
|
-
}
|
|
2986
|
-
function partitionReviewComments(comments, knownFingerprints, model) {
|
|
2987
|
-
const normalized = comments.map((comment) => {
|
|
2988
|
-
const published = { ...comment, body: withCommentProvenance(comment, model) };
|
|
2989
|
-
return { source: comment, published, fingerprint: reviewCommentFingerprint(published) };
|
|
2990
|
-
});
|
|
2991
|
-
return {
|
|
2992
|
-
checkpointed: normalized.filter((comment) => knownFingerprints.has(comment.fingerprint)),
|
|
2993
|
-
candidates: normalized.filter((comment) => !knownFingerprints.has(comment.fingerprint))
|
|
2994
|
-
};
|
|
2995
|
-
}
|
|
2996
2270
|
function reviewResponseResolution(question, requested) {
|
|
2997
2271
|
return question ? false : requested ?? false;
|
|
2998
2272
|
}
|
|
2999
|
-
function
|
|
3000
|
-
if (
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
comments
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
body
|
|
3011
|
-
|
|
3012
|
-
}
|
|
3013
|
-
function resolveReviewThread(comment, threads) {
|
|
3014
|
-
const sourceCommentId = comment.sourceCommentId;
|
|
3015
|
-
if (sourceCommentId) {
|
|
3016
|
-
const identified = threads.find((thread) => thread.id === sourceCommentId || thread.rootCommentId === sourceCommentId || thread.commentIds?.includes(sourceCommentId));
|
|
3017
|
-
if (identified)
|
|
3018
|
-
return identified;
|
|
2273
|
+
function assertRenderedReviewBody(body) {
|
|
2274
|
+
if (/{{[^{}]+}}/.test(body))
|
|
2275
|
+
throw new Error("Review body contains unresolved template placeholders.");
|
|
2276
|
+
if (/<!--/.test(body))
|
|
2277
|
+
throw new Error("Review body contains HTML comments.");
|
|
2278
|
+
const headings = [...body.matchAll(/^## (.+)$/gm)];
|
|
2279
|
+
for (const section of ["Intent", "Changes", "Validation", "References", "Further Work"]) {
|
|
2280
|
+
const index = headings.findIndex((match) => match[1].trim() === section);
|
|
2281
|
+
const current = headings[index];
|
|
2282
|
+
if (!current || current.index === undefined)
|
|
2283
|
+
throw new Error(`Review body requires a filled ${section} section.`);
|
|
2284
|
+
const content = body.slice(current.index + current[0].length, headings[index + 1]?.index).trim();
|
|
2285
|
+
if (!content)
|
|
2286
|
+
throw new Error(`Review body requires a filled ${section} section.`);
|
|
3019
2287
|
}
|
|
3020
|
-
const matches = threads.filter((thread) => thread.file === comment.file && (thread.line ?? undefined) === (comment.line ?? undefined));
|
|
3021
|
-
if (matches.length > 1)
|
|
3022
|
-
throw new Error(`Ambiguous remote review threads at ${comment.file}:${comment.line ?? "file"}: ${matches.map((thread) => thread.id).join(", ")}.`);
|
|
3023
|
-
return matches[0];
|
|
3024
2288
|
}
|
|
3025
2289
|
async function workingTreeDiff(cwd) {
|
|
3026
2290
|
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
@@ -3044,7 +2308,7 @@ function createReviewTools() {
|
|
|
3044
2308
|
defineTool6({
|
|
3045
2309
|
name: "review_context",
|
|
3046
2310
|
label: "review context",
|
|
3047
|
-
description: "Orient to the target,
|
|
2311
|
+
description: "Orient to the target, forge, environment, shared store, and PR/MR.",
|
|
3048
2312
|
promptSnippet: "Call review_context first",
|
|
3049
2313
|
promptGuidelines: ["Call this before every review workflow."],
|
|
3050
2314
|
parameters: parameters(localSchema),
|
|
@@ -3054,19 +2318,15 @@ function createReviewTools() {
|
|
|
3054
2318
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3055
2319
|
const store = await ensureStore(review.cwd);
|
|
3056
2320
|
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
3057
|
-
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree)).catch(() => {
|
|
3058
|
-
return;
|
|
3059
|
-
});
|
|
3060
2321
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
3061
|
-
const backend =
|
|
2322
|
+
const backend = review.forge?.provider ?? "unsupported";
|
|
3062
2323
|
return result2([
|
|
3063
2324
|
`Backend: ${backend}`,
|
|
3064
2325
|
`Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
|
|
3065
2326
|
`Branch: ${review.vcs.branch} → ${baseRef}`,
|
|
3066
2327
|
`Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
|
|
3067
2328
|
`Store: ${store.link} → ${store.dest}`,
|
|
3068
|
-
review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none"
|
|
3069
|
-
session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
|
|
2329
|
+
review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none"
|
|
3070
2330
|
].join(`
|
|
3071
2331
|
`), {
|
|
3072
2332
|
cwd: review.cwd,
|
|
@@ -3074,7 +2334,6 @@ function createReviewTools() {
|
|
|
3074
2334
|
pr: review.pr,
|
|
3075
2335
|
env,
|
|
3076
2336
|
store,
|
|
3077
|
-
session,
|
|
3078
2337
|
baseRef,
|
|
3079
2338
|
backend
|
|
3080
2339
|
});
|
|
@@ -3083,9 +2342,9 @@ function createReviewTools() {
|
|
|
3083
2342
|
defineTool6({
|
|
3084
2343
|
name: "review_status",
|
|
3085
2344
|
label: "review status",
|
|
3086
|
-
description: "Report
|
|
2345
|
+
description: "Report remote review lifecycle status without requiring a forge.",
|
|
3087
2346
|
promptSnippet: "Call review_status for review lifecycle state",
|
|
3088
|
-
promptGuidelines: ["Use this for status even when no forge
|
|
2347
|
+
promptGuidelines: ["Use this for status even when no forge is available."],
|
|
3089
2348
|
parameters: parameters(localSchema),
|
|
3090
2349
|
executionMode: "parallel",
|
|
3091
2350
|
async execute(_id, input) {
|
|
@@ -3098,9 +2357,9 @@ function createReviewTools() {
|
|
|
3098
2357
|
defineTool6({
|
|
3099
2358
|
name: "review_new",
|
|
3100
2359
|
label: "review new",
|
|
3101
|
-
description: "Create a
|
|
2360
|
+
description: "Create a filled remote draft PR/MR.",
|
|
3102
2361
|
promptSnippet: "Call review_new to start",
|
|
3103
|
-
promptGuidelines: ["Use
|
|
2362
|
+
promptGuidelines: ["Use the skill workflow for direct-file local reviews."],
|
|
3104
2363
|
parameters: parameters(openSchema),
|
|
3105
2364
|
executionMode: "sequential",
|
|
3106
2365
|
async execute(_id, input) {
|
|
@@ -3108,7 +2367,7 @@ function createReviewTools() {
|
|
|
3108
2367
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3109
2368
|
await ensureStore(review.cwd);
|
|
3110
2369
|
if (params.local || params.workingTree)
|
|
3111
|
-
return
|
|
2370
|
+
return result2("Local reviews are direct Markdown files; follow the review new workflow instead.");
|
|
3112
2371
|
if (!review.forge)
|
|
3113
2372
|
return result2(unsupportedForgeMessage());
|
|
3114
2373
|
if (review.pr)
|
|
@@ -3116,57 +2375,25 @@ function createReviewTools() {
|
|
|
3116
2375
|
await assertRemoteBranchReady(review.cwd);
|
|
3117
2376
|
const base = params.base ?? await review.forge.defaultBranch();
|
|
3118
2377
|
const template = await loadTemplate("review/draft-pr");
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
head: review.vcs.branch,
|
|
3123
|
-
base
|
|
3124
|
-
});
|
|
2378
|
+
if (!params.body)
|
|
2379
|
+
throw new Error("A fully rendered draft PR body is required.");
|
|
2380
|
+
assertRenderedReviewBody(params.body);
|
|
3125
2381
|
const pr = await review.forge.createDraftPr({
|
|
3126
2382
|
title: params.title ?? deriveTitle(review.vcs.branch),
|
|
3127
|
-
body,
|
|
2383
|
+
body: params.body,
|
|
3128
2384
|
base,
|
|
3129
2385
|
head: review.vcs.branch
|
|
3130
2386
|
});
|
|
3131
|
-
|
|
3132
|
-
const openMessage = launched.launched ? `Opened PR/MR #${pr.number} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`;
|
|
3133
|
-
return result2(`Draft PR/MR created from ${template.source} template: ${pr.url}
|
|
3134
|
-
${openMessage}`, {
|
|
2387
|
+
return result2(`Draft PR/MR created from ${template.source} template: ${pr.url}`, {
|
|
3135
2388
|
pr,
|
|
3136
|
-
template
|
|
3137
|
-
launched
|
|
3138
|
-
});
|
|
3139
|
-
}
|
|
3140
|
-
}),
|
|
3141
|
-
defineTool6({
|
|
3142
|
-
name: "review_open",
|
|
3143
|
-
label: "review open",
|
|
3144
|
-
description: "Open an existing remote PR/MR in the system browser; never creates one.",
|
|
3145
|
-
promptSnippet: "Call review_open to open an existing PR/MR",
|
|
3146
|
-
promptGuidelines: ["Direct users to review_new when no remote review exists."],
|
|
3147
|
-
parameters: parameters(localSchema),
|
|
3148
|
-
executionMode: "sequential",
|
|
3149
|
-
async execute(_id, input) {
|
|
3150
|
-
const params = localSchema.parse(input);
|
|
3151
|
-
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3152
|
-
if (params.local || params.workingTree)
|
|
3153
|
-
return launchLocalReview(review, true);
|
|
3154
|
-
if (!review.forge)
|
|
3155
|
-
return result2(`${unsupportedForgeMessage()} Use review_new with local=true for an offline review.`);
|
|
3156
|
-
if (!review.pr)
|
|
3157
|
-
return result2("No remote PR/MR exists. Use review_new to create a draft review first.");
|
|
3158
|
-
const opened = await openReviewUrl(review.pr.url);
|
|
3159
|
-
return result2(`${opened ? "Opened" : "Open this URL"} PR/MR #${review.pr.number}: ${review.pr.url}`, {
|
|
3160
|
-
pr: review.pr,
|
|
3161
|
-
opened,
|
|
3162
|
-
url: review.pr.url
|
|
2389
|
+
template
|
|
3163
2390
|
});
|
|
3164
2391
|
}
|
|
3165
2392
|
}),
|
|
3166
2393
|
defineTool6({
|
|
3167
2394
|
name: "review_edit",
|
|
3168
2395
|
label: "review edit",
|
|
3169
|
-
description: "
|
|
2396
|
+
description: "Print the selected local review path or remote PR/MR URL without generating findings.",
|
|
3170
2397
|
promptSnippet: "Call review_edit to continue an existing review",
|
|
3171
2398
|
promptGuidelines: ["This tool never creates review findings or a PR/MR."],
|
|
3172
2399
|
parameters: parameters(localSchema),
|
|
@@ -3175,16 +2402,13 @@ ${openMessage}`, {
|
|
|
3175
2402
|
const params = localSchema.parse(input);
|
|
3176
2403
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3177
2404
|
await ensureStore(review.cwd);
|
|
3178
|
-
if (params.local || params.workingTree)
|
|
3179
|
-
|
|
3180
|
-
return result2("No local tuicr review exists. Use review_new with local=true to create one.");
|
|
3181
|
-
return launchLocalReview(review, true);
|
|
3182
|
-
}
|
|
2405
|
+
if (params.local || params.workingTree)
|
|
2406
|
+
return result2("Local reviews are direct Markdown files; print the selected REVIEW.md path from the workflow.");
|
|
3183
2407
|
if (!review.forge)
|
|
3184
2408
|
return result2(unsupportedForgeMessage());
|
|
3185
2409
|
if (!review.pr)
|
|
3186
2410
|
return result2("No remote PR/MR exists. Use review_new to create a draft review first.");
|
|
3187
|
-
return
|
|
2411
|
+
return result2(`PR/MR #${review.pr.number}: ${review.pr.url}`, { pr: review.pr, url: review.pr.url });
|
|
3188
2412
|
}
|
|
3189
2413
|
}),
|
|
3190
2414
|
defineTool6({
|
|
@@ -3252,52 +2476,21 @@ ${openMessage}`, {
|
|
|
3252
2476
|
const params = submitSchema.parse(input);
|
|
3253
2477
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3254
2478
|
const model = modelRoute(ctx);
|
|
3255
|
-
|
|
3256
|
-
|
|
2479
|
+
if (params.local || params.workingTree)
|
|
2480
|
+
return result2("Local reviews are direct Markdown files; follow the review address workflow instead.");
|
|
2481
|
+
if (!review.forge)
|
|
3257
2482
|
return result2(unsupportedForgeMessage());
|
|
3258
2483
|
const findings = dedupeFindings(params.findings);
|
|
3259
|
-
const
|
|
3260
|
-
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
3261
|
-
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
3262
|
-
await writeFile8(artifact, renderReviewDoc({
|
|
3263
|
-
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
3264
|
-
number: useLocalBackend ? undefined : review.pr?.number,
|
|
3265
|
-
url: useLocalBackend ? undefined : review.pr?.url,
|
|
3266
|
-
model,
|
|
3267
|
-
headRef: review.vcs.branch,
|
|
3268
|
-
baseRef,
|
|
3269
|
-
findings,
|
|
3270
|
-
overallIssues: params.overallIssues ?? [],
|
|
3271
|
-
gates,
|
|
3272
|
-
notVerified: params.notVerified ?? []
|
|
3273
|
-
}), "utf8");
|
|
3274
|
-
const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
|
|
2484
|
+
const comments = toReviewComments(findings, model);
|
|
3275
2485
|
const body = (params.overallIssues ?? []).join(`
|
|
3276
2486
|
`);
|
|
3277
|
-
if (useLocalBackend) {
|
|
3278
|
-
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
3279
|
-
if (!session) {
|
|
3280
|
-
return result2(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
|
|
3281
|
-
artifact,
|
|
3282
|
-
count: findings.length
|
|
3283
|
-
});
|
|
3284
|
-
}
|
|
3285
|
-
const backend = createLocalReviewBackend({
|
|
3286
|
-
session: session.path,
|
|
3287
|
-
artifactPath: artifact,
|
|
3288
|
-
author: localReviewAuthor(model)
|
|
3289
|
-
});
|
|
3290
|
-
await backend.stage({ comments, body });
|
|
3291
|
-
return result2(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
3292
|
-
}
|
|
3293
2487
|
if (!review.pr)
|
|
3294
|
-
return result2(
|
|
2488
|
+
return result2("No PR/MR matches this remote target.");
|
|
3295
2489
|
const hasDraft = hasReviewDraft(comments, body);
|
|
3296
2490
|
if (hasDraft) {
|
|
3297
2491
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
3298
2492
|
}
|
|
3299
|
-
return result2(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}
|
|
3300
|
-
artifact,
|
|
2493
|
+
return result2(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}.`, {
|
|
3301
2494
|
pr: review.pr,
|
|
3302
2495
|
count: findings.length
|
|
3303
2496
|
});
|
|
@@ -3306,36 +2499,25 @@ ${openMessage}`, {
|
|
|
3306
2499
|
defineTool6({
|
|
3307
2500
|
name: "review_add_comment",
|
|
3308
2501
|
label: "review add comment",
|
|
3309
|
-
description: "Add one provenance-marked comment through
|
|
2502
|
+
description: "Add one provenance-marked comment through the remote pending-review backend.",
|
|
3310
2503
|
promptSnippet: "Call review_add_comment for incremental comments",
|
|
3311
|
-
promptGuidelines: ["
|
|
2504
|
+
promptGuidelines: ["Use the direct-file workflow for local reviews."],
|
|
3312
2505
|
parameters: parameters(addCommentSchema),
|
|
3313
2506
|
executionMode: "sequential",
|
|
3314
2507
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3315
2508
|
const params = addCommentSchema.parse(input);
|
|
3316
2509
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3317
2510
|
const model = modelRoute(ctx);
|
|
3318
|
-
|
|
3319
|
-
|
|
2511
|
+
if (params.local || params.workingTree)
|
|
2512
|
+
return result2("Local reviews are direct Markdown files; edit the selected REVIEW.md instead.");
|
|
2513
|
+
if (!review.forge)
|
|
3320
2514
|
return result2(unsupportedForgeMessage());
|
|
3321
2515
|
const comment = {
|
|
3322
2516
|
file: params.file,
|
|
3323
2517
|
line: params.line,
|
|
3324
2518
|
side: params.side ?? "RIGHT",
|
|
3325
|
-
body:
|
|
2519
|
+
body: withRemoteProvenance(params.body, model)
|
|
3326
2520
|
};
|
|
3327
|
-
if (useLocalBackend) {
|
|
3328
|
-
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
3329
|
-
if (!session)
|
|
3330
|
-
return result2("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
3331
|
-
const backend = createLocalReviewBackend({
|
|
3332
|
-
session: session.path,
|
|
3333
|
-
artifactPath: "",
|
|
3334
|
-
author: localReviewAuthor(model)
|
|
3335
|
-
});
|
|
3336
|
-
await backend.stage({ comments: [comment], body: "" });
|
|
3337
|
-
return result2(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
3338
|
-
}
|
|
3339
2521
|
if (!review.pr)
|
|
3340
2522
|
return result2("No PR/MR matches this remote review target.");
|
|
3341
2523
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
|
|
@@ -3345,38 +2527,30 @@ ${openMessage}`, {
|
|
|
3345
2527
|
defineTool6({
|
|
3346
2528
|
name: "review_comments",
|
|
3347
2529
|
label: "review comments",
|
|
3348
|
-
description: "Pull review threads
|
|
2530
|
+
description: "Pull remote review threads and return them for the direct-file workflow.",
|
|
3349
2531
|
promptSnippet: "Call review_comments before addressing findings",
|
|
3350
|
-
promptGuidelines: ["
|
|
2532
|
+
promptGuidelines: ["Use the direct-file workflow for local review files."],
|
|
3351
2533
|
parameters: parameters(localSchema),
|
|
3352
2534
|
executionMode: "sequential",
|
|
3353
2535
|
async execute(_id, input) {
|
|
3354
2536
|
const params = localSchema.parse(input);
|
|
3355
2537
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3356
2538
|
let threads;
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
if (!session)
|
|
3361
|
-
return result2("No matching tuicr session found.");
|
|
3362
|
-
artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
3363
|
-
threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
|
|
3364
|
-
} else if (review.pr && review.forge) {
|
|
2539
|
+
if (params.local || params.workingTree)
|
|
2540
|
+
return result2("Local reviews are direct Markdown files; read the selected REVIEW.md instead.");
|
|
2541
|
+
if (review.pr && review.forge) {
|
|
3365
2542
|
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
3366
|
-
const target = await reviewTargetId(review);
|
|
3367
|
-
artifact = await newReviewArtifactPath(review.cwd, target);
|
|
3368
|
-
await writeFile8(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
|
|
3369
2543
|
} else {
|
|
3370
2544
|
return result2(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
3371
2545
|
}
|
|
3372
2546
|
return result2(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
3373
|
-
`) || "No comments.", {
|
|
2547
|
+
`) || "No comments.", { threads });
|
|
3374
2548
|
}
|
|
3375
2549
|
}),
|
|
3376
2550
|
defineTool6({
|
|
3377
2551
|
name: "review_respond",
|
|
3378
2552
|
label: "review respond",
|
|
3379
|
-
description: "
|
|
2553
|
+
description: "Reply to remote review threads.",
|
|
3380
2554
|
promptSnippet: "Call review_respond after addressing a comment",
|
|
3381
2555
|
promptGuidelines: ["Question replies remain unresolved."],
|
|
3382
2556
|
parameters: parameters(respondSchema),
|
|
@@ -3385,36 +2559,8 @@ ${openMessage}`, {
|
|
|
3385
2559
|
const params = respondSchema.parse(input);
|
|
3386
2560
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3387
2561
|
const model = modelRoute(ctx);
|
|
3388
|
-
if (params.local)
|
|
3389
|
-
|
|
3390
|
-
if (!tuicrSession)
|
|
3391
|
-
return result2("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
3392
|
-
const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
|
|
3393
|
-
if (!existsSync2(artifact))
|
|
3394
|
-
return result2("No local review artifact. Run review_comments with local=true first.");
|
|
3395
|
-
const threads = await readThreadArtifact(artifact);
|
|
3396
|
-
const known = threads.find((thread) => thread.id === params.threadId);
|
|
3397
|
-
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
3398
|
-
const awaitUserDeletion = !question && params.resolve === true;
|
|
3399
|
-
await addComment(tuicrSession.path, `${localResponseMarker(params.threadId)}
|
|
3400
|
-
${params.body}`, {
|
|
3401
|
-
targetFile: known?.file,
|
|
3402
|
-
line: known?.line,
|
|
3403
|
-
side: "new",
|
|
3404
|
-
username: localReviewAuthor(model)
|
|
3405
|
-
});
|
|
3406
|
-
await createLocalReviewBackend({
|
|
3407
|
-
session: tuicrSession.path,
|
|
3408
|
-
artifactPath: artifact,
|
|
3409
|
-
author: localReviewAuthor(model)
|
|
3410
|
-
}).reply({
|
|
3411
|
-
threadId: params.threadId,
|
|
3412
|
-
body: params.body,
|
|
3413
|
-
resolve: false,
|
|
3414
|
-
question
|
|
3415
|
-
});
|
|
3416
|
-
return result2(`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 });
|
|
3417
|
-
}
|
|
2562
|
+
if (params.local || params.workingTree)
|
|
2563
|
+
return result2("Local reviews are direct Markdown files; append the response to REVIEW.md instead.");
|
|
3418
2564
|
if (!review.pr || !review.forge)
|
|
3419
2565
|
return result2("No remote PR/MR for this reply.");
|
|
3420
2566
|
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
@@ -3437,7 +2583,7 @@ ${params.body}`, {
|
|
|
3437
2583
|
defineTool6({
|
|
3438
2584
|
name: "review_publish",
|
|
3439
2585
|
label: "review publish",
|
|
3440
|
-
description: "Publish pending remote draft PR/MR review comments and status
|
|
2586
|
+
description: "Publish pending remote draft PR/MR review comments and status.",
|
|
3441
2587
|
promptSnippet: "Call review_publish to make remote review work public",
|
|
3442
2588
|
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
3443
2589
|
parameters: parameters(publishSchema),
|
|
@@ -3445,6 +2591,8 @@ ${params.body}`, {
|
|
|
3445
2591
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3446
2592
|
const params = publishSchema.parse(input);
|
|
3447
2593
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
2594
|
+
if (params.local || params.workingTree)
|
|
2595
|
+
return result2("Local publish is unsupported; select a remote PR/MR.");
|
|
3448
2596
|
if (!review.pr || !review.forge)
|
|
3449
2597
|
return result2("No remote PR/MR to publish.");
|
|
3450
2598
|
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
@@ -3453,34 +2601,25 @@ ${params.body}`, {
|
|
|
3453
2601
|
defineTool6({
|
|
3454
2602
|
name: "review_complete",
|
|
3455
2603
|
label: "review complete",
|
|
3456
|
-
description: "Approve, reject, or
|
|
2604
|
+
description: "Approve, reject, or close a remote review.",
|
|
3457
2605
|
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
3458
|
-
promptGuidelines: ["
|
|
2606
|
+
promptGuidelines: ["Close closes the remote PR/MR without publishing a review first."],
|
|
3459
2607
|
parameters: parameters(completeSchema),
|
|
3460
2608
|
executionMode: "sequential",
|
|
3461
2609
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3462
2610
|
const params = completeSchema.parse(input);
|
|
3463
2611
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3464
|
-
if (params.local)
|
|
3465
|
-
|
|
3466
|
-
if (!session)
|
|
3467
|
-
return result2("No matching local tuicr session to close.");
|
|
3468
|
-
const artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
3469
|
-
if (!existsSync2(artifact))
|
|
3470
|
-
return result2("No local review artifact. Run review_comments with local=true first.");
|
|
3471
|
-
const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
|
|
3472
|
-
await rename2(artifact, archived);
|
|
3473
|
-
await unlink2(session.path);
|
|
3474
|
-
return result2(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
|
|
3475
|
-
artifact: archived,
|
|
3476
|
-
session
|
|
3477
|
-
});
|
|
3478
|
-
}
|
|
2612
|
+
if (params.local || params.workingTree)
|
|
2613
|
+
return result2("Local complete is unsupported; select a remote PR/MR.");
|
|
3479
2614
|
if (!review.pr || !review.forge)
|
|
3480
2615
|
return result2("No remote PR/MR to complete.");
|
|
3481
2616
|
if (!params.action)
|
|
3482
|
-
return result2("Choose a complete action: approve, reject, or
|
|
3483
|
-
|
|
2617
|
+
return result2("Choose a complete action: approve, reject, or close.");
|
|
2618
|
+
if (params.action === "close") {
|
|
2619
|
+
await review.forge.closePr(review.pr.number);
|
|
2620
|
+
return result2(`Closed #${review.pr.number}.`, { pr: review.pr });
|
|
2621
|
+
}
|
|
2622
|
+
const status = params.action === "approve" ? "APPROVE" : "REQUEST_CHANGES";
|
|
3484
2623
|
return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
|
|
3485
2624
|
}
|
|
3486
2625
|
}),
|
|
@@ -3493,10 +2632,10 @@ ${params.body}`, {
|
|
|
3493
2632
|
"Do not query GitHub through MCP or duplicate readiness checks before calling review_merge.",
|
|
3494
2633
|
"This is intentionally GitHub-only until GitLab merge support is added."
|
|
3495
2634
|
],
|
|
3496
|
-
parameters: parameters(contextSchema.extend({ subject:
|
|
2635
|
+
parameters: parameters(contextSchema.extend({ subject: z10.string().optional() })),
|
|
3497
2636
|
executionMode: "sequential",
|
|
3498
2637
|
async execute(_id, input) {
|
|
3499
|
-
const params = contextSchema.extend({ subject:
|
|
2638
|
+
const params = contextSchema.extend({ subject: z10.string().optional() }).parse(input);
|
|
3500
2639
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3501
2640
|
if (review.vcs.provider !== "github" || !review.forge)
|
|
3502
2641
|
return result2("review_merge currently supports GitHub only.");
|
|
@@ -3509,72 +2648,28 @@ ${params.body}`, {
|
|
|
3509
2648
|
await review.forge.mergePr(review.pr.number, subject);
|
|
3510
2649
|
return result2(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
|
|
3511
2650
|
}
|
|
3512
|
-
}),
|
|
3513
|
-
defineTool6({
|
|
3514
|
-
name: "review_launch_ui",
|
|
3515
|
-
label: "review launch UI",
|
|
3516
|
-
description: "Open a tuicr UI in a mux tab, configure Zed, or return the command to run.",
|
|
3517
|
-
promptSnippet: "Call review_launch_ui for the interactive tuicr TUI",
|
|
3518
|
-
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
3519
|
-
parameters: parameters(localSchema),
|
|
3520
|
-
executionMode: "sequential",
|
|
3521
|
-
async execute(_id, input) {
|
|
3522
|
-
const params = localSchema.parse(input);
|
|
3523
|
-
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3524
|
-
const workingTree = Boolean(params.local || params.workingTree);
|
|
3525
|
-
if (!workingTree && !review.forge)
|
|
3526
|
-
return result2(unsupportedForgeMessage());
|
|
3527
|
-
return launchLocalReview(review, workingTree);
|
|
3528
|
-
}
|
|
3529
2651
|
})
|
|
3530
2652
|
];
|
|
3531
2653
|
}
|
|
3532
|
-
async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
3533
|
-
let previous = [];
|
|
3534
|
-
try {
|
|
3535
|
-
previous = parseThreadArtifact(await readFile14(path, "utf8"));
|
|
3536
|
-
} catch (error) {
|
|
3537
|
-
if (error.code !== "ENOENT")
|
|
3538
|
-
throw error;
|
|
3539
|
-
}
|
|
3540
|
-
const previousById = new Map(previous.map((thread) => [thread.id, thread]));
|
|
3541
|
-
const currentIds = new Set(threads.map((thread) => thread.id));
|
|
3542
|
-
const current = threads.map((thread) => {
|
|
3543
|
-
const existing = previousById.get(thread.id);
|
|
3544
|
-
return {
|
|
3545
|
-
...thread,
|
|
3546
|
-
resolved: false,
|
|
3547
|
-
addressed: existing?.addressed ?? Boolean(existing?.reply),
|
|
3548
|
-
reply: existing?.reply
|
|
3549
|
-
};
|
|
3550
|
-
});
|
|
3551
|
-
const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
|
|
3552
|
-
const merged = [...current, ...removed];
|
|
3553
|
-
await writeFile8(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
|
|
3554
|
-
return merged;
|
|
3555
|
-
}
|
|
3556
2654
|
function parameters(schema) {
|
|
3557
|
-
return
|
|
2655
|
+
return z10.toJSONSchema(schema, { io: "input" });
|
|
3558
2656
|
}
|
|
3559
2657
|
function resolveWorkingDirectory(params) {
|
|
3560
2658
|
return params.cwd ?? process.cwd();
|
|
3561
2659
|
}
|
|
2660
|
+
function deriveTitle(branch) {
|
|
2661
|
+
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
2662
|
+
}
|
|
3562
2663
|
function result2(text, details = {}) {
|
|
3563
2664
|
return { content: [{ type: "text", text }], details };
|
|
3564
2665
|
}
|
|
3565
2666
|
function unsupportedForgeMessage() {
|
|
3566
|
-
return "No supported GitHub or GitLab remote was detected.
|
|
2667
|
+
return "No supported GitHub or GitLab remote was detected.";
|
|
3567
2668
|
}
|
|
3568
2669
|
async function collectReviewStatus(review) {
|
|
3569
|
-
const [porcelain, branch
|
|
2670
|
+
const [porcelain, branch] = await Promise.all([
|
|
3570
2671
|
runChecked("git", ["-C", review.cwd, "status", "--porcelain"]),
|
|
3571
|
-
runChecked("git", ["-C", review.cwd, "branch", "--show-current"])
|
|
3572
|
-
resolveTuicrSession(review, true).catch(() => {
|
|
3573
|
-
return;
|
|
3574
|
-
}),
|
|
3575
|
-
resolveTuicrSession(review, false).catch(() => {
|
|
3576
|
-
return;
|
|
3577
|
-
})
|
|
2672
|
+
runChecked("git", ["-C", review.cwd, "branch", "--show-current"])
|
|
3578
2673
|
]);
|
|
3579
2674
|
const entries = porcelain.stdout.split("\\n").filter(Boolean);
|
|
3580
2675
|
const staged = entries.filter((line) => !line.startsWith("??") && line[0] !== " ").length;
|
|
@@ -3585,10 +2680,8 @@ async function collectReviewStatus(review) {
|
|
|
3585
2680
|
const text = [
|
|
3586
2681
|
`Branch: ${branch.stdout.trim() || review.vcs.branch}`,
|
|
3587
2682
|
`Worktree: ${entries.length ? "dirty" : "clean"} (staged=${staged}, unstaged=${unstaged}, untracked=${untracked})`,
|
|
3588
|
-
`Local working-tree tuicr review: ${localSession ? `yes (${localSession.slug}, ${localSession.commentCount} comments)` : "no"}`,
|
|
3589
2683
|
`Remote PR/MR: ${remote}`,
|
|
3590
|
-
`Remote URL: ${url}
|
|
3591
|
-
`Remote tuicr session: ${remoteSession ? `yes (${remoteSession.slug}, ${remoteSession.commentCount} comments)` : "no"}`
|
|
2684
|
+
`Remote URL: ${url}`
|
|
3592
2685
|
].join("\\n");
|
|
3593
2686
|
return {
|
|
3594
2687
|
text,
|
|
@@ -3597,8 +2690,6 @@ async function collectReviewStatus(review) {
|
|
|
3597
2690
|
staged,
|
|
3598
2691
|
unstaged,
|
|
3599
2692
|
untracked,
|
|
3600
|
-
localSession,
|
|
3601
|
-
remoteSession,
|
|
3602
2693
|
pr: review.pr,
|
|
3603
2694
|
url
|
|
3604
2695
|
}
|
|
@@ -3614,167 +2705,13 @@ async function publishResolvedReview(review, params, model) {
|
|
|
3614
2705
|
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
3615
2706
|
assertReviewEventSupported(review.vcs.provider, event);
|
|
3616
2707
|
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
3617
|
-
const promotion = params.local ? await promoteLocalReview(review, remote, model) : undefined;
|
|
3618
2708
|
if (status !== "CLOSE" && review.pr.isDraft)
|
|
3619
2709
|
await review.forge.markReady(review.pr.number);
|
|
3620
2710
|
await remote.publish(event);
|
|
3621
|
-
if (promotion) {
|
|
3622
|
-
promotion.publication.state.bodies = [
|
|
3623
|
-
...new Set([...promotion.publication.state.bodies, ...promotion.bodyFingerprints])
|
|
3624
|
-
];
|
|
3625
|
-
promotion.publication.state.comments = [
|
|
3626
|
-
...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
|
|
3627
|
-
];
|
|
3628
|
-
const published = new Set(promotion.commentFingerprints);
|
|
3629
|
-
promotion.publication.state.stagedComments = promotion.publication.state.stagedComments.filter((fingerprint) => !published.has(fingerprint));
|
|
3630
|
-
await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
|
|
3631
|
-
}
|
|
3632
2711
|
if (status === "CLOSE")
|
|
3633
2712
|
await review.forge.closePr(review.pr.number);
|
|
3634
2713
|
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
3635
|
-
|
|
3636
|
-
const promotedComments = promotion?.promotedComments ?? 0;
|
|
3637
|
-
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
3638
|
-
return result2(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
|
|
3639
|
-
}
|
|
3640
|
-
async function promoteLocalReview(review, remote, model) {
|
|
3641
|
-
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
3642
|
-
const session = await resolvePublishSession(review.cwd, {
|
|
3643
|
-
branch: review.vcs.branch,
|
|
3644
|
-
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
3645
|
-
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
3646
|
-
number: review.pr?.number
|
|
3647
|
-
});
|
|
3648
|
-
if (!session)
|
|
3649
|
-
throw new Error("No matching tuicr session to publish.");
|
|
3650
|
-
const sessionIsWorkingTree = session.kind === "local";
|
|
3651
|
-
const local = createLocalReviewBackend({
|
|
3652
|
-
session: session.path,
|
|
3653
|
-
artifactPath: "",
|
|
3654
|
-
author: localReviewAuthor(model)
|
|
3655
|
-
});
|
|
3656
|
-
const draft = await local.readDraft();
|
|
3657
|
-
const remoteDraft = await remote.readDraft();
|
|
3658
|
-
const knownRootComments = new Set([
|
|
3659
|
-
...publication.state.comments,
|
|
3660
|
-
...publication.state.stagedComments,
|
|
3661
|
-
...remoteDraft.comments.map(reviewCommentFingerprint)
|
|
3662
|
-
]);
|
|
3663
|
-
const { checkpointed, candidates } = partitionReviewComments(draft.comments, knownRootComments, model);
|
|
3664
|
-
const remoteThreads = await remote.listThreads();
|
|
3665
|
-
const threadReplies = sessionIsWorkingTree ? [] : candidates.flatMap(({ source: comment }) => {
|
|
3666
|
-
const thread = resolveReviewThread(comment, remoteThreads);
|
|
3667
|
-
return thread ? [{ comment, thread }] : [];
|
|
3668
|
-
});
|
|
3669
|
-
const unmatchedActions = sessionIsWorkingTree ? [] : candidates.map(({ source }) => source).filter((comment) => parseReviewThreadAction(comment.body).action && !threadReplies.some((reply) => reply.comment === comment));
|
|
3670
|
-
if (unmatchedActions.length > 0) {
|
|
3671
|
-
const locations = unmatchedActions.map(({ file, line }) => `${file}:${line ?? "?"}`).join(", ");
|
|
3672
|
-
throw new Error(`Cannot apply review thread action: no matching remote thread at ${locations}.`);
|
|
3673
|
-
}
|
|
3674
|
-
const comments = candidates.filter(({ source }) => !threadReplies.some((reply) => reply.comment === source)).map(({ published }) => published);
|
|
3675
|
-
const body = draft.body.trim();
|
|
3676
|
-
const bodyFingerprints = body ? [reviewBodyFingerprint(body)] : [];
|
|
3677
|
-
const commentFingerprints = [
|
|
3678
|
-
...checkpointed.map(({ fingerprint }) => fingerprint),
|
|
3679
|
-
...comments.map(reviewCommentFingerprint)
|
|
3680
|
-
];
|
|
3681
|
-
const knownBodies = new Set(publication.state.bodies);
|
|
3682
|
-
if (remoteDraft.body.trim())
|
|
3683
|
-
knownBodies.add(reviewBodyFingerprint(remoteDraft.body));
|
|
3684
|
-
const unpublishedBody = body && !knownBodies.has(reviewBodyFingerprint(body)) ? body : "";
|
|
3685
|
-
const unpublished = unpublishedReviewComments(comments, knownRootComments);
|
|
3686
|
-
if (unpublishedBody)
|
|
3687
|
-
await remote.stage({ comments: [], body: unpublishedBody });
|
|
3688
|
-
let promotedComments = 0;
|
|
3689
|
-
for (const comment of unpublished) {
|
|
3690
|
-
await remote.stage({ comments: [comment], body: "" });
|
|
3691
|
-
publication.state.stagedComments.push(reviewCommentFingerprint(comment));
|
|
3692
|
-
await saveReviewPublicationState(publication.path, publication.state);
|
|
3693
|
-
promotedComments += 1;
|
|
3694
|
-
}
|
|
3695
|
-
const promotedReplies = await promoteLocalReplies(review, remote, publication, model, sessionIsWorkingTree) + await promoteLocationReplies(remote, publication, threadReplies, model);
|
|
3696
|
-
return {
|
|
3697
|
-
publication,
|
|
3698
|
-
bodyFingerprints,
|
|
3699
|
-
commentFingerprints,
|
|
3700
|
-
promotedBodies: unpublishedBody ? 1 : 0,
|
|
3701
|
-
promotedComments,
|
|
3702
|
-
promotedReplies
|
|
3703
|
-
};
|
|
3704
|
-
}
|
|
3705
|
-
async function promoteLocationReplies(remote, publication, replies, model) {
|
|
3706
|
-
let count = 0;
|
|
3707
|
-
for (const { comment, thread } of replies) {
|
|
3708
|
-
const parsed = parseReviewThreadAction(comment.body);
|
|
3709
|
-
const body = parsed.body.trim();
|
|
3710
|
-
const action = parsed.action;
|
|
3711
|
-
const fingerprint = reviewReplyFingerprint(thread.id, `${action ?? "reply"}\x00${body}`);
|
|
3712
|
-
if (publication.state.replies.includes(fingerprint))
|
|
3713
|
-
continue;
|
|
3714
|
-
if (action === "delete") {
|
|
3715
|
-
if (!remote.deleteThread)
|
|
3716
|
-
throw new Error(`The review backend cannot delete thread ${thread.id}.`);
|
|
3717
|
-
await remote.deleteThread(thread.id);
|
|
3718
|
-
publication.state.replies.push(fingerprint);
|
|
3719
|
-
await saveReviewPublicationState(publication.path, publication.state);
|
|
3720
|
-
count += 1;
|
|
3721
|
-
continue;
|
|
3722
|
-
}
|
|
3723
|
-
const remoteBody = withCommentProvenance({ ...comment, body }, model);
|
|
3724
|
-
if (body && !thread.replies?.some((reply) => reply === body || reply === remoteBody)) {
|
|
3725
|
-
await remote.reply({
|
|
3726
|
-
threadId: thread.id,
|
|
3727
|
-
body: remoteBody,
|
|
3728
|
-
resolve: false
|
|
3729
|
-
});
|
|
3730
|
-
}
|
|
3731
|
-
if (action && remote.setResolved)
|
|
3732
|
-
await remote.setResolved(thread.id, action === "resolve");
|
|
3733
|
-
publication.state.replies.push(fingerprint);
|
|
3734
|
-
await saveReviewPublicationState(publication.path, publication.state);
|
|
3735
|
-
count += 1;
|
|
3736
|
-
}
|
|
3737
|
-
return count;
|
|
3738
|
-
}
|
|
3739
|
-
async function promoteLocalReplies(review, remote, publication, model, workingTree) {
|
|
3740
|
-
const target = workingTree ? "uncommitted" : await reviewTargetId(review);
|
|
3741
|
-
const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
3742
|
-
if (!artifact)
|
|
3743
|
-
return 0;
|
|
3744
|
-
if (!workingTree)
|
|
3745
|
-
publication.state.overlayPath = artifact;
|
|
3746
|
-
let count = 0;
|
|
3747
|
-
for (const thread of await readThreadArtifact(artifact)) {
|
|
3748
|
-
if (!thread.reply)
|
|
3749
|
-
continue;
|
|
3750
|
-
const body = withRemoteProvenance(thread.reply, model);
|
|
3751
|
-
const fingerprint = reviewReplyFingerprint(thread.id, body);
|
|
3752
|
-
if (publication.state.replies.includes(fingerprint))
|
|
3753
|
-
continue;
|
|
3754
|
-
if (workingTree) {
|
|
3755
|
-
const draft = workingTreeReplyDraft(thread, body);
|
|
3756
|
-
const comment = draft.comments[0];
|
|
3757
|
-
if (comment) {
|
|
3758
|
-
const commentFingerprint = reviewCommentFingerprint(comment);
|
|
3759
|
-
if (!publication.state.stagedComments.includes(commentFingerprint)) {
|
|
3760
|
-
await remote.stage(draft);
|
|
3761
|
-
publication.state.stagedComments.push(commentFingerprint);
|
|
3762
|
-
}
|
|
3763
|
-
} else {
|
|
3764
|
-
const bodyFingerprint = reviewBodyFingerprint(draft.body);
|
|
3765
|
-
if (!publication.state.bodies.includes(bodyFingerprint)) {
|
|
3766
|
-
await remote.stage(draft);
|
|
3767
|
-
publication.state.bodies.push(bodyFingerprint);
|
|
3768
|
-
}
|
|
3769
|
-
}
|
|
3770
|
-
} else {
|
|
3771
|
-
await remote.reply({ threadId: thread.id, body, resolve: false, question: thread.question });
|
|
3772
|
-
}
|
|
3773
|
-
publication.state.replies.push(fingerprint);
|
|
3774
|
-
await saveReviewPublicationState(publication.path, publication.state);
|
|
3775
|
-
count += 1;
|
|
3776
|
-
}
|
|
3777
|
-
return count;
|
|
2714
|
+
return result2(`Published #${review.pr.number} (${status}).`, { pr: finalPr ?? review.pr, status });
|
|
3778
2715
|
}
|
|
3779
2716
|
async function resolveReviewContext(cwd, target) {
|
|
3780
2717
|
const vcs = await detectVcs(cwd);
|
|
@@ -3786,21 +2723,6 @@ async function resolveReviewContext(cwd, target) {
|
|
|
3786
2723
|
function normalizeTarget(target) {
|
|
3787
2724
|
return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
|
|
3788
2725
|
}
|
|
3789
|
-
function resolveTuicrSession(review, workingTree = false) {
|
|
3790
|
-
return resolveReviewSession(review.cwd, {
|
|
3791
|
-
branch: review.vcs.branch,
|
|
3792
|
-
workingTree,
|
|
3793
|
-
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
3794
|
-
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
3795
|
-
number: review.pr?.number
|
|
3796
|
-
});
|
|
3797
|
-
}
|
|
3798
|
-
async function launchLocalReview(review, workingTree, requestedBase) {
|
|
3799
|
-
const base = workingTree ? requestedBase ?? review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : undefined) : undefined;
|
|
3800
|
-
const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number, base);
|
|
3801
|
-
const commandTarget = workingTree || !review.pr ? "full branch" : `PR/MR #${review.pr.number}`;
|
|
3802
|
-
return result2(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
|
|
3803
|
-
}
|
|
3804
2726
|
async function assertRemoteBranchReady(cwd) {
|
|
3805
2727
|
const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
|
|
3806
2728
|
if (dirty.stdout.trim())
|
|
@@ -3815,112 +2737,16 @@ async function assertRemoteBranchReady(cwd) {
|
|
|
3815
2737
|
if (head.stdout.trim() !== pushed.stdout.trim())
|
|
3816
2738
|
throw new Error("Push the current branch before opening a remote review.");
|
|
3817
2739
|
}
|
|
3818
|
-
function withCommentProvenance(comment, fallbackModel) {
|
|
3819
|
-
const route = comment.author?.match(/^Agent:\s*(.+)$/)?.[1];
|
|
3820
|
-
return route ? withRemoteProvenance(comment.body, route || fallbackModel) : comment.body;
|
|
3821
|
-
}
|
|
3822
|
-
async function reviewTargetId(review) {
|
|
3823
|
-
if (!review.pr)
|
|
3824
|
-
return "uncommitted";
|
|
3825
|
-
if (review.pr.headSha)
|
|
3826
|
-
return review.pr.headSha.slice(0, 12);
|
|
3827
|
-
if (review.pr.headRef === review.vcs.branch)
|
|
3828
|
-
return headSha(review.cwd);
|
|
3829
|
-
const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
|
|
3830
|
-
return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
|
|
3831
|
-
}
|
|
3832
|
-
async function headSha(cwd) {
|
|
3833
|
-
const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
|
|
3834
|
-
return result.stdout.trim();
|
|
3835
|
-
}
|
|
3836
|
-
async function newReviewArtifactPath(cwd, target) {
|
|
3837
|
-
const dir = await reviewsDir(cwd);
|
|
3838
|
-
await mkdir7(dir, { recursive: true });
|
|
3839
|
-
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
3840
|
-
}
|
|
3841
|
-
async function localThreadArtifactPath(cwd, sessionSlug) {
|
|
3842
|
-
const dir = await reviewsDir(cwd);
|
|
3843
|
-
return join17(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
|
|
3844
|
-
}
|
|
3845
|
-
async function readThreadArtifact(path) {
|
|
3846
|
-
try {
|
|
3847
|
-
return parseThreadArtifact(await readFile14(path, "utf8"));
|
|
3848
|
-
} catch (error) {
|
|
3849
|
-
if (error.code === "ENOENT") {
|
|
3850
|
-
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
3851
|
-
}
|
|
3852
|
-
throw error;
|
|
3853
|
-
}
|
|
3854
|
-
}
|
|
3855
|
-
async function latestThreadArtifact(cwd, review, target) {
|
|
3856
|
-
try {
|
|
3857
|
-
return await findLatestThreadArtifact(cwd, review, target);
|
|
3858
|
-
} catch (error) {
|
|
3859
|
-
throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
|
|
3860
|
-
}
|
|
3861
|
-
}
|
|
3862
|
-
async function findLatestThreadArtifact(cwd, review, target) {
|
|
3863
|
-
const dir = await reviewsDir(cwd);
|
|
3864
|
-
const suffix = reviewSlug(target) || "uncommitted";
|
|
3865
|
-
const names = await listReviewArtifactNames(dir);
|
|
3866
|
-
const candidates = await Promise.all(names.flatMap((name) => {
|
|
3867
|
-
if (!name.endsWith(".md"))
|
|
3868
|
-
return [];
|
|
3869
|
-
return [
|
|
3870
|
-
(async () => {
|
|
3871
|
-
const path = join17(dir, name);
|
|
3872
|
-
const info = await stat2(path);
|
|
3873
|
-
return { path, name, content: await readFile14(path, "utf8"), modified: info.mtimeMs };
|
|
3874
|
-
})()
|
|
3875
|
-
];
|
|
3876
|
-
}));
|
|
3877
|
-
return candidates.filter(({ name, content }) => {
|
|
3878
|
-
if (!content.startsWith("<!-- diffpi-threads:"))
|
|
3879
|
-
return false;
|
|
3880
|
-
if (review.pr && target !== "local" && target !== "uncommitted")
|
|
3881
|
-
return content.includes(`- PR/MR: #${review.pr.number}`);
|
|
3882
|
-
return artifactNameMatches(name, suffix);
|
|
3883
|
-
}).sort((a, b) => b.modified - a.modified)[0]?.path;
|
|
3884
|
-
}
|
|
3885
|
-
async function listReviewArtifactNames(dir) {
|
|
3886
|
-
try {
|
|
3887
|
-
return await readdir5(dir);
|
|
3888
|
-
} catch (error) {
|
|
3889
|
-
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
3890
|
-
}
|
|
3891
|
-
}
|
|
3892
|
-
function deriveTitle(branch) {
|
|
3893
|
-
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
3894
|
-
}
|
|
3895
|
-
function uniqueRecordPath(dir, base) {
|
|
3896
|
-
let path = join17(dir, `${base}.md`);
|
|
3897
|
-
let count = 2;
|
|
3898
|
-
while (existsSync2(path))
|
|
3899
|
-
path = join17(dir, `${base}-${count++}.md`);
|
|
3900
|
-
return path;
|
|
3901
|
-
}
|
|
3902
|
-
function artifactNameMatches(name, suffix) {
|
|
3903
|
-
if (!name.endsWith(".md"))
|
|
3904
|
-
return false;
|
|
3905
|
-
const stem = name.slice(0, -3);
|
|
3906
|
-
const marker = `-${suffix}`;
|
|
3907
|
-
const markerIndex = stem.lastIndexOf(marker);
|
|
3908
|
-
if (markerIndex < 0)
|
|
3909
|
-
return false;
|
|
3910
|
-
const tail = stem.slice(markerIndex + marker.length);
|
|
3911
|
-
return tail === "" || /^-\d+$/.test(tail);
|
|
3912
|
-
}
|
|
3913
2740
|
|
|
3914
2741
|
// src/tools/setup.ts
|
|
3915
2742
|
import { defineTool as defineTool7 } from "@earendil-works/pi-coding-agent";
|
|
3916
|
-
import { z as
|
|
3917
|
-
var setupParametersSchema =
|
|
3918
|
-
issueTracker:
|
|
3919
|
-
forges:
|
|
3920
|
-
forge:
|
|
3921
|
-
bindZedKey: z12.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
2743
|
+
import { z as z11 } from "zod";
|
|
2744
|
+
var setupParametersSchema = z11.object({
|
|
2745
|
+
issueTracker: z11.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
2746
|
+
forges: z11.array(z11.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
2747
|
+
forge: z11.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers.")
|
|
3922
2748
|
});
|
|
3923
|
-
var setupParameters =
|
|
2749
|
+
var setupParameters = z11.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
3924
2750
|
var diffpiSetupTool = defineTool7({
|
|
3925
2751
|
name: "diffpi_setup",
|
|
3926
2752
|
label: "diffpi setup",
|
|
@@ -3940,7 +2766,6 @@ var diffpiSetupTool = defineTool7({
|
|
|
3940
2766
|
issueTracker: params.issueTracker,
|
|
3941
2767
|
forges: params.forges,
|
|
3942
2768
|
forge: params.forge,
|
|
3943
|
-
bindZedKey: params.bindZedKey,
|
|
3944
2769
|
installMiseHook: true,
|
|
3945
2770
|
availableModels: ctx.modelRegistry.getAvailable(),
|
|
3946
2771
|
onProgress(message) {
|
|
@@ -3968,7 +2793,6 @@ var diffpiValidateTool = defineTool7({
|
|
|
3968
2793
|
issueTracker: params.issueTracker,
|
|
3969
2794
|
forges: params.forges,
|
|
3970
2795
|
forge: params.forge,
|
|
3971
|
-
bindZedKey: params.bindZedKey,
|
|
3972
2796
|
installMiseHook: true,
|
|
3973
2797
|
dryRun: true,
|
|
3974
2798
|
availableModels: ctx.modelRegistry.getAvailable()
|
|
@@ -3993,11 +2817,11 @@ ${lines.join(`
|
|
|
3993
2817
|
|
|
3994
2818
|
// src/tools/templates.ts
|
|
3995
2819
|
import { defineTool as defineTool8 } from "@earendil-works/pi-coding-agent";
|
|
3996
|
-
import { z as
|
|
3997
|
-
var templateSchema =
|
|
3998
|
-
name:
|
|
3999
|
-
variables:
|
|
4000
|
-
homeDir:
|
|
2820
|
+
import { z as z12 } from "zod";
|
|
2821
|
+
var templateSchema = z12.object({
|
|
2822
|
+
name: z12.string().min(1),
|
|
2823
|
+
variables: z12.record(z12.string(), z12.string()).optional(),
|
|
2824
|
+
homeDir: z12.string().optional()
|
|
4001
2825
|
});
|
|
4002
2826
|
var diffpiTemplateTool = defineTool8({
|
|
4003
2827
|
name: "diffpi_template",
|
|
@@ -4005,7 +2829,7 @@ var diffpiTemplateTool = defineTool8({
|
|
|
4005
2829
|
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
4006
2830
|
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
4007
2831
|
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
4008
|
-
parameters:
|
|
2832
|
+
parameters: z12.toJSONSchema(templateSchema, { io: "input" }),
|
|
4009
2833
|
executionMode: "parallel",
|
|
4010
2834
|
async execute(_id, input) {
|
|
4011
2835
|
const params = templateSchema.parse(input);
|