@difflab/pi 0.3.0-rc.202609200047.ac3661a → 0.3.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 +2 -22
- package/agents/diffpi-orchestrator.md +0 -10
- package/agents/diffpi-worker.md +1 -9
- package/dist/commands/index.d.ts +0 -1
- package/dist/commands/index.d.ts.map +1 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/environment.d.ts.map +1 -1
- package/dist/extensions/index.js +844 -2773
- package/dist/extensions/zedx.d.ts +0 -2
- package/dist/extensions/zedx.d.ts.map +1 -1
- package/dist/index.d.ts +2 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +128 -1140
- package/dist/setup.d.ts.map +1 -1
- package/dist/store.d.ts +0 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +678 -2400
- package/package.json +1 -4
- package/agents/diffpi-planner.md +0 -30
- package/dist/cli/plan.d.ts +0 -7
- package/dist/cli/plan.d.ts.map +0 -1
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -729
- package/dist/commands/background.d.ts +0 -39
- package/dist/commands/background.d.ts.map +0 -1
- package/dist/commands/plan.d.ts +0 -21
- package/dist/commands/plan.d.ts.map +0 -1
- package/dist/plan/annotations.d.ts +0 -25
- package/dist/plan/annotations.d.ts.map +0 -1
- package/dist/plan/escalation.d.ts +0 -4
- package/dist/plan/escalation.d.ts.map +0 -1
- package/dist/plan/execution.d.ts +0 -19
- package/dist/plan/execution.d.ts.map +0 -1
- package/dist/plan/index.d.ts +0 -15
- package/dist/plan/index.d.ts.map +0 -1
- package/dist/plan/lock.d.ts +0 -7
- package/dist/plan/lock.d.ts.map +0 -1
- package/dist/plan/log.d.ts +0 -5
- package/dist/plan/log.d.ts.map +0 -1
- package/dist/plan/markdown.d.ts +0 -6
- package/dist/plan/markdown.d.ts.map +0 -1
- package/dist/plan/store.d.ts +0 -34
- package/dist/plan/store.d.ts.map +0 -1
- package/dist/plan/transitions.d.ts +0 -9
- package/dist/plan/transitions.d.ts.map +0 -1
- package/dist/plan/types.d.ts +0 -146
- package/dist/plan/types.d.ts.map +0 -1
- package/dist/tools/plan.d.ts +0 -6
- package/dist/tools/plan.d.ts.map +0 -1
- package/skills/plan/SKILL.md +0 -13
- package/skills/plan/references/workflows/annotate.md +0 -6
- package/skills/plan/references/workflows/finalize.md +0 -7
- package/skills/plan/references/workflows/go.md +0 -7
- package/skills/plan/references/workflows/help.md +0 -13
- package/skills/plan/references/workflows/init.md +0 -6
- package/skills/plan/references/workflows/new.md +0 -7
- package/skills/plan/references/workflows/update.md +0 -7
- package/templates/plan/PLAN.md +0 -38
package/dist/tools/index.js
CHANGED
|
@@ -124,18 +124,15 @@ function sanitize(value) {
|
|
|
124
124
|
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
// src/tools/
|
|
128
|
-
import {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
import {
|
|
132
|
-
import {
|
|
133
|
-
import { homedir } from "node:os";
|
|
134
|
-
import { dirname, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
|
|
127
|
+
// src/tools/review.ts
|
|
128
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
129
|
+
import { mkdir as mkdir4, readdir, readFile as readFile7, rename as rename2, stat, unlink as unlink2, writeFile as writeFile5 } from "node:fs/promises";
|
|
130
|
+
import { join as join9 } from "node:path";
|
|
131
|
+
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
132
|
+
import { z as z6 } from "zod";
|
|
135
133
|
|
|
136
|
-
// src/
|
|
137
|
-
import {
|
|
138
|
-
import { basename, isAbsolute, join as join2, resolve } from "node:path";
|
|
134
|
+
// src/environment.ts
|
|
135
|
+
import { basename } from "node:path";
|
|
139
136
|
|
|
140
137
|
// src/extensions/processx.ts
|
|
141
138
|
import { spawn } from "node:child_process";
|
|
@@ -211,201 +208,21 @@ function appendBounded(current, next) {
|
|
|
211
208
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
212
209
|
}
|
|
213
210
|
|
|
214
|
-
// src/extensions/gitx.ts
|
|
215
|
-
async function gitToplevel(cwd) {
|
|
216
|
-
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
217
|
-
const top = result.stdout.trim();
|
|
218
|
-
return result.code === 0 && top ? top : resolve(cwd);
|
|
219
|
-
}
|
|
220
|
-
async function inspectGitRepository(cwd) {
|
|
221
|
-
const root = await gitToplevel(cwd);
|
|
222
|
-
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
223
|
-
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
224
|
-
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
225
|
-
const common = commonResult.stdout.trim();
|
|
226
|
-
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join2(root, common)) : root;
|
|
227
|
-
const commonDir = await canonicalPath(commonPath);
|
|
228
|
-
return {
|
|
229
|
-
root,
|
|
230
|
-
commonDir,
|
|
231
|
-
...remote ? { remote } : {},
|
|
232
|
-
name: remote ? repositoryName(remote) : basename(resolve(commonDir, "..")) || basename(root),
|
|
233
|
-
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
function normalizeRemote(remote) {
|
|
237
|
-
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
238
|
-
}
|
|
239
|
-
function repositoryName(remote) {
|
|
240
|
-
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
241
|
-
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
242
|
-
}
|
|
243
|
-
async function canonicalPath(path) {
|
|
244
|
-
try {
|
|
245
|
-
return await realpath(path);
|
|
246
|
-
} catch {
|
|
247
|
-
return resolve(path);
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// src/store.ts
|
|
252
|
-
var STORE_LINK = ".diffpi";
|
|
253
|
-
var LEGACY_STORE_LINK = join3(".pi", "diffpi");
|
|
254
|
-
function storeGlobalRoot(homeDir = homedir()) {
|
|
255
|
-
return join3(homeDir, ".difflab", "diffpi", "projects");
|
|
256
|
-
}
|
|
257
|
-
async function ensureStore(cwd, homeDir = homedir()) {
|
|
258
|
-
const repository = await inspectGitRepository(cwd);
|
|
259
|
-
const root = repository.root;
|
|
260
|
-
const slug = projectSlug(repository);
|
|
261
|
-
const dest = join3(storeGlobalRoot(homeDir), slug);
|
|
262
|
-
const link = join3(root, STORE_LINK);
|
|
263
|
-
await mkdir(dest, { recursive: true });
|
|
264
|
-
try {
|
|
265
|
-
await assertStoreLink(link, dest);
|
|
266
|
-
} catch (error) {
|
|
267
|
-
if (error.code !== "ENOENT")
|
|
268
|
-
throw error;
|
|
269
|
-
await symlink(dest, link);
|
|
270
|
-
}
|
|
271
|
-
await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
|
|
272
|
-
return { slug, root, dest, link, linked: true };
|
|
273
|
-
}
|
|
274
|
-
async function plansDir(cwd, homeDir = homedir()) {
|
|
275
|
-
const store = await ensureStore(cwd, homeDir);
|
|
276
|
-
const dir = join3(store.link, "plan");
|
|
277
|
-
await mkdir(dir, { recursive: true });
|
|
278
|
-
return dir;
|
|
279
|
-
}
|
|
280
|
-
async function reviewsDir(cwd, homeDir = homedir()) {
|
|
281
|
-
const store = await ensureStore(cwd, homeDir);
|
|
282
|
-
const dir = join3(store.link, "review");
|
|
283
|
-
await mkdir(dir, { recursive: true });
|
|
284
|
-
return dir;
|
|
285
|
-
}
|
|
286
|
-
async function completedReviewsDir(cwd, homeDir = homedir()) {
|
|
287
|
-
const store = await ensureStore(cwd, homeDir);
|
|
288
|
-
const dir = join3(store.link, "reviews");
|
|
289
|
-
await mkdir(dir, { recursive: true });
|
|
290
|
-
return dir;
|
|
291
|
-
}
|
|
292
|
-
async function sessionsDir(cwd, homeDir = homedir()) {
|
|
293
|
-
const store = await ensureStore(cwd, homeDir);
|
|
294
|
-
const dir = join3(store.link, "sessions");
|
|
295
|
-
await mkdir(dir, { recursive: true });
|
|
296
|
-
return dir;
|
|
297
|
-
}
|
|
298
|
-
async function assertStoreLink(path, dest) {
|
|
299
|
-
const entry = await lstat(path);
|
|
300
|
-
if (!entry.isSymbolicLink())
|
|
301
|
-
throw new Error(`${path} exists and is not a symlink.`);
|
|
302
|
-
const target = await symlinkTarget(path);
|
|
303
|
-
if (target !== await canonicalPath2(dest))
|
|
304
|
-
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
305
|
-
}
|
|
306
|
-
async function removeLegacyStoreLink(path, dest) {
|
|
307
|
-
try {
|
|
308
|
-
const entry = await lstat(path);
|
|
309
|
-
if (!entry.isSymbolicLink())
|
|
310
|
-
return;
|
|
311
|
-
const target = await symlinkTarget(path);
|
|
312
|
-
if (target === await canonicalPath2(dest))
|
|
313
|
-
await unlink(path);
|
|
314
|
-
} catch (error) {
|
|
315
|
-
if (error.code !== "ENOENT")
|
|
316
|
-
throw error;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
async function symlinkTarget(path) {
|
|
320
|
-
const target = await readlink(path);
|
|
321
|
-
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname(path), target));
|
|
322
|
-
}
|
|
323
|
-
async function canonicalPath2(path) {
|
|
324
|
-
try {
|
|
325
|
-
return await realpath2(path);
|
|
326
|
-
} catch {
|
|
327
|
-
return resolve2(path);
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
function projectSlug(repository) {
|
|
331
|
-
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
332
|
-
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
333
|
-
return `${readable}-${digest}`;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// src/commands/background.ts
|
|
337
|
-
async function launchBackgroundPi(pi, options) {
|
|
338
|
-
const child = shellCommand([
|
|
339
|
-
"pi",
|
|
340
|
-
"--mode",
|
|
341
|
-
"json",
|
|
342
|
-
"--print",
|
|
343
|
-
"--no-session",
|
|
344
|
-
"--offline",
|
|
345
|
-
"--approve",
|
|
346
|
-
"--model",
|
|
347
|
-
options.model,
|
|
348
|
-
"--thinking",
|
|
349
|
-
options.thinking,
|
|
350
|
-
"--append-system-prompt",
|
|
351
|
-
options.agentPath,
|
|
352
|
-
"--",
|
|
353
|
-
options.prompt
|
|
354
|
-
]);
|
|
355
|
-
const command = options.packetPath ? `sh -c ${shellQuote(`trap 'rm -f -- "$1"' EXIT HUP INT TERM; cd -- "$2" || exit; ${child}`)} sh ${shellQuote(options.packetPath)} ${shellQuote(options.cwd)}` : `cd ${shellQuote(options.cwd)} && ${child}`;
|
|
356
|
-
await pi.sendUserMessage(`/bg --agent --name ${shellQuote(options.name)} -- ${command}`, {
|
|
357
|
-
deliverAs: "followUp",
|
|
358
|
-
expandPromptTemplates: true
|
|
359
|
-
});
|
|
360
|
-
return { name: options.name, command, queued: true };
|
|
361
|
-
}
|
|
362
|
-
function shellQuote(value) {
|
|
363
|
-
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
|
364
|
-
}
|
|
365
|
-
function shellCommand(args) {
|
|
366
|
-
return args.map(shellQuote).join(" ");
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
// src/tools/plan.ts
|
|
370
|
-
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
371
|
-
import { z as z4 } from "zod";
|
|
372
|
-
|
|
373
|
-
// src/assets.ts
|
|
374
|
-
import { existsSync } from "node:fs";
|
|
375
|
-
import { dirname as dirname2, join as join4 } from "node:path";
|
|
376
|
-
import { fileURLToPath } from "node:url";
|
|
377
|
-
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
378
|
-
const moduleDir = dirname2(fileURLToPath(moduleUrl));
|
|
379
|
-
const candidates = [join4(moduleDir, name), join4(moduleDir, "..", name), join4(moduleDir, "..", "..", name)];
|
|
380
|
-
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
381
|
-
}
|
|
382
|
-
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
383
|
-
return resolveBundledAssetDir("agents", moduleUrl);
|
|
384
|
-
}
|
|
385
|
-
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
386
|
-
return resolveBundledAssetDir("templates", moduleUrl);
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
// src/environment.ts
|
|
390
|
-
import { readFile as readFile2 } from "node:fs/promises";
|
|
391
|
-
import { basename as basename2, join as join6 } from "node:path";
|
|
392
|
-
|
|
393
211
|
// src/extensions/zedx.ts
|
|
394
|
-
import { mkdir
|
|
395
|
-
import { homedir
|
|
396
|
-
import { dirname
|
|
212
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
213
|
+
import { homedir } from "node:os";
|
|
214
|
+
import { dirname, join as join2 } from "node:path";
|
|
397
215
|
var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
|
|
398
216
|
var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
|
|
399
|
-
var ZED_PLAN_ANNOTATE_TASK_NAME = "diffpi: annotate plan";
|
|
400
217
|
var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
401
218
|
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
402
|
-
function zedTasksPath(homeDir =
|
|
403
|
-
return
|
|
219
|
+
function zedTasksPath(homeDir = homedir()) {
|
|
220
|
+
return join2(homeDir, ".config", "zed", "tasks.json");
|
|
404
221
|
}
|
|
405
|
-
function zedKeymapPath(homeDir =
|
|
406
|
-
return
|
|
222
|
+
function zedKeymapPath(homeDir = homedir()) {
|
|
223
|
+
return join2(homeDir, ".config", "zed", "keymap.json");
|
|
407
224
|
}
|
|
408
|
-
async function ensureZedReviewTask(homeDir =
|
|
225
|
+
async function ensureZedReviewTask(homeDir = homedir(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
|
|
409
226
|
const path = zedTasksPath(homeDir);
|
|
410
227
|
const currentText = await readOptional(path);
|
|
411
228
|
const tasks = parseJsonArray(currentText, path);
|
|
@@ -423,34 +240,7 @@ async function ensureZedReviewTask(homeDir = homedir2(), _command = ["tuicr", "-
|
|
|
423
240
|
await writeJson(path, next);
|
|
424
241
|
return { path, changed, existed: currentText !== undefined };
|
|
425
242
|
}
|
|
426
|
-
async function
|
|
427
|
-
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(packageVersion)) {
|
|
428
|
-
throw new Error(`Invalid @difflab/pi package version: ${packageVersion}.`);
|
|
429
|
-
}
|
|
430
|
-
const path = zedTasksPath(homeDir);
|
|
431
|
-
const currentText = await readOptional(path);
|
|
432
|
-
const tasks = parseJsonArray(currentText, path);
|
|
433
|
-
const task = {
|
|
434
|
-
label: ZED_PLAN_ANNOTATE_TASK_NAME,
|
|
435
|
-
command: "npx",
|
|
436
|
-
args: ["--yes", `@difflab/pi@${packageVersion}`, "plan", "annotate", "--cwd", "$ZED_WORKTREE_ROOT"],
|
|
437
|
-
cwd: "$ZED_WORKTREE_ROOT",
|
|
438
|
-
use_new_terminal: true,
|
|
439
|
-
reveal: "always",
|
|
440
|
-
reveal_target: "center"
|
|
441
|
-
};
|
|
442
|
-
const next = [...tasks];
|
|
443
|
-
const index = next.findIndex((candidate) => candidate.label === task.label);
|
|
444
|
-
if (index >= 0)
|
|
445
|
-
next[index] = { ...next[index], ...task };
|
|
446
|
-
else
|
|
447
|
-
next.push(task);
|
|
448
|
-
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
449
|
-
if (changed)
|
|
450
|
-
await writeJson(path, next);
|
|
451
|
-
return { path, changed, existed: currentText !== undefined };
|
|
452
|
-
}
|
|
453
|
-
async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
243
|
+
async function ensureZedReviewKeybinding(homeDir = homedir()) {
|
|
454
244
|
const path = zedKeymapPath(homeDir);
|
|
455
245
|
const currentText = await readOptional(path);
|
|
456
246
|
const entries = parseJsonArray(currentText, path);
|
|
@@ -469,9 +259,6 @@ async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
|
469
259
|
return { path, changed, existed: currentText !== undefined };
|
|
470
260
|
}
|
|
471
261
|
function zedReviewTaskName(command) {
|
|
472
|
-
if (command.some((value, index) => value === "plan" && command[index + 1] === "annotate")) {
|
|
473
|
-
return ZED_PLAN_ANNOTATE_TASK_NAME;
|
|
474
|
-
}
|
|
475
262
|
return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
|
|
476
263
|
}
|
|
477
264
|
var LOCAL_REVIEW_SCRIPT = `set -eu
|
|
@@ -553,7 +340,7 @@ function parseJsonArray(content, path) {
|
|
|
553
340
|
return value;
|
|
554
341
|
}
|
|
555
342
|
async function writeJson(path, value) {
|
|
556
|
-
await
|
|
343
|
+
await mkdir(dirname(path), { recursive: true });
|
|
557
344
|
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
558
345
|
`, "utf8");
|
|
559
346
|
}
|
|
@@ -583,7 +370,7 @@ function detectMux(env = process.env) {
|
|
|
583
370
|
return "none";
|
|
584
371
|
}
|
|
585
372
|
function detectShell(env = process.env) {
|
|
586
|
-
return env.SHELL ?
|
|
373
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
587
374
|
}
|
|
588
375
|
async function detectVcs(cwd) {
|
|
589
376
|
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
@@ -622,10 +409,7 @@ async function openInNewTab(command, opts) {
|
|
|
622
409
|
if (detectIde(env) === "zed") {
|
|
623
410
|
try {
|
|
624
411
|
const taskName = zedReviewTaskName(command);
|
|
625
|
-
|
|
626
|
-
await ensureZedPlanTask(await packageVersion(), opts.homeDir);
|
|
627
|
-
else
|
|
628
|
-
await ensureZedReviewTask(opts.homeDir, command);
|
|
412
|
+
await ensureZedReviewTask(opts.homeDir, command);
|
|
629
413
|
return {
|
|
630
414
|
launched: false,
|
|
631
415
|
configured: true,
|
|
@@ -641,12 +425,6 @@ async function openInNewTab(command, opts) {
|
|
|
641
425
|
function screenWindowArgs(command, cwd, name) {
|
|
642
426
|
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
643
427
|
}
|
|
644
|
-
async function packageVersion() {
|
|
645
|
-
const value = JSON.parse(await readFile2(join6(resolveBundledAgentsDir(), "..", "package.json"), "utf8"));
|
|
646
|
-
if (typeof value.version !== "string")
|
|
647
|
-
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
648
|
-
return value.version;
|
|
649
|
-
}
|
|
650
428
|
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
651
429
|
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
652
430
|
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
@@ -669,1932 +447,24 @@ async function openMuxTab(mux, command, cwd, name, printable) {
|
|
|
669
447
|
return;
|
|
670
448
|
}
|
|
671
449
|
|
|
672
|
-
// src/extensions/
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
const installedPath = join7(homeDir, ".local", "bin", "mise");
|
|
691
|
-
if (options.dryRun)
|
|
692
|
-
return installedPath;
|
|
693
|
-
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
694
|
-
const executable = await findExecutable(installedPath) ?? await findExecutable("mise");
|
|
695
|
-
if (!executable)
|
|
696
|
-
throw new Error(`mise installation completed, but ${installedPath} was not found.`);
|
|
697
|
-
return executable;
|
|
698
|
-
},
|
|
699
|
-
async hookEnsure(executable, options = {}) {
|
|
700
|
-
const homeDir = options.homeDir ?? homedir3();
|
|
701
|
-
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
702
|
-
const current = await getOptionalFile(hook.path);
|
|
703
|
-
if (current.includes(MISE_HOOK_START))
|
|
704
|
-
return { path: hook.path, changed: false, planned: false };
|
|
705
|
-
if (options.dryRun)
|
|
706
|
-
return { path: hook.path, changed: true, planned: true };
|
|
707
|
-
const separator = current.length === 0 || current.endsWith(`
|
|
708
|
-
`) ? "" : `
|
|
709
|
-
`;
|
|
710
|
-
await mkdir3(dirname4(hook.path), { recursive: true });
|
|
711
|
-
await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
712
|
-
return { path: hook.path, changed: true, planned: false };
|
|
713
|
-
},
|
|
714
|
-
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
715
|
-
const result = await run(executable, ["ls", "--global", "--installed", tool, "--json"]);
|
|
716
|
-
return result.code === 0 && isToolInstalled(result.stdout, minimumVersion);
|
|
717
|
-
},
|
|
718
|
-
async toolInstallGlobal(executable, specification) {
|
|
719
|
-
await runChecked(executable, ["use", "--global", specification]);
|
|
720
|
-
},
|
|
721
|
-
async toolCheckLocal(executable, tool, cwd = process.cwd()) {
|
|
722
|
-
const result = await run(executable, ["ls", "--local", "--installed", tool, "--json"], { cwd });
|
|
723
|
-
return result.code === 0 && isToolInstalled(result.stdout);
|
|
724
|
-
},
|
|
725
|
-
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
726
|
-
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
727
|
-
},
|
|
728
|
-
async toolUpdateAllGlobal(executable, homeDir = homedir3()) {
|
|
729
|
-
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
730
|
-
}
|
|
731
|
-
};
|
|
732
|
-
function getShellHook(shell, executable, homeDir) {
|
|
733
|
-
const command = getShellQuoted(executable);
|
|
734
|
-
switch (shell.toLowerCase()) {
|
|
735
|
-
case "zsh":
|
|
736
|
-
return {
|
|
737
|
-
path: join7(homeDir, ".zshrc"),
|
|
738
|
-
content: `${MISE_HOOK_START}
|
|
739
|
-
eval "$(${command} activate zsh)"
|
|
740
|
-
${MISE_HOOK_END}
|
|
741
|
-
`
|
|
742
|
-
};
|
|
743
|
-
case "fish":
|
|
744
|
-
return {
|
|
745
|
-
path: join7(homeDir, ".config", "fish", "config.fish"),
|
|
746
|
-
content: `${MISE_HOOK_START}
|
|
747
|
-
${command} activate fish | source
|
|
748
|
-
${MISE_HOOK_END}
|
|
749
|
-
`
|
|
750
|
-
};
|
|
751
|
-
case "nu":
|
|
752
|
-
case "nushell":
|
|
753
|
-
return {
|
|
754
|
-
path: join7(homeDir, ".config", "nushell", "config.nu"),
|
|
755
|
-
content: `${MISE_HOOK_START}
|
|
756
|
-
let mise_bin = ${command}
|
|
757
|
-
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
758
|
-
^$mise_bin activate nu | save $mise_path --force
|
|
759
|
-
use ($nu.default-config-dir | path join mise.nu)
|
|
760
|
-
${MISE_HOOK_END}
|
|
761
|
-
`
|
|
762
|
-
};
|
|
763
|
-
case "xonsh":
|
|
764
|
-
return {
|
|
765
|
-
path: join7(homeDir, ".xonshrc"),
|
|
766
|
-
content: `${MISE_HOOK_START}
|
|
767
|
-
execx($(${command} activate xonsh))
|
|
768
|
-
${MISE_HOOK_END}
|
|
769
|
-
`
|
|
770
|
-
};
|
|
771
|
-
case "elvish":
|
|
772
|
-
return {
|
|
773
|
-
path: join7(homeDir, ".config", "elvish", "rc.elv"),
|
|
774
|
-
content: `${MISE_HOOK_START}
|
|
775
|
-
var mise: = (ns [&])
|
|
776
|
-
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
777
|
-
mise:activate
|
|
778
|
-
${MISE_HOOK_END}
|
|
779
|
-
`
|
|
780
|
-
};
|
|
781
|
-
case "pwsh":
|
|
782
|
-
case "powershell":
|
|
783
|
-
return {
|
|
784
|
-
path: join7(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
785
|
-
content: `${MISE_HOOK_START}
|
|
786
|
-
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
787
|
-
${MISE_HOOK_END}
|
|
788
|
-
`
|
|
789
|
-
};
|
|
790
|
-
case "bash":
|
|
791
|
-
default:
|
|
792
|
-
return {
|
|
793
|
-
path: join7(homeDir, ".bashrc"),
|
|
794
|
-
content: `${MISE_HOOK_START}
|
|
795
|
-
eval "$(${command} activate bash)"
|
|
796
|
-
${MISE_HOOK_END}
|
|
797
|
-
`
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
}
|
|
801
|
-
function isToolInstalled(output, minimumVersion) {
|
|
802
|
-
try {
|
|
803
|
-
const value = JSON.parse(output);
|
|
804
|
-
if (!Array.isArray(value))
|
|
805
|
-
return false;
|
|
806
|
-
return value.some((entry) => {
|
|
807
|
-
if (!entry || typeof entry !== "object" || !("installed" in entry) || entry.installed !== true)
|
|
808
|
-
return false;
|
|
809
|
-
if (!minimumVersion)
|
|
810
|
-
return true;
|
|
811
|
-
if (!("version" in entry) || typeof entry.version !== "string")
|
|
812
|
-
return false;
|
|
813
|
-
return isVersionAtLeast(entry.version, minimumVersion);
|
|
814
|
-
});
|
|
815
|
-
} catch {
|
|
816
|
-
return false;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
function isVersionAtLeast(version, minimumVersion) {
|
|
820
|
-
const current = version.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
821
|
-
const minimum = minimumVersion.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
822
|
-
if (!current || !minimum)
|
|
823
|
-
return false;
|
|
824
|
-
for (let index = 0;index < minimum.length; index += 1) {
|
|
825
|
-
if (current[index] !== minimum[index])
|
|
826
|
-
return current[index] > minimum[index];
|
|
827
|
-
}
|
|
828
|
-
return true;
|
|
829
|
-
}
|
|
830
|
-
async function getOptionalFile(path) {
|
|
831
|
-
try {
|
|
832
|
-
return await readFile3(path, "utf8");
|
|
833
|
-
} catch (error) {
|
|
834
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
835
|
-
return "";
|
|
836
|
-
throw error;
|
|
837
|
-
}
|
|
838
|
-
}
|
|
839
|
-
function getShellQuoted(value) {
|
|
840
|
-
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// src/gates.ts
|
|
844
|
-
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
845
|
-
var MISE_GATES = ["format:check", "lint", "test"];
|
|
846
|
-
function checkConventionalSubject(subject) {
|
|
847
|
-
const trimmed = subject.trim();
|
|
848
|
-
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
849
|
-
return {
|
|
850
|
-
name: "conventional-subject",
|
|
851
|
-
status: ok ? "pass" : "warn",
|
|
852
|
-
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
853
|
-
};
|
|
854
|
-
}
|
|
855
|
-
async function runMiseGates(cwd) {
|
|
856
|
-
const tasks = await discoverMiseTasks(cwd);
|
|
857
|
-
const results = [];
|
|
858
|
-
for (const gate of MISE_GATES) {
|
|
859
|
-
const targets = tasks.get(gate) ?? [];
|
|
860
|
-
if (targets.length === 0) {
|
|
861
|
-
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
862
|
-
continue;
|
|
863
|
-
}
|
|
864
|
-
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
865
|
-
const result = await mise.run(["run", ...invocations], { cwd });
|
|
866
|
-
results.push({
|
|
867
|
-
name: gate,
|
|
868
|
-
status: result.code === 0 ? "pass" : "fail",
|
|
869
|
-
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
return results;
|
|
873
|
-
}
|
|
874
|
-
function ciGate(checksOutput) {
|
|
875
|
-
const text = checksOutput.toLowerCase();
|
|
876
|
-
if (!text.trim())
|
|
877
|
-
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
878
|
-
if (/\bfail|error\b/.test(text))
|
|
879
|
-
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
880
|
-
if (/\bpending|in progress|queued\b/.test(text))
|
|
881
|
-
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
882
|
-
return { name: "ci", status: "pass", detail: "CI green" };
|
|
883
|
-
}
|
|
884
|
-
function parseMiseTasks(input) {
|
|
885
|
-
let tasks;
|
|
886
|
-
try {
|
|
887
|
-
tasks = JSON.parse(input);
|
|
888
|
-
} catch {
|
|
889
|
-
return new Map;
|
|
890
|
-
}
|
|
891
|
-
if (!Array.isArray(tasks))
|
|
892
|
-
return new Map;
|
|
893
|
-
const found = new Map;
|
|
894
|
-
for (const gate of MISE_GATES) {
|
|
895
|
-
const targets = tasks.flatMap((task) => {
|
|
896
|
-
if (typeof task.name !== "string")
|
|
897
|
-
return [];
|
|
898
|
-
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
899
|
-
});
|
|
900
|
-
if (targets.length > 0)
|
|
901
|
-
found.set(gate, [...new Set(targets)]);
|
|
902
|
-
}
|
|
903
|
-
return found;
|
|
904
|
-
}
|
|
905
|
-
async function discoverMiseTasks(cwd) {
|
|
906
|
-
const result = await mise.run(["tasks", "--json", "--all"], { cwd });
|
|
907
|
-
if (result.code !== 0)
|
|
908
|
-
return new Map;
|
|
909
|
-
return parseMiseTasks(result.stdout);
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// src/plan/annotations.ts
|
|
913
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
914
|
-
import { readFile as readFile6, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
915
|
-
import { basename as basename4, dirname as dirname5, join as join9 } from "node:path";
|
|
916
|
-
|
|
917
|
-
// src/plan/log.ts
|
|
918
|
-
import { randomUUID } from "node:crypto";
|
|
919
|
-
import { appendFile, readFile as readFile4 } from "node:fs/promises";
|
|
920
|
-
async function appendPlanLog(logPath, event) {
|
|
921
|
-
const entry = {
|
|
922
|
-
...event,
|
|
923
|
-
version: 1,
|
|
924
|
-
eventId: event.eventId ?? randomUUID(),
|
|
925
|
-
timestamp: event.timestamp ?? new Date().toISOString()
|
|
926
|
-
};
|
|
927
|
-
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
928
|
-
`, { encoding: "utf8", mode: 384 });
|
|
929
|
-
return entry;
|
|
930
|
-
}
|
|
931
|
-
|
|
932
|
-
// src/plan/lock.ts
|
|
933
|
-
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
934
|
-
import { mkdir as mkdir4, readFile as readFile5, rm, writeFile as writeFile3 } from "node:fs/promises";
|
|
935
|
-
import { hostname } from "node:os";
|
|
936
|
-
import { join as join8 } from "node:path";
|
|
937
|
-
async function withPlanLock(planDir, operation, options = {}) {
|
|
938
|
-
const lockDir = join8(planDir, ".lock");
|
|
939
|
-
const waitMs = options.waitMs ?? 5000;
|
|
940
|
-
const pollMs = options.pollMs ?? 25;
|
|
941
|
-
const owner = {
|
|
942
|
-
token: randomUUID2(),
|
|
943
|
-
pid: process.pid,
|
|
944
|
-
hostname: hostname(),
|
|
945
|
-
operation: options.operation ?? "plan mutation",
|
|
946
|
-
acquiredAt: new Date().toISOString()
|
|
947
|
-
};
|
|
948
|
-
const started = Date.now();
|
|
949
|
-
while (true) {
|
|
950
|
-
try {
|
|
951
|
-
await mkdir4(lockDir);
|
|
952
|
-
await writeFile3(join8(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
953
|
-
`, { mode: 384 });
|
|
954
|
-
break;
|
|
955
|
-
} catch (error) {
|
|
956
|
-
if (error.code !== "EEXIST")
|
|
957
|
-
throw error;
|
|
958
|
-
if (Date.now() - started >= waitMs) {
|
|
959
|
-
let existing = "unknown owner";
|
|
960
|
-
try {
|
|
961
|
-
existing = (await readFile5(join8(lockDir, "owner.json"), "utf8")).trim();
|
|
962
|
-
} catch {}
|
|
963
|
-
throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
|
|
964
|
-
}
|
|
965
|
-
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
966
|
-
}
|
|
967
|
-
}
|
|
968
|
-
try {
|
|
969
|
-
return await operation();
|
|
970
|
-
} finally {
|
|
971
|
-
let current;
|
|
972
|
-
try {
|
|
973
|
-
current = JSON.parse(await readFile5(join8(lockDir, "owner.json"), "utf8"));
|
|
974
|
-
} catch {}
|
|
975
|
-
if (current?.token === owner.token)
|
|
976
|
-
await rm(lockDir, { recursive: true, force: true });
|
|
977
|
-
}
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
// src/plan/annotations.ts
|
|
981
|
-
async function readPlanAnnotations(record, options = {}) {
|
|
982
|
-
const state = await readAnnotationState(record).catch((error) => {
|
|
983
|
-
if (error.code === "ENOENT")
|
|
984
|
-
return;
|
|
985
|
-
throw error;
|
|
986
|
-
});
|
|
987
|
-
if (!state)
|
|
988
|
-
return { comments: [], pending: [] };
|
|
989
|
-
const execute = options.runtime?.execute ?? executeProcess;
|
|
990
|
-
const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
|
|
991
|
-
if (response.code !== 0) {
|
|
992
|
-
if (/not found|no session|deleted/i.test(response.stderr))
|
|
993
|
-
return { state, comments: [], pending: [] };
|
|
994
|
-
throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
|
|
995
|
-
}
|
|
996
|
-
let raw;
|
|
997
|
-
try {
|
|
998
|
-
raw = JSON.parse(response.stdout);
|
|
999
|
-
} catch {
|
|
1000
|
-
throw new Error("tuicr returned malformed comment JSON.");
|
|
1001
|
-
}
|
|
1002
|
-
if (!Array.isArray(raw))
|
|
1003
|
-
throw new Error("tuicr comment output must be a JSON array.");
|
|
1004
|
-
const lines = record.source.split(`
|
|
1005
|
-
`);
|
|
1006
|
-
const applied = new Set(state.appliedCommentIds);
|
|
1007
|
-
const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
|
|
1008
|
-
const pending = comments.filter((comment) => !comment.applied);
|
|
1009
|
-
return { state, comments: options.includeApplied ? comments : pending, pending };
|
|
1010
|
-
}
|
|
1011
|
-
async function acknowledgePlanAnnotations(record, commentIds, summary) {
|
|
1012
|
-
if (!summary.trim())
|
|
1013
|
-
throw new Error("Annotation acknowledgement summary is required.");
|
|
1014
|
-
return withPlanLock(record.dir, async () => {
|
|
1015
|
-
const state = await readAnnotationState(record);
|
|
1016
|
-
const next = {
|
|
1017
|
-
...state,
|
|
1018
|
-
updatedAt: new Date().toISOString(),
|
|
1019
|
-
appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
|
|
1020
|
-
};
|
|
1021
|
-
await atomicJson(annotationStatePath(record), next);
|
|
1022
|
-
await appendPlanLog(record.logPath, {
|
|
1023
|
-
planRevision: record.document.revision,
|
|
1024
|
-
kind: "annotation",
|
|
1025
|
-
actor: "planner",
|
|
1026
|
-
message: summary,
|
|
1027
|
-
data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
|
|
1028
|
-
});
|
|
1029
|
-
return next;
|
|
1030
|
-
}, { operation: "acknowledge annotations" });
|
|
1031
|
-
}
|
|
1032
|
-
function annotationStatePath(record) {
|
|
1033
|
-
return join9(record.dir, "annotations.json");
|
|
1034
|
-
}
|
|
1035
|
-
async function readAnnotationState(record) {
|
|
1036
|
-
const value = JSON.parse(await readFile6(annotationStatePath(record), "utf8"));
|
|
1037
|
-
if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
|
|
1038
|
-
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
1039
|
-
}
|
|
1040
|
-
return value;
|
|
1041
|
-
}
|
|
1042
|
-
function normalizeComment(value, index, lines, applied) {
|
|
1043
|
-
if (!value || typeof value !== "object")
|
|
1044
|
-
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
1045
|
-
const raw = value;
|
|
1046
|
-
if (typeof raw.id !== "string" || typeof raw.content !== "string")
|
|
1047
|
-
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
1048
|
-
const line = integer(raw.start_line) ?? integer(raw.line);
|
|
1049
|
-
const endLine = integer(raw.end_line) ?? line;
|
|
1050
|
-
const targetPath = typeof raw.path === "string" ? raw.path : undefined;
|
|
1051
|
-
const appliesToPlan = !targetPath || basename4(targetPath) === basename4("PLAN.md");
|
|
1052
|
-
const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
|
|
1053
|
-
return {
|
|
1054
|
-
id: raw.id,
|
|
1055
|
-
body: raw.content,
|
|
1056
|
-
file: targetPath,
|
|
1057
|
-
line,
|
|
1058
|
-
endLine,
|
|
1059
|
-
context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
|
|
1060
|
-
`) : undefined,
|
|
1061
|
-
stale: line !== undefined && !validAnchor,
|
|
1062
|
-
applied: applied.has(raw.id)
|
|
1063
|
-
};
|
|
1064
|
-
}
|
|
1065
|
-
async function executeProcess(command, args, cwd, interactive) {
|
|
1066
|
-
if (!interactive) {
|
|
1067
|
-
const result = await run(command, args, { cwd, capture: "unbounded" });
|
|
1068
|
-
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
1069
|
-
}
|
|
1070
|
-
return new Promise((resolve, reject) => {
|
|
1071
|
-
const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
|
|
1072
|
-
let stderr = "";
|
|
1073
|
-
child.stderr.on("data", (chunk) => {
|
|
1074
|
-
const text = chunk.toString();
|
|
1075
|
-
stderr += text;
|
|
1076
|
-
process.stderr.write(text);
|
|
1077
|
-
});
|
|
1078
|
-
child.on("error", reject);
|
|
1079
|
-
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
async function atomicJson(path, value) {
|
|
1083
|
-
const temp = join9(dirname5(path), `.${basename4(path)}.${crypto.randomUUID()}.tmp`);
|
|
1084
|
-
await writeFile4(temp, `${JSON.stringify(value, null, 2)}
|
|
1085
|
-
`, { mode: 384, flag: "wx" });
|
|
1086
|
-
await rename(temp, path);
|
|
1087
|
-
}
|
|
1088
|
-
function integer(value) {
|
|
1089
|
-
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
1090
|
-
}
|
|
1091
|
-
// src/plan/escalation.ts
|
|
1092
|
-
import { z as z3 } from "zod";
|
|
1093
|
-
var escalationSchema = z3.object({
|
|
1094
|
-
planId: z3.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
1095
|
-
executionId: z3.string().min(1),
|
|
1096
|
-
phaseId: z3.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
1097
|
-
taskId: z3.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
|
1098
|
-
blocker: z3.string().min(1),
|
|
1099
|
-
attempts: z3.array(z3.string()),
|
|
1100
|
-
evidence: z3.array(z3.string()),
|
|
1101
|
-
needsUserDecision: z3.boolean()
|
|
1102
|
-
}).strict();
|
|
1103
|
-
var OPEN = "<diffpi-planner-escalation>";
|
|
1104
|
-
var CLOSE = "</diffpi-planner-escalation>";
|
|
1105
|
-
function renderPlannerEscalation(value) {
|
|
1106
|
-
return `${OPEN}${JSON.stringify(escalationSchema.parse(value))}${CLOSE}`;
|
|
1107
|
-
}
|
|
1108
|
-
// src/plan/execution.ts
|
|
1109
|
-
function createExecutionPacket(plan, coordinator) {
|
|
1110
|
-
if (!plan.execution?.active)
|
|
1111
|
-
throw new Error(`Plan ${plan.id} has no active execution.`);
|
|
1112
|
-
return {
|
|
1113
|
-
version: 1,
|
|
1114
|
-
planId: plan.id,
|
|
1115
|
-
executionId: plan.execution.id,
|
|
1116
|
-
policy: plan.execution.policy,
|
|
1117
|
-
cwd: plan.execution.cwd,
|
|
1118
|
-
branch: plan.execution.branch,
|
|
1119
|
-
coordinator
|
|
1120
|
-
};
|
|
1121
|
-
}
|
|
1122
|
-
function renderExecutionPrompt(packet) {
|
|
1123
|
-
return `Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}. Call plan_context first. Coordinate eligible work, persist every transition and progress event, run phase gates, and honor the commit policy. Do not edit PLAN.md directly. Delegated workers never commit or restructure the plan.`;
|
|
1124
|
-
}
|
|
1125
|
-
// src/plan/transitions.ts
|
|
1126
|
-
var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
1127
|
-
var PLAN_TRANSITIONS = {
|
|
1128
|
-
draft: ["ready"],
|
|
1129
|
-
ready: ["draft", "in_progress"],
|
|
1130
|
-
in_progress: ["blocked", "completed"],
|
|
1131
|
-
blocked: ["draft", "ready", "in_progress"],
|
|
1132
|
-
completed: []
|
|
1133
|
-
};
|
|
1134
|
-
var WORK_TRANSITIONS = {
|
|
1135
|
-
pending: ["in_progress", "skipped"],
|
|
1136
|
-
in_progress: ["blocked", "completed"],
|
|
1137
|
-
blocked: ["pending", "in_progress", "skipped"],
|
|
1138
|
-
completed: [],
|
|
1139
|
-
skipped: []
|
|
1140
|
-
};
|
|
1141
|
-
function isStableId(value) {
|
|
1142
|
-
return STABLE_ID.test(value) && value.length <= 80;
|
|
1143
|
-
}
|
|
1144
|
-
function assertStableId(value, label = "identifier") {
|
|
1145
|
-
if (!isStableId(value))
|
|
1146
|
-
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
1147
|
-
}
|
|
1148
|
-
function assertPlanTransition(from, to) {
|
|
1149
|
-
if (from === to)
|
|
1150
|
-
return;
|
|
1151
|
-
if (!PLAN_TRANSITIONS[from].includes(to))
|
|
1152
|
-
throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
|
|
1153
|
-
}
|
|
1154
|
-
function assertPhaseTransition(from, to) {
|
|
1155
|
-
if (from === to)
|
|
1156
|
-
return;
|
|
1157
|
-
if (!WORK_TRANSITIONS[from].includes(to))
|
|
1158
|
-
throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
|
|
1159
|
-
}
|
|
1160
|
-
function assertTaskTransition(task, to, executionId, actor) {
|
|
1161
|
-
if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to)) {
|
|
1162
|
-
throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
|
|
1163
|
-
}
|
|
1164
|
-
if (task.status === "in_progress" && task.executionId && executionId !== task.executionId) {
|
|
1165
|
-
throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
|
|
1166
|
-
}
|
|
1167
|
-
if (task.status === "in_progress" && task.owner && actor && actor !== task.owner) {
|
|
1168
|
-
throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
function assertUniqueIds(ids, label) {
|
|
1172
|
-
const seen = new Set;
|
|
1173
|
-
for (const id of ids) {
|
|
1174
|
-
assertStableId(id, label);
|
|
1175
|
-
if (seen.has(id))
|
|
1176
|
-
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
1177
|
-
seen.add(id);
|
|
1178
|
-
}
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
// src/plan/markdown.ts
|
|
1182
|
-
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
1183
|
-
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
1184
|
-
var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
|
|
1185
|
-
var SUPPORTED_SCHEMA = 1;
|
|
1186
|
-
function countDesignWords(plan) {
|
|
1187
|
-
return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
|
|
1188
|
-
}
|
|
1189
|
-
function validatePlanDocument(plan, options = {}) {
|
|
1190
|
-
const issues = [];
|
|
1191
|
-
const add = (code, severity, message, path) => issues.push({ code, severity, message, path });
|
|
1192
|
-
try {
|
|
1193
|
-
assertStableId(plan.id, "plan id");
|
|
1194
|
-
assertUniqueIds(plan.phases.map((phase) => phase.id), "phase id");
|
|
1195
|
-
assertUniqueIds(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)), "task id");
|
|
1196
|
-
} catch (error) {
|
|
1197
|
-
add("invalid-id", "error", error.message);
|
|
1198
|
-
}
|
|
1199
|
-
if (plan.schemaVersion !== SUPPORTED_SCHEMA)
|
|
1200
|
-
add("schema", "error", `Unsupported plan schema: ${plan.schemaVersion}.`);
|
|
1201
|
-
const phaseIds = new Set(plan.phases.map((phase) => phase.id));
|
|
1202
|
-
const taskIds = new Set(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)));
|
|
1203
|
-
for (const phase of plan.phases) {
|
|
1204
|
-
for (const dependency of phase.dependencies) {
|
|
1205
|
-
if (!phaseIds.has(dependency))
|
|
1206
|
-
add("dangling-dependency", "error", `Phase ${phase.id} depends on missing ${dependency}.`);
|
|
1207
|
-
}
|
|
1208
|
-
for (const task of phase.tasks) {
|
|
1209
|
-
for (const dependency of task.dependencies) {
|
|
1210
|
-
if (!taskIds.has(dependency))
|
|
1211
|
-
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
1212
|
-
}
|
|
1213
|
-
if (options.strict && task.acceptanceCriteria.length === 0)
|
|
1214
|
-
add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
|
|
1215
|
-
}
|
|
1216
|
-
if (options.strict && phase.tasks.length === 0)
|
|
1217
|
-
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
1218
|
-
}
|
|
1219
|
-
for (const cycle of dependencyCycles(plan))
|
|
1220
|
-
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
1221
|
-
const designWords = countDesignWords(plan);
|
|
1222
|
-
if (designWords > 300)
|
|
1223
|
-
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
1224
|
-
if (options.strict && designWords > 800)
|
|
1225
|
-
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
1226
|
-
if (options.strict && plan.phases.length === 0)
|
|
1227
|
-
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
1228
|
-
if (options.strict && (options.pendingAnnotations ?? 0) > 0)
|
|
1229
|
-
add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
|
|
1230
|
-
return issues;
|
|
1231
|
-
}
|
|
1232
|
-
function renderPlanDocument(plan, previousSource) {
|
|
1233
|
-
if (previousSource && canPatch(previousSource, plan))
|
|
1234
|
-
return patchMarkers(previousSource, plan);
|
|
1235
|
-
const marker = {
|
|
1236
|
-
schemaVersion: plan.schemaVersion,
|
|
1237
|
-
id: plan.id,
|
|
1238
|
-
revision: plan.revision,
|
|
1239
|
-
branch: plan.branch,
|
|
1240
|
-
status: plan.status,
|
|
1241
|
-
execution: plan.execution,
|
|
1242
|
-
createdAt: plan.createdAt,
|
|
1243
|
-
updatedAt: plan.updatedAt
|
|
1244
|
-
};
|
|
1245
|
-
const requirements = plan.requirements.length ? plan.requirements.map((item) => `- ${item}`).join(`
|
|
1246
|
-
`) : "<!-- Add requirements. -->";
|
|
1247
|
-
const phases = plan.phases.map(renderPhase).join(`
|
|
1248
|
-
|
|
1249
|
-
`);
|
|
1250
|
-
const references = plan.references.length ? plan.references.map((reference) => `- <!-- diffpi-reference: ${json(reference)} --> ${reference.value}`).join(`
|
|
1251
|
-
`) : "<!-- Add references. -->";
|
|
1252
|
-
return `<!-- diffpi-plan: ${json(marker)} -->
|
|
1253
|
-
# ${plan.title}
|
|
1254
|
-
|
|
1255
|
-
- **Plan ID:** ${plan.id}
|
|
1256
|
-
- **Branch:** ${plan.branch}
|
|
1257
|
-
- **Status:** ${plan.status}
|
|
1258
|
-
- **Revision:** ${plan.revision}
|
|
1259
|
-
|
|
1260
|
-
## Intent
|
|
1261
|
-
|
|
1262
|
-
${plan.intent || "<!-- Describe the intended outcome. -->"}
|
|
1263
|
-
|
|
1264
|
-
## Requirements
|
|
1265
|
-
|
|
1266
|
-
${requirements}
|
|
1267
|
-
|
|
1268
|
-
## Design
|
|
1269
|
-
|
|
1270
|
-
### Big Ideas
|
|
1271
|
-
|
|
1272
|
-
${plan.design.bigIdeas || "<!-- Describe the main approach. -->"}
|
|
1273
|
-
|
|
1274
|
-
### Key API Addition/Updates
|
|
1275
|
-
|
|
1276
|
-
${plan.design.keyApiUpdates || "<!-- Describe public API changes. -->"}
|
|
1277
|
-
|
|
1278
|
-
### Consequences
|
|
1279
|
-
|
|
1280
|
-
${plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"}
|
|
1281
|
-
|
|
1282
|
-
## Implementation
|
|
1283
|
-
|
|
1284
|
-
${phases || "<!-- Add phases with plan_add_phase. -->"}
|
|
1285
|
-
|
|
1286
|
-
## References
|
|
1287
|
-
|
|
1288
|
-
${references}
|
|
1289
|
-
`;
|
|
1290
|
-
}
|
|
1291
|
-
function parsePlanDocument(source, ref = "PLAN.md") {
|
|
1292
|
-
const planMatch = source.match(PLAN_MARKER);
|
|
1293
|
-
if (!planMatch)
|
|
1294
|
-
throw new Error(`${ref}: missing or malformed diffpi-plan marker.`);
|
|
1295
|
-
if ((source.match(new RegExp(PLAN_MARKER.source, "g")) ?? []).length !== 1)
|
|
1296
|
-
throw new Error(`${ref}: duplicate diffpi-plan marker.`);
|
|
1297
|
-
const marker = parseMarker(planMatch[1], `${ref} plan`);
|
|
1298
|
-
if (marker.schemaVersion !== SUPPORTED_SCHEMA)
|
|
1299
|
-
throw new Error(`${ref}: unsupported plan schema ${String(marker.schemaVersion)}.`);
|
|
1300
|
-
assertStableId(marker.id, "plan id");
|
|
1301
|
-
const title = source.match(/^# (.+)$/m)?.[1]?.trim();
|
|
1302
|
-
if (!title)
|
|
1303
|
-
throw new Error(`${ref}: missing plan title.`);
|
|
1304
|
-
const phases = parsePhases(section(source, "Implementation"), ref);
|
|
1305
|
-
const document = {
|
|
1306
|
-
...marker,
|
|
1307
|
-
schemaVersion: 1,
|
|
1308
|
-
title,
|
|
1309
|
-
intent: cleanPlaceholder(section(source, "Intent")),
|
|
1310
|
-
requirements: parseBullets(section(source, "Requirements")),
|
|
1311
|
-
design: {
|
|
1312
|
-
bigIdeas: cleanPlaceholder(subsection(source, "Design", "Big Ideas")),
|
|
1313
|
-
keyApiUpdates: cleanPlaceholder(subsection(source, "Design", "Key API Addition/Updates")),
|
|
1314
|
-
consequences: cleanPlaceholder(subsection(source, "Design", "Consequences"))
|
|
1315
|
-
},
|
|
1316
|
-
phases,
|
|
1317
|
-
references: parseReferences(section(source, "References"))
|
|
1318
|
-
};
|
|
1319
|
-
const errors = validatePlanDocument(document).filter((issue) => issue.severity === "error");
|
|
1320
|
-
if (errors.length)
|
|
1321
|
-
throw new Error(`${ref}: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
1322
|
-
return document;
|
|
1323
|
-
}
|
|
1324
|
-
function renderPhase(phase) {
|
|
1325
|
-
const marker = {
|
|
1326
|
-
id: phase.id,
|
|
1327
|
-
revision: phase.revision,
|
|
1328
|
-
status: phase.status,
|
|
1329
|
-
gate: phase.gate,
|
|
1330
|
-
commit: phase.commit,
|
|
1331
|
-
blocker: phase.blocker
|
|
1332
|
-
};
|
|
1333
|
-
const dependencies = phase.dependencies.length ? phase.dependencies.join(", ") : "none";
|
|
1334
|
-
return `<!-- diffpi-phase: ${json(marker)} -->
|
|
1335
|
-
### Phase: ${phase.title}
|
|
1336
|
-
|
|
1337
|
-
**Objective:** ${phase.objective}
|
|
1338
|
-
|
|
1339
|
-
**Dependencies:** ${dependencies}
|
|
1340
|
-
|
|
1341
|
-
${phase.tasks.map(renderTask).join(`
|
|
1342
|
-
|
|
1343
|
-
`)}
|
|
1344
|
-
|
|
1345
|
-
<!-- /diffpi-phase -->`;
|
|
1346
|
-
}
|
|
1347
|
-
function renderTask(task) {
|
|
1348
|
-
const marker = {
|
|
1349
|
-
id: task.id,
|
|
1350
|
-
revision: task.revision,
|
|
1351
|
-
status: task.status,
|
|
1352
|
-
owner: task.owner,
|
|
1353
|
-
executionId: task.executionId,
|
|
1354
|
-
blocker: task.blocker
|
|
1355
|
-
};
|
|
1356
|
-
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
1357
|
-
return `<!-- diffpi-task: ${json(marker)} -->
|
|
1358
|
-
- [${checked}] **${task.title}**
|
|
1359
|
-
- Steps: ${list(task.steps ?? [])}
|
|
1360
|
-
- Dependencies: ${list(task.dependencies)}
|
|
1361
|
-
- File scopes: ${list(task.fileScopes)}
|
|
1362
|
-
- Acceptance criteria: ${list(task.acceptanceCriteria)}
|
|
1363
|
-
<!-- /diffpi-task -->`;
|
|
1364
|
-
}
|
|
1365
|
-
function parsePhases(input, ref) {
|
|
1366
|
-
const starts = [...input.matchAll(PHASE_MARKER)];
|
|
1367
|
-
const phases = starts.map((match, index) => {
|
|
1368
|
-
const start = match.index;
|
|
1369
|
-
const end = input.indexOf("<!-- /diffpi-phase -->", start);
|
|
1370
|
-
if (end < 0)
|
|
1371
|
-
throw new Error(`${ref}: phase marker has no closing marker.`);
|
|
1372
|
-
const next = starts[index + 1]?.index;
|
|
1373
|
-
if (next !== undefined && next < end)
|
|
1374
|
-
throw new Error(`${ref}: nested or unclosed phase marker.`);
|
|
1375
|
-
const body = input.slice(start + match[0].length, end);
|
|
1376
|
-
const marker = parseMarker(match[1], `${ref} phase`);
|
|
1377
|
-
assertStableId(marker.id, "phase id");
|
|
1378
|
-
const title = body.match(/^### Phase: (.+)$/m)?.[1]?.trim();
|
|
1379
|
-
const objective = body.match(/^\*\*Objective:\*\*\s*(.*)$/m)?.[1]?.trim();
|
|
1380
|
-
if (!title || !objective)
|
|
1381
|
-
throw new Error(`${ref}: phase ${marker.id} is missing title or objective.`);
|
|
1382
|
-
return {
|
|
1383
|
-
...marker,
|
|
1384
|
-
title,
|
|
1385
|
-
objective,
|
|
1386
|
-
dependencies: parseCsv(body.match(/^\*\*Dependencies:\*\*\s*(.*)$/m)?.[1]),
|
|
1387
|
-
tasks: parseTasks(body, ref)
|
|
1388
|
-
};
|
|
1389
|
-
});
|
|
1390
|
-
assertUniqueIds(phases.map((phase) => phase.id), "phase id");
|
|
1391
|
-
return phases;
|
|
1392
|
-
}
|
|
1393
|
-
function parseTasks(input, ref) {
|
|
1394
|
-
const starts = [...input.matchAll(TASK_MARKER)];
|
|
1395
|
-
const tasks = starts.map((match, index) => {
|
|
1396
|
-
const start = match.index;
|
|
1397
|
-
const end = input.indexOf("<!-- /diffpi-task -->", start);
|
|
1398
|
-
if (end < 0)
|
|
1399
|
-
throw new Error(`${ref}: task marker has no closing marker.`);
|
|
1400
|
-
const next = starts[index + 1]?.index;
|
|
1401
|
-
if (next !== undefined && next < end)
|
|
1402
|
-
throw new Error(`${ref}: nested or unclosed task marker.`);
|
|
1403
|
-
const body = input.slice(start + match[0].length, end);
|
|
1404
|
-
const marker = parseMarker(match[1], `${ref} task`);
|
|
1405
|
-
assertStableId(marker.id, "task id");
|
|
1406
|
-
const title = body.match(/^- \[[ xX]\] \*\*(.+)\*\*$/m)?.[1]?.trim();
|
|
1407
|
-
if (!title)
|
|
1408
|
-
throw new Error(`${ref}: task ${marker.id} is missing its checkbox title.`);
|
|
1409
|
-
return {
|
|
1410
|
-
...marker,
|
|
1411
|
-
title,
|
|
1412
|
-
steps: parseListValue(body, "Steps"),
|
|
1413
|
-
dependencies: parseListValue(body, "Dependencies"),
|
|
1414
|
-
fileScopes: parseListValue(body, "File scopes"),
|
|
1415
|
-
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
1416
|
-
};
|
|
1417
|
-
});
|
|
1418
|
-
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
1419
|
-
return tasks;
|
|
1420
|
-
}
|
|
1421
|
-
function parseReferences(input) {
|
|
1422
|
-
return [...input.matchAll(/^- <!-- diffpi-reference: (\{[^\n]+\}) -->\s*(.*)$/gm)].map((match) => {
|
|
1423
|
-
const marker = parseMarker(match[1], "reference");
|
|
1424
|
-
assertStableId(marker.id, "reference id");
|
|
1425
|
-
return { id: marker.id, value: match[2].trim() || marker.value };
|
|
1426
|
-
});
|
|
1427
|
-
}
|
|
1428
|
-
function canPatch(source, next) {
|
|
1429
|
-
try {
|
|
1430
|
-
const old = parsePlanDocument(source);
|
|
1431
|
-
return JSON.stringify(contentShape(old)) === JSON.stringify(contentShape(next));
|
|
1432
|
-
} catch {
|
|
1433
|
-
return false;
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
function patchMarkers(source, plan) {
|
|
1437
|
-
const planMarker = {
|
|
1438
|
-
schemaVersion: plan.schemaVersion,
|
|
1439
|
-
id: plan.id,
|
|
1440
|
-
revision: plan.revision,
|
|
1441
|
-
branch: plan.branch,
|
|
1442
|
-
status: plan.status,
|
|
1443
|
-
execution: plan.execution,
|
|
1444
|
-
createdAt: plan.createdAt,
|
|
1445
|
-
updatedAt: plan.updatedAt
|
|
1446
|
-
};
|
|
1447
|
-
let output = source.replace(PLAN_MARKER, `<!-- diffpi-plan: ${json(planMarker)} -->`);
|
|
1448
|
-
output = output.replace(/^- \*\*Status:\*\* .*$/m, `- **Status:** ${plan.status}`);
|
|
1449
|
-
output = output.replace(/^- \*\*Revision:\*\* .*$/m, `- **Revision:** ${plan.revision}`);
|
|
1450
|
-
for (const phase of plan.phases) {
|
|
1451
|
-
const marker = {
|
|
1452
|
-
id: phase.id,
|
|
1453
|
-
revision: phase.revision,
|
|
1454
|
-
status: phase.status,
|
|
1455
|
-
gate: phase.gate,
|
|
1456
|
-
commit: phase.commit,
|
|
1457
|
-
blocker: phase.blocker
|
|
1458
|
-
};
|
|
1459
|
-
output = replaceMarkerById(output, "phase", phase.id, marker);
|
|
1460
|
-
for (const task of phase.tasks) {
|
|
1461
|
-
const taskMarker = {
|
|
1462
|
-
id: task.id,
|
|
1463
|
-
revision: task.revision,
|
|
1464
|
-
status: task.status,
|
|
1465
|
-
owner: task.owner,
|
|
1466
|
-
executionId: task.executionId,
|
|
1467
|
-
blocker: task.blocker
|
|
1468
|
-
};
|
|
1469
|
-
output = replaceMarkerById(output, "task", task.id, taskMarker);
|
|
1470
|
-
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
1471
|
-
const escaped = escapeRegExp(task.title);
|
|
1472
|
-
output = output.replace(new RegExp(`^- \\[[ xX]\\] \\*\\*${escaped}\\*\\*$`, "m"), `- [${checked}] **${task.title}**`);
|
|
1473
|
-
}
|
|
1474
|
-
}
|
|
1475
|
-
return output;
|
|
1476
|
-
}
|
|
1477
|
-
function replaceMarkerById(source, kind, id, marker) {
|
|
1478
|
-
const pattern = new RegExp(`<!-- diffpi-${kind}: \\{[^\\n]*"id":"${escapeRegExp(id)}"[^\\n]*\\} -->`);
|
|
1479
|
-
if (!pattern.test(source))
|
|
1480
|
-
throw new Error(`Cannot update missing ${kind} marker ${id}.`);
|
|
1481
|
-
return source.replace(pattern, `<!-- diffpi-${kind}: ${json(marker)} -->`);
|
|
1482
|
-
}
|
|
1483
|
-
function contentShape(plan) {
|
|
1484
|
-
return {
|
|
1485
|
-
title: plan.title,
|
|
1486
|
-
branch: plan.branch,
|
|
1487
|
-
intent: plan.intent,
|
|
1488
|
-
requirements: plan.requirements,
|
|
1489
|
-
design: plan.design,
|
|
1490
|
-
references: plan.references,
|
|
1491
|
-
phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
|
|
1492
|
-
id,
|
|
1493
|
-
title,
|
|
1494
|
-
objective,
|
|
1495
|
-
dependencies,
|
|
1496
|
-
tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
|
|
1497
|
-
id: taskId,
|
|
1498
|
-
title: taskTitle,
|
|
1499
|
-
steps,
|
|
1500
|
-
dependencies: taskDependencies,
|
|
1501
|
-
fileScopes,
|
|
1502
|
-
acceptanceCriteria
|
|
1503
|
-
}))
|
|
1504
|
-
}))
|
|
1505
|
-
};
|
|
1506
|
-
}
|
|
1507
|
-
function dependencyCycles(plan) {
|
|
1508
|
-
const graph = new Map;
|
|
1509
|
-
for (const phase of plan.phases)
|
|
1510
|
-
graph.set(phase.id, phase.dependencies);
|
|
1511
|
-
for (const task of plan.phases.flatMap((phase) => phase.tasks))
|
|
1512
|
-
graph.set(task.id, task.dependencies);
|
|
1513
|
-
const cycles = [];
|
|
1514
|
-
const visiting = new Set;
|
|
1515
|
-
const visited = new Set;
|
|
1516
|
-
const walk = (id, path) => {
|
|
1517
|
-
if (visiting.has(id)) {
|
|
1518
|
-
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
1519
|
-
return;
|
|
1520
|
-
}
|
|
1521
|
-
if (visited.has(id))
|
|
1522
|
-
return;
|
|
1523
|
-
visiting.add(id);
|
|
1524
|
-
for (const dependency of graph.get(id) ?? [])
|
|
1525
|
-
walk(dependency, [...path, id]);
|
|
1526
|
-
visiting.delete(id);
|
|
1527
|
-
visited.add(id);
|
|
1528
|
-
};
|
|
1529
|
-
for (const id of graph.keys())
|
|
1530
|
-
walk(id, []);
|
|
1531
|
-
return cycles;
|
|
1532
|
-
}
|
|
1533
|
-
function section(source, heading) {
|
|
1534
|
-
const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
1535
|
-
if (!match?.index) {
|
|
1536
|
-
if (match?.index === 0)
|
|
1537
|
-
return "";
|
|
1538
|
-
throw new Error(`Missing required heading: ${heading}.`);
|
|
1539
|
-
}
|
|
1540
|
-
const start = match.index + match[0].length;
|
|
1541
|
-
const rest = source.slice(start);
|
|
1542
|
-
const end = rest.search(/^## /m);
|
|
1543
|
-
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
1544
|
-
}
|
|
1545
|
-
function subsection(source, parent, heading) {
|
|
1546
|
-
const body = section(source, parent);
|
|
1547
|
-
const match = body.match(new RegExp(`^### ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
1548
|
-
if (match?.index === undefined)
|
|
1549
|
-
throw new Error(`Missing required heading: ${parent}/${heading}.`);
|
|
1550
|
-
const rest = body.slice(match.index + match[0].length);
|
|
1551
|
-
const end = rest.search(/^### /m);
|
|
1552
|
-
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
1553
|
-
}
|
|
1554
|
-
function parseBullets(input) {
|
|
1555
|
-
return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
|
|
1556
|
-
}
|
|
1557
|
-
function parseListValue(body, label) {
|
|
1558
|
-
const value = body.match(new RegExp(`^ - ${escapeRegExp(label)}:\\s*(.*)$`, "m"))?.[1];
|
|
1559
|
-
return parseCsv(value);
|
|
1560
|
-
}
|
|
1561
|
-
function parseCsv(value) {
|
|
1562
|
-
if (!value || value.trim().toLowerCase() === "none")
|
|
1563
|
-
return [];
|
|
1564
|
-
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1565
|
-
}
|
|
1566
|
-
function cleanPlaceholder(value) {
|
|
1567
|
-
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
1568
|
-
}
|
|
1569
|
-
function parseMarker(value, label) {
|
|
1570
|
-
try {
|
|
1571
|
-
return JSON.parse(value);
|
|
1572
|
-
} catch {
|
|
1573
|
-
throw new Error(`Malformed ${label} marker JSON.`);
|
|
1574
|
-
}
|
|
1575
|
-
}
|
|
1576
|
-
function list(values) {
|
|
1577
|
-
return values.length ? values.join(", ") : "none";
|
|
1578
|
-
}
|
|
1579
|
-
function json(value) {
|
|
1580
|
-
return JSON.stringify(value, (_key, entry) => entry === undefined ? undefined : entry);
|
|
1581
|
-
}
|
|
1582
|
-
function escapeRegExp(value) {
|
|
1583
|
-
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1584
|
-
}
|
|
1585
|
-
// src/plan/store.ts
|
|
1586
|
-
import { open, mkdir as mkdir5, readdir, readFile as readFile8, realpath as realpath3, rename as rename2, rm as rm2 } from "node:fs/promises";
|
|
1587
|
-
import { basename as basename5, dirname as dirname6, join as join11, resolve as resolve3, sep } from "node:path";
|
|
1588
|
-
|
|
1589
|
-
// src/templates.ts
|
|
1590
|
-
import { readFile as readFile7 } from "node:fs/promises";
|
|
1591
|
-
import { homedir as homedir4 } from "node:os";
|
|
1592
|
-
import { join as join10, normalize } from "node:path";
|
|
1593
|
-
async function loadTemplate(name, options = {}) {
|
|
1594
|
-
const relative = templateRelativePath(name);
|
|
1595
|
-
const userPath = join10(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
|
|
1596
|
-
const bundledPath = join10(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
1597
|
-
const user = await readOptionalFile(userPath);
|
|
1598
|
-
if (user !== undefined)
|
|
1599
|
-
return { name, path: userPath, source: "user", content: user };
|
|
1600
|
-
const bundled = await readOptionalFile(bundledPath);
|
|
1601
|
-
if (bundled !== undefined)
|
|
1602
|
-
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
1603
|
-
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
1604
|
-
}
|
|
1605
|
-
function renderTemplate(content, variables) {
|
|
1606
|
-
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
1607
|
-
}
|
|
1608
|
-
function templateRelativePath(name) {
|
|
1609
|
-
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
1610
|
-
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
1611
|
-
throw new Error(`Invalid template name: ${name}`);
|
|
1612
|
-
}
|
|
1613
|
-
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
1614
|
-
}
|
|
1615
|
-
async function readOptionalFile(path) {
|
|
1616
|
-
try {
|
|
1617
|
-
return await readFile7(path, "utf8");
|
|
1618
|
-
} catch (error) {
|
|
1619
|
-
if (error.code === "ENOENT")
|
|
1620
|
-
return;
|
|
1621
|
-
throw error;
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
// src/plan/store.ts
|
|
1626
|
-
function planRecordName(shortSlug, date = new Date) {
|
|
1627
|
-
const slug = normalizeSlug(shortSlug);
|
|
1628
|
-
return `${date.toISOString().slice(2, 10).replaceAll("-", "")}-${slug}`;
|
|
1629
|
-
}
|
|
1630
|
-
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
1631
|
-
const root = await plansDir(cwd, options.homeDir);
|
|
1632
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
1633
|
-
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
1634
|
-
const records = [];
|
|
1635
|
-
for (const entry of entries) {
|
|
1636
|
-
if (!entry.isDirectory() || entry.name === ".tmp" || entry.name === ".lock")
|
|
1637
|
-
continue;
|
|
1638
|
-
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
1639
|
-
continue;
|
|
1640
|
-
const dir = join11(root, entry.name);
|
|
1641
|
-
await assertContained(root, dir);
|
|
1642
|
-
try {
|
|
1643
|
-
const record = await readRecord(dir);
|
|
1644
|
-
if (filters.branch && record.document.branch !== filters.branch)
|
|
1645
|
-
continue;
|
|
1646
|
-
if (filters.statuses && !filters.statuses.includes(record.document.status))
|
|
1647
|
-
continue;
|
|
1648
|
-
records.push(record);
|
|
1649
|
-
} catch (error) {
|
|
1650
|
-
if (error.code !== "ENOENT")
|
|
1651
|
-
throw error;
|
|
1652
|
-
}
|
|
1653
|
-
}
|
|
1654
|
-
records.sort((left, right) => left.id.localeCompare(right.id));
|
|
1655
|
-
return { candidates: records, record: records.length === 1 ? records[0] : undefined, ambiguous: records.length > 1 };
|
|
1656
|
-
}
|
|
1657
|
-
function createPlanStore(options = {}) {
|
|
1658
|
-
const now = options.now ?? (() => new Date);
|
|
1659
|
-
return {
|
|
1660
|
-
context(cwd, query, filters) {
|
|
1661
|
-
return resolvePlan(cwd, query, filters, options);
|
|
1662
|
-
},
|
|
1663
|
-
async init(input) {
|
|
1664
|
-
const root = await plansDir(input.cwd, options.homeDir);
|
|
1665
|
-
const id = planRecordName(input.shortSlug, now());
|
|
1666
|
-
const dir = join11(root, id);
|
|
1667
|
-
await assertContained(root, dirname6(dir));
|
|
1668
|
-
try {
|
|
1669
|
-
await mkdir5(dir);
|
|
1670
|
-
} catch (error) {
|
|
1671
|
-
if (error.code === "EEXIST")
|
|
1672
|
-
throw new Error(`Plan ${id} already exists.`);
|
|
1673
|
-
throw error;
|
|
1674
|
-
}
|
|
1675
|
-
try {
|
|
1676
|
-
const timestamp = now().toISOString();
|
|
1677
|
-
const template = await loadTemplate("plan/PLAN", {
|
|
1678
|
-
homeDir: options.homeDir,
|
|
1679
|
-
bundledDir: options.bundledTemplatesDir
|
|
1680
|
-
});
|
|
1681
|
-
const source = renderTemplate(template.content, {
|
|
1682
|
-
id,
|
|
1683
|
-
branch: input.branch,
|
|
1684
|
-
title: input.title?.trim() || titleFromSlug(input.shortSlug),
|
|
1685
|
-
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
1686
|
-
created_at: timestamp,
|
|
1687
|
-
updated_at: timestamp
|
|
1688
|
-
});
|
|
1689
|
-
const document = parsePlanDocument(source, join11(dir, "PLAN.md"));
|
|
1690
|
-
await atomicWrite(join11(dir, "PLAN.md"), source);
|
|
1691
|
-
await atomicWrite(join11(dir, "logs.txt"), "");
|
|
1692
|
-
await appendPlanLog(join11(dir, "logs.txt"), {
|
|
1693
|
-
planRevision: document.revision,
|
|
1694
|
-
kind: "created",
|
|
1695
|
-
actor: "diffpi",
|
|
1696
|
-
message: `Created plan ${id}.`
|
|
1697
|
-
});
|
|
1698
|
-
return { id, dir, planPath: join11(dir, "PLAN.md"), logPath: join11(dir, "logs.txt"), document, source };
|
|
1699
|
-
} catch (error) {
|
|
1700
|
-
await rm2(dir, { recursive: true, force: true });
|
|
1701
|
-
throw error;
|
|
1702
|
-
}
|
|
1703
|
-
},
|
|
1704
|
-
async read(cwd, query, filters) {
|
|
1705
|
-
const result = await resolvePlan(cwd, query, filters, options);
|
|
1706
|
-
if (!result.record) {
|
|
1707
|
-
if (result.ambiguous)
|
|
1708
|
-
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
1709
|
-
throw new Error(`Plan "${query}" was not found.`);
|
|
1710
|
-
}
|
|
1711
|
-
return result.record;
|
|
1712
|
-
},
|
|
1713
|
-
async mutate(cwd, query, operation, update) {
|
|
1714
|
-
const initial = await this.read(cwd, query);
|
|
1715
|
-
return withPlanLock(initial.dir, async () => {
|
|
1716
|
-
const current = await readRecord(initial.dir);
|
|
1717
|
-
const next = await update(structuredClone(current.document));
|
|
1718
|
-
if (next.id !== current.id)
|
|
1719
|
-
throw new Error("A plan mutation cannot change the plan ID.");
|
|
1720
|
-
const document = {
|
|
1721
|
-
...next,
|
|
1722
|
-
revision: current.document.revision + 1,
|
|
1723
|
-
updatedAt: now().toISOString()
|
|
1724
|
-
};
|
|
1725
|
-
const source = renderPlanDocument(document, current.source);
|
|
1726
|
-
parsePlanDocument(source, current.planPath);
|
|
1727
|
-
await atomicWrite(current.planPath, source);
|
|
1728
|
-
return { ...current, document, source };
|
|
1729
|
-
}, { operation });
|
|
1730
|
-
},
|
|
1731
|
-
async log(cwd, query, event) {
|
|
1732
|
-
const record = await this.read(cwd, query);
|
|
1733
|
-
return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
|
|
1734
|
-
}
|
|
1735
|
-
};
|
|
1736
|
-
}
|
|
1737
|
-
async function readRecord(dir) {
|
|
1738
|
-
const planPath = join11(dir, "PLAN.md");
|
|
1739
|
-
const logPath = join11(dir, "logs.txt");
|
|
1740
|
-
const source = await readFile8(planPath, "utf8");
|
|
1741
|
-
const document = parsePlanDocument(source, planPath);
|
|
1742
|
-
return { id: basename5(dir), dir, planPath, logPath, document, source };
|
|
1743
|
-
}
|
|
1744
|
-
async function atomicWrite(path, content) {
|
|
1745
|
-
const temp = join11(dirname6(path), `.${basename5(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
1746
|
-
const file = await open(temp, "wx", 384);
|
|
1747
|
-
try {
|
|
1748
|
-
await file.writeFile(content, "utf8");
|
|
1749
|
-
await file.sync();
|
|
1750
|
-
} finally {
|
|
1751
|
-
await file.close();
|
|
1752
|
-
}
|
|
1753
|
-
await rename2(temp, path);
|
|
1754
|
-
try {
|
|
1755
|
-
const directory = await open(dirname6(path), "r");
|
|
1756
|
-
try {
|
|
1757
|
-
await directory.sync();
|
|
1758
|
-
} finally {
|
|
1759
|
-
await directory.close();
|
|
1760
|
-
}
|
|
1761
|
-
} catch {}
|
|
1762
|
-
}
|
|
1763
|
-
async function assertContained(root, candidate) {
|
|
1764
|
-
const canonicalRoot = await realpath3(root);
|
|
1765
|
-
let canonicalCandidate;
|
|
1766
|
-
try {
|
|
1767
|
-
canonicalCandidate = await realpath3(candidate);
|
|
1768
|
-
} catch {
|
|
1769
|
-
canonicalCandidate = resolve3(candidate);
|
|
1770
|
-
}
|
|
1771
|
-
if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) {
|
|
1772
|
-
throw new Error(`Plan path escapes the shared store: ${candidate}.`);
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1775
|
-
function normalizeSlug(value) {
|
|
1776
|
-
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
1777
|
-
throw new Error(`Invalid plan slug: ${value}.`);
|
|
1778
|
-
}
|
|
1779
|
-
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1780
|
-
assertStableId(slug, "plan slug");
|
|
1781
|
-
return slug;
|
|
1782
|
-
}
|
|
1783
|
-
function normalizeQuery(value) {
|
|
1784
|
-
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
1785
|
-
throw new Error(`Invalid plan query: ${value}.`);
|
|
1786
|
-
}
|
|
1787
|
-
return value.toLowerCase();
|
|
1788
|
-
}
|
|
1789
|
-
function stripDate(value) {
|
|
1790
|
-
return value.replace(/^\d{6}-/, "");
|
|
1791
|
-
}
|
|
1792
|
-
function titleFromSlug(value) {
|
|
1793
|
-
return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
1794
|
-
}
|
|
1795
|
-
// src/tools/plan.ts
|
|
1796
|
-
var id = z4.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
1797
|
-
var text = z4.string().trim().min(1);
|
|
1798
|
-
var revision = z4.number().int().nonnegative();
|
|
1799
|
-
var cwd = z4.string().optional();
|
|
1800
|
-
var taskDraft = z4.object({
|
|
1801
|
-
id,
|
|
1802
|
-
title: text,
|
|
1803
|
-
steps: z4.array(text).optional(),
|
|
1804
|
-
dependencies: z4.array(id).optional(),
|
|
1805
|
-
fileScopes: z4.array(text).optional(),
|
|
1806
|
-
acceptanceCriteria: z4.array(text).optional()
|
|
1807
|
-
}).strict();
|
|
1808
|
-
var phaseDraft = z4.object({
|
|
1809
|
-
id,
|
|
1810
|
-
title: text,
|
|
1811
|
-
objective: text,
|
|
1812
|
-
dependencies: z4.array(id).optional(),
|
|
1813
|
-
tasks: z4.array(taskDraft).optional()
|
|
1814
|
-
}).strict();
|
|
1815
|
-
var reference = z4.object({ id, value: text }).strict();
|
|
1816
|
-
var design = z4.object({ bigIdeas: z4.string(), keyApiUpdates: z4.string(), consequences: z4.string() }).strict();
|
|
1817
|
-
function createPlanTools(pi, modes, store = createPlanStore()) {
|
|
1818
|
-
return [
|
|
1819
|
-
defineTool3({
|
|
1820
|
-
name: "plan_context",
|
|
1821
|
-
label: "plan context",
|
|
1822
|
-
description: "Resolve one plan and summarize all matching plans without guessing among ambiguous matches.",
|
|
1823
|
-
parameters: parameters(z4.object({
|
|
1824
|
-
cwd,
|
|
1825
|
-
plan: z4.string().optional(),
|
|
1826
|
-
branch: z4.string().optional(),
|
|
1827
|
-
statuses: z4.array(z4.enum(["draft", "ready", "in_progress", "blocked", "completed"])).optional()
|
|
1828
|
-
}).strict()),
|
|
1829
|
-
executionMode: "parallel",
|
|
1830
|
-
async execute(_id, input) {
|
|
1831
|
-
const params = z4.object({
|
|
1832
|
-
cwd,
|
|
1833
|
-
plan: z4.string().optional(),
|
|
1834
|
-
branch: z4.string().optional(),
|
|
1835
|
-
statuses: z4.array(z4.enum(["draft", "ready", "in_progress", "blocked", "completed"])).optional()
|
|
1836
|
-
}).strict().parse(input);
|
|
1837
|
-
const resolution = await store.context(params.cwd ?? process.cwd(), params.plan, {
|
|
1838
|
-
branch: params.branch,
|
|
1839
|
-
statuses: params.statuses
|
|
1840
|
-
});
|
|
1841
|
-
return result(resolution.candidates.length ? resolution.candidates.map((record) => `${record.id}: ${record.document.status} r${record.document.revision} (${record.document.branch})`).join(`
|
|
1842
|
-
`) : "No matching plans.", { selected: resolution.record, candidates: resolution.candidates, ambiguous: resolution.ambiguous });
|
|
1843
|
-
}
|
|
1844
|
-
}),
|
|
1845
|
-
defineTool3({
|
|
1846
|
-
name: "plan_init",
|
|
1847
|
-
label: "plan init",
|
|
1848
|
-
description: "Create a phase-less editable plan draft.",
|
|
1849
|
-
parameters: parameters(z4.object({
|
|
1850
|
-
cwd,
|
|
1851
|
-
shortSlug: id,
|
|
1852
|
-
branch: z4.string().min(1).optional(),
|
|
1853
|
-
title: text.optional(),
|
|
1854
|
-
intent: text.optional(),
|
|
1855
|
-
open: z4.boolean().optional()
|
|
1856
|
-
}).strict()),
|
|
1857
|
-
executionMode: "sequential",
|
|
1858
|
-
async execute(_id, input) {
|
|
1859
|
-
const params = z4.object({
|
|
1860
|
-
cwd,
|
|
1861
|
-
shortSlug: id,
|
|
1862
|
-
branch: z4.string().min(1).optional(),
|
|
1863
|
-
title: text.optional(),
|
|
1864
|
-
intent: text.optional(),
|
|
1865
|
-
open: z4.boolean().optional()
|
|
1866
|
-
}).strict().parse(input);
|
|
1867
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
1868
|
-
const branch = params.branch ?? await currentBranch(workingDirectory);
|
|
1869
|
-
const record = await store.init({ ...params, cwd: workingDirectory, branch });
|
|
1870
|
-
return result(`Created ${record.id} at ${record.planPath}.`, { record, openRequested: params.open === true });
|
|
1871
|
-
}
|
|
1872
|
-
}),
|
|
1873
|
-
defineTool3({
|
|
1874
|
-
name: "plan_update_overview",
|
|
1875
|
-
label: "plan update overview",
|
|
1876
|
-
description: "Replace selected plan overview fields using an expected plan revision.",
|
|
1877
|
-
parameters: parameters(z4.object({
|
|
1878
|
-
cwd,
|
|
1879
|
-
plan: text,
|
|
1880
|
-
expectedPlanRevision: revision,
|
|
1881
|
-
intent: text.optional(),
|
|
1882
|
-
requirements: z4.array(text).optional(),
|
|
1883
|
-
design: design.optional(),
|
|
1884
|
-
references: z4.array(reference).optional()
|
|
1885
|
-
}).strict().refine((value) => value.intent || value.requirements || value.design || value.references, "At least one overview field is required.")),
|
|
1886
|
-
executionMode: "sequential",
|
|
1887
|
-
async execute(_id, input) {
|
|
1888
|
-
const schema = z4.object({
|
|
1889
|
-
cwd,
|
|
1890
|
-
plan: text,
|
|
1891
|
-
expectedPlanRevision: revision,
|
|
1892
|
-
intent: text.optional(),
|
|
1893
|
-
requirements: z4.array(text).optional(),
|
|
1894
|
-
design: design.optional(),
|
|
1895
|
-
references: z4.array(reference).optional()
|
|
1896
|
-
}).strict().refine((value) => value.intent || value.requirements || value.design || value.references, "At least one overview field is required.");
|
|
1897
|
-
const params = schema.parse(input);
|
|
1898
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "update overview", (plan) => {
|
|
1899
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
1900
|
-
assertAuthorable(plan);
|
|
1901
|
-
return resetDraft({
|
|
1902
|
-
...plan,
|
|
1903
|
-
intent: params.intent ?? plan.intent,
|
|
1904
|
-
requirements: params.requirements ?? plan.requirements,
|
|
1905
|
-
design: params.design ?? plan.design,
|
|
1906
|
-
references: params.references ?? plan.references
|
|
1907
|
-
});
|
|
1908
|
-
});
|
|
1909
|
-
return result(`Updated ${record.id} to revision ${record.document.revision}.`, { record });
|
|
1910
|
-
}
|
|
1911
|
-
}),
|
|
1912
|
-
defineTool3({
|
|
1913
|
-
name: "plan_add_phase",
|
|
1914
|
-
label: "plan add phase",
|
|
1915
|
-
description: "Add an ordered phase with stable IDs.",
|
|
1916
|
-
parameters: parameters(z4.object({ cwd, plan: text, expectedPlanRevision: revision, afterPhaseId: id.optional(), phase: phaseDraft }).strict()),
|
|
1917
|
-
executionMode: "sequential",
|
|
1918
|
-
async execute(_id, input) {
|
|
1919
|
-
const params = z4.object({ cwd, plan: text, expectedPlanRevision: revision, afterPhaseId: id.optional(), phase: phaseDraft }).strict().parse(input);
|
|
1920
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "add phase", (plan) => {
|
|
1921
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
1922
|
-
assertAuthorable(plan);
|
|
1923
|
-
if (allIds(plan).has(params.phase.id))
|
|
1924
|
-
throw new Error(`Duplicate stable ID: ${params.phase.id}.`);
|
|
1925
|
-
const phase = newPhase(params.phase);
|
|
1926
|
-
const phases = [...plan.phases];
|
|
1927
|
-
if (params.afterPhaseId) {
|
|
1928
|
-
const index = phases.findIndex((item) => item.id === params.afterPhaseId);
|
|
1929
|
-
if (index < 0)
|
|
1930
|
-
throw new Error(`Unknown phase: ${params.afterPhaseId}.`);
|
|
1931
|
-
phases.splice(index + 1, 0, phase);
|
|
1932
|
-
} else
|
|
1933
|
-
phases.push(phase);
|
|
1934
|
-
assertNewIds(plan, phase);
|
|
1935
|
-
return resetDraft({ ...plan, phases });
|
|
1936
|
-
});
|
|
1937
|
-
return result(`Added phase ${params.phase.id} to ${record.id}.`, { record });
|
|
1938
|
-
}
|
|
1939
|
-
}),
|
|
1940
|
-
defineTool3({
|
|
1941
|
-
name: "plan_remove_phase",
|
|
1942
|
-
label: "plan remove phase",
|
|
1943
|
-
description: "Remove an unfinished phase that has no dependents.",
|
|
1944
|
-
parameters: parameters(z4.object({ cwd, plan: text, expectedPlanRevision: revision, phaseId: id, reason: text }).strict()),
|
|
1945
|
-
executionMode: "sequential",
|
|
1946
|
-
async execute(_id, input) {
|
|
1947
|
-
const params = z4.object({ cwd, plan: text, expectedPlanRevision: revision, phaseId: id, reason: text }).strict().parse(input);
|
|
1948
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "remove phase", (plan) => {
|
|
1949
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
1950
|
-
assertAuthorable(plan);
|
|
1951
|
-
const phase = getPhase(plan, params.phaseId);
|
|
1952
|
-
if (phase.status === "completed" || phase.tasks.some((task) => task.status === "completed"))
|
|
1953
|
-
throw new Error(`Completed phase ${phase.id} cannot be removed.`);
|
|
1954
|
-
if (plan.phases.some((item) => item.dependencies.includes(phase.id)))
|
|
1955
|
-
throw new Error(`Phase ${phase.id} has dependents and cannot be removed.`);
|
|
1956
|
-
return resetDraft({ ...plan, phases: plan.phases.filter((item) => item.id !== phase.id) });
|
|
1957
|
-
});
|
|
1958
|
-
await store.log(params.cwd ?? process.cwd(), record.id, {
|
|
1959
|
-
planRevision: record.document.revision,
|
|
1960
|
-
kind: "updated",
|
|
1961
|
-
actor: "planner",
|
|
1962
|
-
message: `Removed phase ${params.phaseId}: ${params.reason}`,
|
|
1963
|
-
phaseId: params.phaseId
|
|
1964
|
-
});
|
|
1965
|
-
return result(`Removed phase ${params.phaseId}.`, { record });
|
|
1966
|
-
}
|
|
1967
|
-
}),
|
|
1968
|
-
defineTool3({
|
|
1969
|
-
name: "plan_update_phase",
|
|
1970
|
-
label: "plan update phase",
|
|
1971
|
-
description: "Update an unfinished phase and its ordered tasks.",
|
|
1972
|
-
parameters: parameters(z4.object({
|
|
1973
|
-
cwd,
|
|
1974
|
-
plan: text,
|
|
1975
|
-
expectedPlanRevision: revision,
|
|
1976
|
-
phaseId: id,
|
|
1977
|
-
patch: z4.object({
|
|
1978
|
-
title: text.optional(),
|
|
1979
|
-
objective: text.optional(),
|
|
1980
|
-
dependencies: z4.array(id).optional(),
|
|
1981
|
-
tasks: z4.array(taskDraft).optional()
|
|
1982
|
-
}).strict()
|
|
1983
|
-
}).strict()),
|
|
1984
|
-
executionMode: "sequential",
|
|
1985
|
-
async execute(_id, input) {
|
|
1986
|
-
const params = z4.object({
|
|
1987
|
-
cwd,
|
|
1988
|
-
plan: text,
|
|
1989
|
-
expectedPlanRevision: revision,
|
|
1990
|
-
phaseId: id,
|
|
1991
|
-
patch: z4.object({
|
|
1992
|
-
title: text.optional(),
|
|
1993
|
-
objective: text.optional(),
|
|
1994
|
-
dependencies: z4.array(id).optional(),
|
|
1995
|
-
tasks: z4.array(taskDraft).optional()
|
|
1996
|
-
}).strict()
|
|
1997
|
-
}).strict().parse(input);
|
|
1998
|
-
const record = await store.mutate(params.cwd ?? process.cwd(), params.plan, "update phase", (plan) => {
|
|
1999
|
-
assertAuthoringRevision(plan, params.expectedPlanRevision);
|
|
2000
|
-
assertAuthorable(plan, params.phaseId);
|
|
2001
|
-
const existing = getPhase(plan, params.phaseId);
|
|
2002
|
-
if (existing.status === "completed")
|
|
2003
|
-
throw new Error(`Completed phase ${existing.id} cannot be changed.`);
|
|
2004
|
-
const tasks = params.patch.tasks ? reconcileTasks(existing.tasks, params.patch.tasks) : existing.tasks;
|
|
2005
|
-
const updated = {
|
|
2006
|
-
...existing,
|
|
2007
|
-
title: params.patch.title ?? existing.title,
|
|
2008
|
-
objective: params.patch.objective ?? existing.objective,
|
|
2009
|
-
dependencies: params.patch.dependencies ?? existing.dependencies,
|
|
2010
|
-
tasks,
|
|
2011
|
-
revision: existing.revision + 1,
|
|
2012
|
-
status: existing.status === "blocked" ? "in_progress" : existing.status,
|
|
2013
|
-
blocker: existing.status === "blocked" ? undefined : existing.blocker
|
|
2014
|
-
};
|
|
2015
|
-
assertNewIds({ ...plan, phases: plan.phases.filter((phase) => phase.id !== existing.id) }, updated);
|
|
2016
|
-
return resetDraft({
|
|
2017
|
-
...plan,
|
|
2018
|
-
phases: plan.phases.map((phase) => phase.id === existing.id ? updated : phase)
|
|
2019
|
-
});
|
|
2020
|
-
});
|
|
2021
|
-
return result(`Updated phase ${params.phaseId}.`, { record });
|
|
2022
|
-
}
|
|
2023
|
-
}),
|
|
2024
|
-
defineTool3({
|
|
2025
|
-
name: "plan_validate",
|
|
2026
|
-
label: "plan validate",
|
|
2027
|
-
description: "Validate plan markers, dependencies, completeness, annotations, and Design length.",
|
|
2028
|
-
parameters: parameters(z4.object({ cwd, plan: text, strict: z4.boolean().optional() }).strict()),
|
|
2029
|
-
executionMode: "parallel",
|
|
2030
|
-
async execute(_id, input) {
|
|
2031
|
-
const params = z4.object({ cwd, plan: text, strict: z4.boolean().optional() }).strict().parse(input);
|
|
2032
|
-
const record = await store.read(params.cwd ?? process.cwd(), params.plan);
|
|
2033
|
-
const annotations = params.strict ? await readPlanAnnotations(record) : { pending: [] };
|
|
2034
|
-
const issues = validatePlanDocument(record.document, {
|
|
2035
|
-
strict: params.strict,
|
|
2036
|
-
pendingAnnotations: annotations.pending.length
|
|
2037
|
-
});
|
|
2038
|
-
const words = countDesignWords(record.document);
|
|
2039
|
-
return result(issues.length ? issues.map((issue) => `- ${issue.severity}: ${issue.message}`).join(`
|
|
2040
|
-
`) : "Plan is valid.", { record, issues, designWordCount: words, ready: !issues.some((issue) => issue.severity === "error") });
|
|
2041
|
-
}
|
|
2042
|
-
}),
|
|
2043
|
-
defineTool3({
|
|
2044
|
-
name: "plan_log_progress",
|
|
2045
|
-
label: "plan log progress",
|
|
2046
|
-
description: "Append a progress event without changing plan revision.",
|
|
2047
|
-
parameters: parameters(z4.object({
|
|
2048
|
-
cwd,
|
|
2049
|
-
plan: text,
|
|
2050
|
-
actor: text,
|
|
2051
|
-
message: text,
|
|
2052
|
-
executionId: id.optional(),
|
|
2053
|
-
phaseId: id.optional(),
|
|
2054
|
-
taskId: id.optional(),
|
|
2055
|
-
evidence: z4.array(text).optional(),
|
|
2056
|
-
data: z4.record(z4.string(), z4.unknown()).optional()
|
|
2057
|
-
}).strict()),
|
|
2058
|
-
executionMode: "sequential",
|
|
2059
|
-
async execute(_id, input) {
|
|
2060
|
-
const params = z4.object({
|
|
2061
|
-
cwd,
|
|
2062
|
-
plan: text,
|
|
2063
|
-
actor: text,
|
|
2064
|
-
message: text,
|
|
2065
|
-
executionId: id.optional(),
|
|
2066
|
-
phaseId: id.optional(),
|
|
2067
|
-
taskId: id.optional(),
|
|
2068
|
-
evidence: z4.array(text).optional(),
|
|
2069
|
-
data: z4.record(z4.string(), z4.unknown()).optional()
|
|
2070
|
-
}).strict().parse(input);
|
|
2071
|
-
const record = await store.read(params.cwd ?? process.cwd(), params.plan);
|
|
2072
|
-
const entry = await store.log(params.cwd ?? process.cwd(), params.plan, {
|
|
2073
|
-
planRevision: record.document.revision,
|
|
2074
|
-
kind: "progress",
|
|
2075
|
-
actor: params.actor,
|
|
2076
|
-
message: params.message,
|
|
2077
|
-
executionId: params.executionId,
|
|
2078
|
-
phaseId: params.phaseId,
|
|
2079
|
-
taskId: params.taskId,
|
|
2080
|
-
evidence: params.evidence,
|
|
2081
|
-
data: params.data
|
|
2082
|
-
});
|
|
2083
|
-
return result(`Logged ${entry.eventId}.`, { entry });
|
|
2084
|
-
}
|
|
2085
|
-
}),
|
|
2086
|
-
createStatusTool(pi, modes, store),
|
|
2087
|
-
defineTool3({
|
|
2088
|
-
name: "plan_run_gates",
|
|
2089
|
-
label: "plan run gates",
|
|
2090
|
-
description: "Run format check, lint, and tests without holding the plan lock, then persist non-stale results.",
|
|
2091
|
-
parameters: parameters(z4.object({ cwd, plan: text, phaseId: id, expectedPhaseRevision: revision, executionId: id, actor: text }).strict()),
|
|
2092
|
-
executionMode: "sequential",
|
|
2093
|
-
async execute(_id, input) {
|
|
2094
|
-
const params = z4.object({ cwd, plan: text, phaseId: id, expectedPhaseRevision: revision, executionId: id, actor: text }).strict().parse(input);
|
|
2095
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
2096
|
-
const before = await store.read(workingDirectory, params.plan);
|
|
2097
|
-
const phase = getPhase(before.document, params.phaseId);
|
|
2098
|
-
if (phase.revision !== params.expectedPhaseRevision)
|
|
2099
|
-
throw new Error(`Stale phase revision for ${phase.id}.`);
|
|
2100
|
-
if (!before.document.execution?.active || before.document.execution.id !== params.executionId)
|
|
2101
|
-
throw new Error(`Execution ${params.executionId} does not own this plan.`);
|
|
2102
|
-
if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
|
|
2103
|
-
throw new Error(`Phase ${phase.id} still has incomplete tasks.`);
|
|
2104
|
-
const results = await runMiseGates(workingDirectory);
|
|
2105
|
-
const passed = results.every((gate) => gate.status === "pass" || gate.status === "skip");
|
|
2106
|
-
const record = await store.mutate(workingDirectory, params.plan, "persist gates", (plan) => {
|
|
2107
|
-
const current = getPhase(plan, params.phaseId);
|
|
2108
|
-
if (current.revision !== params.expectedPhaseRevision)
|
|
2109
|
-
throw new Error(`Gate results for ${current.id} are stale.`);
|
|
2110
|
-
if (current.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
|
|
2111
|
-
throw new Error(`Gate results for ${current.id} are stale because task state changed.`);
|
|
2112
|
-
const updated = {
|
|
2113
|
-
...current,
|
|
2114
|
-
revision: current.revision + 1,
|
|
2115
|
-
gate: {
|
|
2116
|
-
phaseRevision: params.expectedPhaseRevision,
|
|
2117
|
-
status: passed ? "passed" : "failed",
|
|
2118
|
-
results,
|
|
2119
|
-
completedAt: new Date().toISOString()
|
|
2120
|
-
}
|
|
2121
|
-
};
|
|
2122
|
-
return {
|
|
2123
|
-
...heartbeat(plan),
|
|
2124
|
-
phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
|
|
2125
|
-
};
|
|
2126
|
-
});
|
|
2127
|
-
await store.log(workingDirectory, record.id, {
|
|
2128
|
-
planRevision: record.document.revision,
|
|
2129
|
-
kind: "gate",
|
|
2130
|
-
actor: params.actor,
|
|
2131
|
-
message: `Phase ${phase.id} gates ${passed ? "passed" : "failed"}.`,
|
|
2132
|
-
executionId: params.executionId,
|
|
2133
|
-
phaseId: phase.id,
|
|
2134
|
-
data: { results }
|
|
2135
|
-
});
|
|
2136
|
-
return result(results.map((gate) => `${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
2137
|
-
`), {
|
|
2138
|
-
results,
|
|
2139
|
-
passed,
|
|
2140
|
-
record
|
|
2141
|
-
});
|
|
2142
|
-
}
|
|
2143
|
-
}),
|
|
2144
|
-
defineTool3({
|
|
2145
|
-
name: "plan_annotate",
|
|
2146
|
-
label: "plan annotate",
|
|
2147
|
-
description: "Launch the selected PLAN.md in tuicr standalone file annotation mode.",
|
|
2148
|
-
parameters: parameters(z4.object({ cwd, plan: z4.string().optional() }).strict()),
|
|
2149
|
-
executionMode: "sequential",
|
|
2150
|
-
async execute(_id, input) {
|
|
2151
|
-
const params = z4.object({ cwd, plan: z4.string().optional() }).strict().parse(input);
|
|
2152
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
2153
|
-
const resolution = await store.context(workingDirectory, params.plan);
|
|
2154
|
-
if (!resolution.record)
|
|
2155
|
-
throw new Error(resolution.ambiguous ? `Plan is ambiguous: ${resolution.candidates.map((item) => item.id).join(", ")}.` : "No matching plan.");
|
|
2156
|
-
const cliPath = join12(resolveBundledAgentsDir(), "..", "dist", "cli.js");
|
|
2157
|
-
const command = [
|
|
2158
|
-
process.execPath,
|
|
2159
|
-
cliPath,
|
|
2160
|
-
"plan",
|
|
2161
|
-
"annotate",
|
|
2162
|
-
resolution.record.id,
|
|
2163
|
-
"--cwd",
|
|
2164
|
-
workingDirectory
|
|
2165
|
-
];
|
|
2166
|
-
const launched = await openInNewTab(command, { cwd: workingDirectory, name: "plan-annotate" });
|
|
2167
|
-
const fallbackCommand = `npx --yes @difflab/pi plan annotate ${resolution.record.id} --cwd ${JSON.stringify(workingDirectory)}`;
|
|
2168
|
-
return result(launched.launched ? `Opened ${resolution.record.id} for annotation.` : launched.instruction ?? `Run: ${fallbackCommand}`, { record: resolution.record, launched, fallbackCommand, followUp: `/plan update ${resolution.record.id}` });
|
|
2169
|
-
}
|
|
2170
|
-
}),
|
|
2171
|
-
defineTool3({
|
|
2172
|
-
name: "plan_annotations",
|
|
2173
|
-
label: "plan annotations",
|
|
2174
|
-
description: "Read normalized pending comments from the selected plan annotation session.",
|
|
2175
|
-
parameters: parameters(z4.object({ cwd, plan: z4.string().optional(), includeApplied: z4.boolean().optional() }).strict()),
|
|
2176
|
-
executionMode: "parallel",
|
|
2177
|
-
async execute(_id, input) {
|
|
2178
|
-
const params = z4.object({ cwd, plan: z4.string().optional(), includeApplied: z4.boolean().optional() }).strict().parse(input);
|
|
2179
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
2180
|
-
const resolution = await store.context(workingDirectory, params.plan);
|
|
2181
|
-
if (!resolution.record)
|
|
2182
|
-
throw new Error(resolution.ambiguous ? `Plan is ambiguous: ${resolution.candidates.map((item) => item.id).join(", ")}.` : "No matching plan.");
|
|
2183
|
-
const annotations = await readPlanAnnotations(resolution.record, { includeApplied: params.includeApplied });
|
|
2184
|
-
return result(annotations.comments.length ? annotations.comments.map((comment) => `${comment.id}: ${comment.body}`).join(`
|
|
2185
|
-
`) : "No pending annotations.", { record: resolution.record, ...annotations });
|
|
2186
|
-
}
|
|
2187
|
-
}),
|
|
2188
|
-
defineTool3({
|
|
2189
|
-
name: "plan_ack_annotations",
|
|
2190
|
-
label: "plan acknowledge annotations",
|
|
2191
|
-
description: "Acknowledge only annotation comments successfully applied to the plan.",
|
|
2192
|
-
parameters: parameters(z4.object({ cwd, plan: text, commentIds: z4.array(text).min(1), summary: text }).strict()),
|
|
2193
|
-
executionMode: "sequential",
|
|
2194
|
-
async execute(_id, input) {
|
|
2195
|
-
const params = z4.object({ cwd, plan: text, commentIds: z4.array(text).min(1), summary: text }).strict().parse(input);
|
|
2196
|
-
const record = await store.read(params.cwd ?? process.cwd(), params.plan);
|
|
2197
|
-
const state = await acknowledgePlanAnnotations(record, params.commentIds, params.summary);
|
|
2198
|
-
return result(`Acknowledged ${params.commentIds.length} annotations for ${record.id}.`, {
|
|
2199
|
-
record,
|
|
2200
|
-
state,
|
|
2201
|
-
acknowledged: params.commentIds
|
|
2202
|
-
});
|
|
2203
|
-
}
|
|
2204
|
-
}),
|
|
2205
|
-
defineTool3({
|
|
2206
|
-
name: "plan_start_execution",
|
|
2207
|
-
label: "plan start execution",
|
|
2208
|
-
description: "Start one inline or background plan execution.",
|
|
2209
|
-
parameters: parameters(z4.object({
|
|
2210
|
-
cwd,
|
|
2211
|
-
plan: text,
|
|
2212
|
-
mode: z4.enum(["inline", "background"]),
|
|
2213
|
-
policy: z4.enum(["commit-per-phase", "no-commit"]),
|
|
2214
|
-
actor: text
|
|
2215
|
-
}).strict()),
|
|
2216
|
-
executionMode: "sequential",
|
|
2217
|
-
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2218
|
-
const params = z4.object({
|
|
2219
|
-
cwd,
|
|
2220
|
-
plan: text,
|
|
2221
|
-
mode: z4.enum(["inline", "background"]),
|
|
2222
|
-
policy: z4.enum(["commit-per-phase", "no-commit"]),
|
|
2223
|
-
actor: text
|
|
2224
|
-
}).strict().parse(input);
|
|
2225
|
-
return startExecution(pi, modes, store, params, ctx);
|
|
2226
|
-
}
|
|
2227
|
-
})
|
|
2228
|
-
];
|
|
2229
|
-
}
|
|
2230
|
-
function createStatusTool(pi, modes, store) {
|
|
2231
|
-
const schema = z4.object({
|
|
2232
|
-
cwd,
|
|
2233
|
-
plan: text,
|
|
2234
|
-
target: z4.object({ type: z4.enum(["plan", "phase", "task"]), id: id.optional() }).strict().refine((value) => value.type === "plan" || value.id, "Phase and task targets require an ID."),
|
|
2235
|
-
expectedStatus: z4.enum(["draft", "ready", "in_progress", "blocked", "completed", "pending", "skipped"]),
|
|
2236
|
-
status: z4.enum(["draft", "ready", "in_progress", "blocked", "completed", "pending", "skipped"]),
|
|
2237
|
-
actor: text,
|
|
2238
|
-
message: text,
|
|
2239
|
-
executionId: id.optional(),
|
|
2240
|
-
evidence: z4.array(text).optional(),
|
|
2241
|
-
blockedReason: text.optional(),
|
|
2242
|
-
attempts: z4.array(text).optional(),
|
|
2243
|
-
needsUserDecision: z4.boolean().optional(),
|
|
2244
|
-
commit: z4.object({ sha: z4.string().regex(/^[a-f0-9]{7,64}$/i), subject: text, completedAt: z4.string().datetime() }).strict().optional()
|
|
2245
|
-
}).strict();
|
|
2246
|
-
return defineTool3({
|
|
2247
|
-
name: "plan_update_status",
|
|
2248
|
-
label: "plan update status",
|
|
2249
|
-
description: "Apply an ownership-checked plan, phase, or task status transition and append an audit event.",
|
|
2250
|
-
parameters: parameters(schema),
|
|
2251
|
-
executionMode: "sequential",
|
|
2252
|
-
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
2253
|
-
const params = schema.parse(input);
|
|
2254
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
2255
|
-
let escalation;
|
|
2256
|
-
if (params.commit) {
|
|
2257
|
-
if (params.target.type !== "phase" || params.status !== "completed")
|
|
2258
|
-
throw new Error("Commit metadata is accepted only when completing a phase.");
|
|
2259
|
-
const verified = await verifyCommit(workingDirectory, params.commit.sha);
|
|
2260
|
-
if (verified.sha !== params.commit.sha && !verified.sha.startsWith(params.commit.sha))
|
|
2261
|
-
throw new Error(`Commit ${params.commit.sha} is not the current HEAD ${verified.sha}.`);
|
|
2262
|
-
if (verified.subject !== params.commit.subject)
|
|
2263
|
-
throw new Error(`Commit subject does not match HEAD: ${verified.subject}.`);
|
|
2264
|
-
}
|
|
2265
|
-
const record = await store.mutate(workingDirectory, params.plan, "update status", (plan) => {
|
|
2266
|
-
if (params.target.type === "plan") {
|
|
2267
|
-
if (plan.status !== params.expectedStatus)
|
|
2268
|
-
throw new Error(`Expected plan status ${params.expectedStatus}, found ${plan.status}.`);
|
|
2269
|
-
assertPlanTransition(plan.status, params.status);
|
|
2270
|
-
if (params.status === "ready") {
|
|
2271
|
-
const errors = validatePlanDocument(plan, { strict: true }).filter((issue) => issue.severity === "error");
|
|
2272
|
-
if (errors.length)
|
|
2273
|
-
throw new Error(`Plan cannot become ready: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
2274
|
-
}
|
|
2275
|
-
if ((params.status === "blocked" || params.status === "completed") && plan.status === "in_progress") {
|
|
2276
|
-
assertExecutionOwner(plan, params.executionId);
|
|
2277
|
-
}
|
|
2278
|
-
if (params.status === "completed" && plan.phases.some((phase) => phase.status !== "completed" && phase.status !== "skipped"))
|
|
2279
|
-
throw new Error("Every phase must be complete or skipped before the plan completes.");
|
|
2280
|
-
return {
|
|
2281
|
-
...heartbeat(plan),
|
|
2282
|
-
status: params.status,
|
|
2283
|
-
execution: params.status === "completed" || params.status === "blocked" ? plan.execution && { ...plan.execution, active: false, heartbeatAt: new Date().toISOString() } : plan.execution
|
|
2284
|
-
};
|
|
2285
|
-
}
|
|
2286
|
-
if (!params.target.id)
|
|
2287
|
-
throw new Error("Target ID is required.");
|
|
2288
|
-
const phase = params.target.type === "phase" ? getPhase(plan, params.target.id) : findTaskPhase(plan, params.target.id);
|
|
2289
|
-
if (params.target.type === "phase") {
|
|
2290
|
-
if (phase.status !== params.expectedStatus)
|
|
2291
|
-
throw new Error(`Expected phase status ${params.expectedStatus}, found ${phase.status}.`);
|
|
2292
|
-
assertExecutionOwner(plan, params.executionId);
|
|
2293
|
-
assertPhaseTransition(phase.status, params.status);
|
|
2294
|
-
if (params.status === "completed") {
|
|
2295
|
-
if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
|
|
2296
|
-
throw new Error("All phase tasks must be complete or skipped.");
|
|
2297
|
-
if (phase.gate.status !== "passed")
|
|
2298
|
-
throw new Error("Phase gates must pass before completion.");
|
|
2299
|
-
if (plan.execution?.policy === "commit-per-phase" && !params.commit)
|
|
2300
|
-
throw new Error("Commit-per-phase execution requires commit metadata before phase completion.");
|
|
2301
|
-
if (plan.execution?.policy === "no-commit" && params.commit)
|
|
2302
|
-
throw new Error("No-commit execution cannot record a phase commit.");
|
|
2303
|
-
}
|
|
2304
|
-
const updated = {
|
|
2305
|
-
...phase,
|
|
2306
|
-
status: params.status,
|
|
2307
|
-
revision: phase.revision + 1,
|
|
2308
|
-
commit: params.commit ?? phase.commit,
|
|
2309
|
-
blocker: params.blockedReason ? blocker(params) : phase.blocker
|
|
2310
|
-
};
|
|
2311
|
-
if (params.status === "blocked" && params.blockedReason)
|
|
2312
|
-
escalation = escalationFor(plan, updated.id, undefined, params);
|
|
2313
|
-
return {
|
|
2314
|
-
...heartbeat(plan),
|
|
2315
|
-
phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
|
|
2316
|
-
};
|
|
2317
|
-
}
|
|
2318
|
-
const task = getTask(phase, params.target.id);
|
|
2319
|
-
if (task.status !== params.expectedStatus)
|
|
2320
|
-
throw new Error(`Expected task status ${params.expectedStatus}, found ${task.status}.`);
|
|
2321
|
-
assertExecutionOwner(plan, params.executionId);
|
|
2322
|
-
assertTaskTransition(task, params.status, params.executionId, params.actor);
|
|
2323
|
-
const updatedTask = {
|
|
2324
|
-
...task,
|
|
2325
|
-
status: params.status,
|
|
2326
|
-
revision: task.revision + 1,
|
|
2327
|
-
owner: params.status === "in_progress" ? params.actor : task.owner,
|
|
2328
|
-
executionId: params.status === "in_progress" ? params.executionId : task.executionId,
|
|
2329
|
-
blocker: params.blockedReason ? blocker(params) : task.blocker
|
|
2330
|
-
};
|
|
2331
|
-
if (params.status === "blocked" && params.blockedReason)
|
|
2332
|
-
escalation = escalationFor(plan, phase.id, task.id, params);
|
|
2333
|
-
const updatedPhase = {
|
|
2334
|
-
...phase,
|
|
2335
|
-
revision: phase.revision + 1,
|
|
2336
|
-
tasks: phase.tasks.map((item) => item.id === updatedTask.id ? updatedTask : item)
|
|
2337
|
-
};
|
|
2338
|
-
return {
|
|
2339
|
-
...heartbeat(plan),
|
|
2340
|
-
phases: plan.phases.map((item) => item.id === updatedPhase.id ? updatedPhase : item)
|
|
2341
|
-
};
|
|
2342
|
-
});
|
|
2343
|
-
await store.log(workingDirectory, record.id, {
|
|
2344
|
-
planRevision: record.document.revision,
|
|
2345
|
-
kind: params.status === "blocked" ? "blocker" : "status",
|
|
2346
|
-
actor: params.actor,
|
|
2347
|
-
message: params.message,
|
|
2348
|
-
executionId: params.executionId,
|
|
2349
|
-
phaseId: params.target.type === "phase" ? params.target.id : escalation?.phaseId,
|
|
2350
|
-
taskId: params.target.type === "task" ? params.target.id : undefined,
|
|
2351
|
-
evidence: params.evidence
|
|
2352
|
-
});
|
|
2353
|
-
if (escalation && record.document.execution?.mode !== "background") {
|
|
2354
|
-
const selected = await modes.set("planner", ctx);
|
|
2355
|
-
if (selected.ok)
|
|
2356
|
-
pi.sendUserMessage(renderPlannerEscalation(escalation), {
|
|
2357
|
-
deliverAs: "followUp"
|
|
2358
|
-
});
|
|
2359
|
-
}
|
|
2360
|
-
return result(`Updated ${params.target.type} to ${params.status}.`, { record, escalation });
|
|
2361
|
-
}
|
|
2362
|
-
});
|
|
2363
|
-
}
|
|
2364
|
-
async function startExecution(pi, modes, store, params, ctx) {
|
|
2365
|
-
const workingDirectory = params.cwd ?? process.cwd();
|
|
2366
|
-
const before = await store.read(workingDirectory, params.plan);
|
|
2367
|
-
if (before.document.status !== "ready" && before.document.status !== "blocked")
|
|
2368
|
-
throw new Error(`Plan ${before.id} must be ready or blocked before execution.`);
|
|
2369
|
-
if (before.document.execution?.active)
|
|
2370
|
-
throw new Error(`Plan ${before.id} already has an active execution.`);
|
|
2371
|
-
const branch = await currentBranch(workingDirectory);
|
|
2372
|
-
if (branch !== before.document.branch)
|
|
2373
|
-
throw new Error(`Plan branch is ${before.document.branch}; current branch is ${branch}.`);
|
|
2374
|
-
if (params.policy === "commit-per-phase") {
|
|
2375
|
-
const dirty = await runChecked("git", ["-C", workingDirectory, "status", "--porcelain"]);
|
|
2376
|
-
if (dirty.stdout.trim())
|
|
2377
|
-
throw new Error(`Commit-per-phase requires a clean worktree:
|
|
2378
|
-
${dirty.stdout.trim()}`);
|
|
2379
|
-
}
|
|
2380
|
-
const head = (await runChecked("git", ["-C", workingDirectory, "rev-parse", "HEAD"])).stdout.trim();
|
|
2381
|
-
const executionId = crypto.randomUUID();
|
|
2382
|
-
const timestamp = new Date().toISOString();
|
|
2383
|
-
const record = await store.mutate(workingDirectory, before.id, "start execution", (plan) => ({
|
|
2384
|
-
...plan,
|
|
2385
|
-
status: "in_progress",
|
|
2386
|
-
execution: {
|
|
2387
|
-
id: executionId,
|
|
2388
|
-
mode: params.mode,
|
|
2389
|
-
policy: params.policy,
|
|
2390
|
-
cwd: workingDirectory,
|
|
2391
|
-
branch: plan.branch,
|
|
2392
|
-
baseHead: head,
|
|
2393
|
-
actor: params.actor,
|
|
2394
|
-
startedAt: timestamp,
|
|
2395
|
-
heartbeatAt: timestamp,
|
|
2396
|
-
active: true
|
|
2397
|
-
}
|
|
2398
|
-
}));
|
|
2399
|
-
await store.log(workingDirectory, record.id, {
|
|
2400
|
-
planRevision: record.document.revision,
|
|
2401
|
-
kind: "execution",
|
|
2402
|
-
actor: params.actor,
|
|
2403
|
-
message: `Started ${params.mode} execution ${executionId}.`,
|
|
2404
|
-
executionId
|
|
2405
|
-
});
|
|
2406
|
-
const coordinator = params.mode === "inline" ? "worker" : "orchestrator";
|
|
2407
|
-
const prompt = renderExecutionPrompt(createExecutionPacket(record.document, coordinator));
|
|
2408
|
-
if (params.mode === "inline") {
|
|
2409
|
-
const selected = await modes.set("worker", ctx);
|
|
2410
|
-
if (!selected.ok)
|
|
2411
|
-
throw new Error(selected.message);
|
|
2412
|
-
if (!pi.sendMessage)
|
|
2413
|
-
throw new Error("Inline execution dispatch is unavailable.");
|
|
2414
|
-
pi.sendMessage({ customType: "diffpi-plan-execution", display: false, content: prompt }, { triggerTurn: true, deliverAs: "followUp" });
|
|
2415
|
-
} else {
|
|
2416
|
-
await launchBackgroundPi(pi, {
|
|
2417
|
-
name: `Plan go ${record.id}`,
|
|
2418
|
-
agentPath: join12(resolveBundledAgentsDir(), "diffpi-orchestrator.md"),
|
|
2419
|
-
model: "openai-codex/gpt-5.6-luna",
|
|
2420
|
-
thinking: "medium",
|
|
2421
|
-
cwd: workingDirectory,
|
|
2422
|
-
prompt
|
|
2423
|
-
});
|
|
2424
|
-
}
|
|
2425
|
-
return result(`Started ${params.mode} execution ${executionId}.`, {
|
|
2426
|
-
record,
|
|
2427
|
-
executionId,
|
|
2428
|
-
dispatched: true,
|
|
2429
|
-
queued: true,
|
|
2430
|
-
foregroundModeChanged: params.mode === "inline"
|
|
2431
|
-
});
|
|
2432
|
-
}
|
|
2433
|
-
function newPhase(input) {
|
|
2434
|
-
return {
|
|
2435
|
-
id: input.id,
|
|
2436
|
-
revision: 0,
|
|
2437
|
-
title: input.title,
|
|
2438
|
-
objective: input.objective,
|
|
2439
|
-
dependencies: input.dependencies ?? [],
|
|
2440
|
-
tasks: (input.tasks ?? []).map(newTask),
|
|
2441
|
-
status: "pending",
|
|
2442
|
-
gate: { phaseRevision: 0, status: "pending", results: [] }
|
|
2443
|
-
};
|
|
2444
|
-
}
|
|
2445
|
-
function newTask(input) {
|
|
2446
|
-
return {
|
|
2447
|
-
id: input.id,
|
|
2448
|
-
revision: 0,
|
|
2449
|
-
title: input.title,
|
|
2450
|
-
steps: input.steps,
|
|
2451
|
-
dependencies: input.dependencies ?? [],
|
|
2452
|
-
fileScopes: input.fileScopes ?? [],
|
|
2453
|
-
acceptanceCriteria: input.acceptanceCriteria ?? [],
|
|
2454
|
-
status: "pending"
|
|
2455
|
-
};
|
|
2456
|
-
}
|
|
2457
|
-
function reconcileTasks(existing, drafts) {
|
|
2458
|
-
const byId = new Map(existing.map((task) => [task.id, task]));
|
|
2459
|
-
const requested = new Set(drafts.map((task) => task.id));
|
|
2460
|
-
const removedCompleted = existing.find((task) => task.status === "completed" && !requested.has(task.id));
|
|
2461
|
-
if (removedCompleted)
|
|
2462
|
-
throw new Error(`Completed task ${removedCompleted.id} cannot be removed.`);
|
|
2463
|
-
return drafts.map((draft) => {
|
|
2464
|
-
const current = byId.get(draft.id);
|
|
2465
|
-
if (current?.status === "completed")
|
|
2466
|
-
return current;
|
|
2467
|
-
return current ? {
|
|
2468
|
-
...newTask(draft),
|
|
2469
|
-
revision: current.revision + 1,
|
|
2470
|
-
status: current.status === "blocked" ? "pending" : current.status,
|
|
2471
|
-
owner: current.status === "blocked" ? undefined : current.owner,
|
|
2472
|
-
executionId: current.status === "blocked" ? undefined : current.executionId,
|
|
2473
|
-
blocker: current.status === "blocked" ? undefined : current.blocker
|
|
2474
|
-
} : newTask(draft);
|
|
2475
|
-
});
|
|
2476
|
-
}
|
|
2477
|
-
function assertAuthoringRevision(plan, expected) {
|
|
2478
|
-
if (plan.revision !== expected)
|
|
2479
|
-
throw new Error(`Stale plan revision: expected ${expected}, found ${plan.revision}.`);
|
|
2480
|
-
}
|
|
2481
|
-
function assertAuthorable(plan, blockedPhaseId) {
|
|
2482
|
-
if (plan.status === "completed")
|
|
2483
|
-
throw new Error("Completed plans cannot be structurally changed.");
|
|
2484
|
-
if (!plan.execution?.active)
|
|
2485
|
-
return;
|
|
2486
|
-
if (!blockedPhaseId)
|
|
2487
|
-
throw new Error("An active execution owns this plan.");
|
|
2488
|
-
const phase = getPhase(plan, blockedPhaseId);
|
|
2489
|
-
if (phase.status !== "blocked" && !phase.tasks.some((task) => task.status === "blocked"))
|
|
2490
|
-
throw new Error("Planner may amend only blocked work during an active execution.");
|
|
2491
|
-
}
|
|
2492
|
-
function resetDraft(plan) {
|
|
2493
|
-
return plan.status === "ready" || plan.status === "blocked" ? { ...plan, status: "draft" } : plan;
|
|
2494
|
-
}
|
|
2495
|
-
function allIds(plan) {
|
|
2496
|
-
return new Set([plan.id, ...plan.phases.flatMap((phase) => [phase.id, ...phase.tasks.map((task) => task.id)])]);
|
|
2497
|
-
}
|
|
2498
|
-
function assertNewIds(plan, phase) {
|
|
2499
|
-
const known = allIds(plan);
|
|
2500
|
-
for (const candidate of [phase.id, ...phase.tasks.map((task) => task.id)]) {
|
|
2501
|
-
assertStableId(candidate);
|
|
2502
|
-
if (known.has(candidate))
|
|
2503
|
-
throw new Error(`Duplicate stable ID: ${candidate}.`);
|
|
2504
|
-
known.add(candidate);
|
|
2505
|
-
}
|
|
2506
|
-
}
|
|
2507
|
-
function getPhase(plan, phaseId) {
|
|
2508
|
-
const phase = plan.phases.find((item) => item.id === phaseId);
|
|
2509
|
-
if (!phase)
|
|
2510
|
-
throw new Error(`Unknown phase: ${phaseId}.`);
|
|
2511
|
-
return phase;
|
|
2512
|
-
}
|
|
2513
|
-
function findTaskPhase(plan, taskId) {
|
|
2514
|
-
const phase = plan.phases.find((item) => item.tasks.some((task) => task.id === taskId));
|
|
2515
|
-
if (!phase)
|
|
2516
|
-
throw new Error(`Unknown task: ${taskId}.`);
|
|
2517
|
-
return phase;
|
|
2518
|
-
}
|
|
2519
|
-
function getTask(phase, taskId) {
|
|
2520
|
-
const task = phase.tasks.find((item) => item.id === taskId);
|
|
2521
|
-
if (!task)
|
|
2522
|
-
throw new Error(`Unknown task: ${taskId}.`);
|
|
2523
|
-
return task;
|
|
2524
|
-
}
|
|
2525
|
-
function assertExecutionOwner(plan, executionId) {
|
|
2526
|
-
if (!plan.execution?.active || !executionId || plan.execution.id !== executionId)
|
|
2527
|
-
throw new Error(`Execution ${executionId ?? "(missing)"} does not own this plan.`);
|
|
2528
|
-
}
|
|
2529
|
-
function blocker(params) {
|
|
2530
|
-
return {
|
|
2531
|
-
reason: params.blockedReason,
|
|
2532
|
-
attempts: params.attempts,
|
|
2533
|
-
evidence: params.evidence,
|
|
2534
|
-
needsUserDecision: params.needsUserDecision
|
|
2535
|
-
};
|
|
2536
|
-
}
|
|
2537
|
-
function escalationFor(plan, phaseId, taskId, params) {
|
|
2538
|
-
return {
|
|
2539
|
-
planId: plan.id,
|
|
2540
|
-
executionId: params.executionId,
|
|
2541
|
-
phaseId,
|
|
2542
|
-
taskId,
|
|
2543
|
-
blocker: params.blockedReason,
|
|
2544
|
-
attempts: params.attempts ?? [],
|
|
2545
|
-
evidence: params.evidence ?? [],
|
|
2546
|
-
needsUserDecision: params.needsUserDecision ?? false
|
|
2547
|
-
};
|
|
2548
|
-
}
|
|
2549
|
-
function heartbeat(plan) {
|
|
2550
|
-
return plan.execution?.active ? { ...plan, execution: { ...plan.execution, heartbeatAt: new Date().toISOString() } } : plan;
|
|
2551
|
-
}
|
|
2552
|
-
async function verifyCommit(cwd, requestedSha) {
|
|
2553
|
-
await runChecked("git", ["-C", cwd, "cat-file", "-e", `${requestedSha}^{commit}`]);
|
|
2554
|
-
const [sha, subject] = await Promise.all([
|
|
2555
|
-
runChecked("git", ["-C", cwd, "rev-parse", "HEAD"]),
|
|
2556
|
-
runChecked("git", ["-C", cwd, "log", "-1", "--format=%s"])
|
|
2557
|
-
]);
|
|
2558
|
-
return { sha: sha.stdout.trim(), subject: subject.stdout.trim() };
|
|
2559
|
-
}
|
|
2560
|
-
async function currentBranch(cwd) {
|
|
2561
|
-
const result = await run("git", ["-C", cwd, "branch", "--show-current"]);
|
|
2562
|
-
if (result.code !== 0 || !result.stdout.trim())
|
|
2563
|
-
throw new Error(`Cannot determine current branch in ${cwd}.`);
|
|
2564
|
-
return result.stdout.trim();
|
|
2565
|
-
}
|
|
2566
|
-
function parameters(schema) {
|
|
2567
|
-
return z4.toJSONSchema(schema, { io: "input" });
|
|
2568
|
-
}
|
|
2569
|
-
function result(text, details = {}) {
|
|
2570
|
-
return { content: [{ type: "text", text }], details };
|
|
2571
|
-
}
|
|
2572
|
-
|
|
2573
|
-
// src/tools/review.ts
|
|
2574
|
-
import { existsSync as existsSync2 } from "node:fs";
|
|
2575
|
-
import { mkdir as mkdir6, readdir as readdir2, readFile as readFile12, rename as rename4, stat, unlink as unlink2, writeFile as writeFile7 } from "node:fs/promises";
|
|
2576
|
-
import { join as join14 } from "node:path";
|
|
2577
|
-
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
2578
|
-
import { z as z8 } from "zod";
|
|
2579
|
-
|
|
2580
|
-
// src/extensions/ghx.ts
|
|
2581
|
-
function gh(args, options) {
|
|
2582
|
-
return run("gh", args, options);
|
|
2583
|
-
}
|
|
2584
|
-
function ghChecked(args, options) {
|
|
2585
|
-
return runChecked("gh", args, options);
|
|
2586
|
-
}
|
|
2587
|
-
|
|
2588
|
-
// src/vcs/helpers.ts
|
|
2589
|
-
function isConfirmedMissingChange(provider, output) {
|
|
2590
|
-
const message = output.toLowerCase();
|
|
2591
|
-
return provider === "github" ? message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest") : provider === "gitlab" ? message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message) : false;
|
|
2592
|
-
}
|
|
2593
|
-
function parseJson(value, name) {
|
|
2594
|
-
try {
|
|
2595
|
-
return JSON.parse(value);
|
|
2596
|
-
} catch {
|
|
2597
|
-
throw new Error(`Cannot parse the ${name} response as JSON.`);
|
|
450
|
+
// src/extensions/ghx.ts
|
|
451
|
+
function gh(args, options) {
|
|
452
|
+
return run("gh", args, options);
|
|
453
|
+
}
|
|
454
|
+
function ghChecked(args, options) {
|
|
455
|
+
return runChecked("gh", args, options);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// src/vcs/helpers.ts
|
|
459
|
+
function isConfirmedMissingChange(provider, output) {
|
|
460
|
+
const message = output.toLowerCase();
|
|
461
|
+
return provider === "github" ? message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest") : provider === "gitlab" ? message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message) : false;
|
|
462
|
+
}
|
|
463
|
+
function parseJson(value, name) {
|
|
464
|
+
try {
|
|
465
|
+
return JSON.parse(value);
|
|
466
|
+
} catch {
|
|
467
|
+
throw new Error(`Cannot parse the ${name} response as JSON.`);
|
|
2598
468
|
}
|
|
2599
469
|
}
|
|
2600
470
|
function commandFailure(command, args, result) {
|
|
@@ -2814,26 +684,266 @@ function GitLabVcsBackend(vcs) {
|
|
|
2814
684
|
};
|
|
2815
685
|
}
|
|
2816
686
|
|
|
2817
|
-
// src/vcs/forge-backend.ts
|
|
2818
|
-
function createForgeBackend(vcs) {
|
|
2819
|
-
if (vcs.provider === "github")
|
|
2820
|
-
return GitHubVcsBackend(vcs);
|
|
2821
|
-
if (vcs.provider === "gitlab")
|
|
2822
|
-
return GitLabVcsBackend(vcs);
|
|
2823
|
-
throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
|
|
687
|
+
// src/vcs/forge-backend.ts
|
|
688
|
+
function createForgeBackend(vcs) {
|
|
689
|
+
if (vcs.provider === "github")
|
|
690
|
+
return GitHubVcsBackend(vcs);
|
|
691
|
+
if (vcs.provider === "gitlab")
|
|
692
|
+
return GitLabVcsBackend(vcs);
|
|
693
|
+
throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
|
|
694
|
+
}
|
|
695
|
+
var createVcsBackend = createForgeBackend;
|
|
696
|
+
// src/extensions/misex.ts
|
|
697
|
+
import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
698
|
+
import { homedir as homedir2 } from "node:os";
|
|
699
|
+
import { basename as basename2, dirname as dirname2, join as join3 } from "node:path";
|
|
700
|
+
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
701
|
+
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
702
|
+
var mise = {
|
|
703
|
+
async executableCheck(name = "mise") {
|
|
704
|
+
return findExecutable(name);
|
|
705
|
+
},
|
|
706
|
+
async run(args, options = {}) {
|
|
707
|
+
return run("mise", args, options);
|
|
708
|
+
},
|
|
709
|
+
async install(options = {}) {
|
|
710
|
+
const homeDir = options.homeDir ?? homedir2();
|
|
711
|
+
const platform = options.platform ?? process.platform;
|
|
712
|
+
if (platform === "win32")
|
|
713
|
+
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
714
|
+
const installedPath = join3(homeDir, ".local", "bin", "mise");
|
|
715
|
+
if (options.dryRun)
|
|
716
|
+
return installedPath;
|
|
717
|
+
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
718
|
+
const executable = await findExecutable(installedPath) ?? await findExecutable("mise");
|
|
719
|
+
if (!executable)
|
|
720
|
+
throw new Error(`mise installation completed, but ${installedPath} was not found.`);
|
|
721
|
+
return executable;
|
|
722
|
+
},
|
|
723
|
+
async hookEnsure(executable, options = {}) {
|
|
724
|
+
const homeDir = options.homeDir ?? homedir2();
|
|
725
|
+
const hook = getShellHook(basename2(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
726
|
+
const current = await getOptionalFile(hook.path);
|
|
727
|
+
if (current.includes(MISE_HOOK_START))
|
|
728
|
+
return { path: hook.path, changed: false, planned: false };
|
|
729
|
+
if (options.dryRun)
|
|
730
|
+
return { path: hook.path, changed: true, planned: true };
|
|
731
|
+
const separator = current.length === 0 || current.endsWith(`
|
|
732
|
+
`) ? "" : `
|
|
733
|
+
`;
|
|
734
|
+
await mkdir2(dirname2(hook.path), { recursive: true });
|
|
735
|
+
await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
736
|
+
return { path: hook.path, changed: true, planned: false };
|
|
737
|
+
},
|
|
738
|
+
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
739
|
+
const result = await run(executable, ["ls", "--global", "--installed", tool, "--json"]);
|
|
740
|
+
return result.code === 0 && isToolInstalled(result.stdout, minimumVersion);
|
|
741
|
+
},
|
|
742
|
+
async toolInstallGlobal(executable, specification) {
|
|
743
|
+
await runChecked(executable, ["use", "--global", specification]);
|
|
744
|
+
},
|
|
745
|
+
async toolCheckLocal(executable, tool, cwd = process.cwd()) {
|
|
746
|
+
const result = await run(executable, ["ls", "--local", "--installed", tool, "--json"], { cwd });
|
|
747
|
+
return result.code === 0 && isToolInstalled(result.stdout);
|
|
748
|
+
},
|
|
749
|
+
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
750
|
+
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
751
|
+
},
|
|
752
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir2()) {
|
|
753
|
+
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
754
|
+
}
|
|
755
|
+
};
|
|
756
|
+
function getShellHook(shell, executable, homeDir) {
|
|
757
|
+
const command = getShellQuoted(executable);
|
|
758
|
+
switch (shell.toLowerCase()) {
|
|
759
|
+
case "zsh":
|
|
760
|
+
return {
|
|
761
|
+
path: join3(homeDir, ".zshrc"),
|
|
762
|
+
content: `${MISE_HOOK_START}
|
|
763
|
+
eval "$(${command} activate zsh)"
|
|
764
|
+
${MISE_HOOK_END}
|
|
765
|
+
`
|
|
766
|
+
};
|
|
767
|
+
case "fish":
|
|
768
|
+
return {
|
|
769
|
+
path: join3(homeDir, ".config", "fish", "config.fish"),
|
|
770
|
+
content: `${MISE_HOOK_START}
|
|
771
|
+
${command} activate fish | source
|
|
772
|
+
${MISE_HOOK_END}
|
|
773
|
+
`
|
|
774
|
+
};
|
|
775
|
+
case "nu":
|
|
776
|
+
case "nushell":
|
|
777
|
+
return {
|
|
778
|
+
path: join3(homeDir, ".config", "nushell", "config.nu"),
|
|
779
|
+
content: `${MISE_HOOK_START}
|
|
780
|
+
let mise_bin = ${command}
|
|
781
|
+
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
782
|
+
^$mise_bin activate nu | save $mise_path --force
|
|
783
|
+
use ($nu.default-config-dir | path join mise.nu)
|
|
784
|
+
${MISE_HOOK_END}
|
|
785
|
+
`
|
|
786
|
+
};
|
|
787
|
+
case "xonsh":
|
|
788
|
+
return {
|
|
789
|
+
path: join3(homeDir, ".xonshrc"),
|
|
790
|
+
content: `${MISE_HOOK_START}
|
|
791
|
+
execx($(${command} activate xonsh))
|
|
792
|
+
${MISE_HOOK_END}
|
|
793
|
+
`
|
|
794
|
+
};
|
|
795
|
+
case "elvish":
|
|
796
|
+
return {
|
|
797
|
+
path: join3(homeDir, ".config", "elvish", "rc.elv"),
|
|
798
|
+
content: `${MISE_HOOK_START}
|
|
799
|
+
var mise: = (ns [&])
|
|
800
|
+
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
801
|
+
mise:activate
|
|
802
|
+
${MISE_HOOK_END}
|
|
803
|
+
`
|
|
804
|
+
};
|
|
805
|
+
case "pwsh":
|
|
806
|
+
case "powershell":
|
|
807
|
+
return {
|
|
808
|
+
path: join3(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
809
|
+
content: `${MISE_HOOK_START}
|
|
810
|
+
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
811
|
+
${MISE_HOOK_END}
|
|
812
|
+
`
|
|
813
|
+
};
|
|
814
|
+
case "bash":
|
|
815
|
+
default:
|
|
816
|
+
return {
|
|
817
|
+
path: join3(homeDir, ".bashrc"),
|
|
818
|
+
content: `${MISE_HOOK_START}
|
|
819
|
+
eval "$(${command} activate bash)"
|
|
820
|
+
${MISE_HOOK_END}
|
|
821
|
+
`
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function isToolInstalled(output, minimumVersion) {
|
|
826
|
+
try {
|
|
827
|
+
const value = JSON.parse(output);
|
|
828
|
+
if (!Array.isArray(value))
|
|
829
|
+
return false;
|
|
830
|
+
return value.some((entry) => {
|
|
831
|
+
if (!entry || typeof entry !== "object" || !("installed" in entry) || entry.installed !== true)
|
|
832
|
+
return false;
|
|
833
|
+
if (!minimumVersion)
|
|
834
|
+
return true;
|
|
835
|
+
if (!("version" in entry) || typeof entry.version !== "string")
|
|
836
|
+
return false;
|
|
837
|
+
return isVersionAtLeast(entry.version, minimumVersion);
|
|
838
|
+
});
|
|
839
|
+
} catch {
|
|
840
|
+
return false;
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function isVersionAtLeast(version, minimumVersion) {
|
|
844
|
+
const current = version.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
845
|
+
const minimum = minimumVersion.match(/^v?(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number);
|
|
846
|
+
if (!current || !minimum)
|
|
847
|
+
return false;
|
|
848
|
+
for (let index = 0;index < minimum.length; index += 1) {
|
|
849
|
+
if (current[index] !== minimum[index])
|
|
850
|
+
return current[index] > minimum[index];
|
|
851
|
+
}
|
|
852
|
+
return true;
|
|
853
|
+
}
|
|
854
|
+
async function getOptionalFile(path) {
|
|
855
|
+
try {
|
|
856
|
+
return await readFile2(path, "utf8");
|
|
857
|
+
} catch (error) {
|
|
858
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
859
|
+
return "";
|
|
860
|
+
throw error;
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
function getShellQuoted(value) {
|
|
864
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// src/gates.ts
|
|
868
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
869
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
870
|
+
function checkConventionalSubject(subject) {
|
|
871
|
+
const trimmed = subject.trim();
|
|
872
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
873
|
+
return {
|
|
874
|
+
name: "conventional-subject",
|
|
875
|
+
status: ok ? "pass" : "warn",
|
|
876
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
877
|
+
};
|
|
2824
878
|
}
|
|
2825
|
-
|
|
879
|
+
async function runMiseGates(cwd) {
|
|
880
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
881
|
+
const results = [];
|
|
882
|
+
for (const gate of MISE_GATES) {
|
|
883
|
+
const targets = tasks.get(gate) ?? [];
|
|
884
|
+
if (targets.length === 0) {
|
|
885
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
889
|
+
const result = await mise.run(["run", ...invocations], { cwd });
|
|
890
|
+
results.push({
|
|
891
|
+
name: gate,
|
|
892
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
893
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
return results;
|
|
897
|
+
}
|
|
898
|
+
function ciGate(checksOutput) {
|
|
899
|
+
const text = checksOutput.toLowerCase();
|
|
900
|
+
if (!text.trim())
|
|
901
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
902
|
+
if (/\bfail|error\b/.test(text))
|
|
903
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
904
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
905
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
906
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
907
|
+
}
|
|
908
|
+
function parseMiseTasks(input) {
|
|
909
|
+
let tasks;
|
|
910
|
+
try {
|
|
911
|
+
tasks = JSON.parse(input);
|
|
912
|
+
} catch {
|
|
913
|
+
return new Map;
|
|
914
|
+
}
|
|
915
|
+
if (!Array.isArray(tasks))
|
|
916
|
+
return new Map;
|
|
917
|
+
const found = new Map;
|
|
918
|
+
for (const gate of MISE_GATES) {
|
|
919
|
+
const targets = tasks.flatMap((task) => {
|
|
920
|
+
if (typeof task.name !== "string")
|
|
921
|
+
return [];
|
|
922
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
923
|
+
});
|
|
924
|
+
if (targets.length > 0)
|
|
925
|
+
found.set(gate, [...new Set(targets)]);
|
|
926
|
+
}
|
|
927
|
+
return found;
|
|
928
|
+
}
|
|
929
|
+
async function discoverMiseTasks(cwd) {
|
|
930
|
+
const result = await mise.run(["tasks", "--json", "--all"], { cwd });
|
|
931
|
+
if (result.code !== 0)
|
|
932
|
+
return new Map;
|
|
933
|
+
return parseMiseTasks(result.stdout);
|
|
934
|
+
}
|
|
935
|
+
|
|
2826
936
|
// src/review/types.ts
|
|
2827
|
-
import { z as
|
|
2828
|
-
var severitySchema =
|
|
2829
|
-
var findingSchema =
|
|
2830
|
-
file:
|
|
2831
|
-
line:
|
|
937
|
+
import { z as z3 } from "zod";
|
|
938
|
+
var severitySchema = z3.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
939
|
+
var findingSchema = z3.object({
|
|
940
|
+
file: z3.string().min(1),
|
|
941
|
+
line: z3.number().int().nonnegative(),
|
|
2832
942
|
severity: severitySchema,
|
|
2833
|
-
body:
|
|
2834
|
-
reference:
|
|
943
|
+
body: z3.string().min(1),
|
|
944
|
+
reference: z3.string().optional().default("")
|
|
2835
945
|
});
|
|
2836
|
-
var findingsSchema =
|
|
946
|
+
var findingsSchema = z3.array(findingSchema);
|
|
2837
947
|
function reviewSlug(input) {
|
|
2838
948
|
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
2839
949
|
}
|
|
@@ -2897,17 +1007,17 @@ function renderCommentBody(finding) {
|
|
|
2897
1007
|
return `${prefix}${finding.body}${reference}`;
|
|
2898
1008
|
}
|
|
2899
1009
|
// src/review/review-markdown.ts
|
|
2900
|
-
import { z as
|
|
2901
|
-
var reviewThreadRecordSchema =
|
|
2902
|
-
id:
|
|
2903
|
-
file:
|
|
2904
|
-
line:
|
|
2905
|
-
body:
|
|
2906
|
-
author:
|
|
2907
|
-
resolved:
|
|
2908
|
-
addressed:
|
|
2909
|
-
question:
|
|
2910
|
-
replies:
|
|
1010
|
+
import { z as z4 } from "zod";
|
|
1011
|
+
var reviewThreadRecordSchema = z4.object({
|
|
1012
|
+
id: z4.string().min(1),
|
|
1013
|
+
file: z4.string().optional(),
|
|
1014
|
+
line: z4.number().int().positive().optional(),
|
|
1015
|
+
body: z4.string(),
|
|
1016
|
+
author: z4.string().optional(),
|
|
1017
|
+
resolved: z4.boolean(),
|
|
1018
|
+
addressed: z4.boolean().optional(),
|
|
1019
|
+
question: z4.boolean(),
|
|
1020
|
+
replies: z4.array(z4.string()).optional()
|
|
2911
1021
|
});
|
|
2912
1022
|
function renderReviewDoc(input) {
|
|
2913
1023
|
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
@@ -2994,7 +1104,7 @@ function parseThreadArtifact(content) {
|
|
|
2994
1104
|
let threads;
|
|
2995
1105
|
try {
|
|
2996
1106
|
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
2997
|
-
threads =
|
|
1107
|
+
threads = z4.array(reviewThreadRecordSchema).parse(decoded);
|
|
2998
1108
|
} catch {
|
|
2999
1109
|
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
3000
1110
|
}
|
|
@@ -3367,11 +1477,52 @@ function hasGitlabDraftNotes(input) {
|
|
|
3367
1477
|
}
|
|
3368
1478
|
|
|
3369
1479
|
// src/review/local-review-backend.ts
|
|
3370
|
-
import { readFile as
|
|
1480
|
+
import { readFile as readFile4, writeFile as writeFile3 } from "node:fs/promises";
|
|
1481
|
+
|
|
1482
|
+
// src/extensions/tuicrx.ts
|
|
1483
|
+
import { readFile as readFile3, realpath as realpath2 } from "node:fs/promises";
|
|
1484
|
+
import { resolve as resolve2 } from "node:path";
|
|
1485
|
+
|
|
1486
|
+
// src/extensions/gitx.ts
|
|
1487
|
+
import { realpath } from "node:fs/promises";
|
|
1488
|
+
import { basename as basename3, isAbsolute, join as join4, resolve } from "node:path";
|
|
1489
|
+
async function gitToplevel(cwd) {
|
|
1490
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
1491
|
+
const top = result.stdout.trim();
|
|
1492
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
1493
|
+
}
|
|
1494
|
+
async function inspectGitRepository(cwd) {
|
|
1495
|
+
const root = await gitToplevel(cwd);
|
|
1496
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
1497
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
1498
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
1499
|
+
const common = commonResult.stdout.trim();
|
|
1500
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
|
|
1501
|
+
const commonDir = await canonicalPath(commonPath);
|
|
1502
|
+
return {
|
|
1503
|
+
root,
|
|
1504
|
+
commonDir,
|
|
1505
|
+
...remote ? { remote } : {},
|
|
1506
|
+
name: remote ? repositoryName(remote) : basename3(resolve(commonDir, "..")) || basename3(root),
|
|
1507
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function normalizeRemote(remote) {
|
|
1511
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
1512
|
+
}
|
|
1513
|
+
function repositoryName(remote) {
|
|
1514
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
1515
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
1516
|
+
}
|
|
1517
|
+
async function canonicalPath(path) {
|
|
1518
|
+
try {
|
|
1519
|
+
return await realpath(path);
|
|
1520
|
+
} catch {
|
|
1521
|
+
return resolve(path);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
3371
1524
|
|
|
3372
1525
|
// src/extensions/tuicrx.ts
|
|
3373
|
-
import { readFile as readFile9, realpath as realpath4 } from "node:fs/promises";
|
|
3374
|
-
import { resolve as resolve4 } from "node:path";
|
|
3375
1526
|
async function tuicrAvailable() {
|
|
3376
1527
|
return Boolean(await findExecutable("tuicr"));
|
|
3377
1528
|
}
|
|
@@ -3446,7 +1597,7 @@ async function resolvePrSession(cwd, owner, repo, number) {
|
|
|
3446
1597
|
});
|
|
3447
1598
|
}
|
|
3448
1599
|
async function findMatchingSession(sessions, cwd, branch) {
|
|
3449
|
-
const repository = await
|
|
1600
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
3450
1601
|
for (const session of sessions) {
|
|
3451
1602
|
if (session.kind !== "local")
|
|
3452
1603
|
continue;
|
|
@@ -3454,14 +1605,14 @@ async function findMatchingSession(sessions, cwd, branch) {
|
|
|
3454
1605
|
const data = await readSession(session.path);
|
|
3455
1606
|
if (data.branch_name !== branch || !data.repo_path)
|
|
3456
1607
|
continue;
|
|
3457
|
-
if (await
|
|
1608
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
3458
1609
|
return session;
|
|
3459
1610
|
} catch {}
|
|
3460
1611
|
}
|
|
3461
1612
|
return;
|
|
3462
1613
|
}
|
|
3463
1614
|
async function readSession(path) {
|
|
3464
|
-
const content = await
|
|
1615
|
+
const content = await readFile3(path, "utf8");
|
|
3465
1616
|
try {
|
|
3466
1617
|
return JSON.parse(content);
|
|
3467
1618
|
} catch {
|
|
@@ -3541,11 +1692,11 @@ async function requireTuicr() {
|
|
|
3541
1692
|
function commentAuthor(comment) {
|
|
3542
1693
|
return comment.username ?? comment.author;
|
|
3543
1694
|
}
|
|
3544
|
-
async function
|
|
1695
|
+
async function canonicalPath2(path) {
|
|
3545
1696
|
try {
|
|
3546
|
-
return await
|
|
1697
|
+
return await realpath2(path);
|
|
3547
1698
|
} catch {
|
|
3548
|
-
return
|
|
1699
|
+
return resolve2(path);
|
|
3549
1700
|
}
|
|
3550
1701
|
}
|
|
3551
1702
|
|
|
@@ -3570,7 +1721,7 @@ function LocalReviewBackend(options) {
|
|
|
3570
1721
|
},
|
|
3571
1722
|
async listThreads() {
|
|
3572
1723
|
try {
|
|
3573
|
-
return parseThreadArtifact(await
|
|
1724
|
+
return parseThreadArtifact(await readFile4(options.artifactPath, "utf8"));
|
|
3574
1725
|
} catch (error) {
|
|
3575
1726
|
if (error.code === "ENOENT")
|
|
3576
1727
|
return [];
|
|
@@ -3578,8 +1729,8 @@ function LocalReviewBackend(options) {
|
|
|
3578
1729
|
}
|
|
3579
1730
|
},
|
|
3580
1731
|
async reply(input) {
|
|
3581
|
-
const content = await
|
|
3582
|
-
await
|
|
1732
|
+
const content = await readFile4(options.artifactPath, "utf8");
|
|
1733
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
3583
1734
|
},
|
|
3584
1735
|
async publish() {
|
|
3585
1736
|
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
@@ -3600,15 +1751,100 @@ function createLocalReviewBackend(options) {
|
|
|
3600
1751
|
}
|
|
3601
1752
|
// src/review/review-state.ts
|
|
3602
1753
|
import { createHash as createHash2 } from "node:crypto";
|
|
3603
|
-
import { readFile as
|
|
3604
|
-
import { join as
|
|
3605
|
-
import { z as
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
1754
|
+
import { readFile as readFile5, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
1755
|
+
import { join as join6 } from "node:path";
|
|
1756
|
+
import { z as z5 } from "zod";
|
|
1757
|
+
|
|
1758
|
+
// src/store.ts
|
|
1759
|
+
import { createHash } from "node:crypto";
|
|
1760
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
1761
|
+
import { homedir as homedir3 } from "node:os";
|
|
1762
|
+
import { dirname as dirname3, isAbsolute as isAbsolute2, join as join5, resolve as resolve3 } from "node:path";
|
|
1763
|
+
var STORE_LINK = ".diffpi";
|
|
1764
|
+
var LEGACY_STORE_LINK = join5(".pi", "diffpi");
|
|
1765
|
+
function storeGlobalRoot(homeDir = homedir3()) {
|
|
1766
|
+
return join5(homeDir, ".difflab", "diffpi", "projects");
|
|
1767
|
+
}
|
|
1768
|
+
async function ensureStore(cwd, homeDir = homedir3()) {
|
|
1769
|
+
const repository = await inspectGitRepository(cwd);
|
|
1770
|
+
const root = repository.root;
|
|
1771
|
+
const slug = projectSlug(repository);
|
|
1772
|
+
const dest = join5(storeGlobalRoot(homeDir), slug);
|
|
1773
|
+
const link = join5(root, STORE_LINK);
|
|
1774
|
+
await mkdir3(dest, { recursive: true });
|
|
1775
|
+
try {
|
|
1776
|
+
await assertStoreLink(link, dest);
|
|
1777
|
+
} catch (error) {
|
|
1778
|
+
if (error.code !== "ENOENT")
|
|
1779
|
+
throw error;
|
|
1780
|
+
await symlink(dest, link);
|
|
1781
|
+
}
|
|
1782
|
+
await removeLegacyStoreLink(join5(root, LEGACY_STORE_LINK), dest);
|
|
1783
|
+
return { slug, root, dest, link, linked: true };
|
|
1784
|
+
}
|
|
1785
|
+
async function reviewsDir(cwd, homeDir = homedir3()) {
|
|
1786
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1787
|
+
const dir = join5(store.link, "review");
|
|
1788
|
+
await mkdir3(dir, { recursive: true });
|
|
1789
|
+
return dir;
|
|
1790
|
+
}
|
|
1791
|
+
async function completedReviewsDir(cwd, homeDir = homedir3()) {
|
|
1792
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1793
|
+
const dir = join5(store.link, "reviews");
|
|
1794
|
+
await mkdir3(dir, { recursive: true });
|
|
1795
|
+
return dir;
|
|
1796
|
+
}
|
|
1797
|
+
async function sessionsDir(cwd, homeDir = homedir3()) {
|
|
1798
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1799
|
+
const dir = join5(store.link, "sessions");
|
|
1800
|
+
await mkdir3(dir, { recursive: true });
|
|
1801
|
+
return dir;
|
|
1802
|
+
}
|
|
1803
|
+
async function assertStoreLink(path, dest) {
|
|
1804
|
+
const entry = await lstat(path);
|
|
1805
|
+
if (!entry.isSymbolicLink())
|
|
1806
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
1807
|
+
const target = await symlinkTarget(path);
|
|
1808
|
+
if (target !== await canonicalPath3(dest))
|
|
1809
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1810
|
+
}
|
|
1811
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
1812
|
+
try {
|
|
1813
|
+
const entry = await lstat(path);
|
|
1814
|
+
if (!entry.isSymbolicLink())
|
|
1815
|
+
return;
|
|
1816
|
+
const target = await symlinkTarget(path);
|
|
1817
|
+
if (target === await canonicalPath3(dest))
|
|
1818
|
+
await unlink(path);
|
|
1819
|
+
} catch (error) {
|
|
1820
|
+
if (error.code !== "ENOENT")
|
|
1821
|
+
throw error;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
async function symlinkTarget(path) {
|
|
1825
|
+
const target = await readlink(path);
|
|
1826
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname3(path), target));
|
|
1827
|
+
}
|
|
1828
|
+
async function canonicalPath3(path) {
|
|
1829
|
+
try {
|
|
1830
|
+
return await realpath3(path);
|
|
1831
|
+
} catch {
|
|
1832
|
+
return resolve3(path);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
function projectSlug(repository) {
|
|
1836
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1837
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1838
|
+
return `${readable}-${digest}`;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// src/review/review-state.ts
|
|
1842
|
+
var reviewPublicationStateSchema = z5.object({
|
|
1843
|
+
target: z5.string().optional(),
|
|
1844
|
+
bodies: z5.array(z5.string()).default([]),
|
|
1845
|
+
comments: z5.array(z5.string()).default([]),
|
|
1846
|
+
replies: z5.array(z5.string()).default([]),
|
|
1847
|
+
overlayPath: z5.string().optional()
|
|
3612
1848
|
});
|
|
3613
1849
|
function reviewBodyFingerprint(body) {
|
|
3614
1850
|
return digest(body);
|
|
@@ -3624,15 +1860,15 @@ function unpublishedReviewComments(comments, knownFingerprints) {
|
|
|
3624
1860
|
}
|
|
3625
1861
|
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
3626
1862
|
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
3627
|
-
const path =
|
|
1863
|
+
const path = join6(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
3628
1864
|
try {
|
|
3629
|
-
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await
|
|
1865
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile5(path, "utf8")));
|
|
3630
1866
|
return { path, state: { ...parsed, target } };
|
|
3631
1867
|
} catch (error) {
|
|
3632
1868
|
if (error.code === "ENOENT") {
|
|
3633
1869
|
return { path, state: { target, bodies: [], comments: [], replies: [] } };
|
|
3634
1870
|
}
|
|
3635
|
-
if (error instanceof SyntaxError || error instanceof
|
|
1871
|
+
if (error instanceof SyntaxError || error instanceof z5.ZodError) {
|
|
3636
1872
|
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
3637
1873
|
}
|
|
3638
1874
|
throw error;
|
|
@@ -3640,49 +1876,103 @@ async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
|
3640
1876
|
}
|
|
3641
1877
|
async function saveReviewPublicationState(path, state) {
|
|
3642
1878
|
const temp = `${path}.${process.pid}.tmp`;
|
|
3643
|
-
await
|
|
1879
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
3644
1880
|
`, "utf8");
|
|
3645
|
-
await
|
|
1881
|
+
await rename(temp, path);
|
|
3646
1882
|
}
|
|
3647
1883
|
function digest(value) {
|
|
3648
1884
|
return createHash2("sha256").update(value).digest("hex");
|
|
3649
1885
|
}
|
|
1886
|
+
// src/templates.ts
|
|
1887
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
1888
|
+
import { homedir as homedir4 } from "node:os";
|
|
1889
|
+
import { join as join8, normalize } from "node:path";
|
|
1890
|
+
|
|
1891
|
+
// src/assets.ts
|
|
1892
|
+
import { existsSync } from "node:fs";
|
|
1893
|
+
import { dirname as dirname4, join as join7 } from "node:path";
|
|
1894
|
+
import { fileURLToPath } from "node:url";
|
|
1895
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
1896
|
+
const moduleDir = dirname4(fileURLToPath(moduleUrl));
|
|
1897
|
+
const candidates = [join7(moduleDir, name), join7(moduleDir, "..", name), join7(moduleDir, "..", "..", name)];
|
|
1898
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
1899
|
+
}
|
|
1900
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
1901
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
1902
|
+
}
|
|
1903
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
1904
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// src/templates.ts
|
|
1908
|
+
async function loadTemplate(name, options = {}) {
|
|
1909
|
+
const relative = templateRelativePath(name);
|
|
1910
|
+
const userPath = join8(options.homeDir ?? homedir4(), ".difflab", "diffpi", "templates", relative);
|
|
1911
|
+
const bundledPath = join8(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
1912
|
+
const user = await readOptionalFile(userPath);
|
|
1913
|
+
if (user !== undefined)
|
|
1914
|
+
return { name, path: userPath, source: "user", content: user };
|
|
1915
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
1916
|
+
if (bundled !== undefined)
|
|
1917
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
1918
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
1919
|
+
}
|
|
1920
|
+
function renderTemplate(content, variables) {
|
|
1921
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
1922
|
+
}
|
|
1923
|
+
function templateRelativePath(name) {
|
|
1924
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
1925
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
1926
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
1927
|
+
}
|
|
1928
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
1929
|
+
}
|
|
1930
|
+
async function readOptionalFile(path) {
|
|
1931
|
+
try {
|
|
1932
|
+
return await readFile6(path, "utf8");
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
if (error.code === "ENOENT")
|
|
1935
|
+
return;
|
|
1936
|
+
throw error;
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
|
|
3650
1940
|
// src/tools/review.ts
|
|
3651
|
-
var contextSchema =
|
|
3652
|
-
cwd:
|
|
3653
|
-
target:
|
|
3654
|
-
workingTree:
|
|
1941
|
+
var contextSchema = z6.object({
|
|
1942
|
+
cwd: z6.string().optional(),
|
|
1943
|
+
target: z6.string().optional(),
|
|
1944
|
+
workingTree: z6.boolean().optional()
|
|
3655
1945
|
});
|
|
3656
|
-
var localSchema = contextSchema.extend({ local:
|
|
1946
|
+
var localSchema = contextSchema.extend({ local: z6.boolean().optional() });
|
|
3657
1947
|
var openSchema = localSchema.extend({
|
|
3658
|
-
title:
|
|
3659
|
-
intent:
|
|
3660
|
-
issueUrl:
|
|
3661
|
-
base:
|
|
1948
|
+
title: z6.string().optional(),
|
|
1949
|
+
intent: z6.string().optional(),
|
|
1950
|
+
issueUrl: z6.string().url().optional(),
|
|
1951
|
+
base: z6.string().optional()
|
|
3662
1952
|
});
|
|
3663
1953
|
var submitSchema = localSchema.extend({
|
|
3664
1954
|
findings: findingsSchema,
|
|
3665
|
-
overallIssues:
|
|
3666
|
-
notVerified:
|
|
3667
|
-
title:
|
|
1955
|
+
overallIssues: z6.array(z6.string()).optional(),
|
|
1956
|
+
notVerified: z6.array(z6.string()).optional(),
|
|
1957
|
+
title: z6.string().optional()
|
|
3668
1958
|
});
|
|
3669
1959
|
var addCommentSchema = localSchema.extend({
|
|
3670
|
-
body:
|
|
3671
|
-
file:
|
|
3672
|
-
line:
|
|
3673
|
-
side:
|
|
1960
|
+
body: z6.string().min(1),
|
|
1961
|
+
file: z6.string().min(1),
|
|
1962
|
+
line: z6.number().int().positive(),
|
|
1963
|
+
side: z6.enum(["LEFT", "RIGHT"]).optional()
|
|
3674
1964
|
});
|
|
3675
1965
|
var respondSchema = localSchema.extend({
|
|
3676
|
-
threadId:
|
|
3677
|
-
body:
|
|
3678
|
-
question:
|
|
3679
|
-
resolve:
|
|
1966
|
+
threadId: z6.string().min(1),
|
|
1967
|
+
body: z6.string().min(1),
|
|
1968
|
+
question: z6.boolean().optional(),
|
|
1969
|
+
resolve: z6.boolean().optional()
|
|
3680
1970
|
});
|
|
3681
1971
|
var publishSchema = localSchema.extend({
|
|
3682
|
-
status:
|
|
1972
|
+
status: z6.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
3683
1973
|
});
|
|
3684
1974
|
var completeSchema = localSchema.extend({
|
|
3685
|
-
action:
|
|
1975
|
+
action: z6.enum(["approve", "reject", "abandon"]).optional()
|
|
3686
1976
|
});
|
|
3687
1977
|
function hasReviewDraft(comments, body) {
|
|
3688
1978
|
return comments.length > 0 || body.trim().length > 0;
|
|
@@ -3706,13 +1996,13 @@ async function workingTreeDiff(cwd) {
|
|
|
3706
1996
|
}
|
|
3707
1997
|
function createReviewTools() {
|
|
3708
1998
|
return [
|
|
3709
|
-
|
|
1999
|
+
defineTool3({
|
|
3710
2000
|
name: "review_context",
|
|
3711
2001
|
label: "review context",
|
|
3712
2002
|
description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
|
|
3713
2003
|
promptSnippet: "Call review_context first",
|
|
3714
2004
|
promptGuidelines: ["Call this before every review workflow."],
|
|
3715
|
-
parameters:
|
|
2005
|
+
parameters: parameters(localSchema),
|
|
3716
2006
|
executionMode: "parallel",
|
|
3717
2007
|
async execute(_id, input) {
|
|
3718
2008
|
const params = localSchema.parse(input);
|
|
@@ -3722,7 +2012,7 @@ function createReviewTools() {
|
|
|
3722
2012
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
3723
2013
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
3724
2014
|
const backend = params.local || params.workingTree ? "tuicr" : review.forge?.provider ?? "unsupported";
|
|
3725
|
-
return
|
|
2015
|
+
return result([
|
|
3726
2016
|
`Backend: ${backend}`,
|
|
3727
2017
|
`Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
|
|
3728
2018
|
`Branch: ${review.vcs.branch} → ${baseRef}`,
|
|
@@ -3734,13 +2024,13 @@ function createReviewTools() {
|
|
|
3734
2024
|
`), { ...review, env, store, session, baseRef, backend });
|
|
3735
2025
|
}
|
|
3736
2026
|
}),
|
|
3737
|
-
|
|
2027
|
+
defineTool3({
|
|
3738
2028
|
name: "review_new",
|
|
3739
2029
|
label: "review new",
|
|
3740
2030
|
description: "Create a new local tuicr review or a remote draft PR/MR and open it in tuicr.",
|
|
3741
2031
|
promptSnippet: "Call review_new to start",
|
|
3742
2032
|
promptGuidelines: ["Use local to select tuicr as the review backend."],
|
|
3743
|
-
parameters:
|
|
2033
|
+
parameters: parameters(openSchema),
|
|
3744
2034
|
executionMode: "sequential",
|
|
3745
2035
|
async execute(_id, input) {
|
|
3746
2036
|
const params = openSchema.parse(input);
|
|
@@ -3749,9 +2039,9 @@ function createReviewTools() {
|
|
|
3749
2039
|
if (params.local || params.workingTree)
|
|
3750
2040
|
return launchLocalReview(review, true, params.base);
|
|
3751
2041
|
if (!review.forge)
|
|
3752
|
-
return
|
|
2042
|
+
return result(unsupportedForgeMessage());
|
|
3753
2043
|
if (review.pr)
|
|
3754
|
-
return
|
|
2044
|
+
return result(`A remote review already exists for this branch: ${review.pr.url}. Use review_edit to open it.`);
|
|
3755
2045
|
await assertRemoteBranchReady(review.cwd);
|
|
3756
2046
|
const base = params.base ?? await review.forge.defaultBranch();
|
|
3757
2047
|
const template = await loadTemplate("review/draft-pr");
|
|
@@ -3769,7 +2059,7 @@ function createReviewTools() {
|
|
|
3769
2059
|
});
|
|
3770
2060
|
const launched = await launch(review.cwd, pr.number);
|
|
3771
2061
|
const openMessage = launched.launched ? `Opened PR/MR #${pr.number} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`;
|
|
3772
|
-
return
|
|
2062
|
+
return result(`Draft PR/MR created from ${template.source} template: ${pr.url}
|
|
3773
2063
|
${openMessage}`, {
|
|
3774
2064
|
pr,
|
|
3775
2065
|
template,
|
|
@@ -3777,13 +2067,13 @@ ${openMessage}`, {
|
|
|
3777
2067
|
});
|
|
3778
2068
|
}
|
|
3779
2069
|
}),
|
|
3780
|
-
|
|
2070
|
+
defineTool3({
|
|
3781
2071
|
name: "review_edit",
|
|
3782
2072
|
label: "review edit",
|
|
3783
2073
|
description: "Open an existing local tuicr session or remote PR/MR in tuicr without generating findings.",
|
|
3784
2074
|
promptSnippet: "Call review_edit to continue an existing review",
|
|
3785
2075
|
promptGuidelines: ["This tool never creates review findings or a PR/MR."],
|
|
3786
|
-
parameters:
|
|
2076
|
+
parameters: parameters(localSchema),
|
|
3787
2077
|
executionMode: "sequential",
|
|
3788
2078
|
async execute(_id, input) {
|
|
3789
2079
|
const params = localSchema.parse(input);
|
|
@@ -3791,46 +2081,46 @@ ${openMessage}`, {
|
|
|
3791
2081
|
await ensureStore(review.cwd);
|
|
3792
2082
|
if (params.local || params.workingTree) {
|
|
3793
2083
|
if (!await resolveTuicrSession(review, true))
|
|
3794
|
-
return
|
|
2084
|
+
return result("No local tuicr review exists. Use review_new with local=true to create one.");
|
|
3795
2085
|
return launchLocalReview(review, true);
|
|
3796
2086
|
}
|
|
3797
2087
|
if (!review.forge)
|
|
3798
|
-
return
|
|
2088
|
+
return result(unsupportedForgeMessage());
|
|
3799
2089
|
if (!review.pr)
|
|
3800
|
-
return
|
|
2090
|
+
return result("No remote PR/MR exists. Use review_new to create a draft review first.");
|
|
3801
2091
|
return launchLocalReview(review, false);
|
|
3802
2092
|
}
|
|
3803
2093
|
}),
|
|
3804
|
-
|
|
2094
|
+
defineTool3({
|
|
3805
2095
|
name: "review_diff",
|
|
3806
2096
|
label: "review diff",
|
|
3807
2097
|
description: "Fetch the target PR/MR diff or auto-detected local working-tree diff.",
|
|
3808
2098
|
promptSnippet: "Call review_diff for the code under review",
|
|
3809
2099
|
promptGuidelines: ["Ground findings in this diff."],
|
|
3810
|
-
parameters:
|
|
2100
|
+
parameters: parameters(localSchema),
|
|
3811
2101
|
executionMode: "parallel",
|
|
3812
2102
|
async execute(_id, input) {
|
|
3813
2103
|
const params = localSchema.parse(input);
|
|
3814
2104
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
3815
2105
|
if (params.local || params.workingTree) {
|
|
3816
2106
|
const diff = await workingTreeDiff(review.cwd);
|
|
3817
|
-
return
|
|
2107
|
+
return result(diff || "No working-tree changes.", { diff, target: "local" });
|
|
3818
2108
|
}
|
|
3819
2109
|
if (!review.forge)
|
|
3820
|
-
return
|
|
2110
|
+
return result(unsupportedForgeMessage());
|
|
3821
2111
|
if (!review.pr)
|
|
3822
|
-
return
|
|
2112
|
+
return result("No PR/MR matches this remote review target.");
|
|
3823
2113
|
const diff = await review.forge.prDiff(review.pr.number);
|
|
3824
|
-
return
|
|
2114
|
+
return result(diff || "Empty diff.", { diff, pr: review.pr });
|
|
3825
2115
|
}
|
|
3826
2116
|
}),
|
|
3827
|
-
|
|
2117
|
+
defineTool3({
|
|
3828
2118
|
name: "review_gates",
|
|
3829
2119
|
label: "review gates",
|
|
3830
2120
|
description: "Run format, lint, test, conventional-subject, and available CI checks.",
|
|
3831
2121
|
promptSnippet: "Call review_gates before submitting findings",
|
|
3832
2122
|
promptGuidelines: ["Report skipped gates as skipped."],
|
|
3833
|
-
parameters:
|
|
2123
|
+
parameters: parameters(localSchema),
|
|
3834
2124
|
executionMode: "parallel",
|
|
3835
2125
|
async execute(_id, input) {
|
|
3836
2126
|
const params = localSchema.parse(input);
|
|
@@ -3842,19 +2132,19 @@ ${openMessage}`, {
|
|
|
3842
2132
|
gates.push(checkConventionalSubject(subject));
|
|
3843
2133
|
if (!params.local && review.pr && review.forge)
|
|
3844
2134
|
gates.push(ciGate(await review.forge.prChecks(review.pr.number)));
|
|
3845
|
-
return
|
|
2135
|
+
return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
3846
2136
|
`), {
|
|
3847
2137
|
results: gates
|
|
3848
2138
|
});
|
|
3849
2139
|
}
|
|
3850
2140
|
}),
|
|
3851
|
-
|
|
2141
|
+
defineTool3({
|
|
3852
2142
|
name: "review_submit",
|
|
3853
2143
|
label: "review submit",
|
|
3854
2144
|
description: "Write the review artifact and stage comments in the selected local or remote backend.",
|
|
3855
2145
|
promptSnippet: "Call review_submit with the findings JSON",
|
|
3856
2146
|
promptGuidelines: ["Remote comments remain pending until review_publish."],
|
|
3857
|
-
parameters:
|
|
2147
|
+
parameters: parameters(submitSchema),
|
|
3858
2148
|
executionMode: "sequential",
|
|
3859
2149
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3860
2150
|
const params = submitSchema.parse(input);
|
|
@@ -3862,12 +2152,12 @@ ${openMessage}`, {
|
|
|
3862
2152
|
const model = modelRoute(ctx);
|
|
3863
2153
|
const useLocalBackend = Boolean(params.local || params.workingTree);
|
|
3864
2154
|
if (!useLocalBackend && !review.forge)
|
|
3865
|
-
return
|
|
2155
|
+
return result(unsupportedForgeMessage());
|
|
3866
2156
|
const findings = dedupeFindings(params.findings);
|
|
3867
2157
|
const gates = await runMiseGates(review.cwd);
|
|
3868
2158
|
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
3869
2159
|
const artifact = await newReviewArtifactPath(review.cwd, params.local || params.workingTree || !review.pr ? "uncommitted" : await reviewTargetId(review));
|
|
3870
|
-
await
|
|
2160
|
+
await writeFile5(artifact, renderReviewDoc({
|
|
3871
2161
|
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
3872
2162
|
number: useLocalBackend ? undefined : review.pr?.number,
|
|
3873
2163
|
url: useLocalBackend ? undefined : review.pr?.url,
|
|
@@ -3885,7 +2175,7 @@ ${openMessage}`, {
|
|
|
3885
2175
|
if (useLocalBackend) {
|
|
3886
2176
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
3887
2177
|
if (!session) {
|
|
3888
|
-
return
|
|
2178
|
+
return result(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
|
|
3889
2179
|
artifact,
|
|
3890
2180
|
count: findings.length
|
|
3891
2181
|
});
|
|
@@ -3896,28 +2186,28 @@ ${openMessage}`, {
|
|
|
3896
2186
|
author: localReviewAuthor(model)
|
|
3897
2187
|
});
|
|
3898
2188
|
await backend.stage({ comments, body });
|
|
3899
|
-
return
|
|
2189
|
+
return result(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
3900
2190
|
}
|
|
3901
2191
|
if (!review.pr)
|
|
3902
|
-
return
|
|
2192
|
+
return result(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
3903
2193
|
const hasDraft = hasReviewDraft(comments, body);
|
|
3904
2194
|
if (hasDraft) {
|
|
3905
2195
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body });
|
|
3906
2196
|
}
|
|
3907
|
-
return
|
|
2197
|
+
return result(`${hasDraft ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
|
|
3908
2198
|
artifact,
|
|
3909
2199
|
pr: review.pr,
|
|
3910
2200
|
count: findings.length
|
|
3911
2201
|
});
|
|
3912
2202
|
}
|
|
3913
2203
|
}),
|
|
3914
|
-
|
|
2204
|
+
defineTool3({
|
|
3915
2205
|
name: "review_add_comment",
|
|
3916
2206
|
label: "review add comment",
|
|
3917
2207
|
description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
|
|
3918
2208
|
promptSnippet: "Call review_add_comment for incremental comments",
|
|
3919
2209
|
promptGuidelines: ["Pass local=true when tuicr owns the draft."],
|
|
3920
|
-
parameters:
|
|
2210
|
+
parameters: parameters(addCommentSchema),
|
|
3921
2211
|
executionMode: "sequential",
|
|
3922
2212
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3923
2213
|
const params = addCommentSchema.parse(input);
|
|
@@ -3925,7 +2215,7 @@ ${openMessage}`, {
|
|
|
3925
2215
|
const model = modelRoute(ctx);
|
|
3926
2216
|
const useLocalBackend = Boolean(params.local || params.workingTree);
|
|
3927
2217
|
if (!useLocalBackend && !review.forge)
|
|
3928
|
-
return
|
|
2218
|
+
return result(unsupportedForgeMessage());
|
|
3929
2219
|
const comment = {
|
|
3930
2220
|
file: params.file,
|
|
3931
2221
|
line: params.line,
|
|
@@ -3935,28 +2225,28 @@ ${openMessage}`, {
|
|
|
3935
2225
|
if (useLocalBackend) {
|
|
3936
2226
|
const session = await resolveTuicrSession(review, Boolean(params.local || params.workingTree));
|
|
3937
2227
|
if (!session)
|
|
3938
|
-
return
|
|
2228
|
+
return result("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
3939
2229
|
const backend = createLocalReviewBackend({
|
|
3940
2230
|
session: session.path,
|
|
3941
2231
|
artifactPath: "",
|
|
3942
2232
|
author: localReviewAuthor(model)
|
|
3943
2233
|
});
|
|
3944
2234
|
await backend.stage({ comments: [comment], body: "" });
|
|
3945
|
-
return
|
|
2235
|
+
return result(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
3946
2236
|
}
|
|
3947
2237
|
if (!review.pr)
|
|
3948
|
-
return
|
|
2238
|
+
return result("No PR/MR matches this remote review target.");
|
|
3949
2239
|
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
|
|
3950
|
-
return
|
|
2240
|
+
return result(`Draft comment added to #${review.pr.number}.`, { pr: review.pr, comment });
|
|
3951
2241
|
}
|
|
3952
2242
|
}),
|
|
3953
|
-
|
|
2243
|
+
defineTool3({
|
|
3954
2244
|
name: "review_comments",
|
|
3955
2245
|
label: "review comments",
|
|
3956
2246
|
description: "Pull review threads or local tuicr comments and write a target-named artifact.",
|
|
3957
2247
|
promptSnippet: "Call review_comments before addressing findings",
|
|
3958
2248
|
promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
|
|
3959
|
-
parameters:
|
|
2249
|
+
parameters: parameters(localSchema),
|
|
3960
2250
|
executionMode: "sequential",
|
|
3961
2251
|
async execute(_id, input) {
|
|
3962
2252
|
const params = localSchema.parse(input);
|
|
@@ -3966,28 +2256,28 @@ ${openMessage}`, {
|
|
|
3966
2256
|
if (params.local || params.workingTree) {
|
|
3967
2257
|
const session = await resolveTuicrSession(review, true);
|
|
3968
2258
|
if (!session)
|
|
3969
|
-
return
|
|
2259
|
+
return result("No matching tuicr session found.");
|
|
3970
2260
|
artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
3971
2261
|
threads = await syncLocalThreadArtifact(artifact, review.pr?.title ?? review.vcs.branch, session.slug, toLocalReviewThreads(await readSession(session.path)));
|
|
3972
2262
|
} else if (review.pr && review.forge) {
|
|
3973
2263
|
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
3974
2264
|
const target = await reviewTargetId(review);
|
|
3975
2265
|
artifact = await newReviewArtifactPath(review.cwd, target);
|
|
3976
|
-
await
|
|
2266
|
+
await writeFile5(artifact, renderThreadArtifact(review.pr.title, target, threads, { number: review.pr.number, url: review.pr.url }), "utf8");
|
|
3977
2267
|
} else {
|
|
3978
|
-
return
|
|
2268
|
+
return result(review.forge ? "No PR/MR matches this remote review target." : unsupportedForgeMessage());
|
|
3979
2269
|
}
|
|
3980
|
-
return
|
|
2270
|
+
return result(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
3981
2271
|
`) || "No comments.", { artifact, threads });
|
|
3982
2272
|
}
|
|
3983
2273
|
}),
|
|
3984
|
-
|
|
2274
|
+
defineTool3({
|
|
3985
2275
|
name: "review_respond",
|
|
3986
2276
|
label: "review respond",
|
|
3987
2277
|
description: "Post local question replies to tuicr and record their publish overlay, or reply to remote threads.",
|
|
3988
2278
|
promptSnippet: "Call review_respond after addressing a comment",
|
|
3989
2279
|
promptGuidelines: ["Question replies remain unresolved."],
|
|
3990
|
-
parameters:
|
|
2280
|
+
parameters: parameters(respondSchema),
|
|
3991
2281
|
executionMode: "sequential",
|
|
3992
2282
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
3993
2283
|
const params = respondSchema.parse(input);
|
|
@@ -3996,10 +2286,10 @@ ${openMessage}`, {
|
|
|
3996
2286
|
if (params.local) {
|
|
3997
2287
|
const tuicrSession = await resolveTuicrSession(review, true);
|
|
3998
2288
|
if (!tuicrSession)
|
|
3999
|
-
return
|
|
2289
|
+
return result("No matching tuicr session. Open review_new or review_launch_ui first.");
|
|
4000
2290
|
const artifact = await localThreadArtifactPath(review.cwd, tuicrSession.slug);
|
|
4001
2291
|
if (!existsSync2(artifact))
|
|
4002
|
-
return
|
|
2292
|
+
return result("No local review artifact. Run review_comments with local=true first.");
|
|
4003
2293
|
const threads = await readThreadArtifact(artifact);
|
|
4004
2294
|
const known = threads.find((thread) => thread.id === params.threadId);
|
|
4005
2295
|
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
@@ -4021,10 +2311,10 @@ ${params.body}`, {
|
|
|
4021
2311
|
resolve: false,
|
|
4022
2312
|
question
|
|
4023
2313
|
});
|
|
4024
|
-
return
|
|
2314
|
+
return result(`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 });
|
|
4025
2315
|
}
|
|
4026
2316
|
if (!review.pr || !review.forge)
|
|
4027
|
-
return
|
|
2317
|
+
return result("No remote PR/MR for this reply.");
|
|
4028
2318
|
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
4029
2319
|
const remoteThreads = await remote.listThreads();
|
|
4030
2320
|
const known = remoteThreads.find((thread) => thread.id === params.threadId);
|
|
@@ -4036,35 +2326,35 @@ ${params.body}`, {
|
|
|
4036
2326
|
resolve,
|
|
4037
2327
|
question
|
|
4038
2328
|
});
|
|
4039
|
-
return
|
|
2329
|
+
return result(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
4040
2330
|
pr: review.pr,
|
|
4041
2331
|
resolve
|
|
4042
2332
|
});
|
|
4043
2333
|
}
|
|
4044
2334
|
}),
|
|
4045
|
-
|
|
2335
|
+
defineTool3({
|
|
4046
2336
|
name: "review_publish",
|
|
4047
2337
|
label: "review publish",
|
|
4048
2338
|
description: "Publish pending remote draft PR/MR review comments and status, or promote a local tuicr draft before publishing.",
|
|
4049
2339
|
promptSnippet: "Call review_publish to make remote review work public",
|
|
4050
2340
|
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
4051
|
-
parameters:
|
|
2341
|
+
parameters: parameters(publishSchema),
|
|
4052
2342
|
executionMode: "sequential",
|
|
4053
2343
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
4054
2344
|
const params = publishSchema.parse(input);
|
|
4055
2345
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
4056
2346
|
if (!review.pr || !review.forge)
|
|
4057
|
-
return
|
|
2347
|
+
return result("No remote PR/MR to publish.");
|
|
4058
2348
|
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
4059
2349
|
}
|
|
4060
2350
|
}),
|
|
4061
|
-
|
|
2351
|
+
defineTool3({
|
|
4062
2352
|
name: "review_complete",
|
|
4063
2353
|
label: "review complete",
|
|
4064
2354
|
description: "Approve, reject, or abandon a remote review, or archive a local review artifact.",
|
|
4065
2355
|
promptSnippet: "Call review_complete to finish a review without merging it.",
|
|
4066
2356
|
promptGuidelines: ["A local completion archives the Diffpi artifact and deletes the matching tuicr session."],
|
|
4067
|
-
parameters:
|
|
2357
|
+
parameters: parameters(completeSchema),
|
|
4068
2358
|
executionMode: "sequential",
|
|
4069
2359
|
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
4070
2360
|
const params = completeSchema.parse(input);
|
|
@@ -4072,63 +2362,63 @@ ${params.body}`, {
|
|
|
4072
2362
|
if (params.local) {
|
|
4073
2363
|
const session = await resolveTuicrSession(review, true);
|
|
4074
2364
|
if (!session)
|
|
4075
|
-
return
|
|
2365
|
+
return result("No matching local tuicr session to close.");
|
|
4076
2366
|
const artifact = await localThreadArtifactPath(review.cwd, session.slug);
|
|
4077
2367
|
if (!existsSync2(artifact))
|
|
4078
|
-
return
|
|
2368
|
+
return result("No local review artifact. Run review_comments with local=true first.");
|
|
4079
2369
|
const archived = await uniqueRecordPath(await completedReviewsDir(review.cwd), reviewSlug(session.slug));
|
|
4080
|
-
await
|
|
2370
|
+
await rename2(artifact, archived);
|
|
4081
2371
|
await unlink2(session.path);
|
|
4082
|
-
return
|
|
2372
|
+
return result(`Archived ${archived} and deleted the tuicr session ${session.slug}.`, {
|
|
4083
2373
|
artifact: archived,
|
|
4084
2374
|
session
|
|
4085
2375
|
});
|
|
4086
2376
|
}
|
|
4087
2377
|
if (!review.pr || !review.forge)
|
|
4088
|
-
return
|
|
2378
|
+
return result("No remote PR/MR to complete.");
|
|
4089
2379
|
if (!params.action)
|
|
4090
|
-
return
|
|
2380
|
+
return result("Choose a complete action: approve, reject, or abandon.");
|
|
4091
2381
|
const status = { approve: "APPROVE", reject: "REQUEST_CHANGES", abandon: "CLOSE" }[params.action];
|
|
4092
2382
|
return publishResolvedReview(review, { ...params, status }, modelRoute(ctx));
|
|
4093
2383
|
}
|
|
4094
2384
|
}),
|
|
4095
|
-
|
|
2385
|
+
defineTool3({
|
|
4096
2386
|
name: "review_merge",
|
|
4097
2387
|
label: "review merge",
|
|
4098
2388
|
description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
|
|
4099
2389
|
promptSnippet: "Call review_merge only after review_publish APPROVE",
|
|
4100
2390
|
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
4101
|
-
parameters:
|
|
2391
|
+
parameters: parameters(contextSchema.extend({ subject: z6.string().optional() })),
|
|
4102
2392
|
executionMode: "sequential",
|
|
4103
2393
|
async execute(_id, input) {
|
|
4104
|
-
const params = contextSchema.extend({ subject:
|
|
2394
|
+
const params = contextSchema.extend({ subject: z6.string().optional() }).parse(input);
|
|
4105
2395
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
4106
2396
|
if (review.vcs.provider !== "github" || !review.forge)
|
|
4107
|
-
return
|
|
2397
|
+
return result("review_merge currently supports GitHub only.");
|
|
4108
2398
|
if (!review.pr)
|
|
4109
|
-
return
|
|
2399
|
+
return result("No open PR/MR for this branch.");
|
|
4110
2400
|
const subject = params.subject ?? review.pr.title;
|
|
4111
2401
|
const guard = checkConventionalSubject(subject);
|
|
4112
2402
|
if (guard.status !== "pass")
|
|
4113
|
-
return
|
|
2403
|
+
return result(`Merge blocked: ${guard.detail}`, { pr: review.pr, guard });
|
|
4114
2404
|
await review.forge.mergePr(review.pr.number, subject);
|
|
4115
|
-
return
|
|
2405
|
+
return result(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
|
|
4116
2406
|
}
|
|
4117
2407
|
}),
|
|
4118
|
-
|
|
2408
|
+
defineTool3({
|
|
4119
2409
|
name: "review_launch_ui",
|
|
4120
2410
|
label: "review launch UI",
|
|
4121
2411
|
description: "Open a tuicr UI in a mux tab, configure Zed, or return the command to run.",
|
|
4122
2412
|
promptSnippet: "Call review_launch_ui for the interactive tuicr TUI",
|
|
4123
2413
|
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
4124
|
-
parameters:
|
|
2414
|
+
parameters: parameters(localSchema),
|
|
4125
2415
|
executionMode: "sequential",
|
|
4126
2416
|
async execute(_id, input) {
|
|
4127
2417
|
const params = localSchema.parse(input);
|
|
4128
2418
|
const review = await resolveReviewContext(resolveWorkingDirectory(params), params.target);
|
|
4129
2419
|
const workingTree = Boolean(params.local || params.workingTree);
|
|
4130
2420
|
if (!workingTree && !review.forge)
|
|
4131
|
-
return
|
|
2421
|
+
return result(unsupportedForgeMessage());
|
|
4132
2422
|
return launchLocalReview(review, workingTree);
|
|
4133
2423
|
}
|
|
4134
2424
|
})
|
|
@@ -4137,7 +2427,7 @@ ${params.body}`, {
|
|
|
4137
2427
|
async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
4138
2428
|
let previous = [];
|
|
4139
2429
|
try {
|
|
4140
|
-
previous = parseThreadArtifact(await
|
|
2430
|
+
previous = parseThreadArtifact(await readFile7(path, "utf8"));
|
|
4141
2431
|
} catch (error) {
|
|
4142
2432
|
if (error.code !== "ENOENT")
|
|
4143
2433
|
throw error;
|
|
@@ -4155,16 +2445,16 @@ async function syncLocalThreadArtifact(path, title, sessionSlug, threads) {
|
|
|
4155
2445
|
});
|
|
4156
2446
|
const removed = previous.filter((thread) => !currentIds.has(thread.id)).map((thread) => ({ ...thread, resolved: true }));
|
|
4157
2447
|
const merged = [...current, ...removed];
|
|
4158
|
-
await
|
|
2448
|
+
await writeFile5(path, renderThreadArtifact(title, sessionSlug, merged), "utf8");
|
|
4159
2449
|
return merged;
|
|
4160
2450
|
}
|
|
4161
|
-
function
|
|
4162
|
-
return
|
|
2451
|
+
function parameters(schema) {
|
|
2452
|
+
return z6.toJSONSchema(schema, { io: "input" });
|
|
4163
2453
|
}
|
|
4164
2454
|
function resolveWorkingDirectory(params) {
|
|
4165
2455
|
return params.cwd ?? process.cwd();
|
|
4166
2456
|
}
|
|
4167
|
-
function
|
|
2457
|
+
function result(text, details = {}) {
|
|
4168
2458
|
return { content: [{ type: "text", text }], details };
|
|
4169
2459
|
}
|
|
4170
2460
|
function unsupportedForgeMessage() {
|
|
@@ -4199,7 +2489,7 @@ async function publishResolvedReview(review, params, model) {
|
|
|
4199
2489
|
const promotedBodies = promotion?.promotedBodies ?? 0;
|
|
4200
2490
|
const promotedComments = promotion?.promotedComments ?? 0;
|
|
4201
2491
|
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
4202
|
-
return
|
|
2492
|
+
return result(`Published #${review.pr.number} (${status}); promoted ${promotedBodies} review bodies, ${promotedComments} comments, and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedBodies, promotedComments, promotedReplies });
|
|
4203
2493
|
}
|
|
4204
2494
|
async function promoteLocalReview(review, remote, model, workingTree) {
|
|
4205
2495
|
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
@@ -4288,7 +2578,7 @@ async function launchLocalReview(review, workingTree, requestedBase) {
|
|
|
4288
2578
|
const base = workingTree ? requestedBase ?? review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : undefined) : undefined;
|
|
4289
2579
|
const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number, base);
|
|
4290
2580
|
const commandTarget = workingTree || !review.pr ? "full branch" : `PR/MR #${review.pr.number}`;
|
|
4291
|
-
return
|
|
2581
|
+
return result(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
|
|
4292
2582
|
}
|
|
4293
2583
|
async function assertRemoteBranchReady(cwd) {
|
|
4294
2584
|
const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
|
|
@@ -4320,16 +2610,16 @@ async function headSha(cwd) {
|
|
|
4320
2610
|
}
|
|
4321
2611
|
async function newReviewArtifactPath(cwd, target) {
|
|
4322
2612
|
const dir = await reviewsDir(cwd);
|
|
4323
|
-
await
|
|
2613
|
+
await mkdir4(dir, { recursive: true });
|
|
4324
2614
|
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
4325
2615
|
}
|
|
4326
2616
|
async function localThreadArtifactPath(cwd, sessionSlug) {
|
|
4327
2617
|
const dir = await reviewsDir(cwd);
|
|
4328
|
-
return
|
|
2618
|
+
return join9(dir, `${reviewSlug(sessionSlug) || "local-review"}.md`);
|
|
4329
2619
|
}
|
|
4330
2620
|
async function readThreadArtifact(path) {
|
|
4331
2621
|
try {
|
|
4332
|
-
return parseThreadArtifact(await
|
|
2622
|
+
return parseThreadArtifact(await readFile7(path, "utf8"));
|
|
4333
2623
|
} catch (error) {
|
|
4334
2624
|
if (error.code === "ENOENT") {
|
|
4335
2625
|
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
@@ -4353,9 +2643,9 @@ async function findLatestThreadArtifact(cwd, review, target) {
|
|
|
4353
2643
|
return [];
|
|
4354
2644
|
return [
|
|
4355
2645
|
(async () => {
|
|
4356
|
-
const path =
|
|
2646
|
+
const path = join9(dir, name);
|
|
4357
2647
|
const info = await stat(path);
|
|
4358
|
-
return { path, name, content: await
|
|
2648
|
+
return { path, name, content: await readFile7(path, "utf8"), modified: info.mtimeMs };
|
|
4359
2649
|
})()
|
|
4360
2650
|
];
|
|
4361
2651
|
}));
|
|
@@ -4369,7 +2659,7 @@ async function findLatestThreadArtifact(cwd, review, target) {
|
|
|
4369
2659
|
}
|
|
4370
2660
|
async function listReviewArtifactNames(dir) {
|
|
4371
2661
|
try {
|
|
4372
|
-
return await
|
|
2662
|
+
return await readdir(dir);
|
|
4373
2663
|
} catch (error) {
|
|
4374
2664
|
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
4375
2665
|
}
|
|
@@ -4378,10 +2668,10 @@ function deriveTitle(branch) {
|
|
|
4378
2668
|
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
4379
2669
|
}
|
|
4380
2670
|
function uniqueRecordPath(dir, base) {
|
|
4381
|
-
let path =
|
|
2671
|
+
let path = join9(dir, `${base}.md`);
|
|
4382
2672
|
let count = 2;
|
|
4383
2673
|
while (existsSync2(path))
|
|
4384
|
-
path =
|
|
2674
|
+
path = join9(dir, `${base}-${count++}.md`);
|
|
4385
2675
|
return path;
|
|
4386
2676
|
}
|
|
4387
2677
|
function artifactNameMatches(name, suffix) {
|
|
@@ -4397,26 +2687,26 @@ function artifactNameMatches(name, suffix) {
|
|
|
4397
2687
|
}
|
|
4398
2688
|
|
|
4399
2689
|
// src/tools/setup.ts
|
|
4400
|
-
import { defineTool as
|
|
4401
|
-
import { z as
|
|
2690
|
+
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
2691
|
+
import { z as z8 } from "zod";
|
|
4402
2692
|
|
|
4403
2693
|
// src/setup.ts
|
|
4404
2694
|
import { parseFrontmatter as parseFrontmatter2 } from "@earendil-works/pi-coding-agent";
|
|
4405
|
-
import { readdir as
|
|
2695
|
+
import { readdir as readdir3, readFile as readFile10 } from "node:fs/promises";
|
|
4406
2696
|
import { homedir as homedir8 } from "node:os";
|
|
4407
|
-
import { basename as
|
|
2697
|
+
import { basename as basename4, join as join13 } from "node:path";
|
|
4408
2698
|
|
|
4409
2699
|
// src/config.ts
|
|
4410
2700
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
4411
|
-
import { z as
|
|
2701
|
+
import { z as z7 } from "zod";
|
|
4412
2702
|
import { homedir as homedir5 } from "node:os";
|
|
4413
|
-
import { join as
|
|
2703
|
+
import { join as join10 } from "node:path";
|
|
4414
2704
|
|
|
4415
2705
|
// src/fsx.ts
|
|
4416
|
-
import { readdir as
|
|
2706
|
+
import { readdir as readdir2, readFile as readFile8 } from "node:fs/promises";
|
|
4417
2707
|
async function readTextIfExists(path) {
|
|
4418
2708
|
try {
|
|
4419
|
-
return await
|
|
2709
|
+
return await readFile8(path, "utf8");
|
|
4420
2710
|
} catch (error) {
|
|
4421
2711
|
if (isMissingPath(error))
|
|
4422
2712
|
return;
|
|
@@ -4428,18 +2718,18 @@ function isMissingPath(error) {
|
|
|
4428
2718
|
}
|
|
4429
2719
|
|
|
4430
2720
|
// src/config.ts
|
|
4431
|
-
var modelReferenceSchema =
|
|
4432
|
-
var agentConfigSchema =
|
|
4433
|
-
models:
|
|
2721
|
+
var modelReferenceSchema = z7.string().trim().min(1);
|
|
2722
|
+
var agentConfigSchema = z7.object({
|
|
2723
|
+
models: z7.array(modelReferenceSchema).optional()
|
|
4434
2724
|
}).strict();
|
|
4435
|
-
var diffpiConfigSchema =
|
|
4436
|
-
agents:
|
|
2725
|
+
var diffpiConfigSchema = z7.object({
|
|
2726
|
+
agents: z7.record(z7.string(), agentConfigSchema).optional()
|
|
4437
2727
|
}).strict();
|
|
4438
2728
|
function diffpiConfigPaths(homeDir = homedir5()) {
|
|
4439
|
-
const directory =
|
|
2729
|
+
const directory = join10(homeDir, ".difflab", "diffpi");
|
|
4440
2730
|
return {
|
|
4441
|
-
yaml:
|
|
4442
|
-
json:
|
|
2731
|
+
yaml: join10(directory, "config.yaml"),
|
|
2732
|
+
json: join10(directory, "config.json")
|
|
4443
2733
|
};
|
|
4444
2734
|
}
|
|
4445
2735
|
async function loadDiffpiConfig(options = {}) {
|
|
@@ -4495,12 +2785,12 @@ function normalizeModelReference(value) {
|
|
|
4495
2785
|
}
|
|
4496
2786
|
|
|
4497
2787
|
// src/mcp.ts
|
|
4498
|
-
import { mkdir as
|
|
2788
|
+
import { mkdir as mkdir5, readFile as readFile9, writeFile as writeFile6 } from "node:fs/promises";
|
|
4499
2789
|
import { homedir as homedir6 } from "node:os";
|
|
4500
|
-
import { dirname as
|
|
2790
|
+
import { dirname as dirname5, join as join11 } from "node:path";
|
|
4501
2791
|
var mcp = {
|
|
4502
2792
|
globalConfigPath(homeDir = homedir6()) {
|
|
4503
|
-
return
|
|
2793
|
+
return join11(homeDir, ".config", "mcp", "mcp.json");
|
|
4504
2794
|
},
|
|
4505
2795
|
async serversEnsure(servers, options = {}) {
|
|
4506
2796
|
const path = options.path ?? mcp.globalConfigPath();
|
|
@@ -4513,8 +2803,8 @@ var mcp = {
|
|
|
4513
2803
|
const next = { ...current, mcpServers: nextServers };
|
|
4514
2804
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
4515
2805
|
if (changed && !options.dryRun) {
|
|
4516
|
-
await
|
|
4517
|
-
await
|
|
2806
|
+
await mkdir5(dirname5(path), { recursive: true });
|
|
2807
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
4518
2808
|
`, "utf8");
|
|
4519
2809
|
}
|
|
4520
2810
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -4543,7 +2833,7 @@ function getParsedConfig(content, path) {
|
|
|
4543
2833
|
}
|
|
4544
2834
|
async function getOptionalFile2(path) {
|
|
4545
2835
|
try {
|
|
4546
|
-
return await
|
|
2836
|
+
return await readFile9(path, "utf8");
|
|
4547
2837
|
} catch (error) {
|
|
4548
2838
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
4549
2839
|
return;
|
|
@@ -4555,9 +2845,9 @@ function isRecord(value) {
|
|
|
4555
2845
|
}
|
|
4556
2846
|
|
|
4557
2847
|
// src/pi.ts
|
|
4558
|
-
import { mkdir as
|
|
2848
|
+
import { mkdir as mkdir6, writeFile as writeFile7 } from "node:fs/promises";
|
|
4559
2849
|
import { homedir as homedir7 } from "node:os";
|
|
4560
|
-
import { dirname as
|
|
2850
|
+
import { dirname as dirname6, join as join12 } from "node:path";
|
|
4561
2851
|
var pi = {
|
|
4562
2852
|
executableCheck: findPiExecutable,
|
|
4563
2853
|
packageList: listPiPackages,
|
|
@@ -4570,19 +2860,19 @@ var pi = {
|
|
|
4570
2860
|
configEnsure: ensurePiConfig
|
|
4571
2861
|
};
|
|
4572
2862
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
4573
|
-
const path =
|
|
2863
|
+
const path = join12(agentDir, "agents", filename);
|
|
4574
2864
|
const currentText = await readTextIfExists(path);
|
|
4575
2865
|
const changed = currentText !== content;
|
|
4576
2866
|
if (changed && !dryRun) {
|
|
4577
|
-
await
|
|
4578
|
-
await
|
|
2867
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
2868
|
+
await writeFile7(path, content, "utf8");
|
|
4579
2869
|
}
|
|
4580
2870
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
4581
2871
|
}
|
|
4582
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
4583
|
-
const roots = [
|
|
2872
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join12(homedir7(), ".agents", "skills")) {
|
|
2873
|
+
const roots = [join12(agentDir, "skills"), sharedSkillsDir];
|
|
4584
2874
|
for (const root of roots) {
|
|
4585
|
-
if (await readTextIfExists(
|
|
2875
|
+
if (await readTextIfExists(join12(root, name, "SKILL.md")) !== undefined)
|
|
4586
2876
|
return true;
|
|
4587
2877
|
}
|
|
4588
2878
|
return false;
|
|
@@ -4611,8 +2901,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
4611
2901
|
const next = update(current);
|
|
4612
2902
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
4613
2903
|
if (changed && !dryRun) {
|
|
4614
|
-
await
|
|
4615
|
-
await
|
|
2904
|
+
await mkdir6(dirname6(path), { recursive: true });
|
|
2905
|
+
await writeFile7(path, `${JSON.stringify(next, null, 2)}
|
|
4616
2906
|
`, "utf8");
|
|
4617
2907
|
}
|
|
4618
2908
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -4632,7 +2922,7 @@ async function installPiPackage(executable, source) {
|
|
|
4632
2922
|
await runChecked(executable, ["install", source]);
|
|
4633
2923
|
}
|
|
4634
2924
|
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
4635
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
2925
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join12(process.env.XDG_CONFIG_HOME, "pi") : join12(homeDir, ".pi", "agent"));
|
|
4636
2926
|
}
|
|
4637
2927
|
function parseJsonObject(content, path) {
|
|
4638
2928
|
if (!content?.trim())
|
|
@@ -4684,7 +2974,7 @@ var FORGE_DEPENDENCIES = {
|
|
|
4684
2974
|
};
|
|
4685
2975
|
async function ensureMise(options = {}) {
|
|
4686
2976
|
const homeDir = options.homeDir ?? homedir8();
|
|
4687
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
2977
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join13(homeDir, ".local", "bin", "mise"));
|
|
4688
2978
|
if (current)
|
|
4689
2979
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
4690
2980
|
reportProgress(options, "Installing mise");
|
|
@@ -4733,9 +3023,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
4733
3023
|
async function ensurePiPlugins(options = {}) {
|
|
4734
3024
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
4735
3025
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
4736
|
-
const webSearch = await pi.configEnsure(
|
|
3026
|
+
const webSearch = await pi.configEnsure(join13(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
4737
3027
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
4738
|
-
const lsp = await pi.configEnsure(
|
|
3028
|
+
const lsp = await pi.configEnsure(join13(agentDir, "pi-lsp.json"), (config) => ({
|
|
4739
3029
|
...config,
|
|
4740
3030
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
4741
3031
|
}), options.dryRun);
|
|
@@ -4746,11 +3036,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
4746
3036
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
4747
3037
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR;
|
|
4748
3038
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
4749
|
-
const entries = (await
|
|
3039
|
+
const entries = (await readdir3(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
|
|
4750
3040
|
const actions = [];
|
|
4751
3041
|
for (const entry of entries) {
|
|
4752
|
-
const id =
|
|
4753
|
-
const source = await
|
|
3042
|
+
const id = basename4(entry.name, ".md").replace(/^diffpi-/, "");
|
|
3043
|
+
const source = await readFile10(join13(bundledAgentsDir, entry.name), "utf8");
|
|
4754
3044
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
4755
3045
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
4756
3046
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -4759,7 +3049,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
4759
3049
|
}
|
|
4760
3050
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
4761
3051
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
4762
|
-
const sharedSkillsDir =
|
|
3052
|
+
const sharedSkillsDir = join13(options.homeDir ?? homedir8(), ".agents", "skills");
|
|
4763
3053
|
const actions = [];
|
|
4764
3054
|
for (const source of PI_SKILL_SOURCES) {
|
|
4765
3055
|
const missing = [];
|
|
@@ -4840,8 +3130,7 @@ function setupRequiresRestart(actions) {
|
|
|
4840
3130
|
async function ensureZedIntegration(options = {}) {
|
|
4841
3131
|
if (options.dryRun) {
|
|
4842
3132
|
const actions = [
|
|
4843
|
-
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
|
|
4844
|
-
createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
|
|
3133
|
+
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json")
|
|
4845
3134
|
];
|
|
4846
3135
|
if (options.bindZedKey)
|
|
4847
3136
|
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
@@ -4851,8 +3140,6 @@ async function ensureZedIntegration(options = {}) {
|
|
|
4851
3140
|
try {
|
|
4852
3141
|
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
4853
3142
|
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
4854
|
-
const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
|
|
4855
|
-
actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
|
|
4856
3143
|
} catch (error) {
|
|
4857
3144
|
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
4858
3145
|
}
|
|
@@ -4954,26 +3241,19 @@ function createSetupAction(name, status, detail) {
|
|
|
4954
3241
|
function reportProgress(options, message) {
|
|
4955
3242
|
options.onProgress?.(message);
|
|
4956
3243
|
}
|
|
4957
|
-
async function packageVersion2() {
|
|
4958
|
-
const source = await readFile15(join18(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
4959
|
-
const value = JSON.parse(source);
|
|
4960
|
-
if (typeof value.version !== "string")
|
|
4961
|
-
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
4962
|
-
return value.version;
|
|
4963
|
-
}
|
|
4964
3244
|
function getRecord(value) {
|
|
4965
3245
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
4966
3246
|
}
|
|
4967
3247
|
|
|
4968
3248
|
// src/tools/setup.ts
|
|
4969
|
-
var setupParametersSchema =
|
|
4970
|
-
issueTracker:
|
|
4971
|
-
forges:
|
|
4972
|
-
forge:
|
|
4973
|
-
bindZedKey:
|
|
3249
|
+
var setupParametersSchema = z8.object({
|
|
3250
|
+
issueTracker: z8.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
3251
|
+
forges: z8.array(z8.enum(["github", "gitlab"])).default([]).describe("Hosted VCS MCP servers to configure. Installs matching CLIs and registers each MCP server."),
|
|
3252
|
+
forge: z8.enum(["none", "github", "gitlab"]).optional().describe("Deprecated single-host alias. Prefer forges for one or more VCS MCP servers."),
|
|
3253
|
+
bindZedKey: z8.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
4974
3254
|
});
|
|
4975
|
-
var setupParameters =
|
|
4976
|
-
var diffpiSetupTool =
|
|
3255
|
+
var setupParameters = z8.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
3256
|
+
var diffpiSetupTool = defineTool4({
|
|
4977
3257
|
name: "diffpi_setup",
|
|
4978
3258
|
label: "diffpi setup",
|
|
4979
3259
|
description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
|
|
@@ -5002,7 +3282,7 @@ var diffpiSetupTool = defineTool5({
|
|
|
5002
3282
|
return formatResult(result, "Setup complete.");
|
|
5003
3283
|
}
|
|
5004
3284
|
});
|
|
5005
|
-
var diffpiValidateTool =
|
|
3285
|
+
var diffpiValidateTool = defineTool4({
|
|
5006
3286
|
name: "diffpi_validate",
|
|
5007
3287
|
label: "diffpi validate",
|
|
5008
3288
|
description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
|
|
@@ -5044,20 +3324,20 @@ ${lines.join(`
|
|
|
5044
3324
|
}
|
|
5045
3325
|
|
|
5046
3326
|
// src/tools/templates.ts
|
|
5047
|
-
import { defineTool as
|
|
5048
|
-
import { z as
|
|
5049
|
-
var templateSchema =
|
|
5050
|
-
name:
|
|
5051
|
-
variables:
|
|
5052
|
-
homeDir:
|
|
3327
|
+
import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent";
|
|
3328
|
+
import { z as z9 } from "zod";
|
|
3329
|
+
var templateSchema = z9.object({
|
|
3330
|
+
name: z9.string().min(1),
|
|
3331
|
+
variables: z9.record(z9.string(), z9.string()).optional(),
|
|
3332
|
+
homeDir: z9.string().optional()
|
|
5053
3333
|
});
|
|
5054
|
-
var diffpiTemplateTool =
|
|
3334
|
+
var diffpiTemplateTool = defineTool5({
|
|
5055
3335
|
name: "diffpi_template",
|
|
5056
3336
|
label: "diffpi template",
|
|
5057
3337
|
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
5058
3338
|
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
5059
3339
|
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
5060
|
-
parameters:
|
|
3340
|
+
parameters: z9.toJSONSchema(templateSchema, { io: "input" }),
|
|
5061
3341
|
executionMode: "parallel",
|
|
5062
3342
|
async execute(_id, input) {
|
|
5063
3343
|
const params = templateSchema.parse(input);
|
|
@@ -5078,15 +3358,13 @@ function createPiTools(pi, modes) {
|
|
|
5078
3358
|
createDiffpiReloadTool(pi),
|
|
5079
3359
|
diffpiTemplateTool,
|
|
5080
3360
|
...createModeTools(modes),
|
|
5081
|
-
...createReviewTools()
|
|
5082
|
-
...createPlanTools(pi, modes)
|
|
3361
|
+
...createReviewTools()
|
|
5083
3362
|
];
|
|
5084
3363
|
}
|
|
5085
3364
|
export {
|
|
5086
3365
|
createDiffpiReloadTool,
|
|
5087
3366
|
createModeTools,
|
|
5088
3367
|
createPiTools,
|
|
5089
|
-
createPlanTools,
|
|
5090
3368
|
createReviewTools,
|
|
5091
3369
|
diffpiSetupTool,
|
|
5092
3370
|
diffpiTemplateTool,
|