@difflab/pi 0.2.0 → 0.3.0-rc.202609201609.21ba1d2
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 +2855 -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-parser.d.ts +18 -0
- package/dist/commands/plan-parser.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 +4788 -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 +66 -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 +2817 -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 +4459 -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 +7 -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,1044 @@ 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
|
+
const name = pr === undefined ? "diffpi: local review" : `diffpi: PR #${pr}`;
|
|
1594
|
+
return openInNewTab(command, { cwd, name });
|
|
1595
|
+
}
|
|
1596
|
+
async function resolveSession(cwd, branch) {
|
|
1597
|
+
const sessions = await listSessions(cwd);
|
|
1598
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
1599
|
+
}
|
|
1600
|
+
async function resolveReviewSession(cwd, target) {
|
|
1601
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
1602
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
1603
|
+
}
|
|
1604
|
+
return resolveSession(cwd, target.branch);
|
|
1605
|
+
}
|
|
1606
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
1607
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
1608
|
+
const sessions = await listSessions(cwd);
|
|
1609
|
+
return sessions.find((session) => {
|
|
1610
|
+
const slug = session.slug.toLowerCase();
|
|
1611
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
1612
|
+
});
|
|
1613
|
+
}
|
|
1614
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
1615
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
1616
|
+
for (const session of sessions) {
|
|
1617
|
+
if (session.kind !== "local")
|
|
1618
|
+
continue;
|
|
1619
|
+
try {
|
|
1620
|
+
const data = await readSession(session.path);
|
|
1621
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
1622
|
+
continue;
|
|
1623
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
1624
|
+
return session;
|
|
1625
|
+
} catch {}
|
|
1626
|
+
}
|
|
1627
|
+
return;
|
|
1628
|
+
}
|
|
1629
|
+
async function readSession(path) {
|
|
1630
|
+
const content = await readFile5(path, "utf8");
|
|
1631
|
+
try {
|
|
1632
|
+
return JSON.parse(content);
|
|
1633
|
+
} catch {
|
|
1634
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
function toFindings(session, options = {}) {
|
|
1638
|
+
const comments = [];
|
|
1639
|
+
const include = (comment) => (!options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ")) && (!options.excludeLocalResponses || !isLocalResponse(comment.content));
|
|
1640
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1641
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
1642
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
1643
|
+
if (fileComments.length > 0)
|
|
1644
|
+
bodyParts.push(`File: ${file}
|
|
1645
|
+
|
|
1646
|
+
${fileComments.join(`
|
|
1647
|
+
|
|
1648
|
+
`)}`);
|
|
1649
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
1650
|
+
const line = Number.parseInt(lineKey, 10);
|
|
1651
|
+
if (!Number.isFinite(line))
|
|
1652
|
+
continue;
|
|
1653
|
+
for (const lineComment of lineComments) {
|
|
1654
|
+
if (!include(lineComment))
|
|
1655
|
+
continue;
|
|
1656
|
+
const comment = {
|
|
1657
|
+
file,
|
|
1658
|
+
line,
|
|
1659
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
1660
|
+
body: lineComment.content
|
|
1661
|
+
};
|
|
1662
|
+
const author = commentAuthor(lineComment);
|
|
1663
|
+
if (author)
|
|
1664
|
+
comment.author = author;
|
|
1665
|
+
comments.push(comment);
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
return { comments, body: bodyParts.join(`
|
|
1670
|
+
|
|
1671
|
+
`) };
|
|
1672
|
+
}
|
|
1673
|
+
async function requireTuicr() {
|
|
1674
|
+
if (!await tuicrAvailable())
|
|
1675
|
+
throw new Error("tuicr is not installed or is not available on PATH.");
|
|
1676
|
+
}
|
|
1677
|
+
function commentAuthor(comment) {
|
|
1678
|
+
return comment.username ?? comment.author;
|
|
1679
|
+
}
|
|
1680
|
+
async function canonicalPath2(path) {
|
|
1681
|
+
try {
|
|
1682
|
+
return await realpath2(path);
|
|
1683
|
+
} catch {
|
|
1684
|
+
return resolve2(path);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// src/review/local-review-backend.ts
|
|
1689
|
+
function LocalReviewBackend(options) {
|
|
1690
|
+
return {
|
|
1691
|
+
kind: "local",
|
|
1692
|
+
async stage(draft) {
|
|
1693
|
+
for (const comment of draft.comments) {
|
|
1694
|
+
await addComment(options.session, comment.body, {
|
|
1695
|
+
targetFile: comment.file,
|
|
1696
|
+
line: comment.line,
|
|
1697
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
1698
|
+
username: options.author
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
if (draft.body.trim())
|
|
1702
|
+
await addComment(options.session, draft.body, { username: options.author });
|
|
1703
|
+
},
|
|
1704
|
+
async readDraft() {
|
|
1705
|
+
return toFindings(await readSession(options.session), { excludeLocalResponses: true });
|
|
1706
|
+
},
|
|
1707
|
+
async listThreads() {
|
|
1708
|
+
try {
|
|
1709
|
+
return parseThreadArtifact(await readFile6(options.artifactPath, "utf8"));
|
|
1710
|
+
} catch (error) {
|
|
1711
|
+
if (error.code === "ENOENT")
|
|
1712
|
+
return [];
|
|
1713
|
+
throw error;
|
|
1714
|
+
}
|
|
1715
|
+
},
|
|
1716
|
+
async reply(input) {
|
|
1717
|
+
const content = await readFile6(options.artifactPath, "utf8");
|
|
1718
|
+
await writeFile3(options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question, input.resolve), "utf8");
|
|
1719
|
+
},
|
|
1720
|
+
async publish() {
|
|
1721
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
1722
|
+
}
|
|
1723
|
+
};
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
// src/review/review-backend.ts
|
|
1727
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
1728
|
+
if (vcs.provider === "github")
|
|
1729
|
+
return GitHubReviewBackend(vcs, number);
|
|
1730
|
+
if (vcs.provider === "gitlab")
|
|
1731
|
+
return GitLabReviewBackend(vcs, number);
|
|
1732
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
1733
|
+
}
|
|
1734
|
+
function createLocalReviewBackend(options) {
|
|
1735
|
+
return LocalReviewBackend(options);
|
|
1736
|
+
}
|
|
1737
|
+
// src/review/review-state.ts
|
|
1738
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1739
|
+
import { readFile as readFile7, rename, writeFile as writeFile4 } from "node:fs/promises";
|
|
1740
|
+
import { join as join9 } from "node:path";
|
|
1741
|
+
import { z as z4 } from "zod";
|
|
1742
|
+
|
|
1743
|
+
// src/store.ts
|
|
1744
|
+
import { createHash } from "node:crypto";
|
|
1745
|
+
import { lstat, mkdir as mkdir3, readlink, realpath as realpath3, symlink, unlink } from "node:fs/promises";
|
|
1746
|
+
import { homedir as homedir4 } from "node:os";
|
|
1747
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve3 } from "node:path";
|
|
1748
|
+
var STORE_LINK = ".diffpi";
|
|
1749
|
+
var LEGACY_STORE_LINK = join8(".pi", "diffpi");
|
|
1750
|
+
async function computeProjectSlug(cwd) {
|
|
1751
|
+
return projectSlug(await inspectGitRepository(cwd));
|
|
1752
|
+
}
|
|
1753
|
+
function storeGlobalRoot(homeDir = homedir4()) {
|
|
1754
|
+
return join8(homeDir, ".difflab", "diffpi", "projects");
|
|
1755
|
+
}
|
|
1756
|
+
async function ensureStore(cwd, homeDir = homedir4()) {
|
|
1757
|
+
const repository = await inspectGitRepository(cwd);
|
|
1758
|
+
const root = repository.root;
|
|
1759
|
+
const slug = projectSlug(repository);
|
|
1760
|
+
const dest = join8(storeGlobalRoot(homeDir), slug);
|
|
1761
|
+
const link = join8(root, STORE_LINK);
|
|
1762
|
+
await mkdir3(dest, { recursive: true });
|
|
1763
|
+
try {
|
|
1764
|
+
await assertStoreLink(link, dest);
|
|
1765
|
+
} catch (error) {
|
|
1766
|
+
if (error.code !== "ENOENT")
|
|
1767
|
+
throw error;
|
|
1768
|
+
await symlink(dest, link);
|
|
1769
|
+
}
|
|
1770
|
+
await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
|
|
1771
|
+
return { slug, root, dest, link, linked: true };
|
|
1772
|
+
}
|
|
1773
|
+
async function storeDir(cwd, homeDir = homedir4()) {
|
|
1774
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1775
|
+
return store.dest;
|
|
1776
|
+
}
|
|
1777
|
+
async function plansDir(cwd, homeDir = homedir4()) {
|
|
1778
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1779
|
+
const dir = join8(store.link, "plan");
|
|
1780
|
+
await mkdir3(dir, { recursive: true });
|
|
1781
|
+
return dir;
|
|
1782
|
+
}
|
|
1783
|
+
async function reviewsDir(cwd, homeDir = homedir4()) {
|
|
1784
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1785
|
+
const dir = join8(store.link, "review");
|
|
1786
|
+
await mkdir3(dir, { recursive: true });
|
|
1787
|
+
return dir;
|
|
1788
|
+
}
|
|
1789
|
+
async function sessionsDir(cwd, homeDir = homedir4()) {
|
|
1790
|
+
const store = await ensureStore(cwd, homeDir);
|
|
1791
|
+
const dir = join8(store.link, "sessions");
|
|
1792
|
+
await mkdir3(dir, { recursive: true });
|
|
1793
|
+
return dir;
|
|
1794
|
+
}
|
|
1795
|
+
async function assertStoreLink(path, dest) {
|
|
1796
|
+
const entry = await lstat(path);
|
|
1797
|
+
if (!entry.isSymbolicLink())
|
|
1798
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
1799
|
+
const target = await symlinkTarget(path);
|
|
1800
|
+
if (target !== await canonicalPath3(dest))
|
|
1801
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
1802
|
+
}
|
|
1803
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
1804
|
+
try {
|
|
1805
|
+
const entry = await lstat(path);
|
|
1806
|
+
if (!entry.isSymbolicLink())
|
|
1807
|
+
return;
|
|
1808
|
+
const target = await symlinkTarget(path);
|
|
1809
|
+
if (target === await canonicalPath3(dest))
|
|
1810
|
+
await unlink(path);
|
|
1811
|
+
} catch (error) {
|
|
1812
|
+
if (error.code !== "ENOENT")
|
|
1813
|
+
throw error;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
async function symlinkTarget(path) {
|
|
1817
|
+
const target = await readlink(path);
|
|
1818
|
+
return canonicalPath3(isAbsolute2(target) ? target : resolve3(dirname4(path), target));
|
|
1819
|
+
}
|
|
1820
|
+
async function canonicalPath3(path) {
|
|
1821
|
+
try {
|
|
1822
|
+
return await realpath3(path);
|
|
1823
|
+
} catch {
|
|
1824
|
+
return resolve3(path);
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
function projectSlug(repository) {
|
|
1828
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
1829
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
1830
|
+
return `${readable}-${digest}`;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
// src/review/review-state.ts
|
|
1834
|
+
var reviewPublicationStateSchema = z4.object({
|
|
1835
|
+
target: z4.string().optional(),
|
|
1836
|
+
bodies: z4.array(z4.string()).default([]),
|
|
1837
|
+
comments: z4.array(z4.string()).default([]),
|
|
1838
|
+
replies: z4.array(z4.string()).default([]),
|
|
1839
|
+
overlayPath: z4.string().optional()
|
|
1840
|
+
});
|
|
1841
|
+
function reviewBodyFingerprint(body) {
|
|
1842
|
+
return digest(body);
|
|
1843
|
+
}
|
|
1844
|
+
function reviewCommentFingerprint(comment) {
|
|
1845
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
1846
|
+
}
|
|
1847
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
1848
|
+
return digest(`${threadId}\x00${body}`);
|
|
1849
|
+
}
|
|
1850
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
1851
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
1852
|
+
}
|
|
1853
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
1854
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
1855
|
+
const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
1856
|
+
try {
|
|
1857
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile7(path, "utf8")));
|
|
1858
|
+
return { path, state: { ...parsed, target } };
|
|
1859
|
+
} catch (error) {
|
|
1860
|
+
if (error.code === "ENOENT") {
|
|
1861
|
+
return { path, state: { target, bodies: [], comments: [], replies: [] } };
|
|
1862
|
+
}
|
|
1863
|
+
if (error instanceof SyntaxError || error instanceof z4.ZodError) {
|
|
1864
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
1865
|
+
}
|
|
1866
|
+
throw error;
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
async function saveReviewPublicationState(path, state) {
|
|
1870
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
1871
|
+
await writeFile4(temp, `${JSON.stringify(state, null, 2)}
|
|
1872
|
+
`, "utf8");
|
|
1873
|
+
await rename(temp, path);
|
|
1874
|
+
}
|
|
1875
|
+
function digest(value) {
|
|
1876
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
1877
|
+
}
|
|
1878
|
+
// src/mcp.ts
|
|
1879
|
+
import { mkdir as mkdir4, readFile as readFile8, writeFile as writeFile5 } from "node:fs/promises";
|
|
1880
|
+
import { homedir as homedir5 } from "node:os";
|
|
1881
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
1882
|
+
var mcp = {
|
|
1883
|
+
globalConfigPath(homeDir = homedir5()) {
|
|
1884
|
+
return join10(homeDir, ".config", "mcp", "mcp.json");
|
|
1885
|
+
},
|
|
1886
|
+
async serversEnsure(servers, options = {}) {
|
|
1887
|
+
const path = options.path ?? mcp.globalConfigPath();
|
|
1888
|
+
const currentText = await getOptionalFile2(path);
|
|
1889
|
+
const current = getParsedConfig(currentText, path);
|
|
1890
|
+
const nextServers = { ...current.mcpServers };
|
|
1891
|
+
for (const [name, entry] of Object.entries(servers)) {
|
|
1892
|
+
nextServers[name] = mergeEntry(nextServers[name], entry);
|
|
1893
|
+
}
|
|
1894
|
+
const next = { ...current, mcpServers: nextServers };
|
|
1895
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
1896
|
+
if (changed && !options.dryRun) {
|
|
1897
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
1898
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
1899
|
+
`, "utf8");
|
|
1900
|
+
}
|
|
1901
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
function mergeEntry(current, required) {
|
|
1905
|
+
const merged = { ...current, ...required };
|
|
1906
|
+
if (current?.env || required.env)
|
|
1907
|
+
merged.env = { ...current?.env, ...required.env };
|
|
1908
|
+
return merged;
|
|
1909
|
+
}
|
|
1910
|
+
function getParsedConfig(content, path) {
|
|
1911
|
+
if (!content?.trim())
|
|
1912
|
+
return { mcpServers: {} };
|
|
1913
|
+
try {
|
|
1914
|
+
const value = JSON.parse(content);
|
|
1915
|
+
if (!isRecord(value))
|
|
1916
|
+
throw new Error("not an object");
|
|
1917
|
+
const servers = value.mcpServers;
|
|
1918
|
+
if (servers !== undefined && !isRecord(servers))
|
|
1919
|
+
throw new Error("mcpServers is not an object");
|
|
1920
|
+
return { ...value, mcpServers: servers ?? {} };
|
|
1921
|
+
} catch {
|
|
1922
|
+
throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
async function getOptionalFile2(path) {
|
|
1926
|
+
try {
|
|
1927
|
+
return await readFile8(path, "utf8");
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
1930
|
+
return;
|
|
1931
|
+
throw error;
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
function isRecord(value) {
|
|
1935
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
1936
|
+
}
|
|
405
1937
|
// src/modes.ts
|
|
1938
|
+
import {
|
|
1939
|
+
getAgentDir,
|
|
1940
|
+
parseFrontmatter as parseFrontmatter2
|
|
1941
|
+
} from "@earendil-works/pi-coding-agent";
|
|
1942
|
+
import { readFile as readFile9 } from "node:fs/promises";
|
|
1943
|
+
import { homedir as homedir6 } from "node:os";
|
|
1944
|
+
import { basename as basename4, extname, join as join11 } from "node:path";
|
|
406
1945
|
async function discoverAgentModes(options) {
|
|
407
1946
|
const agentDir = options.agentDir ?? getAgentDir();
|
|
408
|
-
const homeDir = options.homeDir ??
|
|
1947
|
+
const homeDir = options.homeDir ?? homedir6();
|
|
409
1948
|
const modes = new Map;
|
|
410
1949
|
const diagnostics = [];
|
|
411
1950
|
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
412
|
-
await loadAgentModes(
|
|
1951
|
+
await loadAgentModes(join11(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
413
1952
|
if (options.includeSkills) {
|
|
414
|
-
await loadSkillModes(
|
|
415
|
-
await loadSkillModes(
|
|
1953
|
+
await loadSkillModes(join11(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
1954
|
+
await loadSkillModes(join11(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
416
1955
|
}
|
|
417
1956
|
if (options.projectTrusted === true) {
|
|
418
1957
|
if (options.includeSkills) {
|
|
419
|
-
await loadSkillModes(
|
|
420
|
-
await loadSkillModes(
|
|
1958
|
+
await loadSkillModes(join11(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
1959
|
+
await loadSkillModes(join11(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
421
1960
|
}
|
|
422
|
-
await loadAgentModes(
|
|
423
|
-
await loadAgentModes(
|
|
1961
|
+
await loadAgentModes(join11(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
1962
|
+
await loadAgentModes(join11(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
424
1963
|
}
|
|
425
1964
|
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
426
1965
|
const configuredModes = [...modes.values()].map((mode) => ({
|
|
@@ -457,7 +1996,11 @@ function createModeController(pi, options = {}) {
|
|
|
457
1996
|
let active;
|
|
458
1997
|
let baseline;
|
|
459
1998
|
const updateStatus = (ctx) => {
|
|
460
|
-
ctx.
|
|
1999
|
+
const model = ctx.model ? ` · ${ctx.model.provider}/${ctx.model.id}` : "";
|
|
2000
|
+
const label = active?.label.toUpperCase() ?? "DEFAULT";
|
|
2001
|
+
const badge = `AGENT ${label}${model}`;
|
|
2002
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, undefined);
|
|
2003
|
+
ctx.ui.setWidget?.(MODE_WIDGET_KEY, [badge], { placement: "belowEditor" });
|
|
461
2004
|
};
|
|
462
2005
|
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
463
2006
|
cwd: ctx.cwd,
|
|
@@ -512,6 +2055,9 @@ function createModeController(pi, options = {}) {
|
|
|
512
2055
|
}
|
|
513
2056
|
updateStatus(ctx);
|
|
514
2057
|
},
|
|
2058
|
+
refresh(ctx) {
|
|
2059
|
+
updateStatus(ctx);
|
|
2060
|
+
},
|
|
515
2061
|
apply(systemPrompt) {
|
|
516
2062
|
if (!active)
|
|
517
2063
|
return systemPrompt;
|
|
@@ -530,6 +2076,7 @@ ${active.systemPrompt}`;
|
|
|
530
2076
|
}
|
|
531
2077
|
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
532
2078
|
var MODE_STATUS_KEY = "diffpi-mode";
|
|
2079
|
+
var MODE_WIDGET_KEY = "diffpi-mode-badge";
|
|
533
2080
|
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
534
2081
|
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
535
2082
|
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
@@ -583,19 +2130,19 @@ function captureRuntime(pi, ctx) {
|
|
|
583
2130
|
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
584
2131
|
const entries = await readDirectoryIfExists(skillsDir);
|
|
585
2132
|
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
586
|
-
await loadAgentModes(
|
|
2133
|
+
await loadAgentModes(join11(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
587
2134
|
}
|
|
588
2135
|
}
|
|
589
2136
|
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
590
2137
|
const entries = await readDirectoryIfExists(directory);
|
|
591
2138
|
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
592
|
-
const path =
|
|
2139
|
+
const path = join11(directory, entry.name);
|
|
593
2140
|
try {
|
|
594
|
-
const content = await
|
|
2141
|
+
const content = await readFile9(path, "utf8");
|
|
595
2142
|
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
596
2143
|
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
597
2144
|
continue;
|
|
598
|
-
const name = getFrontmatterText(frontmatter.name) ??
|
|
2145
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename4(path, extname(path));
|
|
599
2146
|
const systemPrompt = body.trim();
|
|
600
2147
|
if (!name || name.includes(":") || !systemPrompt) {
|
|
601
2148
|
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
@@ -605,7 +2152,7 @@ async function loadAgentModes(directory, source, modes, diagnostics, skillName)
|
|
|
605
2152
|
modes.set(id, {
|
|
606
2153
|
id,
|
|
607
2154
|
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
608
|
-
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${
|
|
2155
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename4(path)}`,
|
|
609
2156
|
systemPrompt,
|
|
610
2157
|
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
611
2158
|
modelPreferences: [
|
|
@@ -647,9 +2194,9 @@ function isModeBaseline(value) {
|
|
|
647
2194
|
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
2195
|
}
|
|
649
2196
|
// src/pi.ts
|
|
650
|
-
import { mkdir as
|
|
651
|
-
import { homedir as
|
|
652
|
-
import { dirname as
|
|
2197
|
+
import { mkdir as mkdir5, writeFile as writeFile6 } from "node:fs/promises";
|
|
2198
|
+
import { homedir as homedir7 } from "node:os";
|
|
2199
|
+
import { dirname as dirname6, join as join12 } from "node:path";
|
|
653
2200
|
var pi = {
|
|
654
2201
|
executableCheck: findPiExecutable,
|
|
655
2202
|
packageList: listPiPackages,
|
|
@@ -662,19 +2209,19 @@ var pi = {
|
|
|
662
2209
|
configEnsure: ensurePiConfig
|
|
663
2210
|
};
|
|
664
2211
|
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
665
|
-
const path =
|
|
2212
|
+
const path = join12(agentDir, "agents", filename);
|
|
666
2213
|
const currentText = await readTextIfExists(path);
|
|
667
2214
|
const changed = currentText !== content;
|
|
668
2215
|
if (changed && !dryRun) {
|
|
669
|
-
await
|
|
670
|
-
await
|
|
2216
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2217
|
+
await writeFile6(path, content, "utf8");
|
|
671
2218
|
}
|
|
672
2219
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
673
2220
|
}
|
|
674
|
-
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir =
|
|
675
|
-
const roots = [
|
|
2221
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join12(homedir7(), ".agents", "skills")) {
|
|
2222
|
+
const roots = [join12(agentDir, "skills"), sharedSkillsDir];
|
|
676
2223
|
for (const root of roots) {
|
|
677
|
-
if (await readTextIfExists(
|
|
2224
|
+
if (await readTextIfExists(join12(root, name, "SKILL.md")) !== undefined)
|
|
678
2225
|
return true;
|
|
679
2226
|
}
|
|
680
2227
|
return false;
|
|
@@ -703,8 +2250,8 @@ async function ensurePiConfig(path, update, dryRun = false) {
|
|
|
703
2250
|
const next = update(current);
|
|
704
2251
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
705
2252
|
if (changed && !dryRun) {
|
|
706
|
-
await
|
|
707
|
-
await
|
|
2253
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2254
|
+
await writeFile6(path, `${JSON.stringify(next, null, 2)}
|
|
708
2255
|
`, "utf8");
|
|
709
2256
|
}
|
|
710
2257
|
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
@@ -723,8 +2270,8 @@ function hasPiPackage(listOutput, source) {
|
|
|
723
2270
|
async function installPiPackage(executable, source) {
|
|
724
2271
|
await runChecked(executable, ["install", source]);
|
|
725
2272
|
}
|
|
726
|
-
function resolvePiAgentDir(homeDir =
|
|
727
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ?
|
|
2273
|
+
function resolvePiAgentDir(homeDir = homedir7()) {
|
|
2274
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join12(process.env.XDG_CONFIG_HOME, "pi") : join12(homeDir, ".pi", "agent"));
|
|
728
2275
|
}
|
|
729
2276
|
function parseJsonObject(content, path) {
|
|
730
2277
|
if (!content?.trim())
|
|
@@ -736,11 +2283,975 @@ function parseJsonObject(content, path) {
|
|
|
736
2283
|
} catch {}
|
|
737
2284
|
throw new Error(`Expected valid JSON object in ${path}.`);
|
|
738
2285
|
}
|
|
2286
|
+
// src/plan/annotations.ts
|
|
2287
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2288
|
+
import { readFile as readFile12, rename as rename2, writeFile as writeFile8 } from "node:fs/promises";
|
|
2289
|
+
import { basename as basename5, dirname as dirname7, join as join14 } from "node:path";
|
|
2290
|
+
|
|
2291
|
+
// src/plan/log.ts
|
|
2292
|
+
import { randomUUID } from "node:crypto";
|
|
2293
|
+
import { appendFile, readFile as readFile10 } from "node:fs/promises";
|
|
2294
|
+
async function appendPlanLog(logPath, event) {
|
|
2295
|
+
const entry = {
|
|
2296
|
+
...event,
|
|
2297
|
+
version: 1,
|
|
2298
|
+
eventId: event.eventId ?? randomUUID(),
|
|
2299
|
+
timestamp: event.timestamp ?? new Date().toISOString()
|
|
2300
|
+
};
|
|
2301
|
+
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
2302
|
+
`, { encoding: "utf8", mode: 384 });
|
|
2303
|
+
return entry;
|
|
2304
|
+
}
|
|
2305
|
+
async function readPlanLog(logPath) {
|
|
2306
|
+
let source;
|
|
2307
|
+
try {
|
|
2308
|
+
source = await readFile10(logPath, "utf8");
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
if (error.code === "ENOENT")
|
|
2311
|
+
return [];
|
|
2312
|
+
throw error;
|
|
2313
|
+
}
|
|
2314
|
+
return source.split(`
|
|
2315
|
+
`).filter(Boolean).map((line, index) => {
|
|
2316
|
+
try {
|
|
2317
|
+
return JSON.parse(line);
|
|
2318
|
+
} catch {
|
|
2319
|
+
throw new Error(`Malformed plan log entry at line ${index + 1}.`);
|
|
2320
|
+
}
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// src/plan/lock.ts
|
|
2325
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2326
|
+
import { mkdir as mkdir6, readFile as readFile11, rm, writeFile as writeFile7 } from "node:fs/promises";
|
|
2327
|
+
import { hostname } from "node:os";
|
|
2328
|
+
import { join as join13 } from "node:path";
|
|
2329
|
+
async function withPlanLock(planDir, operation, options = {}) {
|
|
2330
|
+
const lockDir = join13(planDir, ".lock");
|
|
2331
|
+
const waitMs = options.waitMs ?? 5000;
|
|
2332
|
+
const pollMs = options.pollMs ?? 25;
|
|
2333
|
+
const owner = {
|
|
2334
|
+
token: randomUUID2(),
|
|
2335
|
+
pid: process.pid,
|
|
2336
|
+
hostname: hostname(),
|
|
2337
|
+
operation: options.operation ?? "plan mutation",
|
|
2338
|
+
acquiredAt: new Date().toISOString()
|
|
2339
|
+
};
|
|
2340
|
+
const started = Date.now();
|
|
2341
|
+
while (true) {
|
|
2342
|
+
try {
|
|
2343
|
+
await mkdir6(lockDir);
|
|
2344
|
+
await writeFile7(join13(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
2345
|
+
`, { mode: 384 });
|
|
2346
|
+
break;
|
|
2347
|
+
} catch (error) {
|
|
2348
|
+
if (error.code !== "EEXIST")
|
|
2349
|
+
throw error;
|
|
2350
|
+
if (Date.now() - started >= waitMs) {
|
|
2351
|
+
let existing = "unknown owner";
|
|
2352
|
+
try {
|
|
2353
|
+
existing = (await readFile11(join13(lockDir, "owner.json"), "utf8")).trim();
|
|
2354
|
+
} catch {}
|
|
2355
|
+
throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
|
|
2356
|
+
}
|
|
2357
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
try {
|
|
2361
|
+
return await operation();
|
|
2362
|
+
} finally {
|
|
2363
|
+
let current;
|
|
2364
|
+
try {
|
|
2365
|
+
current = JSON.parse(await readFile11(join13(lockDir, "owner.json"), "utf8"));
|
|
2366
|
+
} catch {}
|
|
2367
|
+
if (current?.token === owner.token)
|
|
2368
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
2369
|
+
}
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// src/plan/annotations.ts
|
|
2373
|
+
async function annotatePlan(record, runtime = {}) {
|
|
2374
|
+
const execute = runtime.execute ?? executeProcess;
|
|
2375
|
+
const result = await execute("tuicr", ["--file", record.planPath], dirname7(record.planPath), true);
|
|
2376
|
+
const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
|
|
2377
|
+
if (result.code !== 0)
|
|
2378
|
+
throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
|
|
2379
|
+
if (!sessionSlug)
|
|
2380
|
+
throw new Error("tuicr did not report a tuicr-session marker.");
|
|
2381
|
+
const state = await withPlanLock(record.dir, async () => {
|
|
2382
|
+
const previous = await readAnnotationState(record).catch(() => {
|
|
2383
|
+
return;
|
|
2384
|
+
});
|
|
2385
|
+
const next = {
|
|
2386
|
+
schemaVersion: 1,
|
|
2387
|
+
sessionSlug,
|
|
2388
|
+
updatedAt: new Date().toISOString(),
|
|
2389
|
+
appliedCommentIds: previous?.appliedCommentIds ?? []
|
|
2390
|
+
};
|
|
2391
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2392
|
+
return next;
|
|
2393
|
+
}, { operation: "save annotation session" });
|
|
2394
|
+
return { sessionSlug, code: result.code, state };
|
|
2395
|
+
}
|
|
2396
|
+
async function readPlanAnnotations(record, options = {}) {
|
|
2397
|
+
const state = await readAnnotationState(record).catch((error) => {
|
|
2398
|
+
if (error.code === "ENOENT")
|
|
2399
|
+
return;
|
|
2400
|
+
throw error;
|
|
2401
|
+
});
|
|
2402
|
+
if (!state)
|
|
2403
|
+
return { comments: [], pending: [] };
|
|
2404
|
+
const execute = options.runtime?.execute ?? executeProcess;
|
|
2405
|
+
const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
|
|
2406
|
+
if (response.code !== 0) {
|
|
2407
|
+
if (/not found|no session|deleted/i.test(response.stderr))
|
|
2408
|
+
return { state, comments: [], pending: [] };
|
|
2409
|
+
throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
|
|
2410
|
+
}
|
|
2411
|
+
let raw;
|
|
2412
|
+
try {
|
|
2413
|
+
raw = JSON.parse(response.stdout);
|
|
2414
|
+
} catch {
|
|
2415
|
+
throw new Error("tuicr returned malformed comment JSON.");
|
|
2416
|
+
}
|
|
2417
|
+
if (!Array.isArray(raw))
|
|
2418
|
+
throw new Error("tuicr comment output must be a JSON array.");
|
|
2419
|
+
const lines = record.source.split(`
|
|
2420
|
+
`);
|
|
2421
|
+
const applied = new Set(state.appliedCommentIds);
|
|
2422
|
+
const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
|
|
2423
|
+
const pending = comments.filter((comment) => !comment.applied);
|
|
2424
|
+
return { state, comments: options.includeApplied ? comments : pending, pending };
|
|
2425
|
+
}
|
|
2426
|
+
async function acknowledgePlanAnnotations(record, commentIds, summary) {
|
|
2427
|
+
if (!summary.trim())
|
|
2428
|
+
throw new Error("Annotation acknowledgement summary is required.");
|
|
2429
|
+
return withPlanLock(record.dir, async () => {
|
|
2430
|
+
const state = await readAnnotationState(record);
|
|
2431
|
+
const next = {
|
|
2432
|
+
...state,
|
|
2433
|
+
updatedAt: new Date().toISOString(),
|
|
2434
|
+
appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
|
|
2435
|
+
};
|
|
2436
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2437
|
+
await appendPlanLog(record.logPath, {
|
|
2438
|
+
planRevision: record.document.revision,
|
|
2439
|
+
kind: "annotation",
|
|
2440
|
+
actor: "planner",
|
|
2441
|
+
message: summary,
|
|
2442
|
+
data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
|
|
2443
|
+
});
|
|
2444
|
+
return next;
|
|
2445
|
+
}, { operation: "acknowledge annotations" });
|
|
2446
|
+
}
|
|
2447
|
+
function annotationStatePath(record) {
|
|
2448
|
+
return join14(record.dir, "annotations.json");
|
|
2449
|
+
}
|
|
2450
|
+
async function readAnnotationState(record) {
|
|
2451
|
+
const value = JSON.parse(await readFile12(annotationStatePath(record), "utf8"));
|
|
2452
|
+
if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
|
|
2453
|
+
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
2454
|
+
}
|
|
2455
|
+
return value;
|
|
2456
|
+
}
|
|
2457
|
+
function normalizeComment(value, index, lines, applied) {
|
|
2458
|
+
if (!value || typeof value !== "object")
|
|
2459
|
+
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
2460
|
+
const raw = value;
|
|
2461
|
+
if (typeof raw.id !== "string" || typeof raw.content !== "string")
|
|
2462
|
+
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
2463
|
+
const line = integer(raw.start_line) ?? integer(raw.line);
|
|
2464
|
+
const endLine = integer(raw.end_line) ?? line;
|
|
2465
|
+
const targetPath = typeof raw.path === "string" ? raw.path : undefined;
|
|
2466
|
+
const appliesToPlan = !targetPath || basename5(targetPath) === basename5("PLAN.md");
|
|
2467
|
+
const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
|
|
2468
|
+
return {
|
|
2469
|
+
id: raw.id,
|
|
2470
|
+
body: raw.content,
|
|
2471
|
+
file: targetPath,
|
|
2472
|
+
line,
|
|
2473
|
+
endLine,
|
|
2474
|
+
context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
|
|
2475
|
+
`) : undefined,
|
|
2476
|
+
stale: line !== undefined && !validAnchor,
|
|
2477
|
+
applied: applied.has(raw.id)
|
|
2478
|
+
};
|
|
2479
|
+
}
|
|
2480
|
+
async function executeProcess(command, args, cwd, interactive) {
|
|
2481
|
+
if (!interactive) {
|
|
2482
|
+
const result = await run(command, args, { cwd, capture: "unbounded" });
|
|
2483
|
+
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
2484
|
+
}
|
|
2485
|
+
return new Promise((resolve, reject) => {
|
|
2486
|
+
const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
|
|
2487
|
+
let stderr = "";
|
|
2488
|
+
child.stderr.on("data", (chunk) => {
|
|
2489
|
+
const text = chunk.toString();
|
|
2490
|
+
stderr += text;
|
|
2491
|
+
process.stderr.write(text);
|
|
2492
|
+
});
|
|
2493
|
+
child.on("error", reject);
|
|
2494
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
|
|
2495
|
+
});
|
|
2496
|
+
}
|
|
2497
|
+
async function atomicJson(path, value) {
|
|
2498
|
+
const temp = join14(dirname7(path), `.${basename5(path)}.${crypto.randomUUID()}.tmp`);
|
|
2499
|
+
await writeFile8(temp, `${JSON.stringify(value, null, 2)}
|
|
2500
|
+
`, { mode: 384, flag: "wx" });
|
|
2501
|
+
await rename2(temp, path);
|
|
2502
|
+
}
|
|
2503
|
+
function integer(value) {
|
|
2504
|
+
return typeof value === "number" && Number.isInteger(value) ? value : undefined;
|
|
2505
|
+
}
|
|
2506
|
+
// src/plan/escalation.ts
|
|
2507
|
+
import { z as z5 } from "zod";
|
|
2508
|
+
var escalationSchema = z5.object({
|
|
2509
|
+
planId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
2510
|
+
executionId: z5.string().min(1),
|
|
2511
|
+
phaseId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
2512
|
+
taskId: z5.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/).optional(),
|
|
2513
|
+
blocker: z5.string().min(1),
|
|
2514
|
+
attempts: z5.array(z5.string()),
|
|
2515
|
+
evidence: z5.array(z5.string()),
|
|
2516
|
+
needsUserDecision: z5.boolean()
|
|
2517
|
+
}).strict();
|
|
2518
|
+
var OPEN = "<diffpi-planner-escalation>";
|
|
2519
|
+
var CLOSE = "</diffpi-planner-escalation>";
|
|
2520
|
+
function renderPlannerEscalation(value) {
|
|
2521
|
+
return `${OPEN}${JSON.stringify(escalationSchema.parse(value))}${CLOSE}`;
|
|
2522
|
+
}
|
|
2523
|
+
function parsePlannerEscalation(output) {
|
|
2524
|
+
const start = output.indexOf(OPEN);
|
|
2525
|
+
const end = output.indexOf(CLOSE, start + OPEN.length);
|
|
2526
|
+
if (start < 0 || end < 0 || output.indexOf(OPEN, start + OPEN.length) >= 0)
|
|
2527
|
+
throw new Error("Missing or ambiguous planner escalation payload.");
|
|
2528
|
+
const payload = output.slice(start + OPEN.length, end);
|
|
2529
|
+
try {
|
|
2530
|
+
return escalationSchema.parse(JSON.parse(payload));
|
|
2531
|
+
} catch (error) {
|
|
2532
|
+
throw new Error(`Malformed planner escalation payload: ${error instanceof Error ? error.message : String(error)}`);
|
|
2533
|
+
}
|
|
2534
|
+
}
|
|
2535
|
+
// src/plan/execution.ts
|
|
2536
|
+
function createExecutionPacket(plan, coordinator) {
|
|
2537
|
+
if (!plan.execution?.active)
|
|
2538
|
+
throw new Error(`Plan ${plan.id} has no active execution.`);
|
|
2539
|
+
return {
|
|
2540
|
+
version: 1,
|
|
2541
|
+
planId: plan.id,
|
|
2542
|
+
executionId: plan.execution.id,
|
|
2543
|
+
policy: plan.execution.policy,
|
|
2544
|
+
cwd: plan.execution.cwd,
|
|
2545
|
+
branch: plan.execution.branch,
|
|
2546
|
+
coordinator
|
|
2547
|
+
};
|
|
2548
|
+
}
|
|
2549
|
+
function renderExecutionPrompt(packet) {
|
|
2550
|
+
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.`;
|
|
2551
|
+
}
|
|
2552
|
+
function eligiblePlanTasks(plan) {
|
|
2553
|
+
const completedPhases = new Set(plan.phases.filter((phase) => phase.status === "completed" || phase.status === "skipped").map((phase) => phase.id));
|
|
2554
|
+
const completedTasks = new Set(plan.phases.flatMap((phase) => phase.tasks).filter((task) => task.status === "completed" || task.status === "skipped").map((task) => task.id));
|
|
2555
|
+
for (const phase of plan.phases) {
|
|
2556
|
+
if (phase.status === "completed" || phase.status === "skipped")
|
|
2557
|
+
continue;
|
|
2558
|
+
if (!phase.dependencies.every((dependency) => completedPhases.has(dependency)))
|
|
2559
|
+
continue;
|
|
2560
|
+
return phase.tasks.filter((task) => task.status === "pending" && task.dependencies.every((dependency) => completedTasks.has(dependency))).map((task) => ({ phase, task }));
|
|
2561
|
+
}
|
|
2562
|
+
return [];
|
|
2563
|
+
}
|
|
2564
|
+
function tasksMayRunInParallel(left, right) {
|
|
2565
|
+
if (!left.fileScopes.length || !right.fileScopes.length)
|
|
2566
|
+
return false;
|
|
2567
|
+
if (left.dependencies.includes(right.id) || right.dependencies.includes(left.id))
|
|
2568
|
+
return false;
|
|
2569
|
+
return left.fileScopes.every((scope) => right.fileScopes.every((other) => !scopesOverlap(scope, other)));
|
|
2570
|
+
}
|
|
2571
|
+
function phaseCommitCommand(policy) {
|
|
2572
|
+
return policy === "commit-per-phase" ? "/git commit --yes --no-push" : undefined;
|
|
2573
|
+
}
|
|
2574
|
+
function scopesOverlap(left, right) {
|
|
2575
|
+
const normalize = (value) => value.replace(/^\.\//, "").replace(/\*.*$/, "").replace(/\/$/, "");
|
|
2576
|
+
const a = normalize(left);
|
|
2577
|
+
const b = normalize(right);
|
|
2578
|
+
return !a || !b || a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
2579
|
+
}
|
|
2580
|
+
// src/plan/transitions.ts
|
|
2581
|
+
var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
2582
|
+
var PLAN_TRANSITIONS = {
|
|
2583
|
+
draft: ["ready"],
|
|
2584
|
+
ready: ["draft", "in_progress"],
|
|
2585
|
+
in_progress: ["blocked", "completed"],
|
|
2586
|
+
blocked: ["draft", "ready", "in_progress"],
|
|
2587
|
+
completed: []
|
|
2588
|
+
};
|
|
2589
|
+
var WORK_TRANSITIONS = {
|
|
2590
|
+
pending: ["in_progress", "skipped"],
|
|
2591
|
+
in_progress: ["blocked", "completed"],
|
|
2592
|
+
blocked: ["pending", "in_progress", "skipped"],
|
|
2593
|
+
completed: [],
|
|
2594
|
+
skipped: []
|
|
2595
|
+
};
|
|
2596
|
+
function isStableId(value) {
|
|
2597
|
+
return STABLE_ID.test(value) && value.length <= 80;
|
|
2598
|
+
}
|
|
2599
|
+
function assertStableId(value, label = "identifier") {
|
|
2600
|
+
if (!isStableId(value))
|
|
2601
|
+
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
2602
|
+
}
|
|
2603
|
+
function assertPlanTransition(from, to) {
|
|
2604
|
+
if (from === to)
|
|
2605
|
+
return;
|
|
2606
|
+
if (!PLAN_TRANSITIONS[from].includes(to))
|
|
2607
|
+
throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
|
|
2608
|
+
}
|
|
2609
|
+
function assertPhaseTransition(from, to) {
|
|
2610
|
+
if (from === to)
|
|
2611
|
+
return;
|
|
2612
|
+
if (!WORK_TRANSITIONS[from].includes(to))
|
|
2613
|
+
throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
|
|
2614
|
+
}
|
|
2615
|
+
function assertTaskTransition(task, to, executionId, actor) {
|
|
2616
|
+
if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to)) {
|
|
2617
|
+
throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
|
|
2618
|
+
}
|
|
2619
|
+
if (task.status === "in_progress" && task.executionId && executionId !== task.executionId) {
|
|
2620
|
+
throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
|
|
2621
|
+
}
|
|
2622
|
+
if (task.status === "in_progress" && task.owner && actor && actor !== task.owner) {
|
|
2623
|
+
throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
function assertUniqueIds(ids, label) {
|
|
2627
|
+
const seen = new Set;
|
|
2628
|
+
for (const id of ids) {
|
|
2629
|
+
assertStableId(id, label);
|
|
2630
|
+
if (seen.has(id))
|
|
2631
|
+
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
2632
|
+
seen.add(id);
|
|
2633
|
+
}
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// src/plan/markdown.ts
|
|
2637
|
+
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
2638
|
+
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
2639
|
+
var TASK_MARKER = /<!-- diffpi-task: (\{[^\n]+\}) -->/g;
|
|
2640
|
+
var SUPPORTED_SCHEMA = 1;
|
|
2641
|
+
function countDesignWords(plan) {
|
|
2642
|
+
return [plan.design.bigIdeas, plan.design.keyApiUpdates, plan.design.consequences].join(" ").trim().split(/\s+/).filter(Boolean).length;
|
|
2643
|
+
}
|
|
2644
|
+
function validatePlanDocument(plan, options = {}) {
|
|
2645
|
+
const issues = [];
|
|
2646
|
+
const add = (code, severity, message, path) => issues.push({ code, severity, message, path });
|
|
2647
|
+
try {
|
|
2648
|
+
assertStableId(plan.id, "plan id");
|
|
2649
|
+
assertUniqueIds(plan.phases.map((phase) => phase.id), "phase id");
|
|
2650
|
+
assertUniqueIds(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)), "task id");
|
|
2651
|
+
} catch (error) {
|
|
2652
|
+
add("invalid-id", "error", error.message);
|
|
2653
|
+
}
|
|
2654
|
+
if (plan.schemaVersion !== SUPPORTED_SCHEMA)
|
|
2655
|
+
add("schema", "error", `Unsupported plan schema: ${plan.schemaVersion}.`);
|
|
2656
|
+
const phaseIds = new Set(plan.phases.map((phase) => phase.id));
|
|
2657
|
+
const taskIds = new Set(plan.phases.flatMap((phase) => phase.tasks.map((task) => task.id)));
|
|
2658
|
+
for (const phase of plan.phases) {
|
|
2659
|
+
for (const dependency of phase.dependencies) {
|
|
2660
|
+
if (!phaseIds.has(dependency))
|
|
2661
|
+
add("dangling-dependency", "error", `Phase ${phase.id} depends on missing ${dependency}.`);
|
|
2662
|
+
}
|
|
2663
|
+
for (const task of phase.tasks) {
|
|
2664
|
+
for (const dependency of task.dependencies) {
|
|
2665
|
+
if (!taskIds.has(dependency))
|
|
2666
|
+
add("dangling-dependency", "error", `Task ${task.id} depends on missing ${dependency}.`);
|
|
2667
|
+
}
|
|
2668
|
+
if (options.strict && task.acceptanceCriteria.length === 0)
|
|
2669
|
+
add("acceptance", "error", `Task ${task.id} has no acceptance criteria.`, task.id);
|
|
2670
|
+
}
|
|
2671
|
+
if (options.strict && phase.tasks.length === 0)
|
|
2672
|
+
add("empty-phase", "error", `Phase ${phase.id} has no tasks.`, phase.id);
|
|
2673
|
+
}
|
|
2674
|
+
for (const cycle of dependencyCycles(plan))
|
|
2675
|
+
add("dependency-cycle", "error", `Dependency cycle: ${cycle.join(" -> ")}.`);
|
|
2676
|
+
const designWords = countDesignWords(plan);
|
|
2677
|
+
if (designWords > 300)
|
|
2678
|
+
add("design-length", "warning", `Design is ${designWords} words; prefer 300 or fewer.`);
|
|
2679
|
+
if (options.strict && designWords > 800)
|
|
2680
|
+
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
2681
|
+
if (options.strict && plan.phases.length === 0)
|
|
2682
|
+
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
2683
|
+
if (options.strict && (options.pendingAnnotations ?? 0) > 0)
|
|
2684
|
+
add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
|
|
2685
|
+
return issues;
|
|
2686
|
+
}
|
|
2687
|
+
function renderPlanDocument(plan, previousSource) {
|
|
2688
|
+
if (previousSource && canPatch(previousSource, plan))
|
|
2689
|
+
return patchMarkers(previousSource, plan);
|
|
2690
|
+
const marker = {
|
|
2691
|
+
schemaVersion: plan.schemaVersion,
|
|
2692
|
+
id: plan.id,
|
|
2693
|
+
revision: plan.revision,
|
|
2694
|
+
branch: plan.branch,
|
|
2695
|
+
status: plan.status,
|
|
2696
|
+
execution: plan.execution,
|
|
2697
|
+
createdAt: plan.createdAt,
|
|
2698
|
+
updatedAt: plan.updatedAt
|
|
2699
|
+
};
|
|
2700
|
+
const requirements = plan.requirements.length ? plan.requirements.map((item) => `- ${item}`).join(`
|
|
2701
|
+
`) : "<!-- Add requirements. -->";
|
|
2702
|
+
const phases = plan.phases.map(renderPhase).join(`
|
|
2703
|
+
|
|
2704
|
+
`);
|
|
2705
|
+
const references = plan.references.length ? plan.references.map((reference) => `- <!-- diffpi-reference: ${json(reference)} --> ${reference.value}`).join(`
|
|
2706
|
+
`) : "<!-- Add references. -->";
|
|
2707
|
+
return `<!-- diffpi-plan: ${json(marker)} -->
|
|
2708
|
+
# ${plan.title}
|
|
2709
|
+
|
|
2710
|
+
- **Plan ID:** ${plan.id}
|
|
2711
|
+
- **Branch:** ${plan.branch}
|
|
2712
|
+
- **Status:** ${plan.status}
|
|
2713
|
+
- **Revision:** ${plan.revision}
|
|
2714
|
+
|
|
2715
|
+
## Intent
|
|
2716
|
+
|
|
2717
|
+
${plan.intent || "<!-- Describe the intended outcome. -->"}
|
|
2718
|
+
|
|
2719
|
+
## Requirements
|
|
2720
|
+
|
|
2721
|
+
${requirements}
|
|
2722
|
+
|
|
2723
|
+
## Design
|
|
2724
|
+
|
|
2725
|
+
### Big Ideas
|
|
2726
|
+
|
|
2727
|
+
${plan.design.bigIdeas || "<!-- Describe the main approach. -->"}
|
|
2728
|
+
|
|
2729
|
+
### Key API Addition/Updates
|
|
2730
|
+
|
|
2731
|
+
${plan.design.keyApiUpdates || "<!-- Describe public API changes. -->"}
|
|
2732
|
+
|
|
2733
|
+
### Consequences
|
|
2734
|
+
|
|
2735
|
+
${plan.design.consequences || "<!-- Describe trade-offs and limitations. -->"}
|
|
2736
|
+
|
|
2737
|
+
## Implementation
|
|
2738
|
+
|
|
2739
|
+
${phases || "<!-- Add phases with plan_add_phase. -->"}
|
|
2740
|
+
|
|
2741
|
+
## References
|
|
2742
|
+
|
|
2743
|
+
${references}
|
|
2744
|
+
`;
|
|
2745
|
+
}
|
|
2746
|
+
function parsePlanDocument(source, ref = "PLAN.md") {
|
|
2747
|
+
const planMatch = source.match(PLAN_MARKER);
|
|
2748
|
+
if (!planMatch)
|
|
2749
|
+
throw new Error(`${ref}: missing or malformed diffpi-plan marker.`);
|
|
2750
|
+
if ((source.match(new RegExp(PLAN_MARKER.source, "g")) ?? []).length !== 1)
|
|
2751
|
+
throw new Error(`${ref}: duplicate diffpi-plan marker.`);
|
|
2752
|
+
const marker = parseMarker(planMatch[1], `${ref} plan`);
|
|
2753
|
+
if (marker.schemaVersion !== SUPPORTED_SCHEMA)
|
|
2754
|
+
throw new Error(`${ref}: unsupported plan schema ${String(marker.schemaVersion)}.`);
|
|
2755
|
+
assertStableId(marker.id, "plan id");
|
|
2756
|
+
const title = source.match(/^# (.+)$/m)?.[1]?.trim();
|
|
2757
|
+
if (!title)
|
|
2758
|
+
throw new Error(`${ref}: missing plan title.`);
|
|
2759
|
+
const phases = parsePhases(section(source, "Implementation"), ref);
|
|
2760
|
+
const document = {
|
|
2761
|
+
...marker,
|
|
2762
|
+
schemaVersion: 1,
|
|
2763
|
+
title,
|
|
2764
|
+
intent: cleanPlaceholder(section(source, "Intent")),
|
|
2765
|
+
requirements: parseBullets(section(source, "Requirements")),
|
|
2766
|
+
design: {
|
|
2767
|
+
bigIdeas: cleanPlaceholder(subsection(source, "Design", "Big Ideas")),
|
|
2768
|
+
keyApiUpdates: cleanPlaceholder(subsection(source, "Design", "Key API Addition/Updates")),
|
|
2769
|
+
consequences: cleanPlaceholder(subsection(source, "Design", "Consequences"))
|
|
2770
|
+
},
|
|
2771
|
+
phases,
|
|
2772
|
+
references: parseReferences(section(source, "References"))
|
|
2773
|
+
};
|
|
2774
|
+
const errors = validatePlanDocument(document).filter((issue) => issue.severity === "error");
|
|
2775
|
+
if (errors.length)
|
|
2776
|
+
throw new Error(`${ref}: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
2777
|
+
return document;
|
|
2778
|
+
}
|
|
2779
|
+
function renderPhase(phase) {
|
|
2780
|
+
const marker = {
|
|
2781
|
+
id: phase.id,
|
|
2782
|
+
revision: phase.revision,
|
|
2783
|
+
status: phase.status,
|
|
2784
|
+
gate: phase.gate,
|
|
2785
|
+
commit: phase.commit,
|
|
2786
|
+
blocker: phase.blocker
|
|
2787
|
+
};
|
|
2788
|
+
const dependencies = phase.dependencies.length ? phase.dependencies.join(", ") : "none";
|
|
2789
|
+
return `<!-- diffpi-phase: ${json(marker)} -->
|
|
2790
|
+
### Phase: ${phase.title}
|
|
2791
|
+
|
|
2792
|
+
**Objective:** ${phase.objective}
|
|
2793
|
+
|
|
2794
|
+
**Dependencies:** ${dependencies}
|
|
2795
|
+
|
|
2796
|
+
${phase.tasks.map(renderTask).join(`
|
|
2797
|
+
|
|
2798
|
+
`)}
|
|
2799
|
+
|
|
2800
|
+
<!-- /diffpi-phase -->`;
|
|
2801
|
+
}
|
|
2802
|
+
function renderTask(task) {
|
|
2803
|
+
const marker = {
|
|
2804
|
+
id: task.id,
|
|
2805
|
+
revision: task.revision,
|
|
2806
|
+
status: task.status,
|
|
2807
|
+
owner: task.owner,
|
|
2808
|
+
executionId: task.executionId,
|
|
2809
|
+
blocker: task.blocker
|
|
2810
|
+
};
|
|
2811
|
+
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
2812
|
+
return `<!-- diffpi-task: ${json(marker)} -->
|
|
2813
|
+
- [${checked}] **${task.title}**
|
|
2814
|
+
- Steps: ${list(task.steps ?? [])}
|
|
2815
|
+
- Dependencies: ${list(task.dependencies)}
|
|
2816
|
+
- File scopes: ${list(task.fileScopes)}
|
|
2817
|
+
- Acceptance criteria: ${list(task.acceptanceCriteria)}
|
|
2818
|
+
<!-- /diffpi-task -->`;
|
|
2819
|
+
}
|
|
2820
|
+
function parsePhases(input, ref) {
|
|
2821
|
+
const starts = [...input.matchAll(PHASE_MARKER)];
|
|
2822
|
+
const phases = starts.map((match, index) => {
|
|
2823
|
+
const start = match.index;
|
|
2824
|
+
const end = input.indexOf("<!-- /diffpi-phase -->", start);
|
|
2825
|
+
if (end < 0)
|
|
2826
|
+
throw new Error(`${ref}: phase marker has no closing marker.`);
|
|
2827
|
+
const next = starts[index + 1]?.index;
|
|
2828
|
+
if (next !== undefined && next < end)
|
|
2829
|
+
throw new Error(`${ref}: nested or unclosed phase marker.`);
|
|
2830
|
+
const body = input.slice(start + match[0].length, end);
|
|
2831
|
+
const marker = parseMarker(match[1], `${ref} phase`);
|
|
2832
|
+
assertStableId(marker.id, "phase id");
|
|
2833
|
+
const title = body.match(/^### Phase: (.+)$/m)?.[1]?.trim();
|
|
2834
|
+
const objective = body.match(/^\*\*Objective:\*\*\s*(.*)$/m)?.[1]?.trim();
|
|
2835
|
+
if (!title || !objective)
|
|
2836
|
+
throw new Error(`${ref}: phase ${marker.id} is missing title or objective.`);
|
|
2837
|
+
return {
|
|
2838
|
+
...marker,
|
|
2839
|
+
title,
|
|
2840
|
+
objective,
|
|
2841
|
+
dependencies: parseCsv(body.match(/^\*\*Dependencies:\*\*\s*(.*)$/m)?.[1]),
|
|
2842
|
+
tasks: parseTasks(body, ref)
|
|
2843
|
+
};
|
|
2844
|
+
});
|
|
2845
|
+
assertUniqueIds(phases.map((phase) => phase.id), "phase id");
|
|
2846
|
+
return phases;
|
|
2847
|
+
}
|
|
2848
|
+
function parseTasks(input, ref) {
|
|
2849
|
+
const starts = [...input.matchAll(TASK_MARKER)];
|
|
2850
|
+
const tasks = starts.map((match, index) => {
|
|
2851
|
+
const start = match.index;
|
|
2852
|
+
const end = input.indexOf("<!-- /diffpi-task -->", start);
|
|
2853
|
+
if (end < 0)
|
|
2854
|
+
throw new Error(`${ref}: task marker has no closing marker.`);
|
|
2855
|
+
const next = starts[index + 1]?.index;
|
|
2856
|
+
if (next !== undefined && next < end)
|
|
2857
|
+
throw new Error(`${ref}: nested or unclosed task marker.`);
|
|
2858
|
+
const body = input.slice(start + match[0].length, end);
|
|
2859
|
+
const marker = parseMarker(match[1], `${ref} task`);
|
|
2860
|
+
assertStableId(marker.id, "task id");
|
|
2861
|
+
const title = body.match(/^- \[[ xX]\] \*\*(.+)\*\*$/m)?.[1]?.trim();
|
|
2862
|
+
if (!title)
|
|
2863
|
+
throw new Error(`${ref}: task ${marker.id} is missing its checkbox title.`);
|
|
2864
|
+
return {
|
|
2865
|
+
...marker,
|
|
2866
|
+
title,
|
|
2867
|
+
steps: parseListValue(body, "Steps"),
|
|
2868
|
+
dependencies: parseListValue(body, "Dependencies"),
|
|
2869
|
+
fileScopes: parseListValue(body, "File scopes"),
|
|
2870
|
+
acceptanceCriteria: parseListValue(body, "Acceptance criteria")
|
|
2871
|
+
};
|
|
2872
|
+
});
|
|
2873
|
+
assertUniqueIds(tasks.map((task) => task.id), "task id");
|
|
2874
|
+
return tasks;
|
|
2875
|
+
}
|
|
2876
|
+
function parseReferences(input) {
|
|
2877
|
+
return [...input.matchAll(/^- <!-- diffpi-reference: (\{[^\n]+\}) -->\s*(.*)$/gm)].map((match) => {
|
|
2878
|
+
const marker = parseMarker(match[1], "reference");
|
|
2879
|
+
assertStableId(marker.id, "reference id");
|
|
2880
|
+
return { id: marker.id, value: match[2].trim() || marker.value };
|
|
2881
|
+
});
|
|
2882
|
+
}
|
|
2883
|
+
function canPatch(source, next) {
|
|
2884
|
+
try {
|
|
2885
|
+
const old = parsePlanDocument(source);
|
|
2886
|
+
return JSON.stringify(contentShape(old)) === JSON.stringify(contentShape(next));
|
|
2887
|
+
} catch {
|
|
2888
|
+
return false;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
function patchMarkers(source, plan) {
|
|
2892
|
+
const planMarker = {
|
|
2893
|
+
schemaVersion: plan.schemaVersion,
|
|
2894
|
+
id: plan.id,
|
|
2895
|
+
revision: plan.revision,
|
|
2896
|
+
branch: plan.branch,
|
|
2897
|
+
status: plan.status,
|
|
2898
|
+
execution: plan.execution,
|
|
2899
|
+
createdAt: plan.createdAt,
|
|
2900
|
+
updatedAt: plan.updatedAt
|
|
2901
|
+
};
|
|
2902
|
+
let output = source.replace(PLAN_MARKER, `<!-- diffpi-plan: ${json(planMarker)} -->`);
|
|
2903
|
+
output = output.replace(/^- \*\*Status:\*\* .*$/m, `- **Status:** ${plan.status}`);
|
|
2904
|
+
output = output.replace(/^- \*\*Revision:\*\* .*$/m, `- **Revision:** ${plan.revision}`);
|
|
2905
|
+
for (const phase of plan.phases) {
|
|
2906
|
+
const marker = {
|
|
2907
|
+
id: phase.id,
|
|
2908
|
+
revision: phase.revision,
|
|
2909
|
+
status: phase.status,
|
|
2910
|
+
gate: phase.gate,
|
|
2911
|
+
commit: phase.commit,
|
|
2912
|
+
blocker: phase.blocker
|
|
2913
|
+
};
|
|
2914
|
+
output = replaceMarkerById(output, "phase", phase.id, marker);
|
|
2915
|
+
for (const task of phase.tasks) {
|
|
2916
|
+
const taskMarker = {
|
|
2917
|
+
id: task.id,
|
|
2918
|
+
revision: task.revision,
|
|
2919
|
+
status: task.status,
|
|
2920
|
+
owner: task.owner,
|
|
2921
|
+
executionId: task.executionId,
|
|
2922
|
+
blocker: task.blocker
|
|
2923
|
+
};
|
|
2924
|
+
output = replaceMarkerById(output, "task", task.id, taskMarker);
|
|
2925
|
+
const checked = task.status === "completed" || task.status === "skipped" ? "x" : " ";
|
|
2926
|
+
const escaped = escapeRegExp(task.title);
|
|
2927
|
+
output = output.replace(new RegExp(`^- \\[[ xX]\\] \\*\\*${escaped}\\*\\*$`, "m"), `- [${checked}] **${task.title}**`);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
return output;
|
|
2931
|
+
}
|
|
2932
|
+
function replaceMarkerById(source, kind, id, marker) {
|
|
2933
|
+
const pattern = new RegExp(`<!-- diffpi-${kind}: \\{[^\\n]*"id":"${escapeRegExp(id)}"[^\\n]*\\} -->`);
|
|
2934
|
+
if (!pattern.test(source))
|
|
2935
|
+
throw new Error(`Cannot update missing ${kind} marker ${id}.`);
|
|
2936
|
+
return source.replace(pattern, `<!-- diffpi-${kind}: ${json(marker)} -->`);
|
|
2937
|
+
}
|
|
2938
|
+
function contentShape(plan) {
|
|
2939
|
+
return {
|
|
2940
|
+
title: plan.title,
|
|
2941
|
+
branch: plan.branch,
|
|
2942
|
+
intent: plan.intent,
|
|
2943
|
+
requirements: plan.requirements,
|
|
2944
|
+
design: plan.design,
|
|
2945
|
+
references: plan.references,
|
|
2946
|
+
phases: plan.phases.map(({ id, title, objective, dependencies, tasks }) => ({
|
|
2947
|
+
id,
|
|
2948
|
+
title,
|
|
2949
|
+
objective,
|
|
2950
|
+
dependencies,
|
|
2951
|
+
tasks: tasks.map(({ id: taskId, title: taskTitle, steps, dependencies: taskDependencies, fileScopes, acceptanceCriteria }) => ({
|
|
2952
|
+
id: taskId,
|
|
2953
|
+
title: taskTitle,
|
|
2954
|
+
steps,
|
|
2955
|
+
dependencies: taskDependencies,
|
|
2956
|
+
fileScopes,
|
|
2957
|
+
acceptanceCriteria
|
|
2958
|
+
}))
|
|
2959
|
+
}))
|
|
2960
|
+
};
|
|
2961
|
+
}
|
|
2962
|
+
function dependencyCycles(plan) {
|
|
2963
|
+
const graph = new Map;
|
|
2964
|
+
for (const phase of plan.phases)
|
|
2965
|
+
graph.set(phase.id, phase.dependencies);
|
|
2966
|
+
for (const task of plan.phases.flatMap((phase) => phase.tasks))
|
|
2967
|
+
graph.set(task.id, task.dependencies);
|
|
2968
|
+
const cycles = [];
|
|
2969
|
+
const visiting = new Set;
|
|
2970
|
+
const visited = new Set;
|
|
2971
|
+
const walk = (id, path) => {
|
|
2972
|
+
if (visiting.has(id)) {
|
|
2973
|
+
cycles.push([...path.slice(path.indexOf(id)), id]);
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
if (visited.has(id))
|
|
2977
|
+
return;
|
|
2978
|
+
visiting.add(id);
|
|
2979
|
+
for (const dependency of graph.get(id) ?? [])
|
|
2980
|
+
walk(dependency, [...path, id]);
|
|
2981
|
+
visiting.delete(id);
|
|
2982
|
+
visited.add(id);
|
|
2983
|
+
};
|
|
2984
|
+
for (const id of graph.keys())
|
|
2985
|
+
walk(id, []);
|
|
2986
|
+
return cycles;
|
|
2987
|
+
}
|
|
2988
|
+
function section(source, heading) {
|
|
2989
|
+
const match = source.match(new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
2990
|
+
if (!match?.index) {
|
|
2991
|
+
if (match?.index === 0)
|
|
2992
|
+
return "";
|
|
2993
|
+
throw new Error(`Missing required heading: ${heading}.`);
|
|
2994
|
+
}
|
|
2995
|
+
const start = match.index + match[0].length;
|
|
2996
|
+
const rest = source.slice(start);
|
|
2997
|
+
const end = rest.search(/^## /m);
|
|
2998
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
2999
|
+
}
|
|
3000
|
+
function subsection(source, parent, heading) {
|
|
3001
|
+
const body = section(source, parent);
|
|
3002
|
+
const match = body.match(new RegExp(`^### ${escapeRegExp(heading)}\\s*$`, "m"));
|
|
3003
|
+
if (match?.index === undefined)
|
|
3004
|
+
throw new Error(`Missing required heading: ${parent}/${heading}.`);
|
|
3005
|
+
const rest = body.slice(match.index + match[0].length);
|
|
3006
|
+
const end = rest.search(/^### /m);
|
|
3007
|
+
return (end < 0 ? rest : rest.slice(0, end)).trim();
|
|
3008
|
+
}
|
|
3009
|
+
function parseBullets(input) {
|
|
3010
|
+
return [...input.matchAll(/^- (?!<!--)(.+)$/gm)].map((match) => match[1].trim());
|
|
3011
|
+
}
|
|
3012
|
+
function parseListValue(body, label) {
|
|
3013
|
+
const value = body.match(new RegExp(`^ - ${escapeRegExp(label)}:\\s*(.*)$`, "m"))?.[1];
|
|
3014
|
+
return parseCsv(value);
|
|
3015
|
+
}
|
|
3016
|
+
function parseCsv(value) {
|
|
3017
|
+
if (!value || value.trim().toLowerCase() === "none")
|
|
3018
|
+
return [];
|
|
3019
|
+
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
3020
|
+
}
|
|
3021
|
+
function cleanPlaceholder(value) {
|
|
3022
|
+
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
3023
|
+
}
|
|
3024
|
+
function parseMarker(value, label) {
|
|
3025
|
+
try {
|
|
3026
|
+
return JSON.parse(value);
|
|
3027
|
+
} catch {
|
|
3028
|
+
throw new Error(`Malformed ${label} marker JSON.`);
|
|
3029
|
+
}
|
|
3030
|
+
}
|
|
3031
|
+
function list(values) {
|
|
3032
|
+
return values.length ? values.join(", ") : "none";
|
|
3033
|
+
}
|
|
3034
|
+
function json(value) {
|
|
3035
|
+
return JSON.stringify(value, (_key, entry) => entry === undefined ? undefined : entry);
|
|
3036
|
+
}
|
|
3037
|
+
function escapeRegExp(value) {
|
|
3038
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
3039
|
+
}
|
|
3040
|
+
// src/plan/store.ts
|
|
3041
|
+
import { open, mkdir as mkdir7, readdir as readdir2, readFile as readFile14, realpath as realpath4, rename as rename3, rm as rm2 } from "node:fs/promises";
|
|
3042
|
+
import { basename as basename6, dirname as dirname8, join as join16, resolve as resolve4, sep } from "node:path";
|
|
3043
|
+
|
|
3044
|
+
// src/templates.ts
|
|
3045
|
+
import { readFile as readFile13 } from "node:fs/promises";
|
|
3046
|
+
import { homedir as homedir8 } from "node:os";
|
|
3047
|
+
import { join as join15, normalize } from "node:path";
|
|
3048
|
+
async function loadTemplate(name, options = {}) {
|
|
3049
|
+
const relative = templateRelativePath(name);
|
|
3050
|
+
const userPath = join15(options.homeDir ?? homedir8(), ".difflab", "diffpi", "templates", relative);
|
|
3051
|
+
const bundledPath = join15(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
3052
|
+
const user = await readOptionalFile(userPath);
|
|
3053
|
+
if (user !== undefined)
|
|
3054
|
+
return { name, path: userPath, source: "user", content: user };
|
|
3055
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
3056
|
+
if (bundled !== undefined)
|
|
3057
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
3058
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
3059
|
+
}
|
|
3060
|
+
function renderTemplate(content, variables) {
|
|
3061
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
3062
|
+
}
|
|
3063
|
+
function templateRelativePath(name) {
|
|
3064
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
3065
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
3066
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
3067
|
+
}
|
|
3068
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
3069
|
+
}
|
|
3070
|
+
async function readOptionalFile(path) {
|
|
3071
|
+
try {
|
|
3072
|
+
return await readFile13(path, "utf8");
|
|
3073
|
+
} catch (error) {
|
|
3074
|
+
if (error.code === "ENOENT")
|
|
3075
|
+
return;
|
|
3076
|
+
throw error;
|
|
3077
|
+
}
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
// src/plan/store.ts
|
|
3081
|
+
function planRecordName(shortSlug, date = new Date) {
|
|
3082
|
+
const slug = normalizeSlug(shortSlug);
|
|
3083
|
+
return `${date.toISOString().slice(2, 10).replaceAll("-", "")}-${slug}`;
|
|
3084
|
+
}
|
|
3085
|
+
async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
3086
|
+
const root = await plansDir(cwd, options.homeDir);
|
|
3087
|
+
const entries = await readdir2(root, { withFileTypes: true });
|
|
3088
|
+
const normalizedQuery = query ? normalizeQuery(query) : undefined;
|
|
3089
|
+
const records = [];
|
|
3090
|
+
for (const entry of entries) {
|
|
3091
|
+
if (!entry.isDirectory() || entry.name === ".tmp" || entry.name === ".lock")
|
|
3092
|
+
continue;
|
|
3093
|
+
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
3094
|
+
continue;
|
|
3095
|
+
const dir = join16(root, entry.name);
|
|
3096
|
+
await assertContained(root, dir);
|
|
3097
|
+
try {
|
|
3098
|
+
const record = await readRecord(dir);
|
|
3099
|
+
if (filters.branch && record.document.branch !== filters.branch)
|
|
3100
|
+
continue;
|
|
3101
|
+
if (filters.statuses && !filters.statuses.includes(record.document.status))
|
|
3102
|
+
continue;
|
|
3103
|
+
records.push(record);
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
if (error.code !== "ENOENT")
|
|
3106
|
+
throw error;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
records.sort((left, right) => left.id.localeCompare(right.id));
|
|
3110
|
+
return { candidates: records, record: records.length === 1 ? records[0] : undefined, ambiguous: records.length > 1 };
|
|
3111
|
+
}
|
|
3112
|
+
function createPlanStore(options = {}) {
|
|
3113
|
+
const now = options.now ?? (() => new Date);
|
|
3114
|
+
return {
|
|
3115
|
+
context(cwd, query, filters) {
|
|
3116
|
+
return resolvePlan(cwd, query, filters, options);
|
|
3117
|
+
},
|
|
3118
|
+
async init(input) {
|
|
3119
|
+
const root = await plansDir(input.cwd, options.homeDir);
|
|
3120
|
+
const id = planRecordName(input.shortSlug, now());
|
|
3121
|
+
const dir = join16(root, id);
|
|
3122
|
+
await assertContained(root, dirname8(dir));
|
|
3123
|
+
try {
|
|
3124
|
+
await mkdir7(dir);
|
|
3125
|
+
} catch (error) {
|
|
3126
|
+
if (error.code === "EEXIST")
|
|
3127
|
+
throw new Error(`Plan ${id} already exists.`);
|
|
3128
|
+
throw error;
|
|
3129
|
+
}
|
|
3130
|
+
try {
|
|
3131
|
+
const timestamp = now().toISOString();
|
|
3132
|
+
const template = await loadTemplate("plan/PLAN", {
|
|
3133
|
+
homeDir: options.homeDir,
|
|
3134
|
+
bundledDir: options.bundledTemplatesDir
|
|
3135
|
+
});
|
|
3136
|
+
const source = renderTemplate(template.content, {
|
|
3137
|
+
id,
|
|
3138
|
+
branch: input.branch,
|
|
3139
|
+
title: input.title?.trim() || titleFromSlug(input.shortSlug),
|
|
3140
|
+
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
3141
|
+
created_at: timestamp,
|
|
3142
|
+
updated_at: timestamp
|
|
3143
|
+
});
|
|
3144
|
+
const document = parsePlanDocument(source, join16(dir, "PLAN.md"));
|
|
3145
|
+
await atomicWrite(join16(dir, "PLAN.md"), source);
|
|
3146
|
+
await atomicWrite(join16(dir, "logs.txt"), "");
|
|
3147
|
+
await appendPlanLog(join16(dir, "logs.txt"), {
|
|
3148
|
+
planRevision: document.revision,
|
|
3149
|
+
kind: "created",
|
|
3150
|
+
actor: "diffpi",
|
|
3151
|
+
message: `Created plan ${id}.`
|
|
3152
|
+
});
|
|
3153
|
+
return { id, dir, planPath: join16(dir, "PLAN.md"), logPath: join16(dir, "logs.txt"), document, source };
|
|
3154
|
+
} catch (error) {
|
|
3155
|
+
await rm2(dir, { recursive: true, force: true });
|
|
3156
|
+
throw error;
|
|
3157
|
+
}
|
|
3158
|
+
},
|
|
3159
|
+
async read(cwd, query, filters) {
|
|
3160
|
+
const result = await resolvePlan(cwd, query, filters, options);
|
|
3161
|
+
if (!result.record) {
|
|
3162
|
+
if (result.ambiguous)
|
|
3163
|
+
throw new Error(`Plan query "${query}" is ambiguous: ${result.candidates.map((item) => item.id).join(", ")}.`);
|
|
3164
|
+
throw new Error(`Plan "${query}" was not found.`);
|
|
3165
|
+
}
|
|
3166
|
+
return result.record;
|
|
3167
|
+
},
|
|
3168
|
+
async mutate(cwd, query, operation, update) {
|
|
3169
|
+
const initial = await this.read(cwd, query);
|
|
3170
|
+
return withPlanLock(initial.dir, async () => {
|
|
3171
|
+
const current = await readRecord(initial.dir);
|
|
3172
|
+
const next = await update(structuredClone(current.document));
|
|
3173
|
+
if (next.id !== current.id)
|
|
3174
|
+
throw new Error("A plan mutation cannot change the plan ID.");
|
|
3175
|
+
const document = {
|
|
3176
|
+
...next,
|
|
3177
|
+
revision: current.document.revision + 1,
|
|
3178
|
+
updatedAt: now().toISOString()
|
|
3179
|
+
};
|
|
3180
|
+
const source = renderPlanDocument(document, current.source);
|
|
3181
|
+
parsePlanDocument(source, current.planPath);
|
|
3182
|
+
await atomicWrite(current.planPath, source);
|
|
3183
|
+
return { ...current, document, source };
|
|
3184
|
+
}, { operation });
|
|
3185
|
+
},
|
|
3186
|
+
async log(cwd, query, event) {
|
|
3187
|
+
const record = await this.read(cwd, query);
|
|
3188
|
+
return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
async function readRecord(dir) {
|
|
3193
|
+
const planPath = join16(dir, "PLAN.md");
|
|
3194
|
+
const logPath = join16(dir, "logs.txt");
|
|
3195
|
+
const source = await readFile14(planPath, "utf8");
|
|
3196
|
+
const document = parsePlanDocument(source, planPath);
|
|
3197
|
+
return { id: basename6(dir), dir, planPath, logPath, document, source };
|
|
3198
|
+
}
|
|
3199
|
+
async function atomicWrite(path, content) {
|
|
3200
|
+
const temp = join16(dirname8(path), `.${basename6(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
3201
|
+
const file = await open(temp, "wx", 384);
|
|
3202
|
+
try {
|
|
3203
|
+
await file.writeFile(content, "utf8");
|
|
3204
|
+
await file.sync();
|
|
3205
|
+
} finally {
|
|
3206
|
+
await file.close();
|
|
3207
|
+
}
|
|
3208
|
+
await rename3(temp, path);
|
|
3209
|
+
try {
|
|
3210
|
+
const directory = await open(dirname8(path), "r");
|
|
3211
|
+
try {
|
|
3212
|
+
await directory.sync();
|
|
3213
|
+
} finally {
|
|
3214
|
+
await directory.close();
|
|
3215
|
+
}
|
|
3216
|
+
} catch {}
|
|
3217
|
+
}
|
|
3218
|
+
async function assertContained(root, candidate) {
|
|
3219
|
+
const canonicalRoot = await realpath4(root);
|
|
3220
|
+
let canonicalCandidate;
|
|
3221
|
+
try {
|
|
3222
|
+
canonicalCandidate = await realpath4(candidate);
|
|
3223
|
+
} catch {
|
|
3224
|
+
canonicalCandidate = resolve4(candidate);
|
|
3225
|
+
}
|
|
3226
|
+
if (canonicalCandidate !== canonicalRoot && !canonicalCandidate.startsWith(`${canonicalRoot}${sep}`)) {
|
|
3227
|
+
throw new Error(`Plan path escapes the shared store: ${candidate}.`);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
3230
|
+
function normalizeSlug(value) {
|
|
3231
|
+
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
3232
|
+
throw new Error(`Invalid plan slug: ${value}.`);
|
|
3233
|
+
}
|
|
3234
|
+
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3235
|
+
assertStableId(slug, "plan slug");
|
|
3236
|
+
return slug;
|
|
3237
|
+
}
|
|
3238
|
+
function normalizeQuery(value) {
|
|
3239
|
+
if (!value || value.includes("\x00") || value.includes("/") || value.includes("\\") || value.includes("..")) {
|
|
3240
|
+
throw new Error(`Invalid plan query: ${value}.`);
|
|
3241
|
+
}
|
|
3242
|
+
return value.toLowerCase();
|
|
3243
|
+
}
|
|
3244
|
+
function stripDate(value) {
|
|
3245
|
+
return value.replace(/^\d{6}-/, "");
|
|
3246
|
+
}
|
|
3247
|
+
function titleFromSlug(value) {
|
|
3248
|
+
return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
3249
|
+
}
|
|
739
3250
|
// src/setup.ts
|
|
740
3251
|
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
741
|
-
import { readdir as
|
|
742
|
-
import { homedir as
|
|
743
|
-
import { basename as
|
|
3252
|
+
import { readdir as readdir3, readFile as readFile15 } from "node:fs/promises";
|
|
3253
|
+
import { homedir as homedir9 } from "node:os";
|
|
3254
|
+
import { basename as basename7, join as join17 } from "node:path";
|
|
744
3255
|
var MISE_DEPENDENCIES = [
|
|
745
3256
|
{ name: "node", tool: "node", spec: "node@22", minimumVersion: "22.19.0" },
|
|
746
3257
|
{ name: "zellij", tool: "zellij", spec: "zellij@latest", minimumVersion: undefined },
|
|
@@ -768,13 +3279,18 @@ var PI_PACKAGES = [
|
|
|
768
3279
|
];
|
|
769
3280
|
var PI_SKILL_SOURCES = [
|
|
770
3281
|
{ repository: "arabold/docs-mcp-server", skills: ["docs-manage", "docs-search", "fetch-url"] },
|
|
771
|
-
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
3282
|
+
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] },
|
|
3283
|
+
{ repository: "cloudvoyant/codevoyant", skills: ["git"] }
|
|
772
3284
|
];
|
|
773
3285
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
774
3286
|
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
3287
|
+
var FORGE_DEPENDENCIES = {
|
|
3288
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
3289
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
3290
|
+
};
|
|
775
3291
|
async function ensureMise(options = {}) {
|
|
776
|
-
const homeDir = options.homeDir ??
|
|
777
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
3292
|
+
const homeDir = options.homeDir ?? homedir9();
|
|
3293
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join17(homeDir, ".local", "bin", "mise"));
|
|
778
3294
|
if (current)
|
|
779
3295
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
780
3296
|
reportProgress(options, "Installing mise");
|
|
@@ -803,7 +3319,11 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
803
3319
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
804
3320
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
805
3321
|
const actions = [];
|
|
806
|
-
|
|
3322
|
+
const dependencies = [
|
|
3323
|
+
...MISE_DEPENDENCIES,
|
|
3324
|
+
...configuredForges(options).map((forge) => FORGE_DEPENDENCIES[forge])
|
|
3325
|
+
];
|
|
3326
|
+
for (const dependency of dependencies) {
|
|
807
3327
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
808
3328
|
if (installed) {
|
|
809
3329
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -819,9 +3339,9 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
819
3339
|
async function ensurePiPlugins(options = {}) {
|
|
820
3340
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
821
3341
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
822
|
-
const webSearch = await pi.configEnsure(
|
|
3342
|
+
const webSearch = await pi.configEnsure(join17(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
823
3343
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
824
|
-
const lsp = await pi.configEnsure(
|
|
3344
|
+
const lsp = await pi.configEnsure(join17(agentDir, "pi-lsp.json"), (config) => ({
|
|
825
3345
|
...config,
|
|
826
3346
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
827
3347
|
}), options.dryRun);
|
|
@@ -832,11 +3352,11 @@ async function ensurePiAgents(options = {}) {
|
|
|
832
3352
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
833
3353
|
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
834
3354
|
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
835
|
-
const entries = (await
|
|
3355
|
+
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
3356
|
const actions = [];
|
|
837
3357
|
for (const entry of entries) {
|
|
838
|
-
const id =
|
|
839
|
-
const source = await
|
|
3358
|
+
const id = basename7(entry.name, ".md").replace(/^diffpi-/, "");
|
|
3359
|
+
const source = await readFile15(join17(bundledAgentsDir, entry.name), "utf8");
|
|
840
3360
|
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
841
3361
|
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
842
3362
|
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
@@ -845,7 +3365,7 @@ async function ensurePiAgents(options = {}) {
|
|
|
845
3365
|
}
|
|
846
3366
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
847
3367
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
848
|
-
const sharedSkillsDir =
|
|
3368
|
+
const sharedSkillsDir = join17(options.homeDir ?? homedir9(), ".agents", "skills");
|
|
849
3369
|
const actions = [];
|
|
850
3370
|
for (const source of PI_SKILL_SOURCES) {
|
|
851
3371
|
const missing = [];
|
|
@@ -889,6 +3409,14 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
889
3409
|
} else if (options.issueTracker === "jira") {
|
|
890
3410
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
891
3411
|
}
|
|
3412
|
+
for (const forge of configuredForges(options)) {
|
|
3413
|
+
if (forge === "github") {
|
|
3414
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
3415
|
+
continue;
|
|
3416
|
+
}
|
|
3417
|
+
const host = await gitlabMcpHost(projectDir);
|
|
3418
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
3419
|
+
}
|
|
892
3420
|
const result = await mcp.serversEnsure(servers, {
|
|
893
3421
|
dryRun: options.dryRun,
|
|
894
3422
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -905,6 +3433,8 @@ async function setupPi(options = {}) {
|
|
|
905
3433
|
actions.push(...await ensurePiAgents(options));
|
|
906
3434
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
907
3435
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
3436
|
+
if (options.bindZedKey)
|
|
3437
|
+
actions.push(...await ensureZedIntegration(options));
|
|
908
3438
|
return {
|
|
909
3439
|
actions,
|
|
910
3440
|
restartPi: setupRequiresRestart(actions)
|
|
@@ -913,6 +3443,43 @@ async function setupPi(options = {}) {
|
|
|
913
3443
|
function setupRequiresRestart(actions) {
|
|
914
3444
|
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
3445
|
}
|
|
3446
|
+
async function ensureZedIntegration(options = {}) {
|
|
3447
|
+
if (options.dryRun) {
|
|
3448
|
+
const actions = [
|
|
3449
|
+
createSetupAction("Zed review tasks", "planned", "global static runtime-resolver tasks in tasks.json"),
|
|
3450
|
+
createSetupAction("Zed plan task", "planned", "pinned package CLI task in tasks.json")
|
|
3451
|
+
];
|
|
3452
|
+
if (options.bindZedKey)
|
|
3453
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
3454
|
+
return actions;
|
|
3455
|
+
}
|
|
3456
|
+
const actions = [];
|
|
3457
|
+
try {
|
|
3458
|
+
const tasks = await ensureZedReviewTask(options.homeDir);
|
|
3459
|
+
actions.push(createSetupAction("Zed review tasks", tasks.changed ? "installed" : "ready", tasks.path));
|
|
3460
|
+
const planTask = await ensureZedPlanTask(await packageVersion2(), options.homeDir);
|
|
3461
|
+
actions.push(createSetupAction("Zed plan task", planTask.changed ? "installed" : "ready", planTask.path));
|
|
3462
|
+
} catch (error) {
|
|
3463
|
+
actions.push(createSetupAction("Zed review tasks", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3464
|
+
}
|
|
3465
|
+
if (options.bindZedKey) {
|
|
3466
|
+
try {
|
|
3467
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
3468
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
3469
|
+
} catch (error) {
|
|
3470
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
3471
|
+
}
|
|
3472
|
+
}
|
|
3473
|
+
return actions;
|
|
3474
|
+
}
|
|
3475
|
+
async function gitlabMcpHost(projectDir) {
|
|
3476
|
+
try {
|
|
3477
|
+
const vcs = await detectVcs(projectDir);
|
|
3478
|
+
return vcs.provider === "gitlab" && vcs.host ? vcs.host : "gitlab.com";
|
|
3479
|
+
} catch {
|
|
3480
|
+
return "gitlab.com";
|
|
3481
|
+
}
|
|
3482
|
+
}
|
|
916
3483
|
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
917
3484
|
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
918
3485
|
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
@@ -972,6 +3539,10 @@ ${source}`;
|
|
|
972
3539
|
}
|
|
973
3540
|
return actions;
|
|
974
3541
|
}
|
|
3542
|
+
function configuredForges(options) {
|
|
3543
|
+
const selected = options.forges ?? (options.forge && options.forge !== "none" ? [options.forge] : []);
|
|
3544
|
+
return [...new Set(selected)];
|
|
3545
|
+
}
|
|
975
3546
|
function getTextList(value) {
|
|
976
3547
|
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
977
3548
|
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
@@ -989,13 +3560,55 @@ function createSetupAction(name, status, detail) {
|
|
|
989
3560
|
function reportProgress(options, message) {
|
|
990
3561
|
options.onProgress?.(message);
|
|
991
3562
|
}
|
|
3563
|
+
async function packageVersion2() {
|
|
3564
|
+
const source = await readFile15(join17(resolveBundledAgentsDir(), "..", "package.json"), "utf8");
|
|
3565
|
+
const value = JSON.parse(source);
|
|
3566
|
+
if (typeof value.version !== "string")
|
|
3567
|
+
throw new Error("Cannot resolve the installed @difflab/pi version.");
|
|
3568
|
+
return value.version;
|
|
3569
|
+
}
|
|
992
3570
|
function getRecord(value) {
|
|
993
3571
|
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
994
3572
|
}
|
|
995
3573
|
export {
|
|
3574
|
+
CONVENTIONAL_COMMIT,
|
|
3575
|
+
GitHubVcsBackend,
|
|
3576
|
+
GitLabVcsBackend,
|
|
3577
|
+
ZED_LOCAL_REVIEW_TASK_NAME,
|
|
3578
|
+
ZED_PLAN_ANNOTATE_TASK_NAME,
|
|
3579
|
+
ZED_PR_REVIEW_TASK_NAME,
|
|
3580
|
+
ZED_REVIEW_TASK_NAME,
|
|
3581
|
+
acknowledgePlanAnnotations,
|
|
3582
|
+
addComment,
|
|
3583
|
+
annotatePlan,
|
|
3584
|
+
annotationStatePath,
|
|
3585
|
+
appendPlanLog,
|
|
3586
|
+
assertGitHubMergeReady,
|
|
3587
|
+
assertPhaseTransition,
|
|
3588
|
+
assertPlanTransition,
|
|
3589
|
+
assertReviewEventSupported,
|
|
3590
|
+
assertStableId,
|
|
3591
|
+
assertTaskTransition,
|
|
3592
|
+
checkConventionalSubject,
|
|
3593
|
+
ciGate,
|
|
3594
|
+
computeProjectSlug,
|
|
3595
|
+
countDesignWords,
|
|
3596
|
+
createExecutionPacket,
|
|
3597
|
+
createForge,
|
|
3598
|
+
createForgeBackend,
|
|
3599
|
+
createLocalReviewBackend,
|
|
996
3600
|
createModeController,
|
|
3601
|
+
createPlanStore,
|
|
3602
|
+
createRemoteReviewBackend,
|
|
3603
|
+
createVcsBackend,
|
|
3604
|
+
dedupeFindings,
|
|
3605
|
+
detectIde,
|
|
3606
|
+
detectMux,
|
|
3607
|
+
detectShell,
|
|
3608
|
+
detectVcs,
|
|
997
3609
|
diffpiConfigPaths,
|
|
998
3610
|
discoverAgentModes,
|
|
3611
|
+
eligiblePlanTasks,
|
|
999
3612
|
ensureMcpAdapters,
|
|
1000
3613
|
ensureMise,
|
|
1001
3614
|
ensureMiseDeps,
|
|
@@ -1003,12 +3616,74 @@ export {
|
|
|
1003
3616
|
ensurePiAgents,
|
|
1004
3617
|
ensurePiPlugins,
|
|
1005
3618
|
ensurePiSkills,
|
|
3619
|
+
ensureStore,
|
|
3620
|
+
ensureZedPlanTask,
|
|
3621
|
+
ensureZedReviewKeybinding,
|
|
3622
|
+
ensureZedReviewTask,
|
|
1006
3623
|
findPreferredModel,
|
|
3624
|
+
findingSchema,
|
|
3625
|
+
findingsSchema,
|
|
3626
|
+
gitToplevel,
|
|
3627
|
+
githubReviewSubmissionEndpoint,
|
|
3628
|
+
hasGitlabDraftNotes,
|
|
3629
|
+
launch,
|
|
3630
|
+
listSessions,
|
|
1007
3631
|
loadDiffpiConfig,
|
|
3632
|
+
loadReviewPublicationState,
|
|
3633
|
+
loadTemplate,
|
|
3634
|
+
localReviewAuthor,
|
|
1008
3635
|
mcp,
|
|
1009
3636
|
mise,
|
|
3637
|
+
openInNewTab,
|
|
3638
|
+
parseGitlabDiffRefs,
|
|
3639
|
+
parsePlanDocument,
|
|
3640
|
+
parsePlannerEscalation,
|
|
3641
|
+
parseRemote,
|
|
3642
|
+
parseThreadArtifact,
|
|
3643
|
+
phaseCommitCommand,
|
|
1010
3644
|
pi,
|
|
3645
|
+
planRecordName,
|
|
3646
|
+
plansDir,
|
|
3647
|
+
readPlanAnnotations,
|
|
3648
|
+
readPlanLog,
|
|
3649
|
+
readSession,
|
|
3650
|
+
renderExecutionPrompt,
|
|
3651
|
+
renderPlanDocument,
|
|
3652
|
+
renderPlannerEscalation,
|
|
3653
|
+
renderReviewDoc,
|
|
3654
|
+
renderTemplate,
|
|
3655
|
+
renderThreadArtifact,
|
|
1011
3656
|
resolveAgentMode,
|
|
1012
3657
|
resolveAgentModelPreferences,
|
|
1013
|
-
|
|
3658
|
+
resolvePlan,
|
|
3659
|
+
resolvePrSession,
|
|
3660
|
+
resolveReviewSession,
|
|
3661
|
+
resolveSession,
|
|
3662
|
+
reviewBodyFingerprint,
|
|
3663
|
+
reviewCommentFingerprint,
|
|
3664
|
+
reviewRecordName,
|
|
3665
|
+
reviewReplyFingerprint,
|
|
3666
|
+
reviewSlug,
|
|
3667
|
+
reviewsDir,
|
|
3668
|
+
runMiseGates,
|
|
3669
|
+
saveReviewPublicationState,
|
|
3670
|
+
sessionsDir,
|
|
3671
|
+
setupPi,
|
|
3672
|
+
severitySchema,
|
|
3673
|
+
storeDir,
|
|
3674
|
+
storeGlobalRoot,
|
|
3675
|
+
tasksMayRunInParallel,
|
|
3676
|
+
templateRelativePath,
|
|
3677
|
+
toFindings,
|
|
3678
|
+
toReviewComments,
|
|
3679
|
+
tuicrAvailable,
|
|
3680
|
+
unpublishedReviewComments,
|
|
3681
|
+
upsertThreadReply,
|
|
3682
|
+
validatePlanDocument,
|
|
3683
|
+
withPlanLock,
|
|
3684
|
+
withRemoteProvenance,
|
|
3685
|
+
yymmdd,
|
|
3686
|
+
zedKeymapPath,
|
|
3687
|
+
zedReviewTaskName,
|
|
3688
|
+
zedTasksPath
|
|
1014
3689
|
};
|