@difflab/pi 0.2.0 → 0.3.0-rc.202609200047.ac3661a
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 +48 -4
- package/agents/diffpi-orchestrator.md +17 -5
- package/agents/diffpi-planner.md +30 -0
- package/agents/diffpi-reviewer.md +36 -0
- package/agents/diffpi-worker.md +9 -1
- package/dist/assets.d.ts +2 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/cli/plan.d.ts +7 -0
- package/dist/cli/plan.d.ts.map +1 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +729 -0
- package/dist/commands/background.d.ts +39 -0
- package/dist/commands/background.d.ts.map +1 -0
- package/dist/commands/index.d.ts +9 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/mode.d.ts +5 -0
- package/dist/commands/mode.d.ts.map +1 -0
- package/dist/commands/plan.d.ts +21 -0
- package/dist/commands/plan.d.ts.map +1 -0
- package/dist/commands/reload.d.ts +4 -0
- package/dist/commands/reload.d.ts.map +1 -0
- package/dist/commands/review.d.ts +5 -0
- package/dist/commands/review.d.ts.map +1 -0
- package/dist/environment.d.ts +41 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/extensions/ghx.d.ts +5 -0
- package/dist/extensions/ghx.d.ts.map +1 -0
- package/dist/extensions/gitx.d.ts +10 -0
- package/dist/extensions/gitx.d.ts.map +1 -0
- package/dist/extensions/glabx.d.ts +5 -0
- package/dist/extensions/glabx.d.ts.map +1 -0
- package/dist/extensions/index.js +4773 -301
- package/dist/{mise.d.ts → extensions/misex.d.ts} +3 -1
- package/dist/extensions/misex.d.ts.map +1 -0
- package/dist/{process.d.ts → extensions/processx.d.ts} +3 -1
- package/dist/extensions/processx.d.ts.map +1 -0
- package/dist/extensions/tuicrx.d.ts +59 -0
- package/dist/extensions/tuicrx.d.ts.map +1 -0
- package/dist/extensions/zedx.d.ts +18 -0
- package/dist/extensions/zedx.d.ts.map +1 -0
- package/dist/gates.d.ts +12 -0
- package/dist/gates.d.ts.map +1 -0
- package/dist/index.d.ts +27 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2816 -142
- package/dist/modes.d.ts +1 -0
- package/dist/modes.d.ts.map +1 -1
- package/dist/plan/annotations.d.ts +25 -0
- package/dist/plan/annotations.d.ts.map +1 -0
- package/dist/plan/escalation.d.ts +4 -0
- package/dist/plan/escalation.d.ts.map +1 -0
- package/dist/plan/execution.d.ts +19 -0
- package/dist/plan/execution.d.ts.map +1 -0
- package/dist/plan/index.d.ts +15 -0
- package/dist/plan/index.d.ts.map +1 -0
- package/dist/plan/lock.d.ts +7 -0
- package/dist/plan/lock.d.ts.map +1 -0
- package/dist/plan/log.d.ts +5 -0
- package/dist/plan/log.d.ts.map +1 -0
- package/dist/plan/markdown.d.ts +6 -0
- package/dist/plan/markdown.d.ts.map +1 -0
- package/dist/plan/store.d.ts +34 -0
- package/dist/plan/store.d.ts.map +1 -0
- package/dist/plan/transitions.d.ts +9 -0
- package/dist/plan/transitions.d.ts.map +1 -0
- package/dist/plan/types.d.ts +146 -0
- package/dist/plan/types.d.ts.map +1 -0
- package/dist/review/github-review-backend.d.ts +5 -0
- package/dist/review/github-review-backend.d.ts.map +1 -0
- package/dist/review/gitlab-review-backend.d.ts +12 -0
- package/dist/review/gitlab-review-backend.d.ts.map +1 -0
- package/dist/review/index.d.ts +8 -0
- package/dist/review/index.d.ts.map +1 -0
- package/dist/review/local-review-backend.d.ts +3 -0
- package/dist/review/local-review-backend.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +8 -0
- package/dist/review/review-backend.d.ts.map +1 -0
- package/dist/review/review-markdown.d.ts +6 -0
- package/dist/review/review-markdown.d.ts.map +1 -0
- package/dist/review/review-state.d.ts +19 -0
- package/dist/review/review-state.d.ts.map +1 -0
- package/dist/review/types.d.ts +107 -0
- package/dist/review/types.d.ts.map +1 -0
- package/dist/setup.d.ts +7 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/store.d.ts +16 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/templates.d.ts +14 -0
- package/dist/templates.d.ts.map +1 -0
- package/dist/tools/index.d.ts +3 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4444 -299
- package/dist/tools/plan.d.ts +6 -0
- package/dist/tools/plan.d.ts.map +1 -0
- package/dist/tools/review.d.ts +7 -0
- package/dist/tools/review.d.ts.map +1 -0
- package/dist/tools/setup.d.ts.map +1 -1
- package/dist/tools/templates.d.ts +3 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/vcs/forge-backend.d.ts +7 -0
- package/dist/vcs/forge-backend.d.ts.map +1 -0
- package/dist/vcs/github.d.ts +5 -0
- package/dist/vcs/github.d.ts.map +1 -0
- package/dist/vcs/gitlab.d.ts +4 -0
- package/dist/vcs/gitlab.d.ts.map +1 -0
- package/dist/vcs/helpers.d.ts +7 -0
- package/dist/vcs/helpers.d.ts.map +1 -0
- package/dist/vcs/index.d.ts +6 -0
- package/dist/vcs/index.d.ts.map +1 -0
- package/dist/vcs/types.d.ts +32 -0
- package/dist/vcs/types.d.ts.map +1 -0
- package/package.json +6 -2
- package/skills/diffpi-setup/SKILL.md +3 -1
- package/skills/plan/SKILL.md +13 -0
- package/skills/plan/references/workflows/annotate.md +6 -0
- package/skills/plan/references/workflows/finalize.md +7 -0
- package/skills/plan/references/workflows/go.md +7 -0
- package/skills/plan/references/workflows/help.md +13 -0
- package/skills/plan/references/workflows/init.md +6 -0
- package/skills/plan/references/workflows/new.md +7 -0
- package/skills/plan/references/workflows/update.md +7 -0
- package/skills/review/SKILL.md +17 -0
- package/skills/review/references/workflows/address.md +11 -0
- package/skills/review/references/workflows/auto.md +18 -0
- package/skills/review/references/workflows/complete.md +7 -0
- package/skills/review/references/workflows/edit.md +7 -0
- package/skills/review/references/workflows/help.md +30 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +19 -0
- package/skills/review/references/workflows/open.md +3 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/templates/plan/PLAN.md +38 -0
- package/templates/review/draft-pr.md +25 -0
- package/dist/mise.d.ts.map +0 -1
- package/dist/process.d.ts.map +0 -1
package/dist/index.js
CHANGED
|
@@ -94,76 +94,31 @@ ${document}
|
|
|
94
94
|
function normalizeModelReference(value) {
|
|
95
95
|
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
96
96
|
}
|
|
97
|
-
// src/
|
|
98
|
-
import {
|
|
99
|
-
import {
|
|
97
|
+
// src/environment.ts
|
|
98
|
+
import { readFile as readFile3 } from "node:fs/promises";
|
|
99
|
+
import { basename, join as join5 } from "node:path";
|
|
100
|
+
|
|
101
|
+
// src/assets.ts
|
|
102
|
+
import { existsSync } from "node:fs";
|
|
100
103
|
import { dirname, join as join2 } from "node:path";
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const path = options.path ?? mcp.globalConfigPath();
|
|
107
|
-
const currentText = await getOptionalFile(path);
|
|
108
|
-
const current = getParsedConfig(currentText, path);
|
|
109
|
-
const nextServers = { ...current.mcpServers };
|
|
110
|
-
for (const [name, entry] of Object.entries(servers)) {
|
|
111
|
-
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
112
|
-
}
|
|
113
|
-
const next = { ...current, mcpServers: nextServers };
|
|
114
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
115
|
-
if (changed && !options.dryRun) {
|
|
116
|
-
await mkdir(dirname(path), { recursive: true });
|
|
117
|
-
await writeFile(path, `${JSON.stringify(next, null, 2)}
|
|
118
|
-
`, "utf8");
|
|
119
|
-
}
|
|
120
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
121
|
-
}
|
|
122
|
-
};
|
|
123
|
-
function mergeEntry(current, required) {
|
|
124
|
-
const merged = { ...current, ...required };
|
|
125
|
-
if (current?.env || required.env)
|
|
126
|
-
merged.env = { ...current?.env, ...required.env };
|
|
127
|
-
return merged;
|
|
128
|
-
}
|
|
129
|
-
function getParsedConfig(content, path) {
|
|
130
|
-
if (!content?.trim())
|
|
131
|
-
return { mcpServers: {} };
|
|
132
|
-
try {
|
|
133
|
-
const value = JSON.parse(content);
|
|
134
|
-
if (!isRecord(value))
|
|
135
|
-
throw new Error("not an object");
|
|
136
|
-
const servers = value.mcpServers;
|
|
137
|
-
if (servers !== undefined && !isRecord(servers))
|
|
138
|
-
throw new Error("mcpServers is not an object");
|
|
139
|
-
return { ...value, mcpServers: servers ?? {} };
|
|
140
|
-
} catch {
|
|
141
|
-
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
142
|
-
}
|
|
104
|
+
import { fileURLToPath } from "node:url";
|
|
105
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
106
|
+
const moduleDir = dirname(fileURLToPath(moduleUrl));
|
|
107
|
+
const candidates = [join2(moduleDir, name), join2(moduleDir, "..", name), join2(moduleDir, "..", "..", name)];
|
|
108
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
143
109
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return await readFile2(path, "utf8");
|
|
147
|
-
} catch (error) {
|
|
148
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
149
|
-
return;
|
|
150
|
-
throw error;
|
|
151
|
-
}
|
|
110
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
111
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
152
112
|
}
|
|
153
|
-
function
|
|
154
|
-
return
|
|
113
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
114
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
155
115
|
}
|
|
156
|
-
// src/mise.ts
|
|
157
|
-
import { mkdir as mkdir2, readFile as readFile3, writeFile as writeFile2 } from "node:fs/promises";
|
|
158
|
-
import { homedir as homedir3 } from "node:os";
|
|
159
|
-
import { basename, dirname as dirname2, join as join4 } from "node:path";
|
|
160
116
|
|
|
161
|
-
// src/
|
|
117
|
+
// src/extensions/processx.ts
|
|
118
|
+
import { spawn } from "node:child_process";
|
|
162
119
|
import { constants } from "node:fs";
|
|
163
120
|
import { access } from "node:fs/promises";
|
|
164
121
|
import { delimiter, join as join3 } from "node:path";
|
|
165
|
-
import { spawn } from "node:child_process";
|
|
166
|
-
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
167
122
|
async function findExecutable(name) {
|
|
168
123
|
if (name.includes("/")) {
|
|
169
124
|
try {
|
|
@@ -189,18 +144,35 @@ function run(command, args, options = {}) {
|
|
|
189
144
|
const child = spawn(command, args, {
|
|
190
145
|
cwd: options.cwd,
|
|
191
146
|
env: options.env ?? process.env,
|
|
192
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
147
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
193
148
|
});
|
|
194
149
|
let stdout = "";
|
|
195
150
|
let stderr = "";
|
|
196
|
-
|
|
197
|
-
|
|
151
|
+
const stdoutChunks = [];
|
|
152
|
+
const stderrChunks = [];
|
|
153
|
+
const unbounded = options.capture === "unbounded";
|
|
154
|
+
child.stdout?.on("data", (chunk) => {
|
|
155
|
+
const text = chunk.toString();
|
|
156
|
+
if (unbounded)
|
|
157
|
+
stdoutChunks.push(text);
|
|
158
|
+
else
|
|
159
|
+
stdout = appendBounded(stdout, text);
|
|
198
160
|
});
|
|
199
|
-
child.stderr
|
|
200
|
-
|
|
161
|
+
child.stderr?.on("data", (chunk) => {
|
|
162
|
+
const text = chunk.toString();
|
|
163
|
+
if (unbounded)
|
|
164
|
+
stderrChunks.push(text);
|
|
165
|
+
else
|
|
166
|
+
stderr = appendBounded(stderr, text);
|
|
201
167
|
});
|
|
202
168
|
child.on("error", reject);
|
|
203
|
-
child.on("close", (code) => resolve({
|
|
169
|
+
child.on("close", (code) => resolve({
|
|
170
|
+
code: code ?? 1,
|
|
171
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
172
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
173
|
+
}));
|
|
174
|
+
if (options.input !== undefined && child.stdin)
|
|
175
|
+
child.stdin.end(options.input);
|
|
204
176
|
});
|
|
205
177
|
}
|
|
206
178
|
async function runChecked(command, args, options = {}) {
|
|
@@ -210,24 +182,595 @@ async function runChecked(command, args, options = {}) {
|
|
|
210
182
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
211
183
|
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
212
184
|
}
|
|
185
|
+
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
213
186
|
function appendBounded(current, next) {
|
|
214
187
|
const combined = current + next;
|
|
215
188
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
216
189
|
}
|
|
217
190
|
|
|
218
|
-
// src/
|
|
191
|
+
// src/extensions/zedx.ts
|
|
192
|
+
import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
|
|
193
|
+
import { homedir as homedir2 } from "node:os";
|
|
194
|
+
import { dirname as dirname2, join as join4 } from "node:path";
|
|
195
|
+
var ZED_LOCAL_REVIEW_TASK_NAME = "diffpi: tuicr local review";
|
|
196
|
+
var ZED_REVIEW_TASK_NAME = ZED_LOCAL_REVIEW_TASK_NAME;
|
|
197
|
+
var ZED_PR_REVIEW_TASK_NAME = "diffpi: tuicr PR review";
|
|
198
|
+
var ZED_PLAN_ANNOTATE_TASK_NAME = "diffpi: annotate plan";
|
|
199
|
+
var LEGACY_ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
200
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
201
|
+
function zedTasksPath(homeDir = homedir2()) {
|
|
202
|
+
return join4(homeDir, ".config", "zed", "tasks.json");
|
|
203
|
+
}
|
|
204
|
+
function zedKeymapPath(homeDir = homedir2()) {
|
|
205
|
+
return join4(homeDir, ".config", "zed", "keymap.json");
|
|
206
|
+
}
|
|
207
|
+
async function ensureZedReviewTask(homeDir = homedir2(), _command = ["tuicr", "-w", "-r", "main..HEAD"]) {
|
|
208
|
+
const path = zedTasksPath(homeDir);
|
|
209
|
+
const currentText = await readOptional(path);
|
|
210
|
+
const tasks = parseJsonArray(currentText, path);
|
|
211
|
+
const migrated = tasks.filter((task) => task.label !== LEGACY_ZED_REVIEW_TASK_NAME);
|
|
212
|
+
const next = [...migrated];
|
|
213
|
+
for (const task of [localReviewTask(), prReviewTask()]) {
|
|
214
|
+
const index = next.findIndex((existing) => existing.label === task.label);
|
|
215
|
+
if (index >= 0)
|
|
216
|
+
next[index] = { ...next[index], ...task };
|
|
217
|
+
else
|
|
218
|
+
next.push(task);
|
|
219
|
+
}
|
|
220
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
221
|
+
if (changed)
|
|
222
|
+
await writeJson(path, next);
|
|
223
|
+
return { path, changed, existed: currentText !== undefined };
|
|
224
|
+
}
|
|
225
|
+
async function ensureZedPlanTask(packageVersion, homeDir = homedir2()) {
|
|
226
|
+
if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(packageVersion)) {
|
|
227
|
+
throw new Error(`Invalid @difflab/pi package version: ${packageVersion}.`);
|
|
228
|
+
}
|
|
229
|
+
const path = zedTasksPath(homeDir);
|
|
230
|
+
const currentText = await readOptional(path);
|
|
231
|
+
const tasks = parseJsonArray(currentText, path);
|
|
232
|
+
const task = {
|
|
233
|
+
label: ZED_PLAN_ANNOTATE_TASK_NAME,
|
|
234
|
+
command: "npx",
|
|
235
|
+
args: ["--yes", `@difflab/pi@${packageVersion}`, "plan", "annotate", "--cwd", "$ZED_WORKTREE_ROOT"],
|
|
236
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
237
|
+
use_new_terminal: true,
|
|
238
|
+
reveal: "always",
|
|
239
|
+
reveal_target: "center"
|
|
240
|
+
};
|
|
241
|
+
const next = [...tasks];
|
|
242
|
+
const index = next.findIndex((candidate) => candidate.label === task.label);
|
|
243
|
+
if (index >= 0)
|
|
244
|
+
next[index] = { ...next[index], ...task };
|
|
245
|
+
else
|
|
246
|
+
next.push(task);
|
|
247
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
248
|
+
if (changed)
|
|
249
|
+
await writeJson(path, next);
|
|
250
|
+
return { path, changed, existed: currentText !== undefined };
|
|
251
|
+
}
|
|
252
|
+
async function ensureZedReviewKeybinding(homeDir = homedir2()) {
|
|
253
|
+
const path = zedKeymapPath(homeDir);
|
|
254
|
+
const currentText = await readOptional(path);
|
|
255
|
+
const entries = parseJsonArray(currentText, path);
|
|
256
|
+
const migrated = entries.map(migrateReviewKeymapEntry);
|
|
257
|
+
const alreadyBound = migrated.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
258
|
+
const next = alreadyBound ? migrated : [
|
|
259
|
+
...migrated,
|
|
260
|
+
{
|
|
261
|
+
context: "Workspace",
|
|
262
|
+
bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_LOCAL_REVIEW_TASK_NAME }] }
|
|
263
|
+
}
|
|
264
|
+
];
|
|
265
|
+
const changed = JSON.stringify(entries) !== JSON.stringify(next);
|
|
266
|
+
if (changed)
|
|
267
|
+
await writeJson(path, next);
|
|
268
|
+
return { path, changed, existed: currentText !== undefined };
|
|
269
|
+
}
|
|
270
|
+
function zedReviewTaskName(command) {
|
|
271
|
+
if (command.some((value, index) => value === "plan" && command[index + 1] === "annotate")) {
|
|
272
|
+
return ZED_PLAN_ANNOTATE_TASK_NAME;
|
|
273
|
+
}
|
|
274
|
+
return command[0] === "tuicr" && command[1] === "pr" ? ZED_PR_REVIEW_TASK_NAME : ZED_LOCAL_REVIEW_TASK_NAME;
|
|
275
|
+
}
|
|
276
|
+
var LOCAL_REVIEW_SCRIPT = `set -eu
|
|
277
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
278
|
+
branch=$(git branch --show-current)
|
|
279
|
+
base=
|
|
280
|
+
case "$remote" in
|
|
281
|
+
*github.com*)
|
|
282
|
+
base=$(gh pr list --head "$branch" --state open --limit 100 --json baseRefName,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].baseRefName // empty' 2>/dev/null || true)
|
|
283
|
+
[ -n "$base" ] || base=$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || true)
|
|
284
|
+
;;
|
|
285
|
+
*gitlab*)
|
|
286
|
+
mr=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null || true)
|
|
287
|
+
base=$(printf '%s' "$mr" | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(a[0]?.target_branch||"")}catch{}})')
|
|
288
|
+
[ -n "$base" ] || base=$(glab repo view --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{process.stdout.write(JSON.parse(s).default_branch||"")}catch{}})' || true)
|
|
289
|
+
;;
|
|
290
|
+
esac
|
|
291
|
+
[ -n "$base" ] || { echo 'diffpi: could not resolve a GitHub/GitLab review base' >&2; exit 1; }
|
|
292
|
+
exec tuicr -w -r "$base..HEAD"`;
|
|
293
|
+
var PR_REVIEW_SCRIPT = `set -eu
|
|
294
|
+
remote=$(git remote get-url origin 2>/dev/null || true)
|
|
295
|
+
branch=$(git branch --show-current)
|
|
296
|
+
case "$remote" in
|
|
297
|
+
*github.com*) number=$(gh pr list --head "$branch" --state open --limit 100 --json number,createdAt --jq 'sort_by(.createdAt) | reverse | .[0].number // empty' 2>/dev/null || true) ;;
|
|
298
|
+
*gitlab*) number=$(glab mr list --source-branch "$branch" --order created_at --sort desc --per-page 100 --output json 2>/dev/null | node -e 'let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{try{let a=JSON.parse(s);process.stdout.write(String(a[0]?.iid||a[0]?.number||""))}catch{}})' || true) ;;
|
|
299
|
+
*) number= ;;
|
|
300
|
+
esac
|
|
301
|
+
[ -n "$number" ] || { echo 'diffpi: could not resolve the current GitHub PR or GitLab MR' >&2; exit 1; }
|
|
302
|
+
exec tuicr pr "$number"`;
|
|
303
|
+
function staticReviewTask(label, script) {
|
|
304
|
+
return {
|
|
305
|
+
label,
|
|
306
|
+
command: "sh",
|
|
307
|
+
args: ["-lc", script],
|
|
308
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
309
|
+
use_new_terminal: true,
|
|
310
|
+
reveal: "always",
|
|
311
|
+
reveal_target: "center"
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function localReviewTask() {
|
|
315
|
+
return staticReviewTask(ZED_LOCAL_REVIEW_TASK_NAME, LOCAL_REVIEW_SCRIPT);
|
|
316
|
+
}
|
|
317
|
+
function prReviewTask() {
|
|
318
|
+
return staticReviewTask(ZED_PR_REVIEW_TASK_NAME, PR_REVIEW_SCRIPT);
|
|
319
|
+
}
|
|
320
|
+
function bindsReviewTask(payload) {
|
|
321
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_LOCAL_REVIEW_TASK_NAME;
|
|
322
|
+
}
|
|
323
|
+
function migrateReviewKeymapEntry(entry) {
|
|
324
|
+
const bindings = Object.fromEntries(Object.entries(entry.bindings ?? {}).map(([key, action]) => {
|
|
325
|
+
if (Array.isArray(action) && action[0] === "task::Spawn" && typeof action[1] === "object" && action[1] !== null && action[1].task_name === LEGACY_ZED_REVIEW_TASK_NAME) {
|
|
326
|
+
return [key, ["task::Spawn", { ...action[1], task_name: ZED_LOCAL_REVIEW_TASK_NAME }]];
|
|
327
|
+
}
|
|
328
|
+
return [key, action];
|
|
329
|
+
}));
|
|
330
|
+
return entry.bindings ? { ...entry, bindings } : entry;
|
|
331
|
+
}
|
|
332
|
+
async function readOptional(path) {
|
|
333
|
+
try {
|
|
334
|
+
return await readFile2(path, "utf8");
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
337
|
+
return;
|
|
338
|
+
throw error;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
function parseJsonArray(content, path) {
|
|
342
|
+
if (!content?.trim())
|
|
343
|
+
return [];
|
|
344
|
+
let value;
|
|
345
|
+
try {
|
|
346
|
+
value = JSON.parse(content);
|
|
347
|
+
} catch {
|
|
348
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
349
|
+
}
|
|
350
|
+
if (!Array.isArray(value))
|
|
351
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
352
|
+
return value;
|
|
353
|
+
}
|
|
354
|
+
async function writeJson(path, value) {
|
|
355
|
+
await mkdir(dirname2(path), { recursive: true });
|
|
356
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
357
|
+
`, "utf8");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// src/environment.ts
|
|
361
|
+
function detectIde(env = process.env) {
|
|
362
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
363
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
364
|
+
return "zed";
|
|
365
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
366
|
+
return "cursor";
|
|
367
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
368
|
+
return "windsurf";
|
|
369
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
370
|
+
return "jetbrains";
|
|
371
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
372
|
+
return "vscode";
|
|
373
|
+
return "unknown";
|
|
374
|
+
}
|
|
375
|
+
function detectMux(env = process.env) {
|
|
376
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
377
|
+
return "zellij";
|
|
378
|
+
if (env.TMUX)
|
|
379
|
+
return "tmux";
|
|
380
|
+
if (env.STY)
|
|
381
|
+
return "screen";
|
|
382
|
+
return "none";
|
|
383
|
+
}
|
|
384
|
+
function detectShell(env = process.env) {
|
|
385
|
+
return env.SHELL ? basename(env.SHELL) : "unknown";
|
|
386
|
+
}
|
|
387
|
+
async function detectVcs(cwd) {
|
|
388
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
389
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
390
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
391
|
+
return { ...parseRemote(remote), branch, root };
|
|
392
|
+
}
|
|
393
|
+
function parseRemote(remote) {
|
|
394
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
395
|
+
if (!remote)
|
|
396
|
+
return empty;
|
|
397
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
398
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
399
|
+
const match = scp ?? url;
|
|
400
|
+
if (!match)
|
|
401
|
+
return empty;
|
|
402
|
+
const host = match[1];
|
|
403
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
404
|
+
if (segments.length < 2)
|
|
405
|
+
return { ...empty, host };
|
|
406
|
+
const repo = segments.at(-1) ?? "";
|
|
407
|
+
const owner = segments.slice(0, -1).join("/");
|
|
408
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
409
|
+
return { provider, host, owner, repo };
|
|
410
|
+
}
|
|
411
|
+
async function openInNewTab(command, opts) {
|
|
412
|
+
const env = opts.env ?? process.env;
|
|
413
|
+
const name = opts.name ?? "review";
|
|
414
|
+
const printable = command.join(" ");
|
|
415
|
+
const mux = detectMux(env);
|
|
416
|
+
if (mux !== "none") {
|
|
417
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
418
|
+
if (opened)
|
|
419
|
+
return opened;
|
|
420
|
+
}
|
|
421
|
+
if (detectIde(env) === "zed") {
|
|
422
|
+
try {
|
|
423
|
+
const taskName = zedReviewTaskName(command);
|
|
424
|
+
if (taskName === ZED_PLAN_ANNOTATE_TASK_NAME)
|
|
425
|
+
await ensureZedPlanTask(await packageVersion(), opts.homeDir);
|
|
426
|
+
else
|
|
427
|
+
await ensureZedReviewTask(opts.homeDir, command);
|
|
428
|
+
return {
|
|
429
|
+
launched: false,
|
|
430
|
+
configured: true,
|
|
431
|
+
via: "zed-task",
|
|
432
|
+
command: printable,
|
|
433
|
+
taskName,
|
|
434
|
+
instruction: `Run the Zed task "${taskName}".`
|
|
435
|
+
};
|
|
436
|
+
} catch {}
|
|
437
|
+
}
|
|
438
|
+
return { launched: false, via: "print", command: printable };
|
|
439
|
+
}
|
|
440
|
+
function screenWindowArgs(command, cwd, name) {
|
|
441
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
442
|
+
}
|
|
443
|
+
async function packageVersion() {
|
|
444
|
+
const value = JSON.parse(await readFile3(join5(resolveBundledAgentsDir(), "..", "package.json"), "utf8"));
|
|
445
|
+
if (typeof value.version !== "string")
|
|
446
|
+
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
447
|
+
return value.version;
|
|
448
|
+
}
|
|
449
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
450
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
451
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
452
|
+
if (result.code === 0)
|
|
453
|
+
return { launched: true, via: "zellij", command: printable };
|
|
454
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
455
|
+
if (fallback.code === 0)
|
|
456
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
457
|
+
}
|
|
458
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
459
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
460
|
+
if (result.code === 0)
|
|
461
|
+
return { launched: true, via: "tmux", command: printable };
|
|
462
|
+
}
|
|
463
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
464
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
465
|
+
if (result.code === 0)
|
|
466
|
+
return { launched: true, via: "screen", command: printable };
|
|
467
|
+
}
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// src/extensions/gitx.ts
|
|
471
|
+
import { realpath } from "node:fs/promises";
|
|
472
|
+
import { basename as basename2, isAbsolute, join as join6, resolve } from "node:path";
|
|
473
|
+
async function gitToplevel(cwd) {
|
|
474
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
475
|
+
const top = result.stdout.trim();
|
|
476
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
477
|
+
}
|
|
478
|
+
async function inspectGitRepository(cwd) {
|
|
479
|
+
const root = await gitToplevel(cwd);
|
|
480
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
481
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
482
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
483
|
+
const common = commonResult.stdout.trim();
|
|
484
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join6(root, common)) : root;
|
|
485
|
+
const commonDir = await canonicalPath(commonPath);
|
|
486
|
+
return {
|
|
487
|
+
root,
|
|
488
|
+
commonDir,
|
|
489
|
+
...remote ? { remote } : {},
|
|
490
|
+
name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
|
|
491
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function normalizeRemote(remote) {
|
|
495
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
496
|
+
}
|
|
497
|
+
function repositoryName(remote) {
|
|
498
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
499
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
500
|
+
}
|
|
501
|
+
async function canonicalPath(path) {
|
|
502
|
+
try {
|
|
503
|
+
return await realpath(path);
|
|
504
|
+
} catch {
|
|
505
|
+
return resolve(path);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
// src/extensions/ghx.ts
|
|
509
|
+
function gh(args, options) {
|
|
510
|
+
return run("gh", args, options);
|
|
511
|
+
}
|
|
512
|
+
function ghChecked(args, options) {
|
|
513
|
+
return runChecked("gh", args, options);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/vcs/helpers.ts
|
|
517
|
+
function isConfirmedMissingChange(provider, output) {
|
|
518
|
+
const message = output.toLowerCase();
|
|
519
|
+
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;
|
|
520
|
+
}
|
|
521
|
+
function parseJson(value, name) {
|
|
522
|
+
try {
|
|
523
|
+
return JSON.parse(value);
|
|
524
|
+
} catch {
|
|
525
|
+
throw new Error(`Cannot parse the ${name} response as JSON.`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function commandFailure(command, args, result) {
|
|
529
|
+
return new Error(`${command} ${args.join(" ")} failed: ${result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`}`);
|
|
530
|
+
}
|
|
531
|
+
function requireBranchName(output, provider) {
|
|
532
|
+
const branch = output.trim();
|
|
533
|
+
if (!branch || branch === "null")
|
|
534
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
535
|
+
return branch;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/vcs/github.ts
|
|
539
|
+
function GitHubVcsBackend(vcs) {
|
|
540
|
+
const repo = ["--repo", `${vcs.owner}/${vcs.repo}`];
|
|
541
|
+
const viewPr = async (idOrBranch) => {
|
|
542
|
+
const args = [
|
|
543
|
+
"pr",
|
|
544
|
+
"view",
|
|
545
|
+
idOrBranch,
|
|
546
|
+
...repo,
|
|
547
|
+
"--json",
|
|
548
|
+
"number,title,url,isDraft,baseRefName,headRefName,headRefOid"
|
|
549
|
+
];
|
|
550
|
+
const result = await gh(args);
|
|
551
|
+
if (result.code !== 0) {
|
|
552
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
553
|
+
return;
|
|
554
|
+
throw commandFailure("gh", args, result);
|
|
555
|
+
}
|
|
556
|
+
if (!result.stdout.trim())
|
|
557
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
558
|
+
const data = parseJson(result.stdout, "GitHub pull request");
|
|
559
|
+
if (typeof data.number !== "number" || typeof data.title !== "string" || typeof data.url !== "string" || typeof data.isDraft !== "boolean" || typeof data.baseRefName !== "string" || typeof data.headRefName !== "string")
|
|
560
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
561
|
+
return {
|
|
562
|
+
number: data.number,
|
|
563
|
+
title: data.title,
|
|
564
|
+
url: data.url,
|
|
565
|
+
isDraft: data.isDraft,
|
|
566
|
+
baseRef: data.baseRefName,
|
|
567
|
+
headRef: data.headRefName,
|
|
568
|
+
headSha: data.headRefOid
|
|
569
|
+
};
|
|
570
|
+
};
|
|
571
|
+
return {
|
|
572
|
+
provider: "github",
|
|
573
|
+
async createDraftPr(options) {
|
|
574
|
+
const args = [
|
|
575
|
+
"pr",
|
|
576
|
+
"create",
|
|
577
|
+
...repo,
|
|
578
|
+
"--title",
|
|
579
|
+
options.title,
|
|
580
|
+
"--body",
|
|
581
|
+
options.body,
|
|
582
|
+
"--base",
|
|
583
|
+
options.base,
|
|
584
|
+
"--head",
|
|
585
|
+
options.head
|
|
586
|
+
];
|
|
587
|
+
if (options.draft !== false)
|
|
588
|
+
args.push("--draft");
|
|
589
|
+
await ghChecked(args);
|
|
590
|
+
const ref = await viewPr(options.head);
|
|
591
|
+
if (!ref)
|
|
592
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
593
|
+
return ref;
|
|
594
|
+
},
|
|
595
|
+
viewPr,
|
|
596
|
+
async defaultBranch() {
|
|
597
|
+
return requireBranchName((await ghChecked([
|
|
598
|
+
"repo",
|
|
599
|
+
"view",
|
|
600
|
+
`${vcs.owner}/${vcs.repo}`,
|
|
601
|
+
"--json",
|
|
602
|
+
"defaultBranchRef",
|
|
603
|
+
"--jq",
|
|
604
|
+
".defaultBranchRef.name"
|
|
605
|
+
])).stdout, "GitHub");
|
|
606
|
+
},
|
|
607
|
+
async prDiff(id) {
|
|
608
|
+
return (await ghChecked(["pr", "diff", String(id), ...repo], { capture: "unbounded" })).stdout;
|
|
609
|
+
},
|
|
610
|
+
async prChecks(id) {
|
|
611
|
+
return (await gh(["pr", "checks", String(id), ...repo])).stdout;
|
|
612
|
+
},
|
|
613
|
+
async markReady(id) {
|
|
614
|
+
await ghChecked(["pr", "ready", String(id), ...repo]);
|
|
615
|
+
},
|
|
616
|
+
async closePr(id, comment) {
|
|
617
|
+
const args = ["pr", "close", String(id), ...repo];
|
|
618
|
+
if (comment)
|
|
619
|
+
args.push("--comment", comment);
|
|
620
|
+
await ghChecked(args);
|
|
621
|
+
},
|
|
622
|
+
async mergePr(id, subject) {
|
|
623
|
+
const readiness = await ghChecked([
|
|
624
|
+
"pr",
|
|
625
|
+
"view",
|
|
626
|
+
String(id),
|
|
627
|
+
...repo,
|
|
628
|
+
"--json",
|
|
629
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
630
|
+
]);
|
|
631
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
632
|
+
await ghChecked(["pr", "merge", String(id), ...repo, "--squash", "--subject", subject]);
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function assertGitHubMergeReady(input) {
|
|
637
|
+
const data = parseJson(input, "GitHub readiness");
|
|
638
|
+
const blockers = [];
|
|
639
|
+
if (data.state !== "OPEN")
|
|
640
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
641
|
+
if (data.isDraft)
|
|
642
|
+
blockers.push("pull request is still a draft");
|
|
643
|
+
if (data.reviewDecision !== "APPROVED")
|
|
644
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
645
|
+
if (data.mergeStateStatus !== "CLEAN")
|
|
646
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
647
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
648
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
649
|
+
if (check.__typename === "CheckRun") {
|
|
650
|
+
if (check.status !== "COMPLETED")
|
|
651
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
652
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? ""))
|
|
653
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
654
|
+
} else if (check.state !== "SUCCESS")
|
|
655
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
656
|
+
}
|
|
657
|
+
if (blockers.length > 0)
|
|
658
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// src/extensions/glabx.ts
|
|
662
|
+
function glab(args, options) {
|
|
663
|
+
return run("glab", args, options);
|
|
664
|
+
}
|
|
665
|
+
function glabChecked(args, options) {
|
|
666
|
+
return runChecked("glab", args, options);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// src/vcs/gitlab.ts
|
|
670
|
+
function GitLabVcsBackend(vcs) {
|
|
671
|
+
const project = `${vcs.owner}/${vcs.repo}`;
|
|
672
|
+
const viewPr = async (idOrBranch) => {
|
|
673
|
+
const args = ["mr", "view", idOrBranch, "--repo", project, "--output", "json"];
|
|
674
|
+
const result = await glab(args);
|
|
675
|
+
if (result.code !== 0) {
|
|
676
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
677
|
+
return;
|
|
678
|
+
throw commandFailure("glab", args, result);
|
|
679
|
+
}
|
|
680
|
+
if (!result.stdout.trim())
|
|
681
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
682
|
+
const data = parseJson(result.stdout, "GitLab merge request");
|
|
683
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string")
|
|
684
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
685
|
+
return {
|
|
686
|
+
number: data.iid,
|
|
687
|
+
title: data.title,
|
|
688
|
+
url: data.web_url,
|
|
689
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
690
|
+
baseRef: data.target_branch,
|
|
691
|
+
headRef: data.source_branch,
|
|
692
|
+
headSha: data.sha
|
|
693
|
+
};
|
|
694
|
+
};
|
|
695
|
+
return {
|
|
696
|
+
provider: "gitlab",
|
|
697
|
+
async createDraftPr(options) {
|
|
698
|
+
await glabChecked([
|
|
699
|
+
"mr",
|
|
700
|
+
"create",
|
|
701
|
+
"--repo",
|
|
702
|
+
project,
|
|
703
|
+
"--title",
|
|
704
|
+
`Draft: ${options.title}`,
|
|
705
|
+
"--description",
|
|
706
|
+
options.body,
|
|
707
|
+
"--target-branch",
|
|
708
|
+
options.base,
|
|
709
|
+
"--source-branch",
|
|
710
|
+
options.head,
|
|
711
|
+
"--yes"
|
|
712
|
+
]);
|
|
713
|
+
const ref = await viewPr(options.head);
|
|
714
|
+
if (!ref)
|
|
715
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
716
|
+
return ref;
|
|
717
|
+
},
|
|
718
|
+
viewPr,
|
|
719
|
+
async defaultBranch() {
|
|
720
|
+
return requireBranchName((await glabChecked(["api", `projects/${encodeURIComponent(project)}`, "--jq", ".default_branch"])).stdout, "GitLab");
|
|
721
|
+
},
|
|
722
|
+
async prDiff(id) {
|
|
723
|
+
return (await glabChecked(["mr", "diff", String(id), "--repo", project], { capture: "unbounded" })).stdout;
|
|
724
|
+
},
|
|
725
|
+
async prChecks(id) {
|
|
726
|
+
return (await glabChecked([
|
|
727
|
+
"api",
|
|
728
|
+
`projects/${encodeURIComponent(project)}/merge_requests/${id}/pipelines?per_page=100`
|
|
729
|
+
])).stdout;
|
|
730
|
+
},
|
|
731
|
+
async markReady(id) {
|
|
732
|
+
await glabChecked(["mr", "update", String(id), "--repo", project, "--ready"]);
|
|
733
|
+
},
|
|
734
|
+
async closePr(id, comment) {
|
|
735
|
+
if (comment)
|
|
736
|
+
await glabChecked(["mr", "note", String(id), "--repo", project, "--message", comment]);
|
|
737
|
+
await glabChecked(["mr", "close", String(id), "--repo", project]);
|
|
738
|
+
},
|
|
739
|
+
async mergePr() {
|
|
740
|
+
throw new Error("Merge is not supported by the GitLab forge adapter.");
|
|
741
|
+
}
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
// src/vcs/forge-backend.ts
|
|
746
|
+
function createForgeBackend(vcs) {
|
|
747
|
+
if (vcs.provider === "github")
|
|
748
|
+
return GitHubVcsBackend(vcs);
|
|
749
|
+
if (vcs.provider === "gitlab")
|
|
750
|
+
return GitLabVcsBackend(vcs);
|
|
751
|
+
throw new Error("No supported forge detected from the git remote. Use --local for an offline review.");
|
|
752
|
+
}
|
|
753
|
+
var createVcsBackend = createForgeBackend;
|
|
754
|
+
var createForge = createForgeBackend;
|
|
755
|
+
// src/extensions/misex.ts
|
|
756
|
+
import { mkdir as mkdir2, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
|
|
757
|
+
import { homedir as homedir3 } from "node:os";
|
|
758
|
+
import { basename as basename3, dirname as dirname3, join as join7 } from "node:path";
|
|
219
759
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
220
760
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
221
761
|
var mise = {
|
|
222
762
|
async executableCheck(name = "mise") {
|
|
223
763
|
return findExecutable(name);
|
|
224
764
|
},
|
|
765
|
+
async run(args, options = {}) {
|
|
766
|
+
return run("mise", args, options);
|
|
767
|
+
},
|
|
225
768
|
async install(options = {}) {
|
|
226
769
|
const homeDir = options.homeDir ?? homedir3();
|
|
227
770
|
const platform = options.platform ?? process.platform;
|
|
228
771
|
if (platform === "win32")
|
|
229
772
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
230
|
-
const installedPath =
|
|
773
|
+
const installedPath = join7(homeDir, ".local", "bin", "mise");
|
|
231
774
|
if (options.dryRun)
|
|
232
775
|
return installedPath;
|
|
233
776
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -238,8 +781,8 @@ var mise = {
|
|
|
238
781
|
},
|
|
239
782
|
async hookEnsure(executable, options = {}) {
|
|
240
783
|
const homeDir = options.homeDir ?? homedir3();
|
|
241
|
-
const hook = getShellHook(
|
|
242
|
-
const current = await
|
|
784
|
+
const hook = getShellHook(basename3(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
785
|
+
const current = await getOptionalFile(hook.path);
|
|
243
786
|
if (current.includes(MISE_HOOK_START))
|
|
244
787
|
return { path: hook.path, changed: false, planned: false };
|
|
245
788
|
if (options.dryRun)
|
|
@@ -247,7 +790,7 @@ var mise = {
|
|
|
247
790
|
const separator = current.length === 0 || current.endsWith(`
|
|
248
791
|
`) ? "" : `
|
|
249
792
|
`;
|
|
250
|
-
await mkdir2(
|
|
793
|
+
await mkdir2(dirname3(hook.path), { recursive: true });
|
|
251
794
|
await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
252
795
|
return { path: hook.path, changed: true, planned: false };
|
|
253
796
|
},
|
|
@@ -274,7 +817,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
274
817
|
switch (shell.toLowerCase()) {
|
|
275
818
|
case "zsh":
|
|
276
819
|
return {
|
|
277
|
-
path:
|
|
820
|
+
path: join7(homeDir, ".zshrc"),
|
|
278
821
|
content: `${MISE_HOOK_START}
|
|
279
822
|
eval "$(${command} activate zsh)"
|
|
280
823
|
${MISE_HOOK_END}
|
|
@@ -282,7 +825,7 @@ ${MISE_HOOK_END}
|
|
|
282
825
|
};
|
|
283
826
|
case "fish":
|
|
284
827
|
return {
|
|
285
|
-
path:
|
|
828
|
+
path: join7(homeDir, ".config", "fish", "config.fish"),
|
|
286
829
|
content: `${MISE_HOOK_START}
|
|
287
830
|
${command} activate fish | source
|
|
288
831
|
${MISE_HOOK_END}
|
|
@@ -291,7 +834,7 @@ ${MISE_HOOK_END}
|
|
|
291
834
|
case "nu":
|
|
292
835
|
case "nushell":
|
|
293
836
|
return {
|
|
294
|
-
path:
|
|
837
|
+
path: join7(homeDir, ".config", "nushell", "config.nu"),
|
|
295
838
|
content: `${MISE_HOOK_START}
|
|
296
839
|
let mise_bin = ${command}
|
|
297
840
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -302,7 +845,7 @@ ${MISE_HOOK_END}
|
|
|
302
845
|
};
|
|
303
846
|
case "xonsh":
|
|
304
847
|
return {
|
|
305
|
-
path:
|
|
848
|
+
path: join7(homeDir, ".xonshrc"),
|
|
306
849
|
content: `${MISE_HOOK_START}
|
|
307
850
|
execx($(${command} activate xonsh))
|
|
308
851
|
${MISE_HOOK_END}
|
|
@@ -310,7 +853,7 @@ ${MISE_HOOK_END}
|
|
|
310
853
|
};
|
|
311
854
|
case "elvish":
|
|
312
855
|
return {
|
|
313
|
-
path:
|
|
856
|
+
path: join7(homeDir, ".config", "elvish", "rc.elv"),
|
|
314
857
|
content: `${MISE_HOOK_START}
|
|
315
858
|
var mise: = (ns [&])
|
|
316
859
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -321,7 +864,7 @@ ${MISE_HOOK_END}
|
|
|
321
864
|
case "pwsh":
|
|
322
865
|
case "powershell":
|
|
323
866
|
return {
|
|
324
|
-
path:
|
|
867
|
+
path: join7(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
325
868
|
content: `${MISE_HOOK_START}
|
|
326
869
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
327
870
|
${MISE_HOOK_END}
|
|
@@ -330,7 +873,7 @@ ${MISE_HOOK_END}
|
|
|
330
873
|
case "bash":
|
|
331
874
|
default:
|
|
332
875
|
return {
|
|
333
|
-
path:
|
|
876
|
+
path: join7(homeDir, ".bashrc"),
|
|
334
877
|
content: `${MISE_HOOK_START}
|
|
335
878
|
eval "$(${command} activate bash)"
|
|
336
879
|
${MISE_HOOK_END}
|
|
@@ -367,9 +910,9 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
367
910
|
}
|
|
368
911
|
return true;
|
|
369
912
|
}
|
|
370
|
-
async function
|
|
913
|
+
async function getOptionalFile(path) {
|
|
371
914
|
try {
|
|
372
|
-
return await
|
|
915
|
+
return await readFile4(path, "utf8");
|
|
373
916
|
} catch (error) {
|
|
374
917
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
375
918
|
return "";
|
|
@@ -379,48 +922,1043 @@ async function getOptionalFile2(path) {
|
|
|
379
922
|
function getShellQuoted(value) {
|
|
380
923
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
381
924
|
}
|
|
382
|
-
// src/modes.ts
|
|
383
|
-
import {
|
|
384
|
-
getAgentDir,
|
|
385
|
-
parseFrontmatter as parseFrontmatter2
|
|
386
|
-
} from "@earendil-works/pi-coding-agent";
|
|
387
|
-
import { readFile as readFile4 } from "node:fs/promises";
|
|
388
|
-
import { homedir as homedir4 } from "node:os";
|
|
389
|
-
import { basename as basename2, extname, join as join6 } from "node:path";
|
|
390
925
|
|
|
391
|
-
// src/
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
926
|
+
// src/gates.ts
|
|
927
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
928
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
929
|
+
function checkConventionalSubject(subject) {
|
|
930
|
+
const trimmed = subject.trim();
|
|
931
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
932
|
+
return {
|
|
933
|
+
name: "conventional-subject",
|
|
934
|
+
status: ok ? "pass" : "warn",
|
|
935
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
async function runMiseGates(cwd) {
|
|
939
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
940
|
+
const results = [];
|
|
941
|
+
for (const gate of MISE_GATES) {
|
|
942
|
+
const targets = tasks.get(gate) ?? [];
|
|
943
|
+
if (targets.length === 0) {
|
|
944
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
945
|
+
continue;
|
|
946
|
+
}
|
|
947
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
948
|
+
const result = await mise.run(["run", ...invocations], { cwd });
|
|
949
|
+
results.push({
|
|
950
|
+
name: gate,
|
|
951
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
952
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
return results;
|
|
956
|
+
}
|
|
957
|
+
function ciGate(checksOutput) {
|
|
958
|
+
const text = checksOutput.toLowerCase();
|
|
959
|
+
if (!text.trim())
|
|
960
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
961
|
+
if (/\bfail|error\b/.test(text))
|
|
962
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
963
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
964
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
965
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
966
|
+
}
|
|
967
|
+
function parseMiseTasks(input) {
|
|
968
|
+
let tasks;
|
|
969
|
+
try {
|
|
970
|
+
tasks = JSON.parse(input);
|
|
971
|
+
} catch {
|
|
972
|
+
return new Map;
|
|
973
|
+
}
|
|
974
|
+
if (!Array.isArray(tasks))
|
|
975
|
+
return new Map;
|
|
976
|
+
const found = new Map;
|
|
977
|
+
for (const gate of MISE_GATES) {
|
|
978
|
+
const targets = tasks.flatMap((task) => {
|
|
979
|
+
if (typeof task.name !== "string")
|
|
980
|
+
return [];
|
|
981
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
982
|
+
});
|
|
983
|
+
if (targets.length > 0)
|
|
984
|
+
found.set(gate, [...new Set(targets)]);
|
|
985
|
+
}
|
|
986
|
+
return found;
|
|
987
|
+
}
|
|
988
|
+
async function discoverMiseTasks(cwd) {
|
|
989
|
+
const result = await mise.run(["tasks", "--json", "--all"], { cwd });
|
|
990
|
+
if (result.code !== 0)
|
|
991
|
+
return new Map;
|
|
992
|
+
return parseMiseTasks(result.stdout);
|
|
993
|
+
}
|
|
994
|
+
// src/review/types.ts
|
|
995
|
+
import { z as z2 } from "zod";
|
|
996
|
+
var severitySchema = z2.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
997
|
+
var findingSchema = z2.object({
|
|
998
|
+
file: z2.string().min(1),
|
|
999
|
+
line: z2.number().int().nonnegative(),
|
|
1000
|
+
severity: severitySchema,
|
|
1001
|
+
body: z2.string().min(1),
|
|
1002
|
+
reference: z2.string().optional().default("")
|
|
1003
|
+
});
|
|
1004
|
+
var findingsSchema = z2.array(findingSchema);
|
|
1005
|
+
function reviewSlug(input) {
|
|
1006
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
1007
|
+
}
|
|
1008
|
+
function yymmdd(date = new Date) {
|
|
1009
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
1010
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
1011
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
1012
|
+
return `${yy}${mm}${dd}`;
|
|
1013
|
+
}
|
|
1014
|
+
function reviewRecordName(target, date = new Date) {
|
|
1015
|
+
return `${yymmdd(date)}-${reviewSlug(target) || "uncommitted"}`;
|
|
1016
|
+
}
|
|
1017
|
+
function isLocalResponse(body) {
|
|
1018
|
+
return /^<!-- diffpi(?:-local-response:[A-Za-z0-9_-]+)? -->\n/m.test(body);
|
|
1019
|
+
}
|
|
1020
|
+
function dedupeFindings(findings) {
|
|
1021
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
1022
|
+
const byKey = new Map;
|
|
1023
|
+
for (const finding of findings) {
|
|
1024
|
+
const key = `${finding.file}:${finding.line}`;
|
|
1025
|
+
const existing = byKey.get(key);
|
|
1026
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
1027
|
+
byKey.set(key, finding);
|
|
1028
|
+
}
|
|
1029
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
1030
|
+
}
|
|
1031
|
+
function toReviewComments(findings, model) {
|
|
1032
|
+
const comments = [];
|
|
1033
|
+
for (const finding of findings) {
|
|
1034
|
+
if (finding.line <= 0)
|
|
1035
|
+
continue;
|
|
1036
|
+
const body = renderCommentBody(finding);
|
|
1037
|
+
comments.push({
|
|
1038
|
+
file: finding.file,
|
|
1039
|
+
line: finding.line,
|
|
1040
|
+
side: "RIGHT",
|
|
1041
|
+
body: model ? withRemoteProvenance(body, model) : body
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return comments;
|
|
1045
|
+
}
|
|
1046
|
+
function withRemoteProvenance(body, model) {
|
|
1047
|
+
const normalized = body.trimEnd();
|
|
1048
|
+
if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
|
|
1049
|
+
return body;
|
|
1050
|
+
return `${normalized}
|
|
1051
|
+
|
|
1052
|
+
Generated review by Diffpi using \`${model}\`.`;
|
|
1053
|
+
}
|
|
1054
|
+
function localReviewAuthor(model) {
|
|
1055
|
+
return `Agent: ${model}`;
|
|
1056
|
+
}
|
|
1057
|
+
function renderCommentBody(finding) {
|
|
1058
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
1059
|
+
const reference = finding.reference ? `
|
|
1060
|
+
|
|
1061
|
+
> **Reference:** ${finding.reference}` : "";
|
|
1062
|
+
return `${prefix}${finding.body}${reference}`;
|
|
1063
|
+
}
|
|
1064
|
+
// src/review/review-markdown.ts
|
|
1065
|
+
import { z as z3 } from "zod";
|
|
1066
|
+
var reviewThreadRecordSchema = z3.object({
|
|
1067
|
+
id: z3.string().min(1),
|
|
1068
|
+
file: z3.string().optional(),
|
|
1069
|
+
line: z3.number().int().positive().optional(),
|
|
1070
|
+
body: z3.string(),
|
|
1071
|
+
author: z3.string().optional(),
|
|
1072
|
+
resolved: z3.boolean(),
|
|
1073
|
+
addressed: z3.boolean().optional(),
|
|
1074
|
+
question: z3.boolean(),
|
|
1075
|
+
replies: z3.array(z3.string()).optional()
|
|
1076
|
+
});
|
|
1077
|
+
function renderReviewDoc(input) {
|
|
1078
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
1079
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
1080
|
+
if (input.number !== undefined)
|
|
1081
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
1082
|
+
if (input.author)
|
|
1083
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
1084
|
+
if (input.model)
|
|
1085
|
+
lines.push(`- **Review agent**: ${input.model}`);
|
|
1086
|
+
if (input.headRef && input.baseRef)
|
|
1087
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
1088
|
+
if (input.additions !== undefined)
|
|
1089
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
1090
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
1091
|
+
if (input.overallIssues.length > 0) {
|
|
1092
|
+
lines.push("## Overall issues", "");
|
|
1093
|
+
for (const issue of input.overallIssues)
|
|
1094
|
+
lines.push(`- ${issue}`);
|
|
1095
|
+
lines.push("");
|
|
1096
|
+
}
|
|
1097
|
+
lines.push("## Verification", "");
|
|
1098
|
+
for (const gate of input.gates)
|
|
1099
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
1100
|
+
lines.push("");
|
|
1101
|
+
if (input.notVerified.length > 0) {
|
|
1102
|
+
lines.push("## What was NOT verified", "");
|
|
1103
|
+
for (const item of input.notVerified)
|
|
1104
|
+
lines.push(`- ${item}`);
|
|
1105
|
+
lines.push("");
|
|
1106
|
+
}
|
|
1107
|
+
lines.push("## Inline Comments", "");
|
|
1108
|
+
for (const finding of anchored) {
|
|
1109
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
1110
|
+
if (finding.reference)
|
|
1111
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
1112
|
+
lines.push("---", "");
|
|
1113
|
+
}
|
|
1114
|
+
return `${lines.join(`
|
|
1115
|
+
`).trimEnd()}
|
|
1116
|
+
`;
|
|
1117
|
+
}
|
|
1118
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
1119
|
+
const records = threads.map((thread) => ({
|
|
1120
|
+
id: thread.id,
|
|
1121
|
+
file: thread.file,
|
|
1122
|
+
line: thread.line,
|
|
1123
|
+
body: thread.body,
|
|
1124
|
+
author: thread.author,
|
|
1125
|
+
resolved: thread.resolved,
|
|
1126
|
+
addressed: thread.addressed,
|
|
1127
|
+
question: thread.question,
|
|
1128
|
+
replies: thread.replies
|
|
1129
|
+
}));
|
|
1130
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
1131
|
+
const lines = [
|
|
1132
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
1133
|
+
`# Review threads: ${title}`,
|
|
1134
|
+
"",
|
|
1135
|
+
"## Metadata",
|
|
1136
|
+
"",
|
|
1137
|
+
`- Target: ${target}`,
|
|
1138
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
401
1139
|
];
|
|
402
|
-
|
|
1140
|
+
if (options.number !== undefined)
|
|
1141
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
1142
|
+
lines.push("", "## Replies", "");
|
|
1143
|
+
for (const thread of threads) {
|
|
1144
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1145
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
1146
|
+
}
|
|
1147
|
+
lines.push("## Source comments", "");
|
|
1148
|
+
for (const thread of threads) {
|
|
1149
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, `Status: ${thread.resolved ? "resolved" : thread.addressed ? "addressed" : "open"}`, "", thread.body, "", "---", "");
|
|
1150
|
+
}
|
|
1151
|
+
return `${lines.join(`
|
|
1152
|
+
`).trimEnd()}
|
|
1153
|
+
`;
|
|
1154
|
+
}
|
|
1155
|
+
function parseThreadArtifact(content) {
|
|
1156
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
1157
|
+
if (!payload)
|
|
1158
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
1159
|
+
let threads;
|
|
1160
|
+
try {
|
|
1161
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
1162
|
+
threads = z3.array(reviewThreadRecordSchema).parse(decoded);
|
|
1163
|
+
} catch {
|
|
1164
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
1165
|
+
}
|
|
1166
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
1167
|
+
return threads.map((thread) => {
|
|
1168
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
1169
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
1170
|
+
`;
|
|
1171
|
+
const endMarker = `
|
|
1172
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
1173
|
+
const start = replies.indexOf(startMarker);
|
|
1174
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
1175
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
1176
|
+
return reply ? { ...thread, reply } : thread;
|
|
1177
|
+
});
|
|
1178
|
+
}
|
|
1179
|
+
function upsertThreadReply(content, threadId, body, question, resolved) {
|
|
1180
|
+
const threads = parseThreadArtifact(content);
|
|
1181
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
1182
|
+
if (!thread)
|
|
1183
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
1184
|
+
thread.reply = body;
|
|
1185
|
+
thread.addressed = true;
|
|
1186
|
+
if (question !== undefined)
|
|
1187
|
+
thread.question = question;
|
|
1188
|
+
if (resolved !== undefined)
|
|
1189
|
+
thread.resolved = resolved;
|
|
1190
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
1191
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
1192
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
1193
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
1194
|
+
return renderThreadArtifact(title, target, threads, {
|
|
1195
|
+
timestamp,
|
|
1196
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
1197
|
+
url: pr?.[2]
|
|
1198
|
+
});
|
|
1199
|
+
}
|
|
1200
|
+
// src/review/github-review-backend.ts
|
|
1201
|
+
function GitHubReviewBackend(vcs, number) {
|
|
1202
|
+
const pendingReview = async () => {
|
|
1203
|
+
const result = await ghChecked([
|
|
1204
|
+
"api",
|
|
1205
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`,
|
|
1206
|
+
"--jq",
|
|
1207
|
+
'[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
|
|
1208
|
+
]);
|
|
1209
|
+
if (!result.stdout.trim())
|
|
1210
|
+
return;
|
|
1211
|
+
let pending;
|
|
1212
|
+
try {
|
|
1213
|
+
pending = JSON.parse(result.stdout);
|
|
1214
|
+
} catch {
|
|
1215
|
+
throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
|
|
1216
|
+
}
|
|
1217
|
+
if (!pending.id || !pending.nodeId)
|
|
1218
|
+
return;
|
|
1219
|
+
return { id: pending.id, nodeId: pending.nodeId };
|
|
1220
|
+
};
|
|
1221
|
+
const listThreads = async () => {
|
|
1222
|
+
const threads = [];
|
|
1223
|
+
let cursor;
|
|
1224
|
+
do {
|
|
1225
|
+
const args = [
|
|
1226
|
+
"api",
|
|
1227
|
+
"graphql",
|
|
1228
|
+
"-f",
|
|
1229
|
+
`query=${GITHUB_THREADS_QUERY}`,
|
|
1230
|
+
"-f",
|
|
1231
|
+
`owner=${vcs.owner}`,
|
|
1232
|
+
"-f",
|
|
1233
|
+
`repo=${vcs.repo}`,
|
|
1234
|
+
"-F",
|
|
1235
|
+
`number=${number}`
|
|
1236
|
+
];
|
|
1237
|
+
if (cursor)
|
|
1238
|
+
args.push("-f", `after=${cursor}`);
|
|
1239
|
+
const result = await ghChecked(args);
|
|
1240
|
+
let data;
|
|
1241
|
+
try {
|
|
1242
|
+
data = JSON.parse(result.stdout);
|
|
1243
|
+
} catch {
|
|
1244
|
+
throw new Error("Cannot parse GitHub review threads as JSON.");
|
|
1245
|
+
}
|
|
1246
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
1247
|
+
threads.push(...connection?.nodes ?? []);
|
|
1248
|
+
cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
|
|
1249
|
+
if (connection?.pageInfo?.hasNextPage && !cursor) {
|
|
1250
|
+
throw new Error("GitHub review thread pagination did not return an end cursor.");
|
|
1251
|
+
}
|
|
1252
|
+
} while (cursor);
|
|
1253
|
+
return threads.map((thread) => {
|
|
1254
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
1255
|
+
const comment = nodes[0];
|
|
1256
|
+
const body = comment?.body ?? "";
|
|
1257
|
+
return {
|
|
1258
|
+
id: thread.id,
|
|
1259
|
+
file: thread.path,
|
|
1260
|
+
line: thread.line,
|
|
1261
|
+
body,
|
|
1262
|
+
author: comment?.author?.login,
|
|
1263
|
+
resolved: thread.isResolved,
|
|
1264
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1265
|
+
replies: nodes.slice(1).map((node) => node.body)
|
|
1266
|
+
};
|
|
1267
|
+
});
|
|
1268
|
+
};
|
|
1269
|
+
return {
|
|
1270
|
+
kind: "remote",
|
|
1271
|
+
async stage(draft) {
|
|
1272
|
+
const pending = await pendingReview();
|
|
1273
|
+
if (!pending) {
|
|
1274
|
+
const payload = {
|
|
1275
|
+
body: draft.body,
|
|
1276
|
+
comments: draft.comments.map((comment) => ({
|
|
1277
|
+
path: comment.file,
|
|
1278
|
+
line: comment.line,
|
|
1279
|
+
side: comment.side ?? "RIGHT",
|
|
1280
|
+
body: comment.body
|
|
1281
|
+
}))
|
|
1282
|
+
};
|
|
1283
|
+
await ghChecked(["api", "--method", "POST", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
if (draft.body.trim()) {
|
|
1287
|
+
await ghChecked([
|
|
1288
|
+
"api",
|
|
1289
|
+
"--method",
|
|
1290
|
+
"PUT",
|
|
1291
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`,
|
|
1292
|
+
"-f",
|
|
1293
|
+
`body=${draft.body}`
|
|
1294
|
+
]);
|
|
1295
|
+
}
|
|
1296
|
+
for (const comment of draft.comments) {
|
|
1297
|
+
await ghChecked([
|
|
1298
|
+
"api",
|
|
1299
|
+
"graphql",
|
|
1300
|
+
"-f",
|
|
1301
|
+
`query=${GITHUB_ADD_THREAD_MUTATION}`,
|
|
1302
|
+
"-f",
|
|
1303
|
+
`reviewId=${pending.nodeId}`,
|
|
1304
|
+
"-f",
|
|
1305
|
+
`body=${comment.body}`,
|
|
1306
|
+
"-f",
|
|
1307
|
+
`path=${comment.file}`,
|
|
1308
|
+
"-F",
|
|
1309
|
+
`line=${comment.line}`,
|
|
1310
|
+
"-f",
|
|
1311
|
+
`side=${comment.side ?? "RIGHT"}`
|
|
1312
|
+
]);
|
|
1313
|
+
}
|
|
1314
|
+
},
|
|
1315
|
+
async readDraft() {
|
|
1316
|
+
const pending = await pendingReview();
|
|
1317
|
+
if (!pending)
|
|
1318
|
+
return { comments: [], body: "" };
|
|
1319
|
+
const review = await ghChecked(["api", `/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}`]);
|
|
1320
|
+
const comments = await ghChecked([
|
|
1321
|
+
"api",
|
|
1322
|
+
`/repos/${vcs.owner}/${vcs.repo}/pulls/${number}/reviews/${pending.id}/comments`
|
|
1323
|
+
]);
|
|
1324
|
+
let reviewData;
|
|
1325
|
+
let commentData;
|
|
1326
|
+
try {
|
|
1327
|
+
reviewData = JSON.parse(review.stdout);
|
|
1328
|
+
commentData = JSON.parse(comments.stdout);
|
|
1329
|
+
} catch {
|
|
1330
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
1331
|
+
}
|
|
1332
|
+
return {
|
|
1333
|
+
body: reviewData.body ?? "",
|
|
1334
|
+
comments: commentData.map((comment) => ({
|
|
1335
|
+
file: comment.path,
|
|
1336
|
+
line: comment.line ?? comment.original_line ?? 1,
|
|
1337
|
+
side: comment.side,
|
|
1338
|
+
body: comment.body
|
|
1339
|
+
}))
|
|
1340
|
+
};
|
|
1341
|
+
},
|
|
1342
|
+
listThreads,
|
|
1343
|
+
async reply(input) {
|
|
1344
|
+
const threads = await listThreads();
|
|
1345
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1346
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1347
|
+
await ghChecked([
|
|
1348
|
+
"api",
|
|
1349
|
+
"graphql",
|
|
1350
|
+
"-f",
|
|
1351
|
+
`query=${GITHUB_REPLY_MUTATION}`,
|
|
1352
|
+
"-f",
|
|
1353
|
+
`threadId=${input.threadId}`,
|
|
1354
|
+
"-f",
|
|
1355
|
+
`body=${input.body}`
|
|
1356
|
+
]);
|
|
1357
|
+
}
|
|
1358
|
+
if (input.resolve) {
|
|
1359
|
+
await ghChecked([
|
|
1360
|
+
"api",
|
|
1361
|
+
"graphql",
|
|
1362
|
+
"-f",
|
|
1363
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
1364
|
+
"-f",
|
|
1365
|
+
`threadId=${input.threadId}`
|
|
1366
|
+
]);
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
async publish(event) {
|
|
1370
|
+
const pending = await pendingReview();
|
|
1371
|
+
if (!pending && event === "COMMENT")
|
|
1372
|
+
return;
|
|
1373
|
+
if (!pending && event === "REQUEST_CHANGES") {
|
|
1374
|
+
throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
|
|
1375
|
+
}
|
|
1376
|
+
const endpoint = githubReviewSubmissionEndpoint(vcs.owner, vcs.repo, number, pending?.id ?? "");
|
|
1377
|
+
await ghChecked(["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
403
1380
|
}
|
|
1381
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
1382
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
1383
|
+
}
|
|
1384
|
+
var GITHUB_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){nodes{id,isResolved,path,line,comments(first:100){nodes{body,author{login}}}}pageInfo{hasNextPage,endCursor}}}}}`;
|
|
1385
|
+
var GITHUB_ADD_THREAD_MUTATION = `mutation($reviewId:ID!,$body:String!,$path:String!,$line:Int!,$side:DiffSide!){addPullRequestReviewThread(input:{pullRequestReviewId:$reviewId,body:$body,path:$path,line:$line,side:$side}){thread{id}}}`;
|
|
1386
|
+
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
1387
|
+
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
404
1388
|
|
|
1389
|
+
// src/review/gitlab-review-backend.ts
|
|
1390
|
+
function GitLabReviewBackend(vcs, number) {
|
|
1391
|
+
const project = () => `${vcs.owner}/${vcs.repo}`;
|
|
1392
|
+
const mergeRequestEndpoint = () => `projects/${encodeURIComponent(project())}/merge_requests/${number}`;
|
|
1393
|
+
const listThreads = async () => {
|
|
1394
|
+
const discussions = [];
|
|
1395
|
+
for (let page = 1;; page += 1) {
|
|
1396
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`]);
|
|
1397
|
+
let batch;
|
|
1398
|
+
try {
|
|
1399
|
+
batch = JSON.parse(result.stdout);
|
|
1400
|
+
} catch {
|
|
1401
|
+
throw new Error("Cannot parse GitLab review discussions as JSON.");
|
|
1402
|
+
}
|
|
1403
|
+
discussions.push(...batch);
|
|
1404
|
+
if (batch.length < 100)
|
|
1405
|
+
break;
|
|
1406
|
+
}
|
|
1407
|
+
return discussions.map((discussion) => {
|
|
1408
|
+
const notes = discussion.notes ?? [];
|
|
1409
|
+
const note = notes[0];
|
|
1410
|
+
const body = note?.body ?? "";
|
|
1411
|
+
return {
|
|
1412
|
+
id: discussion.id,
|
|
1413
|
+
file: note?.position?.new_path ?? note?.position?.old_path,
|
|
1414
|
+
line: note?.position?.new_line ?? note?.position?.old_line,
|
|
1415
|
+
body,
|
|
1416
|
+
author: note?.author?.username,
|
|
1417
|
+
resolved: Boolean(discussion.resolved),
|
|
1418
|
+
question: /\?\s*$/.test(body.trim()),
|
|
1419
|
+
replies: notes.slice(1).map((reply) => reply.body)
|
|
1420
|
+
};
|
|
1421
|
+
});
|
|
1422
|
+
};
|
|
1423
|
+
return {
|
|
1424
|
+
kind: "remote",
|
|
1425
|
+
async stage(draft) {
|
|
1426
|
+
const endpoint = `${mergeRequestEndpoint()}/draft_notes`;
|
|
1427
|
+
if (draft.body.trim()) {
|
|
1428
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1429
|
+
input: JSON.stringify({ note: draft.body })
|
|
1430
|
+
});
|
|
1431
|
+
}
|
|
1432
|
+
if (draft.comments.length === 0)
|
|
1433
|
+
return;
|
|
1434
|
+
const response = await glabChecked(["api", mergeRequestEndpoint()]);
|
|
1435
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
1436
|
+
for (const comment of draft.comments) {
|
|
1437
|
+
const payload = {
|
|
1438
|
+
note: comment.body,
|
|
1439
|
+
position: {
|
|
1440
|
+
...diffRefs,
|
|
1441
|
+
position_type: "text",
|
|
1442
|
+
new_path: comment.file,
|
|
1443
|
+
old_path: comment.file,
|
|
1444
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
1445
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
1446
|
+
}
|
|
1447
|
+
};
|
|
1448
|
+
await glabChecked(["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
1449
|
+
input: JSON.stringify(payload)
|
|
1450
|
+
});
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
async readDraft() {
|
|
1454
|
+
const result = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1455
|
+
let notes;
|
|
1456
|
+
try {
|
|
1457
|
+
notes = JSON.parse(result.stdout);
|
|
1458
|
+
} catch {
|
|
1459
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
1460
|
+
}
|
|
1461
|
+
const comments = [];
|
|
1462
|
+
const body = [];
|
|
1463
|
+
for (const note of notes) {
|
|
1464
|
+
if (!note.note)
|
|
1465
|
+
continue;
|
|
1466
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
1467
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
1468
|
+
if (file && line) {
|
|
1469
|
+
comments.push({
|
|
1470
|
+
file,
|
|
1471
|
+
line,
|
|
1472
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
1473
|
+
body: note.note
|
|
1474
|
+
});
|
|
1475
|
+
} else {
|
|
1476
|
+
body.push(note.note);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
return { comments, body: body.join(`
|
|
1480
|
+
|
|
1481
|
+
`) };
|
|
1482
|
+
},
|
|
1483
|
+
listThreads,
|
|
1484
|
+
async reply(input) {
|
|
1485
|
+
const endpoint = `${mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
1486
|
+
const threads = await listThreads();
|
|
1487
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
1488
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
1489
|
+
await glabChecked(["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
|
|
1490
|
+
input: JSON.stringify({ body: input.body })
|
|
1491
|
+
});
|
|
1492
|
+
}
|
|
1493
|
+
if (input.resolve)
|
|
1494
|
+
await glabChecked(["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
1495
|
+
},
|
|
1496
|
+
async publish(event) {
|
|
1497
|
+
assertReviewEventSupported(vcs.provider, event);
|
|
1498
|
+
const drafts = await glabChecked(["api", `${mergeRequestEndpoint()}/draft_notes`]);
|
|
1499
|
+
if (hasGitlabDraftNotes(drafts.stdout)) {
|
|
1500
|
+
await glabChecked(["api", "--method", "POST", `${mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
|
|
1501
|
+
}
|
|
1502
|
+
if (event === "APPROVE")
|
|
1503
|
+
await glabChecked(["mr", "approve", String(number), "--repo", project()]);
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
function assertReviewEventSupported(provider, event) {
|
|
1508
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
1509
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
function parseGitlabDiffRefs(input) {
|
|
1513
|
+
let data;
|
|
1514
|
+
try {
|
|
1515
|
+
data = JSON.parse(input);
|
|
1516
|
+
} catch {
|
|
1517
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
1518
|
+
}
|
|
1519
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
1520
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
1521
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
1522
|
+
}
|
|
1523
|
+
return { base_sha, start_sha, head_sha };
|
|
1524
|
+
}
|
|
1525
|
+
function hasGitlabDraftNotes(input) {
|
|
1526
|
+
try {
|
|
1527
|
+
const data = JSON.parse(input);
|
|
1528
|
+
return Array.isArray(data) && data.length > 0;
|
|
1529
|
+
} catch {
|
|
1530
|
+
throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// src/review/local-review-backend.ts
|
|
1535
|
+
import { readFile as readFile6, writeFile as writeFile3 } from "node:fs/promises";
|
|
1536
|
+
|
|
1537
|
+
// src/extensions/tuicrx.ts
|
|
1538
|
+
import { readFile as readFile5, realpath as realpath2 } from "node:fs/promises";
|
|
1539
|
+
import { resolve as resolve2 } from "node:path";
|
|
1540
|
+
async function tuicrAvailable() {
|
|
1541
|
+
return Boolean(await findExecutable("tuicr"));
|
|
1542
|
+
}
|
|
1543
|
+
async function listSessions(repo = ".") {
|
|
1544
|
+
await requireTuicr();
|
|
1545
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
1546
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
1547
|
+
return [];
|
|
1548
|
+
try {
|
|
1549
|
+
const raw = JSON.parse(result.stdout);
|
|
1550
|
+
return raw.map((entry) => ({
|
|
1551
|
+
slug: entry.slug,
|
|
1552
|
+
kind: entry.kind,
|
|
1553
|
+
path: entry.path,
|
|
1554
|
+
updatedAt: entry.updated_at,
|
|
1555
|
+
commentCount: entry.comment_count,
|
|
1556
|
+
anchor: entry.anchor,
|
|
1557
|
+
active: entry.active
|
|
1558
|
+
}));
|
|
1559
|
+
} catch {
|
|
1560
|
+
throw new Error("Cannot parse tuicr review list output.");
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
async function addComment(session, body, opts = {}) {
|
|
1564
|
+
await requireTuicr();
|
|
1565
|
+
const args = ["review", "add", "--session", session, body];
|
|
1566
|
+
if (opts.targetFile)
|
|
1567
|
+
args.push("--target-file", opts.targetFile);
|
|
1568
|
+
if (opts.line !== undefined)
|
|
1569
|
+
args.push("--line", String(opts.line));
|
|
1570
|
+
if (opts.side)
|
|
1571
|
+
args.push("--side", opts.side);
|
|
1572
|
+
if (opts.username)
|
|
1573
|
+
args.push("--username", opts.username);
|
|
1574
|
+
await runChecked("tuicr", args);
|
|
1575
|
+
}
|
|
1576
|
+
async function launch(cwd, pr, localBase) {
|
|
1577
|
+
let command;
|
|
1578
|
+
if (pr !== undefined)
|
|
1579
|
+
command = ["tuicr", "pr", String(pr)];
|
|
1580
|
+
else if (localBase)
|
|
1581
|
+
command = ["tuicr", "-w", "-r", `${localBase}..HEAD`];
|
|
1582
|
+
else
|
|
1583
|
+
throw new Error("Cannot launch a local tuicr review: no base branch could be determined.");
|
|
1584
|
+
if (!await tuicrAvailable()) {
|
|
1585
|
+
return {
|
|
1586
|
+
launched: false,
|
|
1587
|
+
via: "print",
|
|
1588
|
+
command: command.join(" "),
|
|
1589
|
+
reason: "tuicr is not installed or is not available on PATH.",
|
|
1590
|
+
instruction: `Install tuicr, then run: ${command.join(" ")}`
|
|
1591
|
+
};
|
|
1592
|
+
}
|
|
1593
|
+
return openInNewTab(command, { cwd, name: "tuicr" });
|
|
1594
|
+
}
|
|
1595
|
+
async function resolveSession(cwd, branch) {
|
|
1596
|
+
const sessions = await listSessions(cwd);
|
|
1597
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
1598
|
+
}
|
|
1599
|
+
async function resolveReviewSession(cwd, target) {
|
|
1600
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
1601
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
1602
|
+
}
|
|
1603
|
+
return resolveSession(cwd, target.branch);
|
|
1604
|
+
}
|
|
1605
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
1606
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
1607
|
+
const sessions = await listSessions(cwd);
|
|
1608
|
+
return sessions.find((session) => {
|
|
1609
|
+
const slug = session.slug.toLowerCase();
|
|
1610
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
1611
|
+
});
|
|
1612
|
+
}
|
|
1613
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1614
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1615
|
+
for (const session of sessions) {
|
|
1616
|
+
if (session.kind !== "local")
|
|
1617
|
+
continue;
|
|
1618
|
+
try {
|
|
1619
|
+
const data = await readSession(session.path);
|
|
1620
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1621
|
+
continue;
|
|
1622
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1623
|
+
return session;
|
|
1624
|
+
} catch {}
|
|
1625
|
+
}
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
async function readSession(path) {
|
|
1629
|
+
const content = await readFile5(path, "utf8");
|
|
1630
|
+
try {
|
|
1631
|
+
return JSON.parse(content);
|
|
1632
|
+
} catch {
|
|
1633
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
function toFindings(session, options = {}) {
|
|
1637
|
+
const comments = [];
|
|
1638
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
1639
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1640
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1641
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1642
|
+
if (fileComments.length > 0)
|
|
1643
|
+
bodyParts.push(`File: ${file}
|
|
1644
|
+
|
|
1645
|
+
${fileComments.join(`
|
|
1646
|
+
|
|
1647
|
+
`)}`);
|
|
1648
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1649
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1650
|
+
if (!Number.isFinite(line))
|
|
1651
|
+
continue;
|
|
1652
|
+
for (const lineComment of lineComments) {
|
|
1653
|
+
if (!include(lineComment))
|
|
1654
|
+
continue;
|
|
1655
|
+
const comment = {
|
|
1656
|
+
file,
|
|
1657
|
+
line,
|
|
1658
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
1659
|
+
body: lineComment.content
|
|
1660
|
+
};
|
|
1661
|
+
const author = commentAuthor(lineComment);
|
|
1662
|
+
if (author)
|
|
1663
|
+
comment.author = author;
|
|
1664
|
+
comments.push(comment);
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return { comments, body: bodyParts.join(`
|
|
1669
|
+
|
|
1670
|
+
`) };
|
|
1671
|
+
}
|
|
1672
|
+
async function requireTuicr() {
|
|
1673
|
+
if (!await tuicrAvailable())
|
|
1674
|
+
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
1675
|
+
}
|
|
1676
|
+
function commentAuthor(comment) {
|
|
1677
|
+
return comment.username ?? comment.author;
|
|
1678
|
+
}
|
|
1679
|
+
async function canonicalPath2(path) {
|
|
1680
|
+
try {
|
|
1681
|
+
return await realpath2(path);
|
|
1682
|
+
} catch {
|
|
1683
|
+
return resolve2(path);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// src/review/local-review-backend.ts
|
|
1688
|
+
function LocalReviewBackend(options) {
|
|
1689
|
+
return {
|
|
1690
|
+
kind: "local",
|
|
1691
|
+
async stage(draft) {
|
|
1692
|
+
for (const comment of draft.comments) {
|
|
1693
|
+
await addComment(options.session, comment.body, {
|
|
1694
|
+
targetFile: comment.file,
|
|
1695
|
+
line: comment.line,
|
|
1696
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
1697
|
+
username: options.author
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
if (draft.body.trim())
|
|
1701
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
1702
|
+
},
|
|
1703
|
+
async readDraft() {
|
|
1704
|
+
return toFindings(await readSession(options.session), { agentOnly: true, excludeLocalResponses: true });
|
|
1705
|
+
},
|
|
1706
|
+
async listThreads() {
|
|
1707
|
+
try {
|
|
1708
|
+
return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
if (error.code === "ENOENT")
|
|
1711
|
+
return [];
|
|
1712
|
+
throw error;
|
|
1713
|
+
}
|
|
1714
|
+
},
|
|
1715
|
+
async reply(input) {
|
|
1716
|
+
const content = await readFile6(options.artifactPath, "utf8");
|
|
1717
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
1718
|
+
},
|
|
1719
|
+
async publish() {
|
|
1720
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// src/review/review-backend.ts
|
|
1726
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
1727
|
+
if (vcs.provider === "github")
|
|
1728
|
+
return GitHubReviewBackend(vcs, number);
|
|
1729
|
+
if (vcs.provider === "gitlab")
|
|
1730
|
+
return GitLabReviewBackend(vcs, number);
|
|
1731
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1732
|
+
}
|
|
1733
|
+
function createLocalReviewBackend(options) {
|
|
1734
|
+
return LocalReviewBackend(options);
|
|
1735
|
+
}
|
|
1736
|
+
// src/review/review-state.ts
|
|
1737
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1738
|
+
import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
1739
|
+
import { join as join9 } from "node:path";
|
|
1740
|
+
import { z as z4 } from "zod";
|
|
1741
|
+
|
|
1742
|
+
// src/store.ts
|
|
1743
|
+
import { createHash } from "node:crypto";
|
|
1744
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
1745
|
+
import { homedir as homedir4 } from "node:os";
|
|
1746
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
|
|
1747
|
+
var STORE_LINK = ".diffpi";
|
|
1748
|
+
var LEGACY_STORE_LINK = join8(".pi", "diffpi");
|
|
1749
|
+
async function computeProjectSlug(cwd) {
|
|
1750
|
+
return projectSlug(await inspectGitRepository(cwd));
|
|
1751
|
+
}
|
|
1752
|
+
function storeGlobalRoot(homeDir = homedir4()) {
|
|
1753
|
+
return join8(homeDir, ".difflab", "diffpi", "projects");
|
|
1754
|
+
}
|
|
1755
|
+
async function ensureStore(cwd, homeDir = homedir4()) {
|
|
1756
|
+
const repository = await inspectGitRepository(cwd);
|
|
1757
|
+
const root = repository.root;
|
|
1758
|
+
const slug = projectSlug(repository);
|
|
1759
|
+
const dest = join8(storeGlobalRoot(homeDir), slug);
|
|
1760
|
+
const link = join8(root, STORE_LINK);
|
|
1761
|
+
await mkdir3(dest, { recursive: true });
|
|
1762
|
+
try {
|
|
1763
|
+
await assertStoreLink(link, dest);
|
|
1764
|
+
} catch (error) {
|
|
1765
|
+
if (error.code !== "ENOENT")
|
|
1766
|
+
throw error;
|
|
1767
|
+
await symlink(dest, link);
|
|
1768
|
+
}
|
|
1769
|
+
await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
|
|
1770
|
+
return { slug, root, dest, link, linked: true };
|
|
1771
|
+
}
|
|
1772
|
+
async function storeDir(cwd, homeDir = homedir4()) {
|
|
1773
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1774
|
+
return store.dest;
|
|
1775
|
+
}
|
|
1776
|
+
async function plansDir(cwd, homeDir = homedir4()) {
|
|
1777
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1778
|
+
const dir = join8(store.link, "plan");
|
|
1779
|
+
await mkdir3(dir, { recursive: true });
|
|
1780
|
+
return dir;
|
|
1781
|
+
}
|
|
1782
|
+
async function reviewsDir(cwd, homeDir = homedir4()) {
|
|
1783
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1784
|
+
const dir = join8(store.link, "review");
|
|
1785
|
+
await mkdir3(dir, { recursive: true });
|
|
1786
|
+
return dir;
|
|
1787
|
+
}
|
|
1788
|
+
async function sessionsDir(cwd, homeDir = homedir4()) {
|
|
1789
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1790
|
+
const dir = join8(store.link, "sessions");
|
|
1791
|
+
await mkdir3(dir, { recursive: true });
|
|
1792
|
+
return dir;
|
|
1793
|
+
}
|
|
1794
|
+
async function assertStoreLink(path, dest) {
|
|
1795
|
+
const entry = await lstat(path);
|
|
1796
|
+
if (!entry.isSymbolicLink())
|
|
1797
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
1798
|
+
const target = await symlinkTarget(path);
|
|
1799
|
+
if (target !== await canonicalPath3(dest))
|
|
1800
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1801
|
+
}
|
|
1802
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
1803
|
+
try {
|
|
1804
|
+
const entry = await lstat(path);
|
|
1805
|
+
if (!entry.isSymbolicLink())
|
|
1806
|
+
return;
|
|
1807
|
+
const target = await symlinkTarget(path);
|
|
1808
|
+
if (target === await canonicalPath3(dest))
|
|
1809
|
+
await unlink(path);
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
if (error.code !== "ENOENT")
|
|
1812
|
+
throw error;
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
async function symlinkTarget(path) {
|
|
1816
|
+
const target = await readlink(path);
|
|
1817
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
|
|
1818
|
+
}
|
|
1819
|
+
async function canonicalPath3(path) {
|
|
1820
|
+
try {
|
|
1821
|
+
return await realpath3(path);
|
|
1822
|
+
} catch {
|
|
1823
|
+
return resolve3(path);
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
function projectSlug(repository) {
|
|
1827
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1828
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1829
|
+
return `${readable}-${digest}`;
|
|
1830
|
+
}
|
|
1831
|
+
|
|
1832
|
+
// src/review/review-state.ts
|
|
1833
|
+
var reviewPublicationStateSchema = z4.object({
|
|
1834
|
+
target: z4.string().optional(),
|
|
1835
|
+
bodies: z4.array(z4.string()).default([]),
|
|
1836
|
+
comments: z4.array(z4.string()).default([]),
|
|
1837
|
+
replies: z4.array(z4.string()).default([]),
|
|
1838
|
+
overlayPath: z4.string().optional()
|
|
1839
|
+
});
|
|
1840
|
+
function reviewBodyFingerprint(body) {
|
|
1841
|
+
return digest(body);
|
|
1842
|
+
}
|
|
1843
|
+
function reviewCommentFingerprint(comment) {
|
|
1844
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
1845
|
+
}
|
|
1846
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
1847
|
+
return digest(`${threadId}\x00${body}`);
|
|
1848
|
+
}
|
|
1849
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
1850
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
1851
|
+
}
|
|
1852
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
1853
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
1854
|
+
const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
1855
|
+
try {
|
|
1856
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
|
|
1857
|
+
return { path, state: { ...parsed, target } };
|
|
1858
|
+
} catch (error) {
|
|
1859
|
+
if (error.code === "ENOENT") {
|
|
1860
|
+
return { path, state: { target, bodies: [], comments: [], replies: [] } };
|
|
1861
|
+
}
|
|
1862
|
+
if (error instanceof SyntaxError || error instanceof z4.ZodError) {
|
|
1863
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
1864
|
+
}
|
|
1865
|
+
throw error;
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
async function saveReviewPublicationState(path, state) {
|
|
1869
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1870
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
1871
|
+
`, "utf8");
|
|
1872
|
+
await rename(temp, path);
|
|
1873
|
+
}
|
|
1874
|
+
function digest(value) {
|
|
1875
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1876
|
+
}
|
|
1877
|
+
// src/mcp.ts
|
|
1878
|
+
import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
|
|
1879
|
+
import { homedir as homedir5 } from "node:os";
|
|
1880
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
1881
|
+
var mcp = {
|
|
1882
|
+
globalConfigPath(homeDir = homedir5()) {
|
|
1883
|
+
return join10(homeDir, ".config", "mcp", "mcp.json");
|
|
1884
|
+
},
|
|
1885
|
+
async serversEnsure(servers, options = {}) {
|
|
1886
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
1887
|
+
const currentText = await getOptionalFile2(path);
|
|
1888
|
+
const current = getParsedConfig(currentText, path);
|
|
1889
|
+
const nextServers = { ...current.mcpServers };
|
|
1890
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
1891
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
1892
|
+
}
|
|
1893
|
+
const next = { ...current, mcpServers: nextServers };
|
|
1894
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1895
|
+
if (changed && !options.dryRun) {
|
|
1896
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1897
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
1898
|
+
`, "utf8");
|
|
1899
|
+
}
|
|
1900
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
function mergeEntry(current, required) {
|
|
1904
|
+
const merged = { ...current, ...required };
|
|
1905
|
+
if (current?.env || required.env)
|
|
1906
|
+
merged.env = { ...current?.env, ...required.env };
|
|
1907
|
+
return merged;
|
|
1908
|
+
}
|
|
1909
|
+
function getParsedConfig(content, path) {
|
|
1910
|
+
if (!content?.trim())
|
|
1911
|
+
return { mcpServers: {} };
|
|
1912
|
+
try {
|
|
1913
|
+
const value = JSON.parse(content);
|
|
1914
|
+
if (!isRecord(value))
|
|
1915
|
+
throw new Error("not an object");
|
|
1916
|
+
const servers = value.mcpServers;
|
|
1917
|
+
if (servers !== undefined && !isRecord(servers))
|
|
1918
|
+
throw new Error("mcpServers is not an object");
|
|
1919
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
1920
|
+
} catch {
|
|
1921
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
async function getOptionalFile2(path) {
|
|
1925
|
+
try {
|
|
1926
|
+
return await readFile8(path, "utf8");
|
|
1927
|
+
} catch (error) {
|
|
1928
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1929
|
+
return;
|
|
1930
|
+
throw error;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
function isRecord(value) {
|
|
1934
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1935
|
+
}
|
|
405
1936
|
// src/modes.ts
|
|
1937
|
+
import {
|
|
1938
|
+
getAgentDir,
|
|
1939
|
+
parseFrontmatter as parseFrontmatter2
|
|
1940
|
+
} from "@earendil-works/pi-coding-agent";
|
|
1941
|
+
import { readFile as readFile9 } from "node:fs/promises";
|
|
1942
|
+
import { homedir as homedir6 } from "node:os";
|
|
1943
|
+
import { basename as basename4, extname, join as join11 } from "node:path";
|
|
406
1944
|
async function discoverAgentModes(options) {
|
|
407
1945
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
408
|
-
const homeDir = options.homeDir ??
|
|
1946
|
+
const homeDir = options.homeDir ?? homedir6();
|
|
409
1947
|
const modes = new Map;
|
|
410
1948
|
const diagnostics = [];
|
|
411
1949
|
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
412
|
-
await loadAgentModes(
|
|
1950
|
+
await loadAgentModes(join11(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
413
1951
|
if (options.includeSkills) {
|
|
414
|
-
await loadSkillModes(
|
|
415
|
-
await loadSkillModes(
|
|
1952
|
+
await loadSkillModes(join11(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
1953
|
+
await loadSkillModes(join11(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
416
1954
|
}
|
|
417
1955
|
if (options.projectTrusted === true) {
|
|
418
1956
|
if (options.includeSkills) {
|
|
419
|
-
await loadSkillModes(
|
|
420
|
-
await loadSkillModes(
|
|
1957
|
+
await loadSkillModes(join11(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
1958
|
+
await loadSkillModes(join11(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
421
1959
|
}
|
|
422
|
-
await loadAgentModes(
|
|
423
|
-
await loadAgentModes(
|
|
1960
|
+
await loadAgentModes(join11(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
1961
|
+
await loadAgentModes(join11(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
424
1962
|
}
|
|
425
1963
|
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
426
1964
|
const configuredModes = [...modes.values()].map((mode) => ({
|
|
@@ -457,7 +1995,11 @@ function createModeController(pi, options = {}) {
|
|
|
457
1995
|
let active;
|
|
458
1996
|
let baseline;
|
|
459
1997
|
const updateStatus = (ctx) => {
|
|
460
|
-
ctx.
|
|
1998
|
+
const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
|
|
1999
|
+
const label = active?.label.toUpperCase() ?? "DEFAULT";
|
|
2000
|
+
const badge = `AGENT ${label}${model}`;
|
|
2001
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
|
|
2002
|
+
ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
|
|
461
2003
|
};
|
|
462
2004
|
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
463
2005
|
cwd: ctx.cwd,
|
|
@@ -512,6 +2054,9 @@ function createModeController(pi, options = {}) {
|
|
|
512
2054
|
}
|
|
513
2055
|
updateStatus(ctx);
|
|
514
2056
|
},
|
|
2057
|
+
refresh(ctx) {
|
|
2058
|
+
updateStatus(ctx);
|
|
2059
|
+
},
|
|
515
2060
|
apply(systemPrompt) {
|
|
516
2061
|
if (!active)
|
|
517
2062
|
return systemPrompt;
|
|
@@ -530,6 +2075,7 @@ ${active.systemPrompt}`;
|
|
|
530
2075
|
}
|
|
531
2076
|
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
532
2077
|
var MODE_STATUS_KEY = "diffpi-mode";
|
|
2078
|
+
var MODE_WIDGET_KEY = "diffpi-mode-badge";
|
|
533
2079
|
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
534
2080
|
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
535
2081
|
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
@@ -583,19 +2129,19 @@ function captureRuntime(pi, ctx) {
|
|
|
583
2129
|
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
584
2130
|
const entries = await readDirectoryIfExists(skillsDir);
|
|
585
2131
|
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
586
|
-
await loadAgentModes(
|
|
2132
|
+
await loadAgentModes(join11(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
587
2133
|
}
|
|
588
2134
|
}
|
|
589
2135
|
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
590
2136
|
const entries = await readDirectoryIfExists(directory);
|
|
591
2137
|
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
592
|
-
const path =
|
|
2138
|
+
const path = join11(directory, entry.name);
|
|
593
2139
|
try {
|
|
594
|
-
const content = await
|
|
2140
|
+
const content = await readFile9(path, "utf8");
|
|
595
2141
|
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
596
2142
|
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
597
2143
|
continue;
|
|
598
|
-
const name = getFrontmatterText(frontmatter.name) ??
|
|
2144
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
|
|
599
2145
|
const systemPrompt = body.trim();
|
|
600
2146
|
if (!name || name.includes(":") || !systemPrompt) {
|
|
601
2147
|
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
@@ -605,7 +2151,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
605
2151
|
modes.set(id, {
|
|
606
2152
|
id,
|
|
607
2153
|
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
608
|
-
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${
|
|
2154
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
|
|
609
2155
|
systemPrompt,
|
|
610
2156
|
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
611
2157
|
modelPreferences: [
|
|
@@ -647,9 +2193,9 @@ function isModeBaseline(value) {
|
|
|
647
2193
|
return (model === undefined || typeof model.provider === "string" && typeof model.id === "string") && candidate.thinkingLevel !== undefined && THINKING_LEVELS.has(candidate.thinkingLevel) && Array.isArray(candidate.tools) && candidate.tools.every((tool) => typeof tool === "string");
|
|
648
2194
|
}
|
|
649
2195
|
// src/pi.ts
|
|
650
|
-
import { mkdir as
|
|
651
|
-
import { homedir as
|
|
652
|
-
import { dirname as
|
|
2196
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2197
|
+
import { homedir as homedir7 } from "node:os";
|
|
2198
|
+
import { dirname as dirname6, join as join12 } from "node:path";
|
|
653
2199
|
var pi = {
|
|
654
2200
|
executableCheck: findPiExecutable,
|
|
655
2201
|
packageList: listPiPackages,
|
|
@@ -662,19 +2208,19 @@ var pi = {
|
|
|
662
2208
|
configEnsure: ensurePiConfig
|
|
663
2209
|
};
|
|
664
2210
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
665
|
-
const path =
|
|
2211
|
+
const path = join12(agentDir, "agents", filename);
|
|
666
2212
|
const currentText = await readTextIfExists(path);
|
|
667
2213
|
const changed = currentText !== content;
|
|
668
2214
|
if (changed && !dryRun) {
|
|
669
|
-
await
|
|
670
|
-
await
|
|
2215
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2216
|
+
await writeFile6(path, content, "utf8");
|
|
671
2217
|
}
|
|
672
2218
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
673
2219
|
}
|
|
674
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
675
|
-
const roots = [
|
|
2220
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join12(homedir7(), ".agents", "skills")) {
|
|
2221
|
+
const roots = [join12(agentDir, "skills"), sharedSkillsDir];
|
|
676
2222
|
for (const root of roots) {
|
|
677
|
-
if (await readTextIfExists(
|
|
2223
|
+
if (await readTextIfExists(join12(root, name, "SKILL.md")) !== undefined)
|
|
678
2224
|
return true;
|
|
679
2225
|
}
|
|
680
2226
|
return false;
|
|
@@ -703,8 +2249,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
703
2249
|
const next = update(current);
|
|
704
2250
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
705
2251
|
if (changed && !dryRun) {
|
|
706
|
-
await
|
|
707
|
-
await
|
|
2252
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2253
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
708
2254
|
`, "utf8");
|
|
709
2255
|
}
|
|
710
2256
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -723,8 +2269,8 @@ function hasPiPackage(listOutput, source) {
|
|
|
723
2269
|
async function installPiPackage(executable, source) {
|
|
724
2270
|
await runChecked(executable, ["install", source]);
|
|
725
2271
|
}
|
|
726
|
-
function resolvePiAgentDir(homeDir =
|
|
727
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
2272
|
+
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
2273
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join12(process.env.XDG_CONFIG_HOME, "pi") : join12(homeDir, ".pi", "agent"));
|
|
728
2274
|
}
|
|
729
2275
|
function parseJsonObject(content, path) {
|
|
730
2276
|
if (!content?.trim())
|
|
@@ -736,11 +2282,975 @@ function parseJsonObject(content, path) {
|
|
|
736
2282
|
} catch {}
|
|
737
2283
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
738
2284
|
}
|
|
2285
|
+
// src/plan/annotations.ts
|
|
2286
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2287
|
+
import { readFile as readFile12, rename as rename2, writeFile as writeFile8 } from "node:fs/promises";
|
|
2288
|
+
import { basename as basename5, dirname as dirname7, join as join14 } from "node:path";
|
|
2289
|
+
|
|
2290
|
+
// src/plan/log.ts
|
|
2291
|
+
import { randomUUID } from "node:crypto";
|
|
2292
|
+
import { appendFile, readFile as readFile10 } from "node:fs/promises";
|
|
2293
|
+
async function appendPlanLog(logPath, event) {
|
|
2294
|
+
const entry = {
|
|
2295
|
+
...event,
|
|
2296
|
+
version: 1,
|
|
2297
|
+
eventId: event.eventId ?? randomUUID(),
|
|
2298
|
+
timestamp: event.timestamp ?? new Date().toISOString()
|
|
2299
|
+
};
|
|
2300
|
+
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
2301
|
+
`, { encoding: "utf8", mode: 384 });
|
|
2302
|
+
return entry;
|
|
2303
|
+
}
|
|
2304
|
+
async function readPlanLog(logPath) {
|
|
2305
|
+
let source;
|
|
2306
|
+
try {
|
|
2307
|
+
source = await readFile10(logPath, "utf8");
|
|
2308
|
+
} catch (error) {
|
|
2309
|
+
if (error.code === "ENOENT")
|
|
2310
|
+
return [];
|
|
2311
|
+
throw error;
|
|
2312
|
+
}
|
|
2313
|
+
return source.split(`
|
|
2314
|
+
`).filter(Boolean).map((line, index) => {
|
|
2315
|
+
try {
|
|
2316
|
+
return JSON.parse(line);
|
|
2317
|
+
} catch {
|
|
2318
|
+
throw new Error(`Malformed plan log entry at line ${index + 1}.`);
|
|
2319
|
+
}
|
|
2320
|
+
});
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
// src/plan/lock.ts
|
|
2324
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2325
|
+
import { mkdir as mkdir6, readFile as readFile11, rm, writeFile as writeFile7 } from "node:fs/promises";
|
|
2326
|
+
import { hostname } from "node:os";
|
|
2327
|
+
import { join as join13 } from "node:path";
|
|
2328
|
+
async function withPlanLock(planDir, operation, options = {}) {
|
|
2329
|
+
const lockDir = join13(planDir, ".lock");
|
|
2330
|
+
const waitMs = options.waitMs ?? 5000;
|
|
2331
|
+
const pollMs = options.pollMs ?? 25;
|
|
2332
|
+
const owner = {
|
|
2333
|
+
token: randomUUID2(),
|
|
2334
|
+
pid: process.pid,
|
|
2335
|
+
hostname: hostname(),
|
|
2336
|
+
operation: options.operation ?? "plan mutation",
|
|
2337
|
+
acquiredAt: new Date().toISOString()
|
|
2338
|
+
};
|
|
2339
|
+
const started = Date.now();
|
|
2340
|
+
while (true) {
|
|
2341
|
+
try {
|
|
2342
|
+
await mkdir6(lockDir);
|
|
2343
|
+
await writeFile7(join13(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
2344
|
+
`, { mode: 384 });
|
|
2345
|
+
break;
|
|
2346
|
+
} catch (error) {
|
|
2347
|
+
if (error.code !== "EEXIST")
|
|
2348
|
+
throw error;
|
|
2349
|
+
if (Date.now() - started >= waitMs) {
|
|
2350
|
+
let existing = "unknown owner";
|
|
2351
|
+
try {
|
|
2352
|
+
existing = (await readFile11(join13(lockDir, "owner.json"), "utf8")).trim();
|
|
2353
|
+
} catch {}
|
|
2354
|
+
throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
|
|
2355
|
+
}
|
|
2356
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
2359
|
+
try {
|
|
2360
|
+
return await operation();
|
|
2361
|
+
} finally {
|
|
2362
|
+
let current;
|
|
2363
|
+
try {
|
|
2364
|
+
current = JSON.parse(await readFile11(join13(lockDir, "owner.json"), "utf8"));
|
|
2365
|
+
} catch {}
|
|
2366
|
+
if (current?.token === owner.token)
|
|
2367
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
// src/plan/annotations.ts
|
|
2372
|
+
async function annotatePlan(record, runtime = {}) {
|
|
2373
|
+
const execute = runtime.execute ?? executeProcess;
|
|
2374
|
+
const result = await execute("tuicr", ["--file", record.planPath], dirname7(record.planPath), true);
|
|
2375
|
+
const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
|
|
2376
|
+
if (result.code !== 0)
|
|
2377
|
+
throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
|
|
2378
|
+
if (!sessionSlug)
|
|
2379
|
+
throw new Error("tuicr did not report a tuicr-session marker.");
|
|
2380
|
+
const state = await withPlanLock(record.dir, async () => {
|
|
2381
|
+
const previous = await readAnnotationState(record).catch(() => {
|
|
2382
|
+
return;
|
|
2383
|
+
});
|
|
2384
|
+
const next = {
|
|
2385
|
+
schemaVersion: 1,
|
|
2386
|
+
sessionSlug,
|
|
2387
|
+
updatedAt: new Date().toISOString(),
|
|
2388
|
+
appliedCommentIds: previous?.appliedCommentIds ?? []
|
|
2389
|
+
};
|
|
2390
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2391
|
+
return next;
|
|
2392
|
+
}, { operation: "save annotation session" });
|
|
2393
|
+
return { sessionSlug, code: result.code, state };
|
|
2394
|
+
}
|
|
2395
|
+
async function readPlanAnnotations(record, options = {}) {
|
|
2396
|
+
const state = await readAnnotationState(record).catch((error) => {
|
|
2397
|
+
if (error.code === "ENOENT")
|
|
2398
|
+
return;
|
|
2399
|
+
throw error;
|
|
2400
|
+
});
|
|
2401
|
+
if (!state)
|
|
2402
|
+
return { comments: [], pending: [] };
|
|
2403
|
+
const execute = options.runtime?.execute ?? executeProcess;
|
|
2404
|
+
const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
|
|
2405
|
+
if (response.code !== 0) {
|
|
2406
|
+
if (/not found|no session|deleted/i.test(response.stderr))
|
|
2407
|
+
return { state, comments: [], pending: [] };
|
|
2408
|
+
throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
|
|
2409
|
+
}
|
|
2410
|
+
let raw;
|
|
2411
|
+
try {
|
|
2412
|
+
raw = JSON.parse(response.stdout);
|
|
2413
|
+
} catch {
|
|
2414
|
+
throw new Error("tuicr returned malformed comment JSON.");
|
|
2415
|
+
}
|
|
2416
|
+
if (!Array.isArray(raw))
|
|
2417
|
+
throw new Error("tuicr comment output must be a JSON array.");
|
|
2418
|
+
const lines = record.source.split(`
|
|
2419
|
+
`);
|
|
2420
|
+
const applied = new Set(state.appliedCommentIds);
|
|
2421
|
+
const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
|
|
2422
|
+
const pending = comments.filter((comment) => !comment.applied);
|
|
2423
|
+
return { state, comments: options.includeApplied ? comments : pending, pending };
|
|
2424
|
+
}
|
|
2425
|
+
async function acknowledgePlanAnnotations(record, commentIds, summary) {
|
|
2426
|
+
if (!summary.trim())
|
|
2427
|
+
throw new Error("Annotation acknowledgement summary is required.");
|
|
2428
|
+
return withPlanLock(record.dir, async () => {
|
|
2429
|
+
const state = await readAnnotationState(record);
|
|
2430
|
+
const next = {
|
|
2431
|
+
...state,
|
|
2432
|
+
updatedAt: new Date().toISOString(),
|
|
2433
|
+
appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
|
|
2434
|
+
};
|
|
2435
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2436
|
+
await appendPlanLog(record.logPath, {
|
|
2437
|
+
planRevision: record.document.revision,
|
|
2438
|
+
kind: "annotation",
|
|
2439
|
+
actor: "planner",
|
|
2440
|
+
message: summary,
|
|
2441
|
+
data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
|
|
2442
|
+
});
|
|
2443
|
+
return next;
|
|
2444
|
+
}, { operation: "acknowledge annotations" });
|
|
2445
|
+
}
|
|
2446
|
+
function annotationStatePath(record) {
|
|
2447
|
+
return join14(record.dir, "annotations.json");
|
|
2448
|
+
}
|
|
2449
|
+
async function readAnnotationState(record) {
|
|
2450
|
+
const value = JSON.parse(await readFile12(annotationStatePath(record), "utf8"));
|
|
2451
|
+
if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
|
|
2452
|
+
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
2453
|
+
}
|
|
2454
|
+
return value;
|
|
2455
|
+
}
|
|
2456
|
+
function normalizeComment(value, index, lines, applied) {
|
|
2457
|
+
if (!value || typeof value !== "object")
|
|
2458
|
+
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
2459
|
+
const raw = value;
|
|
2460
|
+
if (typeof raw.id !== "string" || typeof raw.content !== "string")
|
|
2461
|
+
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
2462
|
+
const line = integer(raw.start_line) ?? integer(raw.line);
|
|
2463
|
+
const endLine = integer(raw.end_line) ?? line;
|
|
2464
|
+
const targetPath = typeof raw.path === "string" ? raw.path : undefined;
|
|
2465
|
+
const appliesToPlan = !targetPath || basename5(targetPath) === basename5("PLAN.md");
|
|
2466
|
+
const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
|
|
2467
|
+
return {
|
|
2468
|
+
id: raw.id,
|
|
2469
|
+
body: raw.content,
|
|
2470
|
+
file: targetPath,
|
|
2471
|
+
line,
|
|
2472
|
+
endLine,
|
|
2473
|
+
context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
|
|
2474
|
+
`) : undefined,
|
|
2475
|
+
stale: line !== undefined && !validAnchor,
|
|
2476
|
+
applied: applied.has(raw.id)
|
|
2477
|
+
};
|
|
2478
|
+
}
|
|
2479
|
+
async function executeProcess(command, args, cwd, interactive) {
|
|
2480
|
+
if (!interactive) {
|
|
2481
|
+
const result = await run(command, args, { cwd, capture: "unbounded" });
|
|
2482
|
+
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
2483
|
+
}
|
|
2484
|
+
return new Promise((resolve, reject) => {
|
|
2485
|
+
const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
|
|
2486
|
+
let stderr = "";
|
|
2487
|
+
child.stderr.on("data", (chunk) => {
|
|
2488
|
+
const text = chunk.toString();
|
|
2489
|
+
stderr += text;
|
|
2490
|
+
process.stderr.write(text);
|
|
2491
|
+
});
|
|
2492
|
+
child.on("error", reject);
|
|
2493
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
async function atomicJson(path, value) {
|
|
2497
|
+
const temp = join14(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
|
|
2498
|
+
await writeFile8(temp, `${JSON.stringify(value, null, 2)}
|
|
2499
|
+
`, { mode: 384, flag: "wx" });
|
|
2500
|
+
await rename2(temp, path);
|
|
2501
|
+
}
|
|
2502
|
+
function integer(value) {
|
|
2503
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
2504
|
+
}
|
|
2505
|
+
// src/plan/escalation.ts
|
|
2506
|
+
import { z as z5 } from "zod";
|
|
2507
|
+
var escalationSchema = z5.object({
|
|
2508
|
+
planId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
2509
|
+
executionId: z5.string().min(1),
|
|
2510
|
+
phaseId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
2511
|
+
taskId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
|
2512
|
+
blocker: z5.string().min(1),
|
|
2513
|
+
attempts: z5.array(z5.string()),
|
|
2514
|
+
evidence: z5.array(z5.string()),
|
|
2515
|
+
needsUserDecision: z5.boolean()
|
|
2516
|
+
}).strict();
|
|
2517
|
+
var OPEN = "<diffpi-planner-escalation>";
|
|
2518
|
+
var CLOSE = "</diffpi-planner-escalation>";
|
|
2519
|
+
function renderPlannerEscalation(value) {
|
|
2520
|
+
return `${OPEN}${JSON.stringify(escalationSchema.parse(value))}${CLOSE}`;
|
|
2521
|
+
}
|
|
2522
|
+
function parsePlannerEscalation(output) {
|
|
2523
|
+
const start = output.indexOf(OPEN);
|
|
2524
|
+
const end = output.indexOf(CLOSE, start + OPEN.length);
|
|
2525
|
+
if (start < 0 || end < 0 || output.indexOf(OPEN, start + OPEN.length) >= 0)
|
|
2526
|
+
throw new Error("Missing or ambiguous planner escalation payload.");
|
|
2527
|
+
const payload = output.slice(start + OPEN.length, end);
|
|
2528
|
+
try {
|
|
2529
|
+
return escalationSchema.parse(JSON.parse(payload));
|
|
2530
|
+
} catch (error) {
|
|
2531
|
+
throw new Error(`Malformed planner escalation payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
2532
|
+
}
|
|
2533
|
+
}
|
|
2534
|
+
// src/plan/execution.ts
|
|
2535
|
+
function createExecutionPacket(plan, coordinator) {
|
|
2536
|
+
if (!plan.execution?.active)
|
|
2537
|
+
throw new Error(`Plan ${plan.id} has no active execution.`);
|
|
2538
|
+
return {
|
|
2539
|
+
version: 1,
|
|
2540
|
+
planId: plan.id,
|
|
2541
|
+
executionId: plan.execution.id,
|
|
2542
|
+
policy: plan.execution.policy,
|
|
2543
|
+
cwd: plan.execution.cwd,
|
|
2544
|
+
branch: plan.execution.branch,
|
|
2545
|
+
coordinator
|
|
2546
|
+
};
|
|
2547
|
+
}
|
|
2548
|
+
function renderExecutionPrompt(packet) {
|
|
2549
|
+
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.`;
|
|
2550
|
+
}
|
|
2551
|
+
function eligiblePlanTasks(plan) {
|
|
2552
|
+
const completedPhases = new Set(plan.phases.filter((phase) => phase.status === "completed" || phase.status === "skipped").map((phase) => phase.id));
|
|
2553
|
+
const completedTasks = new Set(plan.phases.flatMap((phase) => phase.tasks).filter((task) => task.status === "completed" || task.status === "skipped").map((task) => task.id));
|
|
2554
|
+
for (const phase of plan.phases) {
|
|
2555
|
+
if (phase.status === "completed" || phase.status === "skipped")
|
|
2556
|
+
continue;
|
|
2557
|
+
if (!phase.dependencies.every((dependency) => completedPhases.has(dependency)))
|
|
2558
|
+
continue;
|
|
2559
|
+
return phase.tasks.filter((task) => task.status === "pending" && task.dependencies.every((dependency) => completedTasks.has(dependency))).map((task) => ({ phase, task }));
|
|
2560
|
+
}
|
|
2561
|
+
return [];
|
|
2562
|
+
}
|
|
2563
|
+
function tasksMayRunInParallel(left, right) {
|
|
2564
|
+
if (!left.fileScopes.length || !right.fileScopes.length)
|
|
2565
|
+
return false;
|
|
2566
|
+
if (left.dependencies.includes(right.id) || right.dependencies.includes(left.id))
|
|
2567
|
+
return false;
|
|
2568
|
+
return left.fileScopes.every((scope) => right.fileScopes.every((other) => !scopesOverlap(scope, other)));
|
|
2569
|
+
}
|
|
2570
|
+
function phaseCommitCommand(policy) {
|
|
2571
|
+
return policy === "commit-per-phase" ? "/git commit --yes --no-push" : undefined;
|
|
2572
|
+
}
|
|
2573
|
+
function scopesOverlap(left, right) {
|
|
2574
|
+
const normalize = (value) => value.replace(/^\.\//, "").replace(/\*.*$/, "").replace(/\/$/, "");
|
|
2575
|
+
const a = normalize(left);
|
|
2576
|
+
const b = normalize(right);
|
|
2577
|
+
return !a || !b || a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
2578
|
+
}
|
|
2579
|
+
// src/plan/transitions.ts
|
|
2580
|
+
var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2581
|
+
var PLAN_TRANSITIONS = {
|
|
2582
|
+
draft: ["ready"],
|
|
2583
|
+
ready: ["draft", "in_progress"],
|
|
2584
|
+
in_progress: ["blocked", "completed"],
|
|
2585
|
+
blocked: ["draft", "ready", "in_progress"],
|
|
2586
|
+
completed: []
|
|
2587
|
+
};
|
|
2588
|
+
var WORK_TRANSITIONS = {
|
|
2589
|
+
pending: ["in_progress", "skipped"],
|
|
2590
|
+
in_progress: ["blocked", "completed"],
|
|
2591
|
+
blocked: ["pending", "in_progress", "skipped"],
|
|
2592
|
+
completed: [],
|
|
2593
|
+
skipped: []
|
|
2594
|
+
};
|
|
2595
|
+
function isStableId(value) {
|
|
2596
|
+
return STABLE_ID.test(value) && value.length <= 80;
|
|
2597
|
+
}
|
|
2598
|
+
function assertStableId(value, label = "identifier") {
|
|
2599
|
+
if (!isStableId(value))
|
|
2600
|
+
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
2601
|
+
}
|
|
2602
|
+
function assertPlanTransition(from, to) {
|
|
2603
|
+
if (from === to)
|
|
2604
|
+
return;
|
|
2605
|
+
if (!PLAN_TRANSITIONS[from].includes(to))
|
|
2606
|
+
throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
|
|
2607
|
+
}
|
|
2608
|
+
function assertPhaseTransition(from, to) {
|
|
2609
|
+
if (from === to)
|
|
2610
|
+
return;
|
|
2611
|
+
if (!WORK_TRANSITIONS[from].includes(to))
|
|
2612
|
+
throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
|
|
2613
|
+
}
|
|
2614
|
+
function assertTaskTransition(task, to, executionId, actor) {
|
|
2615
|
+
if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to)) {
|
|
2616
|
+
throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
|
|
2617
|
+
}
|
|
2618
|
+
if (task.status === "in_progress" && task.executionId && executionId !== task.executionId) {
|
|
2619
|
+
throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
|
|
2620
|
+
}
|
|
2621
|
+
if (task.status === "in_progress" && task.owner && actor && actor !== task.owner) {
|
|
2622
|
+
throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
function assertUniqueIds(ids, label) {
|
|
2626
|
+
const seen = new Set;
|
|
2627
|
+
for (const id of ids) {
|
|
2628
|
+
assertStableId(id, label);
|
|
2629
|
+
if (seen.has(id))
|
|
2630
|
+
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
2631
|
+
seen.add(id);
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
|
|
2635
|
+
// src/plan/markdown.ts
|
|
2636
|
+
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
2637
|
+
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
2638
|
+
var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
|
|
2639
|
+
var SUPPORTED_SCHEMA = 1;
|
|
2640
|
+
function countDesignWords(plan) {
|
|
2641
|
+
return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
|
|
2642
|
+
}
|
|
2643
|
+
function validatePlanDocument(plan, options = {}) {
|
|
2644
|
+
const issues = [];
|
|
2645
|
+
const add = (code, severity, message, path) => issues.push({ code, severity, message, path });
|
|
2646
|
+
try {
|
|
2647
|
+
assertStableId(plan.id, "plan id");
|
|
2648
|
+
assertUniqueIds(plan.phases.map((phase) => phase.id), "phase id");
|
|
2649
|
+
assertUniqueIds(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)), "task id");
|
|
2650
|
+
} catch (error) {
|
|
2651
|
+
add("invalid-id", "error", error.message);
|
|
2652
|
+
}
|
|
2653
|
+
if (plan.schemaVersion !== SUPPORTED_SCHEMA)
|
|
2654
|
+
add("schema", "error", `Unsupported plan schema: ${plan.schemaVersion}.`);
|
|
2655
|
+
const phaseIds = new Set(plan.phases.map((phase) => phase.id));
|
|
2656
|
+
const taskIds = new Set(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)));
|
|
2657
|
+
for (const phase of plan.phases) {
|
|
2658
|
+
for (const dependency of phase.dependencies) {
|
|
2659
|
+
if (!phaseIds.has(dependency))
|
|
2660
|
+
add("dangling-dependency", "error", `Phase ${phase.id} depends on missing ${dependency}.`);
|
|
2661
|
+
}
|
|
2662
|
+
for (const task of phase.tasks) {
|
|
2663
|
+
for (const dependency of task.dependencies) {
|
|
2664
|
+
if (!taskIds.has(dependency))
|
|
2665
|
+
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
2666
|
+
}
|
|
2667
|
+
if (options.strict && task.acceptanceCriteria.length === 0)
|
|
2668
|
+
add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
|
|
2669
|
+
}
|
|
2670
|
+
if (options.strict && phase.tasks.length === 0)
|
|
2671
|
+
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
2672
|
+
}
|
|
2673
|
+
for (const cycle of dependencyCycles(plan))
|
|
2674
|
+
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
2675
|
+
const designWords = countDesignWords(plan);
|
|
2676
|
+
if (designWords > 300)
|
|
2677
|
+
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
2678
|
+
if (options.strict && designWords > 800)
|
|
2679
|
+
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
2680
|
+
if (options.strict && plan.phases.length === 0)
|
|
2681
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
2682
|
+
if (options.strict && (options.pendingAnnotations ?? 0) > 0)
|
|
2683
|
+
add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
|
|
2684
|
+
return issues;
|
|
2685
|
+
}
|
|
2686
|
+
function renderPlanDocument(plan, previousSource) {
|
|
2687
|
+
if (previousSource && canPatch(previousSource, plan))
|
|
2688
|
+
return patchMarkers(previousSource, plan);
|
|
2689
|
+
const marker = {
|
|
2690
|
+
schemaVersion: plan.schemaVersion,
|
|
2691
|
+
id: plan.id,
|
|
2692
|
+
revision: plan.revision,
|
|
2693
|
+
branch: plan.branch,
|
|
2694
|
+
status: plan.status,
|
|
2695
|
+
execution: plan.execution,
|
|
2696
|
+
createdAt: plan.createdAt,
|
|
2697
|
+
updatedAt: plan.updatedAt
|
|
2698
|
+
};
|
|
2699
|
+
const requirements = plan.requirements.length ? plan.requirements.map((item) => `- ${item}`).join(`
|
|
2700
|
+
`) : "<!-- Add requirements. -->";
|
|
2701
|
+
const phases = plan.phases.map(renderPhase).join(`
|
|
2702
|
+
|
|
2703
|
+
`);
|
|
2704
|
+
const references = plan.references.length ? plan.references.map((reference) => `- <!-- diffpi-reference: ${json(reference)} --> ${reference.value}`).join(`
|
|
2705
|
+
`) : "<!-- Add references. -->";
|
|
2706
|
+
return `<!-- diffpi-plan: ${json(marker)} -->
|
|
2707
|
+
# ${plan.title}
|
|
2708
|
+
|
|
2709
|
+
- **Plan ID:** ${plan.id}
|
|
2710
|
+
- **Branch:** ${plan.branch}
|
|
2711
|
+
- **Status:** ${plan.status}
|
|
2712
|
+
- **Revision:** ${plan.revision}
|
|
2713
|
+
|
|
2714
|
+
## Intent
|
|
2715
|
+
|
|
2716
|
+
${plan.intent || "<!-- Describe the intended outcome. -->"}
|
|
2717
|
+
|
|
2718
|
+
## Requirements
|
|
2719
|
+
|
|
2720
|
+
${requirements}
|
|
2721
|
+
|
|
2722
|
+
## Design
|
|
2723
|
+
|
|
2724
|
+
### Big Ideas
|
|
2725
|
+
|
|
2726
|
+
${plan.design.bigIdeas || "<!-- Describe the main approach. -->"}
|
|
2727
|
+
|
|
2728
|
+
### Key API Addition/Updates
|
|
2729
|
+
|
|
2730
|
+
${plan.design.keyApiUpdates || "<!-- Describe public API changes. -->"}
|
|
2731
|
+
|
|
2732
|
+
### Consequences
|
|
2733
|
+
|
|
2734
|
+
${plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"}
|
|
2735
|
+
|
|
2736
|
+
## Implementation
|
|
2737
|
+
|
|
2738
|
+
${phases || "<!-- Add phases with plan_add_phase. -->"}
|
|
2739
|
+
|
|
2740
|
+
## References
|
|
2741
|
+
|
|
2742
|
+
${references}
|
|
2743
|
+
`;
|
|
2744
|
+
}
|
|
2745
|
+
function parsePlanDocument(source, ref = "PLAN.md") {
|
|
2746
|
+
const planMatch = source.match(PLAN_MARKER);
|
|
2747
|
+
if (!planMatch)
|
|
2748
|
+
throw new Error(`${ref}: missing or malformed diffpi-plan marker.`);
|
|
2749
|
+
if ((source.match(new RegExp(PLAN_MARKER.source, "g")) ?? []).length !== 1)
|
|
2750
|
+
throw new Error(`${ref}: duplicate diffpi-plan marker.`);
|
|
2751
|
+
const marker = parseMarker(planMatch[1], `${ref} plan`);
|
|
2752
|
+
if (marker.schemaVersion !== SUPPORTED_SCHEMA)
|
|
2753
|
+
throw new Error(`${ref}: unsupported plan schema ${String(marker.schemaVersion)}.`);
|
|
2754
|
+
assertStableId(marker.id, "plan id");
|
|
2755
|
+
const title = source.match(/^# (.+)$/m)?.[1]?.trim();
|
|
2756
|
+
if (!title)
|
|
2757
|
+
throw new Error(`${ref}: missing plan title.`);
|
|
2758
|
+
const phases = parsePhases(section(source, "Implementation"), ref);
|
|
2759
|
+
const document = {
|
|
2760
|
+
...marker,
|
|
2761
|
+
schemaVersion: 1,
|
|
2762
|
+
title,
|
|
2763
|
+
intent: cleanPlaceholder(section(source, "Intent")),
|
|
2764
|
+
requirements: parseBullets(section(source, "Requirements")),
|
|
2765
|
+
design: {
|
|
2766
|
+
bigIdeas: cleanPlaceholder(subsection(source, "Design", "Big Ideas")),
|
|
2767
|
+
keyApiUpdates: cleanPlaceholder(subsection(source, "Design", "Key API Addition/Updates")),
|
|
2768
|
+
consequences: cleanPlaceholder(subsection(source, "Design", "Consequences"))
|
|
2769
|
+
},
|
|
2770
|
+
phases,
|
|
2771
|
+
references: parseReferences(section(source, "References"))
|
|
2772
|
+
};
|
|
2773
|
+
const errors = validatePlanDocument(document).filter((issue) => issue.severity === "error");
|
|
2774
|
+
if (errors.length)
|
|
2775
|
+
throw new Error(`${ref}: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
2776
|
+
return document;
|
|
2777
|
+
}
|
|
2778
|
+
function renderPhase(phase) {
|
|
2779
|
+
const marker = {
|
|
2780
|
+
id: phase.id,
|
|
2781
|
+
revision: phase.revision,
|
|
2782
|
+
status: phase.status,
|
|
2783
|
+
gate: phase.gate,
|
|
2784
|
+
commit: phase.commit,
|
|
2785
|
+
blocker: phase.blocker
|
|
2786
|
+
};
|
|
2787
|
+
const dependencies = phase.dependencies.length ? phase.dependencies.join(", ") : "none";
|
|
2788
|
+
return `<!-- diffpi-phase: ${json(marker)} -->
|
|
2789
|
+
### Phase: ${phase.title}
|
|
2790
|
+
|
|
2791
|
+
**Objective:** ${phase.objective}
|
|
2792
|
+
|
|
2793
|
+
**Dependencies:** ${dependencies}
|
|
2794
|
+
|
|
2795
|
+
${phase.tasks.map(renderTask).join(`
|
|
2796
|
+
|
|
2797
|
+
`)}
|
|
2798
|
+
|
|
2799
|
+
<!-- /diffpi-phase -->`;
|
|
2800
|
+
}
|
|
2801
|
+
function renderTask(task) {
|
|
2802
|
+
const marker = {
|
|
2803
|
+
id: task.id,
|
|
2804
|
+
revision: task.revision,
|
|
2805
|
+
status: task.status,
|
|
2806
|
+
owner: task.owner,
|
|
2807
|
+
executionId: task.executionId,
|
|
2808
|
+
blocker: task.blocker
|
|
2809
|
+
};
|
|
2810
|
+
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
2811
|
+
return `<!-- diffpi-task: ${json(marker)} -->
|
|
2812
|
+
- [${checked}] **${task.title}**
|
|
2813
|
+
- Steps: ${list(task.steps ?? [])}
|
|
2814
|
+
- Dependencies: ${list(task.dependencies)}
|
|
2815
|
+
- File scopes: ${list(task.fileScopes)}
|
|
2816
|
+
- Acceptance criteria: ${list(task.acceptanceCriteria)}
|
|
2817
|
+
<!-- /diffpi-task -->`;
|
|
2818
|
+
}
|
|
2819
|
+
function parsePhases(input, ref) {
|
|
2820
|
+
const starts = [...input.matchAll(PHASE_MARKER)];
|
|
2821
|
+
const phases = starts.map((match, index) => {
|
|
2822
|
+
const start = match.index;
|
|
2823
|
+
const end = input.indexOf("<!-- /diffpi-phase -->", start);
|
|
2824
|
+
if (end < 0)
|
|
2825
|
+
throw new Error(`${ref}: phase marker has no closing marker.`);
|
|
2826
|
+
const next = starts[index + 1]?.index;
|
|
2827
|
+
if (next !== undefined && next < end)
|
|
2828
|
+
throw new Error(`${ref}: nested or unclosed phase marker.`);
|
|
2829
|
+
const body = input.slice(start + match[0].length, end);
|
|
2830
|
+
const marker = parseMarker(match[1], `${ref} phase`);
|
|
2831
|
+
assertStableId(marker.id, "phase id");
|
|
2832
|
+
const title = body.match(/^### Phase: (.+)$/m)?.[1]?.trim();
|
|
2833
|
+
const objective = body.match(/^\*\*Objective:\*\*\s*(.*)$/m)?.[1]?.trim();
|
|
2834
|
+
if (!title || !objective)
|
|
2835
|
+
throw new Error(`${ref}: phase ${marker.id} is missing title or objective.`);
|
|
2836
|
+
return {
|
|
2837
|
+
...marker,
|
|
2838
|
+
title,
|
|
2839
|
+
objective,
|
|
2840
|
+
dependencies: parseCsv(body.match(/^\*\*Dependencies:\*\*\s*(.*)$/m)?.[1]),
|
|
2841
|
+
tasks: parseTasks(body, ref)
|
|
2842
|
+
};
|
|
2843
|
+
});
|
|
2844
|
+
assertUniqueIds(phases.map((phase) => phase.id), "phase id");
|
|
2845
|
+
return phases;
|
|
2846
|
+
}
|
|
2847
|
+
function parseTasks(input, ref) {
|
|
2848
|
+
const starts = [...input.matchAll(TASK_MARKER)];
|
|
2849
|
+
const tasks = starts.map((match, index) => {
|
|
2850
|
+
const start = match.index;
|
|
2851
|
+
const end = input.indexOf("<!-- /diffpi-task -->", start);
|
|
2852
|
+
if (end < 0)
|
|
2853
|
+
throw new Error(`${ref}: task marker has no closing marker.`);
|
|
2854
|
+
const next = starts[index + 1]?.index;
|
|
2855
|
+
if (next !== undefined && next < end)
|
|
2856
|
+
throw new Error(`${ref}: nested or unclosed task marker.`);
|
|
2857
|
+
const body = input.slice(start + match[0].length, end);
|
|
2858
|
+
const marker = parseMarker(match[1], `${ref} task`);
|
|
2859
|
+
assertStableId(marker.id, "task id");
|
|
2860
|
+
const title = body.match(/^- \[[ xX]\] \*\*(.+)\*\*$/m)?.[1]?.trim();
|
|
2861
|
+
if (!title)
|
|
2862
|
+
throw new Error(`${ref}: task ${marker.id} is missing its checkbox title.`);
|
|
2863
|
+
return {
|
|
2864
|
+
...marker,
|
|
2865
|
+
title,
|
|
2866
|
+
steps: parseListValue(body, "Steps"),
|
|
2867
|
+
dependencies: parseListValue(body, "Dependencies"),
|
|
2868
|
+
fileScopes: parseListValue(body, "File scopes"),
|
|
2869
|
+
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
2870
|
+
};
|
|
2871
|
+
});
|
|
2872
|
+
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
2873
|
+
return tasks;
|
|
2874
|
+
}
|
|
2875
|
+
function parseReferences(input) {
|
|
2876
|
+
return [...input.matchAll(/^- <!-- diffpi-reference: (\{[^\n]+\}) -->\s*(.*)$/gm)].map((match) => {
|
|
2877
|
+
const marker = parseMarker(match[1], "reference");
|
|
2878
|
+
assertStableId(marker.id, "reference id");
|
|
2879
|
+
return { id: marker.id, value: match[2].trim() || marker.value };
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
function canPatch(source, next) {
|
|
2883
|
+
try {
|
|
2884
|
+
const old = parsePlanDocument(source);
|
|
2885
|
+
return JSON.stringify(contentShape(old)) === JSON.stringify(contentShape(next));
|
|
2886
|
+
} catch {
|
|
2887
|
+
return false;
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
function patchMarkers(source, plan) {
|
|
2891
|
+
const planMarker = {
|
|
2892
|
+
schemaVersion: plan.schemaVersion,
|
|
2893
|
+
id: plan.id,
|
|
2894
|
+
revision: plan.revision,
|
|
2895
|
+
branch: plan.branch,
|
|
2896
|
+
status: plan.status,
|
|
2897
|
+
execution: plan.execution,
|
|
2898
|
+
createdAt: plan.createdAt,
|
|
2899
|
+
updatedAt: plan.updatedAt
|
|
2900
|
+
};
|
|
2901
|
+
let output = source.replace(PLAN_MARKER, `<!-- diffpi-plan: ${json(planMarker)} -->`);
|
|
2902
|
+
output = output.replace(/^- \*\*Status:\*\* .*$/m, `- **Status:** ${plan.status}`);
|
|
2903
|
+
output = output.replace(/^- \*\*Revision:\*\* .*$/m, `- **Revision:** ${plan.revision}`);
|
|
2904
|
+
for (const phase of plan.phases) {
|
|
2905
|
+
const marker = {
|
|
2906
|
+
id: phase.id,
|
|
2907
|
+
revision: phase.revision,
|
|
2908
|
+
status: phase.status,
|
|
2909
|
+
gate: phase.gate,
|
|
2910
|
+
commit: phase.commit,
|
|
2911
|
+
blocker: phase.blocker
|
|
2912
|
+
};
|
|
2913
|
+
output = replaceMarkerById(output, "phase", phase.id, marker);
|
|
2914
|
+
for (const task of phase.tasks) {
|
|
2915
|
+
const taskMarker = {
|
|
2916
|
+
id: task.id,
|
|
2917
|
+
revision: task.revision,
|
|
2918
|
+
status: task.status,
|
|
2919
|
+
owner: task.owner,
|
|
2920
|
+
executionId: task.executionId,
|
|
2921
|
+
blocker: task.blocker
|
|
2922
|
+
};
|
|
2923
|
+
output = replaceMarkerById(output, "task", task.id, taskMarker);
|
|
2924
|
+
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
2925
|
+
const escaped = escapeRegExp(task.title);
|
|
2926
|
+
output = output.replace(new RegExp(`^- \\[[ xX]\\] \\*\\*${escaped}\\*\\*$`, "m"), `- [${checked}] **${task.title}**`);
|
|
2927
|
+
}
|
|
2928
|
+
}
|
|
2929
|
+
return output;
|
|
2930
|
+
}
|
|
2931
|
+
function replaceMarkerById(source, kind, id, marker) {
|
|
2932
|
+
const pattern = new RegExp(`<!-- diffpi-${kind}: \\{[^\\n]*"id":"${escapeRegExp(id)}"[^\\n]*\\} -->`);
|
|
2933
|
+
if (!pattern.test(source))
|
|
2934
|
+
throw new Error(`Cannot update missing ${kind} marker ${id}.`);
|
|
2935
|
+
return source.replace(pattern, `<!-- diffpi-${kind}: ${json(marker)} -->`);
|
|
2936
|
+
}
|
|
2937
|
+
function contentShape(plan) {
|
|
2938
|
+
return {
|
|
2939
|
+
title: plan.title,
|
|
2940
|
+
branch: plan.branch,
|
|
2941
|
+
intent: plan.intent,
|
|
2942
|
+
requirements: plan.requirements,
|
|
2943
|
+
design: plan.design,
|
|
2944
|
+
references: plan.references,
|
|
2945
|
+
phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
|
|
2946
|
+
id,
|
|
2947
|
+
title,
|
|
2948
|
+
objective,
|
|
2949
|
+
dependencies,
|
|
2950
|
+
tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
|
|
2951
|
+
id: taskId,
|
|
2952
|
+
title: taskTitle,
|
|
2953
|
+
steps,
|
|
2954
|
+
dependencies: taskDependencies,
|
|
2955
|
+
fileScopes,
|
|
2956
|
+
acceptanceCriteria
|
|
2957
|
+
}))
|
|
2958
|
+
}))
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
function dependencyCycles(plan) {
|
|
2962
|
+
const graph = new Map;
|
|
2963
|
+
for (const phase of plan.phases)
|
|
2964
|
+
graph.set(phase.id, phase.dependencies);
|
|
2965
|
+
for (const task of plan.phases.flatMap((phase) => phase.tasks))
|
|
2966
|
+
graph.set(task.id, task.dependencies);
|
|
2967
|
+
const cycles = [];
|
|
2968
|
+
const visiting = new Set;
|
|
2969
|
+
const visited = new Set;
|
|
2970
|
+
const walk = (id, path) => {
|
|
2971
|
+
if (visiting.has(id)) {
|
|
2972
|
+
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
if (visited.has(id))
|
|
2976
|
+
return;
|
|
2977
|
+
visiting.add(id);
|
|
2978
|
+
for (const dependency of graph.get(id) ?? [])
|
|
2979
|
+
walk(dependency, [...path, id]);
|
|
2980
|
+
visiting.delete(id);
|
|
2981
|
+
visited.add(id);
|
|
2982
|
+
};
|
|
2983
|
+
for (const id of graph.keys())
|
|
2984
|
+
walk(id, []);
|
|
2985
|
+
return cycles;
|
|
2986
|
+
}
|
|
2987
|
+
function section(source, heading) {
|
|
2988
|
+
const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
2989
|
+
if (!match?.index) {
|
|
2990
|
+
if (match?.index === 0)
|
|
2991
|
+
return "";
|
|
2992
|
+
throw new Error(`Missing required heading: ${heading}.`);
|
|
2993
|
+
}
|
|
2994
|
+
const start = match.index + match[0].length;
|
|
2995
|
+
const rest = source.slice(start);
|
|
2996
|
+
const end = rest.search(/^## /m);
|
|
2997
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
2998
|
+
}
|
|
2999
|
+
function subsection(source, parent, heading) {
|
|
3000
|
+
const body = section(source, parent);
|
|
3001
|
+
const match = body.match(new RegExp(`^### ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
3002
|
+
if (match?.index === undefined)
|
|
3003
|
+
throw new Error(`Missing required heading: ${parent}/${heading}.`);
|
|
3004
|
+
const rest = body.slice(match.index + match[0].length);
|
|
3005
|
+
const end = rest.search(/^### /m);
|
|
3006
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
3007
|
+
}
|
|
3008
|
+
function parseBullets(input) {
|
|
3009
|
+
return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
|
|
3010
|
+
}
|
|
3011
|
+
function parseListValue(body, label) {
|
|
3012
|
+
const value = body.match(new RegExp(`^ - ${escapeRegExp(label)}:\\s*(.*)$`, "m"))?.[1];
|
|
3013
|
+
return parseCsv(value);
|
|
3014
|
+
}
|
|
3015
|
+
function parseCsv(value) {
|
|
3016
|
+
if (!value || value.trim().toLowerCase() === "none")
|
|
3017
|
+
return [];
|
|
3018
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
3019
|
+
}
|
|
3020
|
+
function cleanPlaceholder(value) {
|
|
3021
|
+
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
3022
|
+
}
|
|
3023
|
+
function parseMarker(value, label) {
|
|
3024
|
+
try {
|
|
3025
|
+
return JSON.parse(value);
|
|
3026
|
+
} catch {
|
|
3027
|
+
throw new Error(`Malformed ${label} marker JSON.`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
function list(values) {
|
|
3031
|
+
return values.length ? values.join(", ") : "none";
|
|
3032
|
+
}
|
|
3033
|
+
function json(value) {
|
|
3034
|
+
return JSON.stringify(value, (_key, entry) => entry === undefined ? undefined : entry);
|
|
3035
|
+
}
|
|
3036
|
+
function escapeRegExp(value) {
|
|
3037
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3038
|
+
}
|
|
3039
|
+
// src/plan/store.ts
|
|
3040
|
+
import { open, mkdir as mkdir7, readdir as readdir2, readFile as readFile14, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
|
|
3041
|
+
import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
|
|
3042
|
+
|
|
3043
|
+
// src/templates.ts
|
|
3044
|
+
import { readFile as readFile13 } from "node:fs/promises";
|
|
3045
|
+
import { homedir as homedir8 } from "node:os";
|
|
3046
|
+
import { join as join15, normalize } from "node:path";
|
|
3047
|
+
async function loadTemplate(name, options = {}) {
|
|
3048
|
+
const relative = templateRelativePath(name);
|
|
3049
|
+
const userPath = join15(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
|
|
3050
|
+
const bundledPath = join15(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
3051
|
+
const user = await readOptionalFile(userPath);
|
|
3052
|
+
if (user !== undefined)
|
|
3053
|
+
return { name, path: userPath, source: "user", content: user };
|
|
3054
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
3055
|
+
if (bundled !== undefined)
|
|
3056
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
3057
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
3058
|
+
}
|
|
3059
|
+
function renderTemplate(content, variables) {
|
|
3060
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
3061
|
+
}
|
|
3062
|
+
function templateRelativePath(name) {
|
|
3063
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
3064
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
3065
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
3066
|
+
}
|
|
3067
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
3068
|
+
}
|
|
3069
|
+
async function readOptionalFile(path) {
|
|
3070
|
+
try {
|
|
3071
|
+
return await readFile13(path, "utf8");
|
|
3072
|
+
} catch (error) {
|
|
3073
|
+
if (error.code === "ENOENT")
|
|
3074
|
+
return;
|
|
3075
|
+
throw error;
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
3079
|
+
// src/plan/store.ts
|
|
3080
|
+
function planRecordName(shortSlug, date = new Date) {
|
|
3081
|
+
const slug = normalizeSlug(shortSlug);
|
|
3082
|
+
return `${date.toISOString().slice(2, 10).replaceAll("-", "")}-${slug}`;
|
|
3083
|
+
}
|
|
3084
|
+
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
3085
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
3086
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
3087
|
+
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
3088
|
+
const records = [];
|
|
3089
|
+
for (const entry of entries) {
|
|
3090
|
+
if (!entry.isDirectory() || entry.name === ".tmp" || entry.name === ".lock")
|
|
3091
|
+
continue;
|
|
3092
|
+
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
3093
|
+
continue;
|
|
3094
|
+
const dir = join16(root, entry.name);
|
|
3095
|
+
await assertContained(root, dir);
|
|
3096
|
+
try {
|
|
3097
|
+
const record = await readRecord(dir);
|
|
3098
|
+
if (filters.branch && record.document.branch !== filters.branch)
|
|
3099
|
+
continue;
|
|
3100
|
+
if (filters.statuses && !filters.statuses.includes(record.document.status))
|
|
3101
|
+
continue;
|
|
3102
|
+
records.push(record);
|
|
3103
|
+
} catch (error) {
|
|
3104
|
+
if (error.code !== "ENOENT")
|
|
3105
|
+
throw error;
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
records.sort((left, right) => left.id.localeCompare(right.id));
|
|
3109
|
+
return { candidates: records, record: records.length === 1 ? records[0] : undefined, ambiguous: records.length > 1 };
|
|
3110
|
+
}
|
|
3111
|
+
function createPlanStore(options = {}) {
|
|
3112
|
+
const now = options.now ?? (() => new Date);
|
|
3113
|
+
return {
|
|
3114
|
+
context(cwd, query, filters) {
|
|
3115
|
+
return resolvePlan(cwd, query, filters, options);
|
|
3116
|
+
},
|
|
3117
|
+
async init(input) {
|
|
3118
|
+
const root = await plansDir(input.cwd, options.homeDir);
|
|
3119
|
+
const id = planRecordName(input.shortSlug, now());
|
|
3120
|
+
const dir = join16(root, id);
|
|
3121
|
+
await assertContained(root, dirname8(dir));
|
|
3122
|
+
try {
|
|
3123
|
+
await mkdir7(dir);
|
|
3124
|
+
} catch (error) {
|
|
3125
|
+
if (error.code === "EEXIST")
|
|
3126
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
3127
|
+
throw error;
|
|
3128
|
+
}
|
|
3129
|
+
try {
|
|
3130
|
+
const timestamp = now().toISOString();
|
|
3131
|
+
const template = await loadTemplate("plan/PLAN", {
|
|
3132
|
+
homeDir: options.homeDir,
|
|
3133
|
+
bundledDir: options.bundledTemplatesDir
|
|
3134
|
+
});
|
|
3135
|
+
const source = renderTemplate(template.content, {
|
|
3136
|
+
id,
|
|
3137
|
+
branch: input.branch,
|
|
3138
|
+
title: input.title?.trim() || titleFromSlug(input.shortSlug),
|
|
3139
|
+
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
3140
|
+
created_at: timestamp,
|
|
3141
|
+
updated_at: timestamp
|
|
3142
|
+
});
|
|
3143
|
+
const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
|
|
3144
|
+
await atomicWrite(join16(dir, "PLAN.md"), source);
|
|
3145
|
+
await atomicWrite(join16(dir, "logs.txt"), "");
|
|
3146
|
+
await appendPlanLog(join16(dir, "logs.txt"), {
|
|
3147
|
+
planRevision: document.revision,
|
|
3148
|
+
kind: "created",
|
|
3149
|
+
actor: "diffpi",
|
|
3150
|
+
message: `Created plan ${id}.`
|
|
3151
|
+
});
|
|
3152
|
+
return { id, dir, planPath: join16(dir, "PLAN.md"), logPath: join16(dir, "logs.txt"), document, source };
|
|
3153
|
+
} catch (error) {
|
|
3154
|
+
await rm2(dir, { recursive: true, force: true });
|
|
3155
|
+
throw error;
|
|
3156
|
+
}
|
|
3157
|
+
},
|
|
3158
|
+
async read(cwd, query, filters) {
|
|
3159
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
3160
|
+
if (!result.record) {
|
|
3161
|
+
if (result.ambiguous)
|
|
3162
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
3163
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
3164
|
+
}
|
|
3165
|
+
return result.record;
|
|
3166
|
+
},
|
|
3167
|
+
async mutate(cwd, query, operation, update) {
|
|
3168
|
+
const initial = await this.read(cwd, query);
|
|
3169
|
+
return withPlanLock(initial.dir, async () => {
|
|
3170
|
+
const current = await readRecord(initial.dir);
|
|
3171
|
+
const next = await update(structuredClone(current.document));
|
|
3172
|
+
if (next.id !== current.id)
|
|
3173
|
+
throw new Error("A plan mutation cannot change the plan ID.");
|
|
3174
|
+
const document = {
|
|
3175
|
+
...next,
|
|
3176
|
+
revision: current.document.revision + 1,
|
|
3177
|
+
updatedAt: now().toISOString()
|
|
3178
|
+
};
|
|
3179
|
+
const source = renderPlanDocument(document, current.source);
|
|
3180
|
+
parsePlanDocument(source, current.planPath);
|
|
3181
|
+
await atomicWrite(current.planPath, source);
|
|
3182
|
+
return { ...current, document, source };
|
|
3183
|
+
}, { operation });
|
|
3184
|
+
},
|
|
3185
|
+
async log(cwd, query, event) {
|
|
3186
|
+
const record = await this.read(cwd, query);
|
|
3187
|
+
return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
|
|
3188
|
+
}
|
|
3189
|
+
};
|
|
3190
|
+
}
|
|
3191
|
+
async function readRecord(dir) {
|
|
3192
|
+
const planPath = join16(dir, "PLAN.md");
|
|
3193
|
+
const logPath = join16(dir, "logs.txt");
|
|
3194
|
+
const source = await readFile14(planPath, "utf8");
|
|
3195
|
+
const document = parsePlanDocument(source, planPath);
|
|
3196
|
+
return { id: basename6(dir), dir, planPath, logPath, document, source };
|
|
3197
|
+
}
|
|
3198
|
+
async function atomicWrite(path, content) {
|
|
3199
|
+
const temp = join16(dirname8(path), `.${basename6(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
3200
|
+
const file = await open(temp, "wx", 384);
|
|
3201
|
+
try {
|
|
3202
|
+
await file.writeFile(content, "utf8");
|
|
3203
|
+
await file.sync();
|
|
3204
|
+
} finally {
|
|
3205
|
+
await file.close();
|
|
3206
|
+
}
|
|
3207
|
+
await rename3(temp, path);
|
|
3208
|
+
try {
|
|
3209
|
+
const directory = await open(dirname8(path), "r");
|
|
3210
|
+
try {
|
|
3211
|
+
await directory.sync();
|
|
3212
|
+
} finally {
|
|
3213
|
+
await directory.close();
|
|
3214
|
+
}
|
|
3215
|
+
} catch {}
|
|
3216
|
+
}
|
|
3217
|
+
async function assertContained(root, candidate) {
|
|
3218
|
+
const canonicalRoot = await realpath4(root);
|
|
3219
|
+
let canonicalCandidate;
|
|
3220
|
+
try {
|
|
3221
|
+
canonicalCandidate = await realpath4(candidate);
|
|
3222
|
+
} catch {
|
|
3223
|
+
canonicalCandidate = resolve4(candidate);
|
|
3224
|
+
}
|
|
3225
|
+
if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) {
|
|
3226
|
+
throw new Error(`Plan path escapes the shared store: ${candidate}.`);
|
|
3227
|
+
}
|
|
3228
|
+
}
|
|
3229
|
+
function normalizeSlug(value) {
|
|
3230
|
+
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
3231
|
+
throw new Error(`Invalid plan slug: ${value}.`);
|
|
3232
|
+
}
|
|
3233
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3234
|
+
assertStableId(slug, "plan slug");
|
|
3235
|
+
return slug;
|
|
3236
|
+
}
|
|
3237
|
+
function normalizeQuery(value) {
|
|
3238
|
+
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
3239
|
+
throw new Error(`Invalid plan query: ${value}.`);
|
|
3240
|
+
}
|
|
3241
|
+
return value.toLowerCase();
|
|
3242
|
+
}
|
|
3243
|
+
function stripDate(value) {
|
|
3244
|
+
return value.replace(/^\d{6}-/, "");
|
|
3245
|
+
}
|
|
3246
|
+
function titleFromSlug(value) {
|
|
3247
|
+
return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
3248
|
+
}
|
|
739
3249
|
// src/setup.ts
|
|
740
3250
|
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
741
|
-
import { readdir as
|
|
742
|
-
import { homedir as
|
|
743
|
-
import { basename as
|
|
3251
|
+
import { readdir as readdir3, readFile as readFile15 } from "node:fs/promises";
|
|
3252
|
+
import { homedir as homedir9 } from "node:os";
|
|
3253
|
+
import { basename as basename7, join as join17 } from "node:path";
|
|
744
3254
|
var MISE_DEPENDENCIES = [
|
|
745
3255
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
746
3256
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -768,13 +3278,18 @@ var PI_PACKAGES = [
|
|
|
768
3278
|
];
|
|
769
3279
|
var PI_SKILL_SOURCES = [
|
|
770
3280
|
{ repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
|
|
771
|
-
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
3281
|
+
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
|
|
3282
|
+
{ repository: "cloudvoyant/codevoyant", skills: ["git"] }
|
|
772
3283
|
];
|
|
773
3284
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
774
3285
|
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
3286
|
+
var FORGE_DEPENDENCIES = {
|
|
3287
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
3288
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
3289
|
+
};
|
|
775
3290
|
async function ensureMise(options = {}) {
|
|
776
|
-
const homeDir = options.homeDir ??
|
|
777
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
3291
|
+
const homeDir = options.homeDir ?? homedir9();
|
|
3292
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
|
|
778
3293
|
if (current)
|
|
779
3294
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
780
3295
|
reportProgress(options, "Installing mise");
|
|
@@ -803,7 +3318,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
803
3318
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
804
3319
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
805
3320
|
const actions = [];
|
|
806
|
-
|
|
3321
|
+
const dependencies = [
|
|
3322
|
+
...MISE_DEPENDENCIES,
|
|
3323
|
+
...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
|
|
3324
|
+
];
|
|
3325
|
+
for (const dependency of dependencies) {
|
|
807
3326
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
808
3327
|
if (installed) {
|
|
809
3328
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -819,9 +3338,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
819
3338
|
async function ensurePiPlugins(options = {}) {
|
|
820
3339
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
821
3340
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
822
|
-
const webSearch = await pi.configEnsure(
|
|
3341
|
+
const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
823
3342
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
824
|
-
const lsp = await pi.configEnsure(
|
|
3343
|
+
const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
|
|
825
3344
|
...config,
|
|
826
3345
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
827
3346
|
}), options.dryRun);
|
|
@@ -832,11 +3351,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
832
3351
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
833
3352
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
834
3353
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
835
|
-
const entries = (await
|
|
3354
|
+
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));
|
|
836
3355
|
const actions = [];
|
|
837
3356
|
for (const entry of entries) {
|
|
838
|
-
const id =
|
|
839
|
-
const source = await
|
|
3357
|
+
const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
|
|
3358
|
+
const source = await readFile15(join17(bundledAgentsDir, entry.name), "utf8");
|
|
840
3359
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
841
3360
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
842
3361
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -845,7 +3364,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
845
3364
|
}
|
|
846
3365
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
847
3366
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
848
|
-
const sharedSkillsDir =
|
|
3367
|
+
const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
|
|
849
3368
|
const actions = [];
|
|
850
3369
|
for (const source of PI_SKILL_SOURCES) {
|
|
851
3370
|
const missing = [];
|
|
@@ -889,6 +3408,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
889
3408
|
} else if (options.issueTracker === "jira") {
|
|
890
3409
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
891
3410
|
}
|
|
3411
|
+
for (const forge of configuredForges(options)) {
|
|
3412
|
+
if (forge === "github") {
|
|
3413
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
3414
|
+
continue;
|
|
3415
|
+
}
|
|
3416
|
+
const host = await gitlabMcpHost(projectDir);
|
|
3417
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
3418
|
+
}
|
|
892
3419
|
const result = await mcp.serversEnsure(servers, {
|
|
893
3420
|
dryRun: options.dryRun,
|
|
894
3421
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -905,6 +3432,8 @@ async function setupPi(options = {}) {
|
|
|
905
3432
|
actions.push(...await ensurePiAgents(options));
|
|
906
3433
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
907
3434
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
3435
|
+
if (options.bindZedKey)
|
|
3436
|
+
actions.push(...await ensureZedIntegration(options));
|
|
908
3437
|
return {
|
|
909
3438
|
actions,
|
|
910
3439
|
restartPi: setupRequiresRestart(actions)
|
|
@@ -913,6 +3442,43 @@ async function setupPi(options = {}) {
|
|
|
913
3442
|
function setupRequiresRestart(actions) {
|
|
914
3443
|
return actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi agent ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"));
|
|
915
3444
|
}
|
|
3445
|
+
async function ensureZedIntegration(options = {}) {
|
|
3446
|
+
if (options.dryRun) {
|
|
3447
|
+
const actions = [
|
|
3448
|
+
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json"),
|
|
3449
|
+
createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
|
|
3450
|
+
];
|
|
3451
|
+
if (options.bindZedKey)
|
|
3452
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
3453
|
+
return actions;
|
|
3454
|
+
}
|
|
3455
|
+
const actions = [];
|
|
3456
|
+
try {
|
|
3457
|
+
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
3458
|
+
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
3459
|
+
const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
|
|
3460
|
+
actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
|
|
3461
|
+
} catch (error) {
|
|
3462
|
+
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3463
|
+
}
|
|
3464
|
+
if (options.bindZedKey) {
|
|
3465
|
+
try {
|
|
3466
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
3467
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
3468
|
+
} catch (error) {
|
|
3469
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3470
|
+
}
|
|
3471
|
+
}
|
|
3472
|
+
return actions;
|
|
3473
|
+
}
|
|
3474
|
+
async function gitlabMcpHost(projectDir) {
|
|
3475
|
+
try {
|
|
3476
|
+
const vcs = await detectVcs(projectDir);
|
|
3477
|
+
return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
|
|
3478
|
+
} catch {
|
|
3479
|
+
return "gitlab.com";
|
|
3480
|
+
}
|
|
3481
|
+
}
|
|
916
3482
|
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
917
3483
|
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
918
3484
|
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
@@ -972,6 +3538,10 @@ ${source}`;
|
|
|
972
3538
|
}
|
|
973
3539
|
return actions;
|
|
974
3540
|
}
|
|
3541
|
+
function configuredForges(options) {
|
|
3542
|
+
const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
|
|
3543
|
+
return [...new Set(selected)];
|
|
3544
|
+
}
|
|
975
3545
|
function getTextList(value) {
|
|
976
3546
|
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
977
3547
|
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
@@ -989,13 +3559,55 @@ function createSetupAction(name, status, detail) {
|
|
|
989
3559
|
function reportProgress(options, message) {
|
|
990
3560
|
options.onProgress?.(message);
|
|
991
3561
|
}
|
|
3562
|
+
async function packageVersion2() {
|
|
3563
|
+
const source = await readFile15(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
3564
|
+
const value = JSON.parse(source);
|
|
3565
|
+
if (typeof value.version !== "string")
|
|
3566
|
+
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
3567
|
+
return value.version;
|
|
3568
|
+
}
|
|
992
3569
|
function getRecord(value) {
|
|
993
3570
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
994
3571
|
}
|
|
995
3572
|
export {
|
|
3573
|
+
CONVENTIONAL_COMMIT,
|
|
3574
|
+
GitHubVcsBackend,
|
|
3575
|
+
GitLabVcsBackend,
|
|
3576
|
+
ZED_LOCAL_REVIEW_TASK_NAME,
|
|
3577
|
+
ZED_PLAN_ANNOTATE_TASK_NAME,
|
|
3578
|
+
ZED_PR_REVIEW_TASK_NAME,
|
|
3579
|
+
ZED_REVIEW_TASK_NAME,
|
|
3580
|
+
acknowledgePlanAnnotations,
|
|
3581
|
+
addComment,
|
|
3582
|
+
annotatePlan,
|
|
3583
|
+
annotationStatePath,
|
|
3584
|
+
appendPlanLog,
|
|
3585
|
+
assertGitHubMergeReady,
|
|
3586
|
+
assertPhaseTransition,
|
|
3587
|
+
assertPlanTransition,
|
|
3588
|
+
assertReviewEventSupported,
|
|
3589
|
+
assertStableId,
|
|
3590
|
+
assertTaskTransition,
|
|
3591
|
+
checkConventionalSubject,
|
|
3592
|
+
ciGate,
|
|
3593
|
+
computeProjectSlug,
|
|
3594
|
+
countDesignWords,
|
|
3595
|
+
createExecutionPacket,
|
|
3596
|
+
createForge,
|
|
3597
|
+
createForgeBackend,
|
|
3598
|
+
createLocalReviewBackend,
|
|
996
3599
|
createModeController,
|
|
3600
|
+
createPlanStore,
|
|
3601
|
+
createRemoteReviewBackend,
|
|
3602
|
+
createVcsBackend,
|
|
3603
|
+
dedupeFindings,
|
|
3604
|
+
detectIde,
|
|
3605
|
+
detectMux,
|
|
3606
|
+
detectShell,
|
|
3607
|
+
detectVcs,
|
|
997
3608
|
diffpiConfigPaths,
|
|
998
3609
|
discoverAgentModes,
|
|
3610
|
+
eligiblePlanTasks,
|
|
999
3611
|
ensureMcpAdapters,
|
|
1000
3612
|
ensureMise,
|
|
1001
3613
|
ensureMiseDeps,
|
|
@@ -1003,12 +3615,74 @@ export {
|
|
|
1003
3615
|
ensurePiAgents,
|
|
1004
3616
|
ensurePiPlugins,
|
|
1005
3617
|
ensurePiSkills,
|
|
3618
|
+
ensureStore,
|
|
3619
|
+
ensureZedPlanTask,
|
|
3620
|
+
ensureZedReviewKeybinding,
|
|
3621
|
+
ensureZedReviewTask,
|
|
1006
3622
|
findPreferredModel,
|
|
3623
|
+
findingSchema,
|
|
3624
|
+
findingsSchema,
|
|
3625
|
+
gitToplevel,
|
|
3626
|
+
githubReviewSubmissionEndpoint,
|
|
3627
|
+
hasGitlabDraftNotes,
|
|
3628
|
+
launch,
|
|
3629
|
+
listSessions,
|
|
1007
3630
|
loadDiffpiConfig,
|
|
3631
|
+
loadReviewPublicationState,
|
|
3632
|
+
loadTemplate,
|
|
3633
|
+
localReviewAuthor,
|
|
1008
3634
|
mcp,
|
|
1009
3635
|
mise,
|
|
3636
|
+
openInNewTab,
|
|
3637
|
+
parseGitlabDiffRefs,
|
|
3638
|
+
parsePlanDocument,
|
|
3639
|
+
parsePlannerEscalation,
|
|
3640
|
+
parseRemote,
|
|
3641
|
+
parseThreadArtifact,
|
|
3642
|
+
phaseCommitCommand,
|
|
1010
3643
|
pi,
|
|
3644
|
+
planRecordName,
|
|
3645
|
+
plansDir,
|
|
3646
|
+
readPlanAnnotations,
|
|
3647
|
+
readPlanLog,
|
|
3648
|
+
readSession,
|
|
3649
|
+
renderExecutionPrompt,
|
|
3650
|
+
renderPlanDocument,
|
|
3651
|
+
renderPlannerEscalation,
|
|
3652
|
+
renderReviewDoc,
|
|
3653
|
+
renderTemplate,
|
|
3654
|
+
renderThreadArtifact,
|
|
1011
3655
|
resolveAgentMode,
|
|
1012
3656
|
resolveAgentModelPreferences,
|
|
1013
|
-
|
|
3657
|
+
resolvePlan,
|
|
3658
|
+
resolvePrSession,
|
|
3659
|
+
resolveReviewSession,
|
|
3660
|
+
resolveSession,
|
|
3661
|
+
reviewBodyFingerprint,
|
|
3662
|
+
reviewCommentFingerprint,
|
|
3663
|
+
reviewRecordName,
|
|
3664
|
+
reviewReplyFingerprint,
|
|
3665
|
+
reviewSlug,
|
|
3666
|
+
reviewsDir,
|
|
3667
|
+
runMiseGates,
|
|
3668
|
+
saveReviewPublicationState,
|
|
3669
|
+
sessionsDir,
|
|
3670
|
+
setupPi,
|
|
3671
|
+
severitySchema,
|
|
3672
|
+
storeDir,
|
|
3673
|
+
storeGlobalRoot,
|
|
3674
|
+
tasksMayRunInParallel,
|
|
3675
|
+
templateRelativePath,
|
|
3676
|
+
toFindings,
|
|
3677
|
+
toReviewComments,
|
|
3678
|
+
tuicrAvailable,
|
|
3679
|
+
unpublishedReviewComments,
|
|
3680
|
+
upsertThreadReply,
|
|
3681
|
+
validatePlanDocument,
|
|
3682
|
+
withPlanLock,
|
|
3683
|
+
withRemoteProvenance,
|
|
3684
|
+
yymmdd,
|
|
3685
|
+
zedKeymapPath,
|
|
3686
|
+
zedReviewTaskName,
|
|
3687
|
+
zedTasksPath
|
|
1014
3688
|
};
|