@difflab/pi 0.1.0 → 0.2.0-rc.202609170958.44c1e9f
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 +36 -1
- package/agents/diffpi-autonomous.md +15 -0
- package/agents/diffpi-copilot.md +22 -0
- package/agents/diffpi-orchestrator.md +23 -0
- package/agents/diffpi-planner.md +15 -0
- package/agents/diffpi-reviewer.md +27 -0
- package/agents/diffpi-tutor.md +20 -0
- package/agents/diffpi-worker.md +19 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/config.d.ts +28 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/environment.d.ts +40 -0
- package/dist/environment.d.ts.map +1 -0
- package/dist/extensions/index.js +2919 -183
- package/dist/forge.d.ts +48 -0
- package/dist/forge.d.ts.map +1 -0
- package/dist/fsx.d.ts +6 -0
- package/dist/fsx.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 +26 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2162 -162
- package/dist/modes.d.ts +59 -0
- package/dist/modes.d.ts.map +1 -0
- package/dist/pi.d.ts +21 -5
- package/dist/pi.d.ts.map +1 -1
- package/dist/process.d.ts +2 -0
- package/dist/process.d.ts.map +1 -1
- package/dist/review-backend.d.ts +15 -0
- package/dist/review-backend.d.ts.map +1 -0
- package/dist/review-publication.d.ts +17 -0
- package/dist/review-publication.d.ts.map +1 -0
- package/dist/review-types.d.ts +49 -0
- package/dist/review-types.d.ts.map +1 -0
- package/dist/review.d.ts +62 -0
- package/dist/review.d.ts.map +1 -0
- package/dist/setup.d.ts +11 -0
- package/dist/setup.d.ts.map +1 -1
- package/dist/store.d.ts +15 -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 +6 -2
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2631 -172
- package/dist/tools/modes.d.ts +4 -0
- package/dist/tools/modes.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/tuicr.d.ts +55 -0
- package/dist/tuicr.d.ts.map +1 -0
- package/dist/zed.d.ts +11 -0
- package/dist/zed.d.ts.map +1 -0
- package/package.json +4 -2
- package/skills/diffpi-setup/SKILL.md +15 -1
- package/skills/mode/SKILL.md +38 -0
- package/skills/review/SKILL.md +15 -0
- package/skills/review/references/workflows/address.md +10 -0
- package/skills/review/references/workflows/edit.md +9 -0
- package/skills/review/references/workflows/help.md +12 -0
- package/skills/review/references/workflows/merge.md +6 -0
- package/skills/review/references/workflows/new.md +9 -0
- package/skills/review/references/workflows/open.md +7 -0
- package/skills/review/references/workflows/publish.md +8 -0
- package/templates/review/draft-pr.md +18 -0
package/dist/extensions/index.js
CHANGED
|
@@ -13323,11 +13323,378 @@ function rpiv_ask_user_question_default(pi) {
|
|
|
13323
13323
|
registerAskUserQuestionReconciler(pi);
|
|
13324
13324
|
}
|
|
13325
13325
|
|
|
13326
|
+
// src/modes.ts
|
|
13327
|
+
import {
|
|
13328
|
+
getAgentDir,
|
|
13329
|
+
parseFrontmatter as parseFrontmatter2
|
|
13330
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13331
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
13332
|
+
import { homedir as homedir3 } from "node:os";
|
|
13333
|
+
import { basename, extname, join as join5 } from "node:path";
|
|
13334
|
+
|
|
13335
|
+
// src/assets.ts
|
|
13336
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
13337
|
+
import { dirname as dirname2, join as join3 } from "node:path";
|
|
13338
|
+
import { fileURLToPath } from "node:url";
|
|
13339
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
13340
|
+
const moduleDir = dirname2(fileURLToPath(moduleUrl));
|
|
13341
|
+
const candidates = [join3(moduleDir, name), join3(moduleDir, "..", name), join3(moduleDir, "..", "..", name)];
|
|
13342
|
+
return candidates.find((path) => existsSync2(path)) ?? candidates[1];
|
|
13343
|
+
}
|
|
13344
|
+
function resolveBundledAgentsDir(moduleUrl = import.meta.url) {
|
|
13345
|
+
return resolveBundledAssetDir("agents", moduleUrl);
|
|
13346
|
+
}
|
|
13347
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
13348
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
13349
|
+
}
|
|
13350
|
+
|
|
13351
|
+
// src/config.ts
|
|
13352
|
+
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
13353
|
+
import { z } from "zod";
|
|
13354
|
+
import { homedir as homedir2 } from "node:os";
|
|
13355
|
+
import { join as join4 } from "node:path";
|
|
13356
|
+
|
|
13357
|
+
// src/fsx.ts
|
|
13358
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
13359
|
+
async function readDirectoryIfExists(path) {
|
|
13360
|
+
try {
|
|
13361
|
+
return await readdir(path, { withFileTypes: true });
|
|
13362
|
+
} catch (error) {
|
|
13363
|
+
if (isMissingPath(error))
|
|
13364
|
+
return [];
|
|
13365
|
+
throw error;
|
|
13366
|
+
}
|
|
13367
|
+
}
|
|
13368
|
+
async function readTextIfExists(path) {
|
|
13369
|
+
try {
|
|
13370
|
+
return await readFile(path, "utf8");
|
|
13371
|
+
} catch (error) {
|
|
13372
|
+
if (isMissingPath(error))
|
|
13373
|
+
return;
|
|
13374
|
+
throw error;
|
|
13375
|
+
}
|
|
13376
|
+
}
|
|
13377
|
+
function isMissingPath(error) {
|
|
13378
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
13379
|
+
}
|
|
13380
|
+
|
|
13381
|
+
// src/config.ts
|
|
13382
|
+
var modelReferenceSchema = z.string().trim().min(1);
|
|
13383
|
+
var agentConfigSchema = z.object({
|
|
13384
|
+
models: z.array(modelReferenceSchema).optional()
|
|
13385
|
+
}).strict();
|
|
13386
|
+
var diffpiConfigSchema = z.object({
|
|
13387
|
+
agents: z.record(z.string(), agentConfigSchema).optional()
|
|
13388
|
+
}).strict();
|
|
13389
|
+
function diffpiConfigPaths(homeDir = homedir2()) {
|
|
13390
|
+
const directory = join4(homeDir, ".difflab", "diffpi");
|
|
13391
|
+
return {
|
|
13392
|
+
yaml: join4(directory, "config.yaml"),
|
|
13393
|
+
json: join4(directory, "config.json")
|
|
13394
|
+
};
|
|
13395
|
+
}
|
|
13396
|
+
async function loadDiffpiConfig(options = {}) {
|
|
13397
|
+
const paths = diffpiConfigPaths(options.homeDir);
|
|
13398
|
+
for (const [format, path] of [
|
|
13399
|
+
["yaml", paths.yaml],
|
|
13400
|
+
["json", paths.json]
|
|
13401
|
+
]) {
|
|
13402
|
+
const content = await readTextIfExists(path);
|
|
13403
|
+
if (content === undefined)
|
|
13404
|
+
continue;
|
|
13405
|
+
try {
|
|
13406
|
+
const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
|
|
13407
|
+
return { config: diffpiConfigSchema.parse(value ?? {}), path };
|
|
13408
|
+
} catch (error) {
|
|
13409
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
13410
|
+
throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
|
|
13411
|
+
}
|
|
13412
|
+
}
|
|
13413
|
+
return { config: {} };
|
|
13414
|
+
}
|
|
13415
|
+
function resolveAgentModelPreferences(agentId, profilePreferences, config) {
|
|
13416
|
+
const override = config.agents?.[agentId];
|
|
13417
|
+
if (override && Object.hasOwn(override, "models"))
|
|
13418
|
+
return [...override.models ?? []];
|
|
13419
|
+
return [...profilePreferences];
|
|
13420
|
+
}
|
|
13421
|
+
function findPreferredModel(models, preference) {
|
|
13422
|
+
const normalizedPreference = normalizeModelReference(preference);
|
|
13423
|
+
const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
|
|
13424
|
+
if (exactReference)
|
|
13425
|
+
return exactReference;
|
|
13426
|
+
const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
|
|
13427
|
+
const normalizedIdPreference = normalizeModelReference(idPreference);
|
|
13428
|
+
const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
|
|
13429
|
+
if (exactId)
|
|
13430
|
+
return exactId;
|
|
13431
|
+
const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
|
|
13432
|
+
return models.find((model) => {
|
|
13433
|
+
const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
|
|
13434
|
+
return preferenceTokens.every((token) => modelTokens.has(token));
|
|
13435
|
+
});
|
|
13436
|
+
}
|
|
13437
|
+
function parseYamlConfig(content) {
|
|
13438
|
+
const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
|
|
13439
|
+
return parseFrontmatter(`---
|
|
13440
|
+
${document}
|
|
13441
|
+
---
|
|
13442
|
+
`).frontmatter;
|
|
13443
|
+
}
|
|
13444
|
+
function normalizeModelReference(value) {
|
|
13445
|
+
return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
13446
|
+
}
|
|
13447
|
+
|
|
13448
|
+
// src/modes.ts
|
|
13449
|
+
async function discoverAgentModes(options) {
|
|
13450
|
+
const agentDir = options.agentDir ?? getAgentDir();
|
|
13451
|
+
const homeDir = options.homeDir ?? homedir3();
|
|
13452
|
+
const modes = new Map;
|
|
13453
|
+
const diagnostics = [];
|
|
13454
|
+
await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
|
|
13455
|
+
await loadAgentModes(join5(agentDir, "agents"), "user agent", modes, diagnostics);
|
|
13456
|
+
if (options.includeSkills) {
|
|
13457
|
+
await loadSkillModes(join5(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
|
|
13458
|
+
await loadSkillModes(join5(agentDir, "skills"), "pi user skill", modes, diagnostics);
|
|
13459
|
+
}
|
|
13460
|
+
if (options.projectTrusted === true) {
|
|
13461
|
+
if (options.includeSkills) {
|
|
13462
|
+
await loadSkillModes(join5(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
|
|
13463
|
+
await loadSkillModes(join5(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
|
|
13464
|
+
}
|
|
13465
|
+
await loadAgentModes(join5(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
|
|
13466
|
+
await loadAgentModes(join5(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
|
|
13467
|
+
}
|
|
13468
|
+
const userConfig = await loadDiffpiConfig({ homeDir });
|
|
13469
|
+
const configuredModes = [...modes.values()].map((mode) => ({
|
|
13470
|
+
...mode,
|
|
13471
|
+
modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
|
|
13472
|
+
}));
|
|
13473
|
+
return {
|
|
13474
|
+
modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
|
|
13475
|
+
diagnostics
|
|
13476
|
+
};
|
|
13477
|
+
}
|
|
13478
|
+
function resolveAgentMode(modes, requested) {
|
|
13479
|
+
const name = requested.trim();
|
|
13480
|
+
if (!name)
|
|
13481
|
+
return { ok: false, message: "Agent name is required." };
|
|
13482
|
+
const exact = modes.find((mode) => mode.id === name);
|
|
13483
|
+
if (exact)
|
|
13484
|
+
return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
|
|
13485
|
+
const lowerName = name.toLowerCase();
|
|
13486
|
+
const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
|
|
13487
|
+
if (matches.length === 1) {
|
|
13488
|
+
const active = matches[0];
|
|
13489
|
+
return { ok: true, active, message: `Active inline agent: ${active.id}.` };
|
|
13490
|
+
}
|
|
13491
|
+
if (matches.length > 1) {
|
|
13492
|
+
return {
|
|
13493
|
+
ok: false,
|
|
13494
|
+
message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
|
|
13495
|
+
};
|
|
13496
|
+
}
|
|
13497
|
+
return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
|
|
13498
|
+
}
|
|
13499
|
+
function createModeController(pi, options = {}) {
|
|
13500
|
+
let active;
|
|
13501
|
+
let baseline;
|
|
13502
|
+
const updateStatus = (ctx) => {
|
|
13503
|
+
ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
|
|
13504
|
+
};
|
|
13505
|
+
const list = (ctx, listOptions = {}) => discoverAgentModes({
|
|
13506
|
+
cwd: ctx.cwd,
|
|
13507
|
+
agentDir: options.agentDir,
|
|
13508
|
+
bundledAgentsDir: options.bundledAgentsDir,
|
|
13509
|
+
homeDir: options.homeDir,
|
|
13510
|
+
projectTrusted: ctx.isProjectTrusted(),
|
|
13511
|
+
includeSkills: listOptions.includeSkills
|
|
13512
|
+
});
|
|
13513
|
+
return {
|
|
13514
|
+
list,
|
|
13515
|
+
async set(agent, ctx) {
|
|
13516
|
+
const catalog = await list(ctx, { includeSkills: agent.includes(":") });
|
|
13517
|
+
const result = resolveAgentMode(catalog.modes, agent);
|
|
13518
|
+
if (!result.ok || !result.active)
|
|
13519
|
+
return result;
|
|
13520
|
+
baseline ??= captureRuntime(pi, ctx);
|
|
13521
|
+
if (active && baseline)
|
|
13522
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
13523
|
+
active = result.active;
|
|
13524
|
+
const runtimeMessage = await applyModeRuntime(pi, active, ctx);
|
|
13525
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
|
|
13526
|
+
updateStatus(ctx);
|
|
13527
|
+
return { ...result, message: `${result.message} ${runtimeMessage}` };
|
|
13528
|
+
},
|
|
13529
|
+
async unset(ctx) {
|
|
13530
|
+
if (!active)
|
|
13531
|
+
return { ok: true, message: "Inline agent is already clear." };
|
|
13532
|
+
if (baseline)
|
|
13533
|
+
await restoreRuntime(pi, baseline, ctx);
|
|
13534
|
+
active = undefined;
|
|
13535
|
+
pi.appendEntry(MODE_STATE_ENTRY, { active: null });
|
|
13536
|
+
baseline = undefined;
|
|
13537
|
+
updateStatus(ctx);
|
|
13538
|
+
return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
|
|
13539
|
+
},
|
|
13540
|
+
async restore(ctx) {
|
|
13541
|
+
const previousActive = active;
|
|
13542
|
+
const previousBaseline = baseline;
|
|
13543
|
+
const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
|
|
13544
|
+
const restored = entry?.data?.active;
|
|
13545
|
+
const restoredBaseline = entry?.data?.baseline;
|
|
13546
|
+
if (isAgentModeSnapshot(restored)) {
|
|
13547
|
+
active = restored;
|
|
13548
|
+
baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
|
|
13549
|
+
await applyModeRuntime(pi, active, ctx);
|
|
13550
|
+
} else {
|
|
13551
|
+
if (previousActive && previousBaseline)
|
|
13552
|
+
pi.setActiveTools(previousBaseline.tools);
|
|
13553
|
+
active = undefined;
|
|
13554
|
+
baseline = undefined;
|
|
13555
|
+
}
|
|
13556
|
+
updateStatus(ctx);
|
|
13557
|
+
},
|
|
13558
|
+
apply(systemPrompt) {
|
|
13559
|
+
if (!active)
|
|
13560
|
+
return systemPrompt;
|
|
13561
|
+
if (active.promptStrategy === "replace")
|
|
13562
|
+
return active.systemPrompt;
|
|
13563
|
+
return `${systemPrompt}
|
|
13564
|
+
|
|
13565
|
+
## Active inline agent: ${active.label}
|
|
13566
|
+
|
|
13567
|
+
${active.systemPrompt}`;
|
|
13568
|
+
},
|
|
13569
|
+
getActive() {
|
|
13570
|
+
return active;
|
|
13571
|
+
}
|
|
13572
|
+
};
|
|
13573
|
+
}
|
|
13574
|
+
var MODE_STATE_ENTRY = "diffpi-mode-state";
|
|
13575
|
+
var MODE_STATUS_KEY = "diffpi-mode";
|
|
13576
|
+
var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
|
|
13577
|
+
var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
|
|
13578
|
+
var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
|
13579
|
+
async function applyModeRuntime(pi, mode, ctx) {
|
|
13580
|
+
let selectedModel;
|
|
13581
|
+
if (mode.modelPreferences.length > 0) {
|
|
13582
|
+
const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
|
|
13583
|
+
const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
|
|
13584
|
+
for (const preference of mode.modelPreferences) {
|
|
13585
|
+
const model = findPreferredModel(availableModels, preference);
|
|
13586
|
+
if (model && await pi.setModel(model)) {
|
|
13587
|
+
selectedModel = `${model.provider}/${model.id}`;
|
|
13588
|
+
break;
|
|
13589
|
+
}
|
|
13590
|
+
}
|
|
13591
|
+
}
|
|
13592
|
+
if (mode.thinkingLevel)
|
|
13593
|
+
pi.setThinkingLevel(mode.thinkingLevel);
|
|
13594
|
+
if (mode.tools.length > 0) {
|
|
13595
|
+
const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
13596
|
+
const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
|
|
13597
|
+
if (selectedTools.length > 0)
|
|
13598
|
+
pi.setActiveTools(selectedTools);
|
|
13599
|
+
}
|
|
13600
|
+
const parts = [];
|
|
13601
|
+
if (mode.modelPreferences.length > 0) {
|
|
13602
|
+
parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
|
|
13603
|
+
}
|
|
13604
|
+
if (mode.thinkingLevel)
|
|
13605
|
+
parts.push(`Thinking: ${mode.thinkingLevel}.`);
|
|
13606
|
+
if (mode.tools.length > 0)
|
|
13607
|
+
parts.push("Applied the profile tool set.");
|
|
13608
|
+
return parts.join(" ") || "The profile changes the prompt only.";
|
|
13609
|
+
}
|
|
13610
|
+
async function restoreRuntime(pi, state, ctx) {
|
|
13611
|
+
if (state.model) {
|
|
13612
|
+
const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
|
|
13613
|
+
if (model)
|
|
13614
|
+
await pi.setModel(model);
|
|
13615
|
+
}
|
|
13616
|
+
pi.setThinkingLevel(state.thinkingLevel);
|
|
13617
|
+
pi.setActiveTools(state.tools);
|
|
13618
|
+
}
|
|
13619
|
+
function captureRuntime(pi, ctx) {
|
|
13620
|
+
return {
|
|
13621
|
+
model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
|
|
13622
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
13623
|
+
tools: pi.getActiveTools()
|
|
13624
|
+
};
|
|
13625
|
+
}
|
|
13626
|
+
async function loadSkillModes(skillsDir, source, modes, diagnostics) {
|
|
13627
|
+
const entries = await readDirectoryIfExists(skillsDir);
|
|
13628
|
+
for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13629
|
+
await loadAgentModes(join5(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
|
|
13630
|
+
}
|
|
13631
|
+
}
|
|
13632
|
+
async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
|
|
13633
|
+
const entries = await readDirectoryIfExists(directory);
|
|
13634
|
+
for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
13635
|
+
const path = join5(directory, entry.name);
|
|
13636
|
+
try {
|
|
13637
|
+
const content = await readFile2(path, "utf8");
|
|
13638
|
+
const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
13639
|
+
if (frontmatter.enabled === false || frontmatter.inline === false)
|
|
13640
|
+
continue;
|
|
13641
|
+
const name = getFrontmatterText(frontmatter.name) ?? basename(path, extname(path));
|
|
13642
|
+
const systemPrompt = body.trim();
|
|
13643
|
+
if (!name || name.includes(":") || !systemPrompt) {
|
|
13644
|
+
diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
|
|
13645
|
+
continue;
|
|
13646
|
+
}
|
|
13647
|
+
const id = skillName ? `${skillName}:${name}` : name;
|
|
13648
|
+
modes.set(id, {
|
|
13649
|
+
id,
|
|
13650
|
+
label: getFrontmatterText(frontmatter.display_name) ?? name,
|
|
13651
|
+
description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename(path)}`,
|
|
13652
|
+
systemPrompt,
|
|
13653
|
+
promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
|
|
13654
|
+
modelPreferences: [
|
|
13655
|
+
...getFrontmatterList(frontmatter.model),
|
|
13656
|
+
...getFrontmatterList(frontmatter.model_fallbacks)
|
|
13657
|
+
],
|
|
13658
|
+
thinkingLevel: getThinkingLevel(frontmatter.thinking),
|
|
13659
|
+
tools: getFrontmatterList(frontmatter.tools),
|
|
13660
|
+
source,
|
|
13661
|
+
sourcePath: path
|
|
13662
|
+
});
|
|
13663
|
+
} catch (error) {
|
|
13664
|
+
diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
13665
|
+
}
|
|
13666
|
+
}
|
|
13667
|
+
}
|
|
13668
|
+
function getFrontmatterText(value) {
|
|
13669
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
13670
|
+
}
|
|
13671
|
+
function getFrontmatterList(value) {
|
|
13672
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
13673
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
13674
|
+
}
|
|
13675
|
+
function getThinkingLevel(value) {
|
|
13676
|
+
const level = getFrontmatterText(value);
|
|
13677
|
+
return level && THINKING_LEVELS.has(level) ? level : undefined;
|
|
13678
|
+
}
|
|
13679
|
+
function isAgentModeSnapshot(value) {
|
|
13680
|
+
if (!value || typeof value !== "object")
|
|
13681
|
+
return false;
|
|
13682
|
+
const candidate = value;
|
|
13683
|
+
return typeof candidate.id === "string" && typeof candidate.label === "string" && typeof candidate.description === "string" && typeof candidate.systemPrompt === "string" && (candidate.promptStrategy === "append" || candidate.promptStrategy === "replace") && Array.isArray(candidate.modelPreferences) && (candidate.thinkingLevel === undefined || THINKING_LEVELS.has(candidate.thinkingLevel)) && Array.isArray(candidate.tools) && typeof candidate.source === "string" && typeof candidate.sourcePath === "string";
|
|
13684
|
+
}
|
|
13685
|
+
function isModeBaseline(value) {
|
|
13686
|
+
if (!value || typeof value !== "object")
|
|
13687
|
+
return false;
|
|
13688
|
+
const candidate = value;
|
|
13689
|
+
const model = candidate.model;
|
|
13690
|
+
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");
|
|
13691
|
+
}
|
|
13692
|
+
|
|
13326
13693
|
// src/tools/reload.ts
|
|
13327
13694
|
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
13328
|
-
import { z } from "zod";
|
|
13329
|
-
var reloadParametersSchema =
|
|
13330
|
-
var reloadParameters =
|
|
13695
|
+
import { z as z2 } from "zod";
|
|
13696
|
+
var reloadParametersSchema = z2.object({});
|
|
13697
|
+
var reloadParameters = z2.toJSONSchema(reloadParametersSchema, { io: "input" });
|
|
13331
13698
|
function createDiffpiReloadTool(pi) {
|
|
13332
13699
|
return defineTool({
|
|
13333
13700
|
name: "diffpi_reload",
|
|
@@ -13350,21 +13717,2280 @@ function createDiffpiReloadTool(pi) {
|
|
|
13350
13717
|
});
|
|
13351
13718
|
}
|
|
13352
13719
|
|
|
13720
|
+
// src/tools/modes.ts
|
|
13721
|
+
import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
|
|
13722
|
+
import { z as z3 } from "zod";
|
|
13723
|
+
var emptyParametersSchema = z3.object({});
|
|
13724
|
+
var emptyParameters = z3.toJSONSchema(emptyParametersSchema, { io: "input" });
|
|
13725
|
+
var listParametersSchema = z3.object({
|
|
13726
|
+
includeSkills: z3.boolean().optional().describe("Include skill-owned agents using skill:agent ids.")
|
|
13727
|
+
});
|
|
13728
|
+
var listParameters = z3.toJSONSchema(listParametersSchema, { io: "input" });
|
|
13729
|
+
var setParametersSchema = z3.object({
|
|
13730
|
+
agent: z3.string().trim().min(1).describe("Inline agent id from diffpi_modes_list.")
|
|
13731
|
+
});
|
|
13732
|
+
var setParameters = z3.toJSONSchema(setParametersSchema, { io: "input" });
|
|
13733
|
+
function createModeTools(controller) {
|
|
13734
|
+
return [
|
|
13735
|
+
defineTool2({
|
|
13736
|
+
name: "diffpi_modes_list",
|
|
13737
|
+
label: "diffpi modes list",
|
|
13738
|
+
description: "List inline agents shared with the subagent plugin, optionally including skill-owned agents.",
|
|
13739
|
+
promptSnippet: "List inline agents before selecting one when the requested agent is unclear",
|
|
13740
|
+
promptGuidelines: [
|
|
13741
|
+
"Call diffpi_modes_list when the user asks which inline agents are available.",
|
|
13742
|
+
"Set includeSkills to true only when the user asks for skill agents or runs /skill:mode --include-skills.",
|
|
13743
|
+
"Inline mode applies the profile prompt, first available preferred model, thinking level, and available tools."
|
|
13744
|
+
],
|
|
13745
|
+
parameters: listParameters,
|
|
13746
|
+
executionMode: "parallel",
|
|
13747
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13748
|
+
const params = listParametersSchema.parse(input);
|
|
13749
|
+
const catalog = await controller.list(ctx, { includeSkills: params.includeSkills });
|
|
13750
|
+
return {
|
|
13751
|
+
content: [{ type: "text", text: formatCatalog(catalog, controller.getActive()?.id) }],
|
|
13752
|
+
details: { active: controller.getActive()?.id, catalog }
|
|
13753
|
+
};
|
|
13754
|
+
}
|
|
13755
|
+
}),
|
|
13756
|
+
defineTool2({
|
|
13757
|
+
name: "diffpi_modes_set",
|
|
13758
|
+
label: "diffpi modes set",
|
|
13759
|
+
description: "Set a validated available agent as the inline behavioral agent for subsequent chat turns.",
|
|
13760
|
+
promptSnippet: "Set the inline behavioral agent only after the user chooses one",
|
|
13761
|
+
promptGuidelines: [
|
|
13762
|
+
"Call diffpi_modes_set only after the user explicitly selects an agent.",
|
|
13763
|
+
"Use the exact skill:agent id for a skill-owned agent.",
|
|
13764
|
+
"The selected prompt takes effect on the next model turn."
|
|
13765
|
+
],
|
|
13766
|
+
parameters: setParameters,
|
|
13767
|
+
executionMode: "sequential",
|
|
13768
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13769
|
+
const params = setParametersSchema.parse(input);
|
|
13770
|
+
const result = await controller.set(params.agent, ctx);
|
|
13771
|
+
if (!result.ok)
|
|
13772
|
+
throw new Error(result.message);
|
|
13773
|
+
return {
|
|
13774
|
+
content: [{ type: "text", text: `${result.message} The prompt takes effect on the next turn.` }],
|
|
13775
|
+
details: { active: result.active }
|
|
13776
|
+
};
|
|
13777
|
+
}
|
|
13778
|
+
}),
|
|
13779
|
+
defineTool2({
|
|
13780
|
+
name: "diffpi_modes_unset",
|
|
13781
|
+
label: "diffpi modes unset",
|
|
13782
|
+
description: "Clear the inline behavioral agent and restore default Pi prompting for subsequent turns.",
|
|
13783
|
+
promptSnippet: "Clear the inline agent when the user asks for default behavior",
|
|
13784
|
+
promptGuidelines: [
|
|
13785
|
+
"Call diffpi_modes_unset only when the user explicitly asks to clear the active inline agent."
|
|
13786
|
+
],
|
|
13787
|
+
parameters: emptyParameters,
|
|
13788
|
+
executionMode: "sequential",
|
|
13789
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13790
|
+
emptyParametersSchema.parse(input);
|
|
13791
|
+
const result = await controller.unset(ctx);
|
|
13792
|
+
return {
|
|
13793
|
+
content: [{ type: "text", text: result.message }],
|
|
13794
|
+
details: { active: controller.getActive()?.id }
|
|
13795
|
+
};
|
|
13796
|
+
}
|
|
13797
|
+
})
|
|
13798
|
+
];
|
|
13799
|
+
}
|
|
13800
|
+
function formatCatalog(catalog, active) {
|
|
13801
|
+
const lines = [`Active inline agent: ${active ?? "default"}.`, "", "Available inline agents:"];
|
|
13802
|
+
for (const mode of catalog.modes) {
|
|
13803
|
+
const runtime = [mode.modelPreferences[0], mode.thinkingLevel].filter(Boolean).join(", ");
|
|
13804
|
+
lines.push(`- ${mode.id} [${mode.promptStrategy}${runtime ? `; ${runtime}` : ""}] — ${sanitize(mode.description)} (${mode.source})`);
|
|
13805
|
+
}
|
|
13806
|
+
if (catalog.diagnostics.length > 0) {
|
|
13807
|
+
lines.push("", "Skipped agent files:");
|
|
13808
|
+
for (const diagnostic of catalog.diagnostics)
|
|
13809
|
+
lines.push(`- ${sanitize(diagnostic)}`);
|
|
13810
|
+
}
|
|
13811
|
+
lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
|
|
13812
|
+
return lines.join(`
|
|
13813
|
+
`);
|
|
13814
|
+
}
|
|
13815
|
+
function sanitize(value) {
|
|
13816
|
+
return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
|
|
13817
|
+
}
|
|
13818
|
+
|
|
13819
|
+
// src/tools/review.ts
|
|
13820
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
13821
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile8, stat, writeFile as writeFile4 } from "node:fs/promises";
|
|
13822
|
+
import { join as join11 } from "node:path";
|
|
13823
|
+
import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
|
|
13824
|
+
import { z as z6 } from "zod";
|
|
13825
|
+
|
|
13826
|
+
// src/environment.ts
|
|
13827
|
+
import { basename as basename2 } from "node:path";
|
|
13828
|
+
|
|
13829
|
+
// src/process.ts
|
|
13830
|
+
import { constants } from "node:fs";
|
|
13831
|
+
import { access } from "node:fs/promises";
|
|
13832
|
+
import { delimiter, join as join6 } from "node:path";
|
|
13833
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
13834
|
+
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
13835
|
+
async function findExecutable(name) {
|
|
13836
|
+
if (name.includes("/")) {
|
|
13837
|
+
try {
|
|
13838
|
+
await access(name, constants.X_OK);
|
|
13839
|
+
return name;
|
|
13840
|
+
} catch {
|
|
13841
|
+
return;
|
|
13842
|
+
}
|
|
13843
|
+
}
|
|
13844
|
+
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
13845
|
+
if (!directory)
|
|
13846
|
+
continue;
|
|
13847
|
+
const candidate = join6(directory, name);
|
|
13848
|
+
try {
|
|
13849
|
+
await access(candidate, constants.X_OK);
|
|
13850
|
+
return candidate;
|
|
13851
|
+
} catch {}
|
|
13852
|
+
}
|
|
13853
|
+
return;
|
|
13854
|
+
}
|
|
13855
|
+
function run(command, args, options = {}) {
|
|
13856
|
+
return new Promise((resolve, reject) => {
|
|
13857
|
+
const child = spawn2(command, args, {
|
|
13858
|
+
cwd: options.cwd,
|
|
13859
|
+
env: options.env ?? process.env,
|
|
13860
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
|
|
13861
|
+
});
|
|
13862
|
+
let stdout = "";
|
|
13863
|
+
let stderr = "";
|
|
13864
|
+
const stdoutChunks = [];
|
|
13865
|
+
const stderrChunks = [];
|
|
13866
|
+
const unbounded = options.capture === "unbounded";
|
|
13867
|
+
child.stdout?.on("data", (chunk) => {
|
|
13868
|
+
const text = chunk.toString();
|
|
13869
|
+
if (unbounded)
|
|
13870
|
+
stdoutChunks.push(text);
|
|
13871
|
+
else
|
|
13872
|
+
stdout = appendBounded(stdout, text);
|
|
13873
|
+
});
|
|
13874
|
+
child.stderr?.on("data", (chunk) => {
|
|
13875
|
+
const text = chunk.toString();
|
|
13876
|
+
if (unbounded)
|
|
13877
|
+
stderrChunks.push(text);
|
|
13878
|
+
else
|
|
13879
|
+
stderr = appendBounded(stderr, text);
|
|
13880
|
+
});
|
|
13881
|
+
child.on("error", reject);
|
|
13882
|
+
child.on("close", (code) => resolve({
|
|
13883
|
+
code: code ?? 1,
|
|
13884
|
+
stdout: unbounded ? stdoutChunks.join("") : stdout,
|
|
13885
|
+
stderr: unbounded ? stderrChunks.join("") : stderr
|
|
13886
|
+
}));
|
|
13887
|
+
if (options.input !== undefined && child.stdin)
|
|
13888
|
+
child.stdin.end(options.input);
|
|
13889
|
+
});
|
|
13890
|
+
}
|
|
13891
|
+
async function runChecked(command, args, options = {}) {
|
|
13892
|
+
const result = await run(command, args, options);
|
|
13893
|
+
if (result.code === 0)
|
|
13894
|
+
return result;
|
|
13895
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
13896
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
13897
|
+
}
|
|
13898
|
+
function appendBounded(current, next) {
|
|
13899
|
+
const combined = current + next;
|
|
13900
|
+
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
13901
|
+
}
|
|
13902
|
+
|
|
13903
|
+
// src/zed.ts
|
|
13904
|
+
import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
|
|
13905
|
+
import { homedir as homedir4 } from "node:os";
|
|
13906
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
13907
|
+
var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
|
|
13908
|
+
var REVIEW_KEYBINDING = "cmd-alt-r";
|
|
13909
|
+
var REVIEW_TASK = {
|
|
13910
|
+
label: ZED_REVIEW_TASK_NAME,
|
|
13911
|
+
command: "tuicr",
|
|
13912
|
+
args: ["-w"],
|
|
13913
|
+
cwd: "$ZED_WORKTREE_ROOT",
|
|
13914
|
+
use_new_terminal: true,
|
|
13915
|
+
reveal: "always"
|
|
13916
|
+
};
|
|
13917
|
+
function zedTasksPath(homeDir = homedir4()) {
|
|
13918
|
+
return join7(homeDir, ".config", "zed", "tasks.json");
|
|
13919
|
+
}
|
|
13920
|
+
function zedKeymapPath(homeDir = homedir4()) {
|
|
13921
|
+
return join7(homeDir, ".config", "zed", "keymap.json");
|
|
13922
|
+
}
|
|
13923
|
+
async function ensureZedReviewTask(homeDir = homedir4()) {
|
|
13924
|
+
const path = zedTasksPath(homeDir);
|
|
13925
|
+
const currentText = await readOptional(path);
|
|
13926
|
+
const tasks = parseJsonArray(currentText, path);
|
|
13927
|
+
const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
|
|
13928
|
+
const next = [...tasks];
|
|
13929
|
+
if (index >= 0)
|
|
13930
|
+
next[index] = { ...tasks[index], ...REVIEW_TASK };
|
|
13931
|
+
else
|
|
13932
|
+
next.push(REVIEW_TASK);
|
|
13933
|
+
const changed = JSON.stringify(tasks) !== JSON.stringify(next);
|
|
13934
|
+
if (changed)
|
|
13935
|
+
await writeJson(path, next);
|
|
13936
|
+
return { path, changed, existed: currentText !== undefined };
|
|
13937
|
+
}
|
|
13938
|
+
async function ensureZedReviewKeybinding(homeDir = homedir4()) {
|
|
13939
|
+
const path = zedKeymapPath(homeDir);
|
|
13940
|
+
const currentText = await readOptional(path);
|
|
13941
|
+
const entries = parseJsonArray(currentText, path);
|
|
13942
|
+
const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
|
|
13943
|
+
if (alreadyBound)
|
|
13944
|
+
return { path, changed: false, existed: currentText !== undefined };
|
|
13945
|
+
const next = [
|
|
13946
|
+
...entries,
|
|
13947
|
+
{ context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
|
|
13948
|
+
];
|
|
13949
|
+
await writeJson(path, next);
|
|
13950
|
+
return { path, changed: true, existed: currentText !== undefined };
|
|
13951
|
+
}
|
|
13952
|
+
function bindsReviewTask(payload) {
|
|
13953
|
+
return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
|
|
13954
|
+
}
|
|
13955
|
+
async function readOptional(path) {
|
|
13956
|
+
try {
|
|
13957
|
+
return await readFile3(path, "utf8");
|
|
13958
|
+
} catch (error) {
|
|
13959
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13960
|
+
return;
|
|
13961
|
+
throw error;
|
|
13962
|
+
}
|
|
13963
|
+
}
|
|
13964
|
+
function parseJsonArray(content, path) {
|
|
13965
|
+
if (!content?.trim())
|
|
13966
|
+
return [];
|
|
13967
|
+
let value;
|
|
13968
|
+
try {
|
|
13969
|
+
value = JSON.parse(content);
|
|
13970
|
+
} catch {
|
|
13971
|
+
throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
|
|
13972
|
+
}
|
|
13973
|
+
if (!Array.isArray(value))
|
|
13974
|
+
throw new Error(`Expected a JSON array in ${path}.`);
|
|
13975
|
+
return value;
|
|
13976
|
+
}
|
|
13977
|
+
async function writeJson(path, value) {
|
|
13978
|
+
await mkdir(dirname3(path), { recursive: true });
|
|
13979
|
+
await writeFile(path, `${JSON.stringify(value, null, 2)}
|
|
13980
|
+
`, "utf8");
|
|
13981
|
+
}
|
|
13982
|
+
|
|
13983
|
+
// src/environment.ts
|
|
13984
|
+
function detectIde(env = process.env) {
|
|
13985
|
+
const program = (env.TERM_PROGRAM ?? "").toLowerCase();
|
|
13986
|
+
if (env.ZED_TERM === "true" || program === "zed")
|
|
13987
|
+
return "zed";
|
|
13988
|
+
if (env.CURSOR_TRACE_ID || program === "cursor")
|
|
13989
|
+
return "cursor";
|
|
13990
|
+
if (env.WINDSURF_ENV || program === "windsurf")
|
|
13991
|
+
return "windsurf";
|
|
13992
|
+
if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
|
|
13993
|
+
return "jetbrains";
|
|
13994
|
+
if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
|
|
13995
|
+
return "vscode";
|
|
13996
|
+
return "unknown";
|
|
13997
|
+
}
|
|
13998
|
+
function detectMux(env = process.env) {
|
|
13999
|
+
if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
|
|
14000
|
+
return "zellij";
|
|
14001
|
+
if (env.TMUX)
|
|
14002
|
+
return "tmux";
|
|
14003
|
+
if (env.STY)
|
|
14004
|
+
return "screen";
|
|
14005
|
+
return "none";
|
|
14006
|
+
}
|
|
14007
|
+
function detectShell(env = process.env) {
|
|
14008
|
+
return env.SHELL ? basename2(env.SHELL) : "unknown";
|
|
14009
|
+
}
|
|
14010
|
+
async function detectVcs(cwd) {
|
|
14011
|
+
const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
|
|
14012
|
+
const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
|
|
14013
|
+
const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
|
|
14014
|
+
return { ...parseRemote(remote), branch, root };
|
|
14015
|
+
}
|
|
14016
|
+
function parseRemote(remote) {
|
|
14017
|
+
const empty = { provider: "none", host: "", owner: "", repo: "" };
|
|
14018
|
+
if (!remote)
|
|
14019
|
+
return empty;
|
|
14020
|
+
const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
|
|
14021
|
+
const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
|
|
14022
|
+
const match = scp ?? url;
|
|
14023
|
+
if (!match)
|
|
14024
|
+
return empty;
|
|
14025
|
+
const host = match[1];
|
|
14026
|
+
const segments = match[2].split("/").filter(Boolean);
|
|
14027
|
+
if (segments.length < 2)
|
|
14028
|
+
return { ...empty, host };
|
|
14029
|
+
const repo = segments.at(-1) ?? "";
|
|
14030
|
+
const owner = segments.slice(0, -1).join("/");
|
|
14031
|
+
const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
|
|
14032
|
+
return { provider, host, owner, repo };
|
|
14033
|
+
}
|
|
14034
|
+
async function openInNewTab(command, opts) {
|
|
14035
|
+
const env = opts.env ?? process.env;
|
|
14036
|
+
const name = opts.name ?? "review";
|
|
14037
|
+
const printable = command.join(" ");
|
|
14038
|
+
const mux = detectMux(env);
|
|
14039
|
+
if (mux !== "none") {
|
|
14040
|
+
const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
|
|
14041
|
+
if (opened)
|
|
14042
|
+
return opened;
|
|
14043
|
+
}
|
|
14044
|
+
if (detectIde(env) === "zed") {
|
|
14045
|
+
try {
|
|
14046
|
+
await ensureZedReviewTask(opts.homeDir);
|
|
14047
|
+
return {
|
|
14048
|
+
launched: false,
|
|
14049
|
+
configured: true,
|
|
14050
|
+
via: "zed-task",
|
|
14051
|
+
command: printable,
|
|
14052
|
+
taskName: ZED_REVIEW_TASK_NAME,
|
|
14053
|
+
instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
|
|
14054
|
+
};
|
|
14055
|
+
} catch {}
|
|
14056
|
+
}
|
|
14057
|
+
return { launched: false, via: "print", command: printable };
|
|
14058
|
+
}
|
|
14059
|
+
async function openMuxTab(mux, command, cwd, name, printable) {
|
|
14060
|
+
if (mux === "zellij" && await findExecutable("zellij")) {
|
|
14061
|
+
const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
14062
|
+
if (result.code === 0)
|
|
14063
|
+
return { launched: true, via: "zellij", command: printable };
|
|
14064
|
+
const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
|
|
14065
|
+
if (fallback.code === 0)
|
|
14066
|
+
return { launched: true, via: "zellij-run", command: printable };
|
|
14067
|
+
}
|
|
14068
|
+
if (mux === "tmux" && await findExecutable("tmux")) {
|
|
14069
|
+
const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
|
|
14070
|
+
if (result.code === 0)
|
|
14071
|
+
return { launched: true, via: "tmux", command: printable };
|
|
14072
|
+
}
|
|
14073
|
+
if (mux === "screen" && await findExecutable("screen")) {
|
|
14074
|
+
const result = await run("screen", screenWindowArgs(command, cwd, name));
|
|
14075
|
+
if (result.code === 0)
|
|
14076
|
+
return { launched: true, via: "screen", command: printable };
|
|
14077
|
+
}
|
|
14078
|
+
return;
|
|
14079
|
+
}
|
|
14080
|
+
function screenWindowArgs(command, cwd, name) {
|
|
14081
|
+
return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
|
|
14082
|
+
}
|
|
14083
|
+
|
|
14084
|
+
// src/forge.ts
|
|
14085
|
+
function createForge(vcs) {
|
|
14086
|
+
if (vcs.provider === "github")
|
|
14087
|
+
return new GithubForge(vcs);
|
|
14088
|
+
if (vcs.provider === "gitlab")
|
|
14089
|
+
return new GitlabForge(vcs);
|
|
14090
|
+
throw new Error("No forge detected from the git remote. Use --local for an offline review.");
|
|
14091
|
+
}
|
|
14092
|
+
|
|
14093
|
+
class GithubForge {
|
|
14094
|
+
vcs;
|
|
14095
|
+
provider = "github";
|
|
14096
|
+
constructor(vcs) {
|
|
14097
|
+
this.vcs = vcs;
|
|
14098
|
+
}
|
|
14099
|
+
repoFlag() {
|
|
14100
|
+
return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
|
|
14101
|
+
}
|
|
14102
|
+
async createDraftPr(options) {
|
|
14103
|
+
const args = [
|
|
14104
|
+
"pr",
|
|
14105
|
+
"create",
|
|
14106
|
+
...this.repoFlag(),
|
|
14107
|
+
"--title",
|
|
14108
|
+
options.title,
|
|
14109
|
+
"--body",
|
|
14110
|
+
options.body,
|
|
14111
|
+
"--base",
|
|
14112
|
+
options.base,
|
|
14113
|
+
"--head",
|
|
14114
|
+
options.head
|
|
14115
|
+
];
|
|
14116
|
+
if (options.draft !== false)
|
|
14117
|
+
args.push("--draft");
|
|
14118
|
+
await runChecked("gh", args);
|
|
14119
|
+
const ref = await this.viewPr(options.head);
|
|
14120
|
+
if (!ref)
|
|
14121
|
+
throw new Error("Draft PR created but could not be resolved.");
|
|
14122
|
+
return ref;
|
|
14123
|
+
}
|
|
14124
|
+
async viewPr(idOrBranch) {
|
|
14125
|
+
const args = [
|
|
14126
|
+
"pr",
|
|
14127
|
+
"view",
|
|
14128
|
+
idOrBranch,
|
|
14129
|
+
...this.repoFlag(),
|
|
14130
|
+
"--json",
|
|
14131
|
+
"number,title,url,isDraft,baseRefName,headRefName,headRefOid"
|
|
14132
|
+
];
|
|
14133
|
+
const result = await run("gh", args);
|
|
14134
|
+
if (result.code !== 0) {
|
|
14135
|
+
if (isConfirmedMissingChange("github", result.stderr || result.stdout))
|
|
14136
|
+
return;
|
|
14137
|
+
throw commandFailure("gh", args, result);
|
|
14138
|
+
}
|
|
14139
|
+
if (!result.stdout.trim())
|
|
14140
|
+
throw new Error("GitHub returned an empty pull request response.");
|
|
14141
|
+
let data;
|
|
14142
|
+
try {
|
|
14143
|
+
data = JSON.parse(result.stdout);
|
|
14144
|
+
} catch {
|
|
14145
|
+
throw new Error("Cannot parse the GitHub pull request response as JSON.");
|
|
14146
|
+
}
|
|
14147
|
+
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") {
|
|
14148
|
+
throw new Error("GitHub returned an invalid pull request response.");
|
|
14149
|
+
}
|
|
14150
|
+
return {
|
|
14151
|
+
number: data.number,
|
|
14152
|
+
title: data.title,
|
|
14153
|
+
url: data.url,
|
|
14154
|
+
isDraft: data.isDraft,
|
|
14155
|
+
baseRef: data.baseRefName,
|
|
14156
|
+
headRef: data.headRefName,
|
|
14157
|
+
headSha: data.headRefOid
|
|
14158
|
+
};
|
|
14159
|
+
}
|
|
14160
|
+
async defaultBranch() {
|
|
14161
|
+
const result = await runChecked("gh", [
|
|
14162
|
+
"repo",
|
|
14163
|
+
"view",
|
|
14164
|
+
`${this.vcs.owner}/${this.vcs.repo}`,
|
|
14165
|
+
"--json",
|
|
14166
|
+
"defaultBranchRef",
|
|
14167
|
+
"--jq",
|
|
14168
|
+
".defaultBranchRef.name"
|
|
14169
|
+
]);
|
|
14170
|
+
return requireBranchName(result.stdout, "GitHub");
|
|
14171
|
+
}
|
|
14172
|
+
async prDiff(id) {
|
|
14173
|
+
const result = await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" });
|
|
14174
|
+
return result.stdout;
|
|
14175
|
+
}
|
|
14176
|
+
async prChecks(id) {
|
|
14177
|
+
const result = await run("gh", ["pr", "checks", String(id), ...this.repoFlag()]);
|
|
14178
|
+
return result.stdout;
|
|
14179
|
+
}
|
|
14180
|
+
async markReady(id) {
|
|
14181
|
+
await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
|
|
14182
|
+
}
|
|
14183
|
+
async closePr(id, comment) {
|
|
14184
|
+
const args = ["pr", "close", String(id), ...this.repoFlag()];
|
|
14185
|
+
if (comment)
|
|
14186
|
+
args.push("--comment", comment);
|
|
14187
|
+
await runChecked("gh", args);
|
|
14188
|
+
}
|
|
14189
|
+
async mergePr(id, subject) {
|
|
14190
|
+
const readiness = await runChecked("gh", [
|
|
14191
|
+
"pr",
|
|
14192
|
+
"view",
|
|
14193
|
+
String(id),
|
|
14194
|
+
...this.repoFlag(),
|
|
14195
|
+
"--json",
|
|
14196
|
+
"isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
|
|
14197
|
+
]);
|
|
14198
|
+
assertGitHubMergeReady(readiness.stdout);
|
|
14199
|
+
await runChecked("gh", ["pr", "merge", String(id), ...this.repoFlag(), "--squash", "--subject", subject]);
|
|
14200
|
+
}
|
|
14201
|
+
}
|
|
14202
|
+
|
|
14203
|
+
class GitlabForge {
|
|
14204
|
+
vcs;
|
|
14205
|
+
provider = "gitlab";
|
|
14206
|
+
constructor(vcs) {
|
|
14207
|
+
this.vcs = vcs;
|
|
14208
|
+
}
|
|
14209
|
+
project() {
|
|
14210
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
14211
|
+
}
|
|
14212
|
+
async createDraftPr(options) {
|
|
14213
|
+
await runChecked("glab", [
|
|
14214
|
+
"mr",
|
|
14215
|
+
"create",
|
|
14216
|
+
"--repo",
|
|
14217
|
+
this.project(),
|
|
14218
|
+
"--title",
|
|
14219
|
+
`Draft: ${options.title}`,
|
|
14220
|
+
"--description",
|
|
14221
|
+
options.body,
|
|
14222
|
+
"--target-branch",
|
|
14223
|
+
options.base,
|
|
14224
|
+
"--source-branch",
|
|
14225
|
+
options.head,
|
|
14226
|
+
"--yes"
|
|
14227
|
+
]);
|
|
14228
|
+
const ref = await this.viewPr(options.head);
|
|
14229
|
+
if (!ref)
|
|
14230
|
+
throw new Error("Draft MR created but could not be resolved.");
|
|
14231
|
+
return ref;
|
|
14232
|
+
}
|
|
14233
|
+
async viewPr(idOrBranch) {
|
|
14234
|
+
const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
|
|
14235
|
+
const result = await run("glab", args);
|
|
14236
|
+
if (result.code !== 0) {
|
|
14237
|
+
if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
|
|
14238
|
+
return;
|
|
14239
|
+
throw commandFailure("glab", args, result);
|
|
14240
|
+
}
|
|
14241
|
+
if (!result.stdout.trim())
|
|
14242
|
+
throw new Error("GitLab returned an empty merge request response.");
|
|
14243
|
+
let data;
|
|
14244
|
+
try {
|
|
14245
|
+
data = JSON.parse(result.stdout);
|
|
14246
|
+
} catch {
|
|
14247
|
+
throw new Error("Cannot parse the GitLab merge request response as JSON.");
|
|
14248
|
+
}
|
|
14249
|
+
if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
|
|
14250
|
+
throw new Error("GitLab returned an invalid merge request response.");
|
|
14251
|
+
}
|
|
14252
|
+
return {
|
|
14253
|
+
number: data.iid,
|
|
14254
|
+
title: data.title,
|
|
14255
|
+
url: data.web_url,
|
|
14256
|
+
isDraft: Boolean(data.draft ?? data.work_in_progress),
|
|
14257
|
+
baseRef: data.target_branch,
|
|
14258
|
+
headRef: data.source_branch,
|
|
14259
|
+
headSha: data.sha
|
|
14260
|
+
};
|
|
14261
|
+
}
|
|
14262
|
+
async defaultBranch() {
|
|
14263
|
+
const result = await runChecked("glab", [
|
|
14264
|
+
"api",
|
|
14265
|
+
`projects/${encodeURIComponent(this.project())}`,
|
|
14266
|
+
"--jq",
|
|
14267
|
+
".default_branch"
|
|
14268
|
+
]);
|
|
14269
|
+
return requireBranchName(result.stdout, "GitLab");
|
|
14270
|
+
}
|
|
14271
|
+
async prDiff(id) {
|
|
14272
|
+
return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
|
|
14273
|
+
}
|
|
14274
|
+
async prChecks(id) {
|
|
14275
|
+
return (await runChecked("glab", [
|
|
14276
|
+
"api",
|
|
14277
|
+
`projects/${encodeURIComponent(this.project())}/merge_requests/${id}/pipelines?per_page=100`
|
|
14278
|
+
])).stdout;
|
|
14279
|
+
}
|
|
14280
|
+
async markReady(id) {
|
|
14281
|
+
await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
|
|
14282
|
+
}
|
|
14283
|
+
async closePr(id, comment) {
|
|
14284
|
+
if (comment)
|
|
14285
|
+
await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
|
|
14286
|
+
await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
|
|
14287
|
+
}
|
|
14288
|
+
async mergePr() {
|
|
14289
|
+
throw new Error("Merge is not supported by the GitLab forge adapter.");
|
|
14290
|
+
}
|
|
14291
|
+
}
|
|
14292
|
+
function assertGitHubMergeReady(input) {
|
|
14293
|
+
let data;
|
|
14294
|
+
try {
|
|
14295
|
+
data = JSON.parse(input);
|
|
14296
|
+
} catch {
|
|
14297
|
+
throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
|
|
14298
|
+
}
|
|
14299
|
+
const blockers = [];
|
|
14300
|
+
if (data.state !== "OPEN")
|
|
14301
|
+
blockers.push(`pull request state is ${data.state ?? "unknown"}`);
|
|
14302
|
+
if (data.isDraft)
|
|
14303
|
+
blockers.push("pull request is still a draft");
|
|
14304
|
+
if (data.reviewDecision !== "APPROVED")
|
|
14305
|
+
blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
|
|
14306
|
+
if (data.mergeStateStatus !== "CLEAN")
|
|
14307
|
+
blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
|
|
14308
|
+
for (const check of data.statusCheckRollup ?? []) {
|
|
14309
|
+
const name = check.name ?? check.context ?? "unnamed check";
|
|
14310
|
+
if (check.__typename === "CheckRun") {
|
|
14311
|
+
if (check.status !== "COMPLETED")
|
|
14312
|
+
blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
|
|
14313
|
+
else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
|
|
14314
|
+
blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
|
|
14315
|
+
}
|
|
14316
|
+
} else if (check.state !== "SUCCESS")
|
|
14317
|
+
blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
|
|
14318
|
+
}
|
|
14319
|
+
if (blockers.length > 0)
|
|
14320
|
+
throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
|
|
14321
|
+
}
|
|
14322
|
+
function isConfirmedMissingChange(provider, output) {
|
|
14323
|
+
const message = output.toLowerCase();
|
|
14324
|
+
if (provider === "github") {
|
|
14325
|
+
return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
|
|
14326
|
+
}
|
|
14327
|
+
if (provider === "gitlab") {
|
|
14328
|
+
return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
|
|
14329
|
+
}
|
|
14330
|
+
return false;
|
|
14331
|
+
}
|
|
14332
|
+
function commandFailure(command, args, result) {
|
|
14333
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
14334
|
+
return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
14335
|
+
}
|
|
14336
|
+
function requireBranchName(output, provider) {
|
|
14337
|
+
const branch = output.trim();
|
|
14338
|
+
if (!branch || branch === "null")
|
|
14339
|
+
throw new Error(`${provider} did not return a default branch.`);
|
|
14340
|
+
return branch;
|
|
14341
|
+
}
|
|
14342
|
+
|
|
14343
|
+
// src/gates.ts
|
|
14344
|
+
var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
|
|
14345
|
+
var MISE_GATES = ["format:check", "lint", "test"];
|
|
14346
|
+
function checkConventionalSubject(subject) {
|
|
14347
|
+
const trimmed = subject.trim();
|
|
14348
|
+
const ok = CONVENTIONAL_COMMIT.test(trimmed);
|
|
14349
|
+
return {
|
|
14350
|
+
name: "conventional-subject",
|
|
14351
|
+
status: ok ? "pass" : "warn",
|
|
14352
|
+
detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
|
|
14353
|
+
};
|
|
14354
|
+
}
|
|
14355
|
+
async function runMiseGates(cwd) {
|
|
14356
|
+
const tasks = await discoverMiseTasks(cwd);
|
|
14357
|
+
const results = [];
|
|
14358
|
+
for (const gate of MISE_GATES) {
|
|
14359
|
+
const targets = tasks.get(gate) ?? [];
|
|
14360
|
+
if (targets.length === 0) {
|
|
14361
|
+
results.push({ name: gate, status: "skip", detail: "no mise recipe" });
|
|
14362
|
+
continue;
|
|
14363
|
+
}
|
|
14364
|
+
const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
|
|
14365
|
+
const result = await run("mise", ["run", ...invocations], { cwd });
|
|
14366
|
+
results.push({
|
|
14367
|
+
name: gate,
|
|
14368
|
+
status: result.code === 0 ? "pass" : "fail",
|
|
14369
|
+
detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
|
|
14370
|
+
});
|
|
14371
|
+
}
|
|
14372
|
+
return results;
|
|
14373
|
+
}
|
|
14374
|
+
function ciGate(checksOutput) {
|
|
14375
|
+
const text = checksOutput.toLowerCase();
|
|
14376
|
+
if (!text.trim())
|
|
14377
|
+
return { name: "ci", status: "skip", detail: "no CI output" };
|
|
14378
|
+
if (/\bfail|error\b/.test(text))
|
|
14379
|
+
return { name: "ci", status: "warn", detail: "CI failing" };
|
|
14380
|
+
if (/\bpending|in progress|queued\b/.test(text))
|
|
14381
|
+
return { name: "ci", status: "warn", detail: "CI pending" };
|
|
14382
|
+
return { name: "ci", status: "pass", detail: "CI green" };
|
|
14383
|
+
}
|
|
14384
|
+
async function discoverMiseTasks(cwd) {
|
|
14385
|
+
const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
|
|
14386
|
+
if (result.code !== 0)
|
|
14387
|
+
return new Map;
|
|
14388
|
+
return parseMiseTasks(result.stdout);
|
|
14389
|
+
}
|
|
14390
|
+
function parseMiseTasks(input) {
|
|
14391
|
+
let tasks;
|
|
14392
|
+
try {
|
|
14393
|
+
tasks = JSON.parse(input);
|
|
14394
|
+
} catch {
|
|
14395
|
+
return new Map;
|
|
14396
|
+
}
|
|
14397
|
+
if (!Array.isArray(tasks))
|
|
14398
|
+
return new Map;
|
|
14399
|
+
const found = new Map;
|
|
14400
|
+
for (const gate of MISE_GATES) {
|
|
14401
|
+
const targets = tasks.flatMap((task) => {
|
|
14402
|
+
if (typeof task.name !== "string")
|
|
14403
|
+
return [];
|
|
14404
|
+
return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
|
|
14405
|
+
});
|
|
14406
|
+
if (targets.length > 0)
|
|
14407
|
+
found.set(gate, [...new Set(targets)]);
|
|
14408
|
+
}
|
|
14409
|
+
return found;
|
|
14410
|
+
}
|
|
14411
|
+
|
|
14412
|
+
// src/review-backend.ts
|
|
14413
|
+
import { readFile as readFile5, writeFile as writeFile2 } from "node:fs/promises";
|
|
14414
|
+
|
|
14415
|
+
// src/review.ts
|
|
14416
|
+
import { z as z4 } from "zod";
|
|
14417
|
+
var severitySchema = z4.enum(["BLOCKING", "CONSIDER", "NOTE"]);
|
|
14418
|
+
var findingSchema = z4.object({
|
|
14419
|
+
file: z4.string().min(1),
|
|
14420
|
+
line: z4.number().int().nonnegative(),
|
|
14421
|
+
severity: severitySchema,
|
|
14422
|
+
body: z4.string().min(1),
|
|
14423
|
+
reference: z4.string().optional().default("")
|
|
14424
|
+
});
|
|
14425
|
+
var findingsSchema = z4.array(findingSchema);
|
|
14426
|
+
var reviewThreadRecordSchema = z4.object({
|
|
14427
|
+
id: z4.string().min(1),
|
|
14428
|
+
file: z4.string().optional(),
|
|
14429
|
+
line: z4.number().int().positive().optional(),
|
|
14430
|
+
body: z4.string(),
|
|
14431
|
+
author: z4.string().optional(),
|
|
14432
|
+
resolved: z4.boolean(),
|
|
14433
|
+
question: z4.boolean(),
|
|
14434
|
+
replies: z4.array(z4.string()).optional()
|
|
14435
|
+
});
|
|
14436
|
+
function reviewSlug(input) {
|
|
14437
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
|
|
14438
|
+
}
|
|
14439
|
+
function yymmdd(date = new Date) {
|
|
14440
|
+
const yy = String(date.getFullYear() % 100).padStart(2, "0");
|
|
14441
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
14442
|
+
const dd = String(date.getDate()).padStart(2, "0");
|
|
14443
|
+
return `${yy}${mm}${dd}`;
|
|
14444
|
+
}
|
|
14445
|
+
function reviewRecordName(target, date = new Date) {
|
|
14446
|
+
return `${yymmdd(date)}-${reviewSlug(target) || "local"}`;
|
|
14447
|
+
}
|
|
14448
|
+
function dedupeFindings(findings) {
|
|
14449
|
+
const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
|
|
14450
|
+
const byKey = new Map;
|
|
14451
|
+
for (const finding of findings) {
|
|
14452
|
+
const key = `${finding.file}:${finding.line}`;
|
|
14453
|
+
const existing = byKey.get(key);
|
|
14454
|
+
if (!existing || rank[finding.severity] > rank[existing.severity])
|
|
14455
|
+
byKey.set(key, finding);
|
|
14456
|
+
}
|
|
14457
|
+
return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
|
|
14458
|
+
}
|
|
14459
|
+
function toReviewComments(findings, model) {
|
|
14460
|
+
const comments = [];
|
|
14461
|
+
for (const finding of findings) {
|
|
14462
|
+
if (finding.line <= 0)
|
|
14463
|
+
continue;
|
|
14464
|
+
const body = renderCommentBody(finding);
|
|
14465
|
+
comments.push({
|
|
14466
|
+
file: finding.file,
|
|
14467
|
+
line: finding.line,
|
|
14468
|
+
side: "RIGHT",
|
|
14469
|
+
body: model ? withRemoteProvenance(body, model) : body
|
|
14470
|
+
});
|
|
14471
|
+
}
|
|
14472
|
+
return comments;
|
|
14473
|
+
}
|
|
14474
|
+
function withRemoteProvenance(body, model) {
|
|
14475
|
+
const normalized = body.trimEnd();
|
|
14476
|
+
if (/Generated review by Diffpi using `[^`]+`\.$/.test(normalized))
|
|
14477
|
+
return body;
|
|
14478
|
+
return `${normalized}
|
|
14479
|
+
|
|
14480
|
+
Generated review by Diffpi using \`${model}\`.`;
|
|
14481
|
+
}
|
|
14482
|
+
function localReviewAuthor(model) {
|
|
14483
|
+
return `Agent: ${model}`;
|
|
14484
|
+
}
|
|
14485
|
+
function renderReviewDoc(input) {
|
|
14486
|
+
const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
|
|
14487
|
+
const lines = [`# Review: ${input.title}`, "", "## Metadata"];
|
|
14488
|
+
if (input.number !== undefined)
|
|
14489
|
+
lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
|
|
14490
|
+
if (input.author)
|
|
14491
|
+
lines.push(`- **Author**: ${input.author}`);
|
|
14492
|
+
if (input.model)
|
|
14493
|
+
lines.push(`- **Review agent**: ${input.model}`);
|
|
14494
|
+
if (input.headRef && input.baseRef)
|
|
14495
|
+
lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
|
|
14496
|
+
if (input.additions !== undefined)
|
|
14497
|
+
lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
|
|
14498
|
+
lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
|
|
14499
|
+
if (input.overallIssues.length > 0) {
|
|
14500
|
+
lines.push("## Overall issues", "");
|
|
14501
|
+
for (const issue of input.overallIssues)
|
|
14502
|
+
lines.push(`- ${issue}`);
|
|
14503
|
+
lines.push("");
|
|
14504
|
+
}
|
|
14505
|
+
lines.push("## Verification", "");
|
|
14506
|
+
for (const gate of input.gates)
|
|
14507
|
+
lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
|
|
14508
|
+
lines.push("");
|
|
14509
|
+
if (input.notVerified.length > 0) {
|
|
14510
|
+
lines.push("## What was NOT verified", "");
|
|
14511
|
+
for (const item of input.notVerified)
|
|
14512
|
+
lines.push(`- ${item}`);
|
|
14513
|
+
lines.push("");
|
|
14514
|
+
}
|
|
14515
|
+
lines.push("## Inline Comments", "");
|
|
14516
|
+
for (const finding of anchored) {
|
|
14517
|
+
lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
|
|
14518
|
+
if (finding.reference)
|
|
14519
|
+
lines.push(`> **Reference:** ${finding.reference}`, "");
|
|
14520
|
+
lines.push("---", "");
|
|
14521
|
+
}
|
|
14522
|
+
return `${lines.join(`
|
|
14523
|
+
`).trimEnd()}
|
|
14524
|
+
`;
|
|
14525
|
+
}
|
|
14526
|
+
function renderThreadArtifact(title, target, threads, options = {}) {
|
|
14527
|
+
const records = threads.map((thread) => ({
|
|
14528
|
+
id: thread.id,
|
|
14529
|
+
file: thread.file,
|
|
14530
|
+
line: thread.line,
|
|
14531
|
+
body: thread.body,
|
|
14532
|
+
author: thread.author,
|
|
14533
|
+
resolved: thread.resolved,
|
|
14534
|
+
question: thread.question,
|
|
14535
|
+
replies: thread.replies
|
|
14536
|
+
}));
|
|
14537
|
+
const payload = Buffer.from(JSON.stringify(records), "utf8").toString("base64url");
|
|
14538
|
+
const lines = [
|
|
14539
|
+
`<!-- diffpi-threads:${payload} -->`,
|
|
14540
|
+
`# Review threads: ${title}`,
|
|
14541
|
+
"",
|
|
14542
|
+
"## Metadata",
|
|
14543
|
+
"",
|
|
14544
|
+
`- Target: ${target}`,
|
|
14545
|
+
`- Pulled: ${options.timestamp ?? new Date().toISOString()}`
|
|
14546
|
+
];
|
|
14547
|
+
if (options.number !== undefined)
|
|
14548
|
+
lines.push(`- PR/MR: #${options.number}${options.url ? ` — ${options.url}` : ""}`);
|
|
14549
|
+
lines.push("", "## Replies", "");
|
|
14550
|
+
for (const thread of threads) {
|
|
14551
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
14552
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} (${thread.id})`, "", `<!-- diffpi-reply-start:${id} -->`, thread.reply ?? "", `<!-- diffpi-reply-end:${id} -->`, "");
|
|
14553
|
+
}
|
|
14554
|
+
lines.push("## Source comments", "");
|
|
14555
|
+
for (const thread of threads) {
|
|
14556
|
+
lines.push(`### ${thread.file ?? "review"}:${thread.line ?? "n/a"} — ${thread.author ?? "unknown"}`, "", `Thread: ${thread.id}`, "", thread.body, "", "---", "");
|
|
14557
|
+
}
|
|
14558
|
+
return `${lines.join(`
|
|
14559
|
+
`).trimEnd()}
|
|
14560
|
+
`;
|
|
14561
|
+
}
|
|
14562
|
+
function parseThreadArtifact(content) {
|
|
14563
|
+
const payload = content.match(/^<!-- diffpi-threads:([A-Za-z0-9_-]+) -->$/m)?.[1];
|
|
14564
|
+
if (!payload)
|
|
14565
|
+
throw new Error("This file is not a Diffpi thread artifact.");
|
|
14566
|
+
let threads;
|
|
14567
|
+
try {
|
|
14568
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
|
|
14569
|
+
threads = z4.array(reviewThreadRecordSchema).parse(decoded);
|
|
14570
|
+
} catch {
|
|
14571
|
+
throw new Error("Cannot parse the Diffpi thread artifact payload.");
|
|
14572
|
+
}
|
|
14573
|
+
const replies = content.split(/^## Source comments$/m, 1)[0] ?? "";
|
|
14574
|
+
return threads.map((thread) => {
|
|
14575
|
+
const id = Buffer.from(thread.id, "utf8").toString("base64url");
|
|
14576
|
+
const startMarker = `<!-- diffpi-reply-start:${id} -->
|
|
14577
|
+
`;
|
|
14578
|
+
const endMarker = `
|
|
14579
|
+
<!-- diffpi-reply-end:${id} -->`;
|
|
14580
|
+
const start = replies.indexOf(startMarker);
|
|
14581
|
+
const end = start < 0 ? -1 : replies.indexOf(endMarker, start + startMarker.length);
|
|
14582
|
+
const reply = start >= 0 && end >= 0 ? replies.slice(start + startMarker.length, end).trim() : "";
|
|
14583
|
+
return reply ? { ...thread, reply } : thread;
|
|
14584
|
+
});
|
|
14585
|
+
}
|
|
14586
|
+
function upsertThreadReply(content, threadId, body, question) {
|
|
14587
|
+
const threads = parseThreadArtifact(content);
|
|
14588
|
+
const thread = threads.find((candidate) => candidate.id === threadId);
|
|
14589
|
+
if (!thread)
|
|
14590
|
+
throw new Error(`Review thread ${threadId} was not found in the local artifact.`);
|
|
14591
|
+
thread.reply = body;
|
|
14592
|
+
if (question !== undefined)
|
|
14593
|
+
thread.question = question;
|
|
14594
|
+
const title = content.match(/^# Review threads: (.+)$/m)?.[1] ?? "review";
|
|
14595
|
+
const target = content.match(/^- Target: (.+)$/m)?.[1] ?? "local";
|
|
14596
|
+
const timestamp = content.match(/^- Pulled: (.+)$/m)?.[1];
|
|
14597
|
+
const pr = content.match(/^- PR\/MR: #(\d+)(?: — (.+))?$/m);
|
|
14598
|
+
return renderThreadArtifact(title, target, threads, {
|
|
14599
|
+
timestamp,
|
|
14600
|
+
number: pr ? Number.parseInt(pr[1], 10) : undefined,
|
|
14601
|
+
url: pr?.[2]
|
|
14602
|
+
});
|
|
14603
|
+
}
|
|
14604
|
+
function renderCommentBody(finding) {
|
|
14605
|
+
const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
|
|
14606
|
+
const reference = finding.reference ? `
|
|
14607
|
+
|
|
14608
|
+
> **Reference:** ${finding.reference}` : "";
|
|
14609
|
+
return `${prefix}${finding.body}${reference}`;
|
|
14610
|
+
}
|
|
14611
|
+
|
|
14612
|
+
// src/tuicr.ts
|
|
14613
|
+
import { readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
|
|
14614
|
+
import { resolve as resolve3 } from "node:path";
|
|
14615
|
+
|
|
14616
|
+
// src/store.ts
|
|
14617
|
+
import { createHash } from "node:crypto";
|
|
14618
|
+
import { lstat, mkdir as mkdir2, readlink, realpath, symlink, unlink } from "node:fs/promises";
|
|
14619
|
+
import { homedir as homedir5 } from "node:os";
|
|
14620
|
+
import { dirname as dirname4, isAbsolute as isAbsolute2, join as join8, resolve as resolve2 } from "node:path";
|
|
14621
|
+
var STORE_LINK = ".diffpi";
|
|
14622
|
+
var LEGACY_STORE_LINK = join8(".pi", "diffpi");
|
|
14623
|
+
async function gitToplevel(cwd) {
|
|
14624
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
14625
|
+
const top = result.stdout.trim();
|
|
14626
|
+
return result.code === 0 && top ? top : resolve2(cwd);
|
|
14627
|
+
}
|
|
14628
|
+
async function computeProjectSlug(cwd) {
|
|
14629
|
+
const root = await gitToplevel(cwd);
|
|
14630
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
14631
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
14632
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
14633
|
+
const common = commonResult.stdout.trim();
|
|
14634
|
+
let commonPath = root;
|
|
14635
|
+
if (commonResult.code === 0 && common) {
|
|
14636
|
+
const resolvedCommon = isAbsolute2(common) ? common : join8(root, common);
|
|
14637
|
+
commonPath = resolve2(resolvedCommon);
|
|
14638
|
+
}
|
|
14639
|
+
const canonicalCommon = await canonicalPath(commonPath);
|
|
14640
|
+
const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
|
|
14641
|
+
const name = remote ? repositoryName(remote) : basename3(resolve2(canonicalCommon, "..")) || basename3(root);
|
|
14642
|
+
const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
14643
|
+
const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
|
|
14644
|
+
return `${readable}-${digest}`;
|
|
14645
|
+
}
|
|
14646
|
+
function storeGlobalRoot(homeDir = homedir5()) {
|
|
14647
|
+
return join8(homeDir, ".difflab", "diffpi", "projects");
|
|
14648
|
+
}
|
|
14649
|
+
async function ensureStore(cwd, homeDir = homedir5()) {
|
|
14650
|
+
const root = await gitToplevel(cwd);
|
|
14651
|
+
const slug = await computeProjectSlug(root);
|
|
14652
|
+
const dest = join8(storeGlobalRoot(homeDir), slug);
|
|
14653
|
+
const link = join8(root, STORE_LINK);
|
|
14654
|
+
await mkdir2(dest, { recursive: true });
|
|
14655
|
+
try {
|
|
14656
|
+
await assertStoreLink(link, dest);
|
|
14657
|
+
} catch (error) {
|
|
14658
|
+
if (error.code !== "ENOENT")
|
|
14659
|
+
throw error;
|
|
14660
|
+
await symlink(dest, link);
|
|
14661
|
+
}
|
|
14662
|
+
await removeLegacyStoreLink(join8(root, LEGACY_STORE_LINK), dest);
|
|
14663
|
+
return { slug, root, dest, link, linked: true };
|
|
14664
|
+
}
|
|
14665
|
+
async function reviewsDir(cwd, homeDir = homedir5()) {
|
|
14666
|
+
const store = await ensureStore(cwd, homeDir);
|
|
14667
|
+
const dir = join8(store.link, "reviews");
|
|
14668
|
+
await mkdir2(dir, { recursive: true });
|
|
14669
|
+
return dir;
|
|
14670
|
+
}
|
|
14671
|
+
async function sessionsDir(cwd, homeDir = homedir5()) {
|
|
14672
|
+
const store = await ensureStore(cwd, homeDir);
|
|
14673
|
+
const dir = join8(store.link, "sessions");
|
|
14674
|
+
await mkdir2(dir, { recursive: true });
|
|
14675
|
+
return dir;
|
|
14676
|
+
}
|
|
14677
|
+
async function assertStoreLink(path, dest) {
|
|
14678
|
+
const entry = await lstat(path);
|
|
14679
|
+
if (!entry.isSymbolicLink())
|
|
14680
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
14681
|
+
const target = await symlinkTarget(path);
|
|
14682
|
+
if (target !== await canonicalPath(dest))
|
|
14683
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
14684
|
+
}
|
|
14685
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
14686
|
+
try {
|
|
14687
|
+
const entry = await lstat(path);
|
|
14688
|
+
if (!entry.isSymbolicLink())
|
|
14689
|
+
return;
|
|
14690
|
+
const target = await symlinkTarget(path);
|
|
14691
|
+
if (target === await canonicalPath(dest))
|
|
14692
|
+
await unlink(path);
|
|
14693
|
+
} catch (error) {
|
|
14694
|
+
if (error.code !== "ENOENT")
|
|
14695
|
+
throw error;
|
|
14696
|
+
}
|
|
14697
|
+
}
|
|
14698
|
+
async function symlinkTarget(path) {
|
|
14699
|
+
const target = await readlink(path);
|
|
14700
|
+
return canonicalPath(isAbsolute2(target) ? target : resolve2(dirname4(path), target));
|
|
14701
|
+
}
|
|
14702
|
+
async function canonicalPath(path) {
|
|
14703
|
+
try {
|
|
14704
|
+
return await realpath(path);
|
|
14705
|
+
} catch {
|
|
14706
|
+
return resolve2(path);
|
|
14707
|
+
}
|
|
14708
|
+
}
|
|
14709
|
+
function normalizeRemote(remote) {
|
|
14710
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
14711
|
+
}
|
|
14712
|
+
function repositoryName(remote) {
|
|
14713
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
14714
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
14715
|
+
}
|
|
14716
|
+
function basename3(path) {
|
|
14717
|
+
const parts = resolve2(path).split(/[/\\]/).filter(Boolean);
|
|
14718
|
+
return parts.at(-1) ?? "";
|
|
14719
|
+
}
|
|
14720
|
+
|
|
14721
|
+
// src/tuicr.ts
|
|
14722
|
+
async function listSessions(repo = ".") {
|
|
14723
|
+
const result = await run("tuicr", ["review", "list", "--repo", repo]);
|
|
14724
|
+
if (result.code !== 0 || !result.stdout.trim())
|
|
14725
|
+
return [];
|
|
14726
|
+
let raw;
|
|
14727
|
+
try {
|
|
14728
|
+
raw = JSON.parse(result.stdout);
|
|
14729
|
+
} catch {
|
|
14730
|
+
return [];
|
|
14731
|
+
}
|
|
14732
|
+
return raw.map((entry) => ({
|
|
14733
|
+
slug: entry.slug,
|
|
14734
|
+
kind: entry.kind,
|
|
14735
|
+
path: entry.path,
|
|
14736
|
+
updatedAt: entry.updated_at,
|
|
14737
|
+
commentCount: entry.comment_count,
|
|
14738
|
+
anchor: entry.anchor,
|
|
14739
|
+
active: entry.active
|
|
14740
|
+
}));
|
|
14741
|
+
}
|
|
14742
|
+
async function resolveSession(cwd, branch) {
|
|
14743
|
+
const sessions = await listSessions(cwd);
|
|
14744
|
+
return findMatchingSession(sessions, cwd, branch);
|
|
14745
|
+
}
|
|
14746
|
+
async function resolveReviewSession(cwd, target) {
|
|
14747
|
+
if (!target.workingTree && target.owner && target.repo && target.number !== undefined) {
|
|
14748
|
+
return resolvePrSession(cwd, target.owner, target.repo, target.number);
|
|
14749
|
+
}
|
|
14750
|
+
return resolveSession(cwd, target.branch);
|
|
14751
|
+
}
|
|
14752
|
+
async function resolvePrSession(cwd, owner, repo, number) {
|
|
14753
|
+
const coordinate = `${owner}/${repo}`.toLowerCase();
|
|
14754
|
+
const sessions = await listSessions(cwd);
|
|
14755
|
+
return sessions.find((session) => {
|
|
14756
|
+
const slug = session.slug.toLowerCase();
|
|
14757
|
+
return session.kind === "pr" && slug.includes(coordinate) && (slug.endsWith(`/pr/${number}`) || slug.endsWith(`/mr/${number}`));
|
|
14758
|
+
});
|
|
14759
|
+
}
|
|
14760
|
+
async function findMatchingSession(sessions, cwd, branch) {
|
|
14761
|
+
const repository = await canonicalPath2(await gitToplevel(cwd));
|
|
14762
|
+
for (const session of sessions) {
|
|
14763
|
+
if (session.kind !== "local")
|
|
14764
|
+
continue;
|
|
14765
|
+
try {
|
|
14766
|
+
const data = await readSession(session.path);
|
|
14767
|
+
if (data.branch_name !== branch || !data.repo_path)
|
|
14768
|
+
continue;
|
|
14769
|
+
if (await canonicalPath2(data.repo_path) === repository)
|
|
14770
|
+
return session;
|
|
14771
|
+
} catch {}
|
|
14772
|
+
}
|
|
14773
|
+
return;
|
|
14774
|
+
}
|
|
14775
|
+
async function readSession(path) {
|
|
14776
|
+
const content = await readFile4(path, "utf8");
|
|
14777
|
+
try {
|
|
14778
|
+
return JSON.parse(content);
|
|
14779
|
+
} catch {
|
|
14780
|
+
throw new Error(`Cannot parse tuicr session JSON: ${path}`);
|
|
14781
|
+
}
|
|
14782
|
+
}
|
|
14783
|
+
async function addComment(session, body, opts = {}) {
|
|
14784
|
+
const args = ["review", "add", "--session", session, body];
|
|
14785
|
+
if (opts.targetFile)
|
|
14786
|
+
args.push("--target-file", opts.targetFile);
|
|
14787
|
+
if (opts.line !== undefined)
|
|
14788
|
+
args.push("--line", String(opts.line));
|
|
14789
|
+
if (opts.side)
|
|
14790
|
+
args.push("--side", opts.side);
|
|
14791
|
+
if (opts.username)
|
|
14792
|
+
args.push("--username", opts.username);
|
|
14793
|
+
await runChecked("tuicr", args);
|
|
14794
|
+
}
|
|
14795
|
+
async function launch(cwd, pr) {
|
|
14796
|
+
const command = pr === undefined ? ["tuicr", "-w"] : ["tuicr", "pr", String(pr)];
|
|
14797
|
+
return openInNewTab(command, { cwd, name: "tuicr" });
|
|
14798
|
+
}
|
|
14799
|
+
function toFindings(session, options = {}) {
|
|
14800
|
+
const comments = [];
|
|
14801
|
+
const include = (comment) => !options.agentOnly || commentAuthor(comment)?.startsWith("Agent: ");
|
|
14802
|
+
const bodyParts = (session.review_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
14803
|
+
for (const [file, entry] of Object.entries(session.files ?? {})) {
|
|
14804
|
+
const fileComments = (entry.file_comments ?? []).flatMap((comment) => include(comment) ? [comment.content] : []);
|
|
14805
|
+
if (fileComments.length > 0)
|
|
14806
|
+
bodyParts.push(`File: ${file}
|
|
14807
|
+
|
|
14808
|
+
${fileComments.join(`
|
|
14809
|
+
|
|
14810
|
+
`)}`);
|
|
14811
|
+
for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
|
|
14812
|
+
const line = Number.parseInt(lineKey, 10);
|
|
14813
|
+
if (!Number.isFinite(line))
|
|
14814
|
+
continue;
|
|
14815
|
+
for (const lineComment of lineComments) {
|
|
14816
|
+
if (!include(lineComment))
|
|
14817
|
+
continue;
|
|
14818
|
+
const comment = {
|
|
14819
|
+
file,
|
|
14820
|
+
line,
|
|
14821
|
+
side: lineComment.side === "old" ? "LEFT" : "RIGHT",
|
|
14822
|
+
body: lineComment.content
|
|
14823
|
+
};
|
|
14824
|
+
const author = commentAuthor(lineComment);
|
|
14825
|
+
if (author)
|
|
14826
|
+
comment.author = author;
|
|
14827
|
+
comments.push(comment);
|
|
14828
|
+
}
|
|
14829
|
+
}
|
|
14830
|
+
}
|
|
14831
|
+
return {
|
|
14832
|
+
comments,
|
|
14833
|
+
body: bodyParts.join(`
|
|
14834
|
+
|
|
14835
|
+
`)
|
|
14836
|
+
};
|
|
14837
|
+
}
|
|
14838
|
+
function commentAuthor(comment) {
|
|
14839
|
+
return comment.username ?? comment.author;
|
|
14840
|
+
}
|
|
14841
|
+
async function canonicalPath2(path) {
|
|
14842
|
+
try {
|
|
14843
|
+
return await realpath2(path);
|
|
14844
|
+
} catch {
|
|
14845
|
+
return resolve3(path);
|
|
14846
|
+
}
|
|
14847
|
+
}
|
|
14848
|
+
|
|
14849
|
+
// src/review-backend.ts
|
|
14850
|
+
function createRemoteReviewBackend(vcs, number) {
|
|
14851
|
+
if (vcs.provider === "github")
|
|
14852
|
+
return new GithubReviewBackend(vcs, number);
|
|
14853
|
+
if (vcs.provider === "gitlab")
|
|
14854
|
+
return new GitlabReviewBackend(vcs, number);
|
|
14855
|
+
throw new Error("A remote review backend requires a GitHub or GitLab repository.");
|
|
14856
|
+
}
|
|
14857
|
+
function createLocalReviewBackend(options) {
|
|
14858
|
+
return new LocalReviewBackend(options);
|
|
14859
|
+
}
|
|
14860
|
+
|
|
14861
|
+
class LocalReviewBackend {
|
|
14862
|
+
options;
|
|
14863
|
+
kind = "local";
|
|
14864
|
+
constructor(options) {
|
|
14865
|
+
this.options = options;
|
|
14866
|
+
}
|
|
14867
|
+
async stage(draft) {
|
|
14868
|
+
for (const comment of draft.comments) {
|
|
14869
|
+
await addComment(this.options.session, comment.body, {
|
|
14870
|
+
targetFile: comment.file,
|
|
14871
|
+
line: comment.line,
|
|
14872
|
+
side: comment.side === "LEFT" ? "old" : "new",
|
|
14873
|
+
username: this.options.author
|
|
14874
|
+
});
|
|
14875
|
+
}
|
|
14876
|
+
if (draft.body.trim()) {
|
|
14877
|
+
await addComment(this.options.session, draft.body, { username: this.options.author });
|
|
14878
|
+
}
|
|
14879
|
+
}
|
|
14880
|
+
async readDraft() {
|
|
14881
|
+
return toFindings(await readSession(this.options.session), { agentOnly: true });
|
|
14882
|
+
}
|
|
14883
|
+
async listThreads() {
|
|
14884
|
+
try {
|
|
14885
|
+
return parseThreadArtifact(await readFile5(this.options.artifactPath, "utf8"));
|
|
14886
|
+
} catch (error) {
|
|
14887
|
+
if (error.code === "ENOENT")
|
|
14888
|
+
return [];
|
|
14889
|
+
throw error;
|
|
14890
|
+
}
|
|
14891
|
+
}
|
|
14892
|
+
async reply(input) {
|
|
14893
|
+
const content = await readFile5(this.options.artifactPath, "utf8");
|
|
14894
|
+
await writeFile2(this.options.artifactPath, upsertThreadReply(content, input.threadId, input.body, input.question), "utf8");
|
|
14895
|
+
}
|
|
14896
|
+
async publish() {
|
|
14897
|
+
throw new Error("Promote a local draft through a remote review backend before publishing it.");
|
|
14898
|
+
}
|
|
14899
|
+
}
|
|
14900
|
+
|
|
14901
|
+
class GithubReviewBackend {
|
|
14902
|
+
vcs;
|
|
14903
|
+
number;
|
|
14904
|
+
kind = "remote";
|
|
14905
|
+
constructor(vcs, number) {
|
|
14906
|
+
this.vcs = vcs;
|
|
14907
|
+
this.number = number;
|
|
14908
|
+
}
|
|
14909
|
+
async stage(draft) {
|
|
14910
|
+
const pending = await this.pendingReview();
|
|
14911
|
+
if (!pending) {
|
|
14912
|
+
const payload = {
|
|
14913
|
+
body: draft.body,
|
|
14914
|
+
comments: draft.comments.map((comment) => ({
|
|
14915
|
+
path: comment.file,
|
|
14916
|
+
line: comment.line,
|
|
14917
|
+
side: comment.side ?? "RIGHT",
|
|
14918
|
+
body: comment.body
|
|
14919
|
+
}))
|
|
14920
|
+
};
|
|
14921
|
+
await runChecked("gh", [
|
|
14922
|
+
"api",
|
|
14923
|
+
"--method",
|
|
14924
|
+
"POST",
|
|
14925
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
|
|
14926
|
+
"--input",
|
|
14927
|
+
"-"
|
|
14928
|
+
], { input: JSON.stringify(payload) });
|
|
14929
|
+
return;
|
|
14930
|
+
}
|
|
14931
|
+
if (draft.body.trim()) {
|
|
14932
|
+
await runChecked("gh", [
|
|
14933
|
+
"api",
|
|
14934
|
+
"--method",
|
|
14935
|
+
"PUT",
|
|
14936
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`,
|
|
14937
|
+
"-f",
|
|
14938
|
+
`body=${draft.body}`
|
|
14939
|
+
]);
|
|
14940
|
+
}
|
|
14941
|
+
for (const comment of draft.comments) {
|
|
14942
|
+
await runChecked("gh", [
|
|
14943
|
+
"api",
|
|
14944
|
+
"graphql",
|
|
14945
|
+
"-f",
|
|
14946
|
+
`query=${GITHUB_ADD_THREAD_MUTATION}`,
|
|
14947
|
+
"-f",
|
|
14948
|
+
`reviewId=${pending.nodeId}`,
|
|
14949
|
+
"-f",
|
|
14950
|
+
`body=${comment.body}`,
|
|
14951
|
+
"-f",
|
|
14952
|
+
`path=${comment.file}`,
|
|
14953
|
+
"-F",
|
|
14954
|
+
`line=${comment.line}`,
|
|
14955
|
+
"-f",
|
|
14956
|
+
`side=${comment.side ?? "RIGHT"}`
|
|
14957
|
+
]);
|
|
14958
|
+
}
|
|
14959
|
+
}
|
|
14960
|
+
async readDraft() {
|
|
14961
|
+
const pending = await this.pendingReview();
|
|
14962
|
+
if (!pending)
|
|
14963
|
+
return { comments: [], body: "" };
|
|
14964
|
+
const review = await runChecked("gh", [
|
|
14965
|
+
"api",
|
|
14966
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}`
|
|
14967
|
+
]);
|
|
14968
|
+
const comments = await runChecked("gh", [
|
|
14969
|
+
"api",
|
|
14970
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews/${pending.id}/comments`
|
|
14971
|
+
]);
|
|
14972
|
+
let reviewData;
|
|
14973
|
+
let commentData;
|
|
14974
|
+
try {
|
|
14975
|
+
reviewData = JSON.parse(review.stdout);
|
|
14976
|
+
commentData = JSON.parse(comments.stdout);
|
|
14977
|
+
} catch {
|
|
14978
|
+
throw new Error("Cannot parse the pending GitHub review as JSON.");
|
|
14979
|
+
}
|
|
14980
|
+
return {
|
|
14981
|
+
body: reviewData.body ?? "",
|
|
14982
|
+
comments: commentData.map((comment) => ({
|
|
14983
|
+
file: comment.path,
|
|
14984
|
+
line: comment.line ?? comment.original_line ?? 1,
|
|
14985
|
+
side: comment.side,
|
|
14986
|
+
body: comment.body
|
|
14987
|
+
}))
|
|
14988
|
+
};
|
|
14989
|
+
}
|
|
14990
|
+
async listThreads() {
|
|
14991
|
+
const threads = [];
|
|
14992
|
+
let cursor;
|
|
14993
|
+
do {
|
|
14994
|
+
const args = [
|
|
14995
|
+
"api",
|
|
14996
|
+
"graphql",
|
|
14997
|
+
"-f",
|
|
14998
|
+
`query=${GITHUB_THREADS_QUERY}`,
|
|
14999
|
+
"-f",
|
|
15000
|
+
`owner=${this.vcs.owner}`,
|
|
15001
|
+
"-f",
|
|
15002
|
+
`repo=${this.vcs.repo}`,
|
|
15003
|
+
"-F",
|
|
15004
|
+
`number=${this.number}`
|
|
15005
|
+
];
|
|
15006
|
+
if (cursor)
|
|
15007
|
+
args.push("-f", `after=${cursor}`);
|
|
15008
|
+
const result = await runChecked("gh", args);
|
|
15009
|
+
let data;
|
|
15010
|
+
try {
|
|
15011
|
+
data = JSON.parse(result.stdout);
|
|
15012
|
+
} catch {
|
|
15013
|
+
throw new Error("Cannot parse GitHub review threads as JSON.");
|
|
15014
|
+
}
|
|
15015
|
+
const connection = data.data?.repository?.pullRequest?.reviewThreads;
|
|
15016
|
+
threads.push(...connection?.nodes ?? []);
|
|
15017
|
+
cursor = connection?.pageInfo?.hasNextPage ? connection.pageInfo.endCursor : undefined;
|
|
15018
|
+
if (connection?.pageInfo?.hasNextPage && !cursor) {
|
|
15019
|
+
throw new Error("GitHub review thread pagination did not return an end cursor.");
|
|
15020
|
+
}
|
|
15021
|
+
} while (cursor);
|
|
15022
|
+
return threads.map((thread) => {
|
|
15023
|
+
const nodes = thread.comments?.nodes ?? [];
|
|
15024
|
+
const comment = nodes[0];
|
|
15025
|
+
const body = comment?.body ?? "";
|
|
15026
|
+
return {
|
|
15027
|
+
id: thread.id,
|
|
15028
|
+
file: thread.path,
|
|
15029
|
+
line: thread.line,
|
|
15030
|
+
body,
|
|
15031
|
+
author: comment?.author?.login,
|
|
15032
|
+
resolved: thread.isResolved,
|
|
15033
|
+
question: /\?\s*$/.test(body.trim()),
|
|
15034
|
+
replies: nodes.slice(1).map((node) => node.body)
|
|
15035
|
+
};
|
|
15036
|
+
});
|
|
15037
|
+
}
|
|
15038
|
+
async reply(input) {
|
|
15039
|
+
const threads = await this.listThreads();
|
|
15040
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
15041
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
15042
|
+
await runChecked("gh", [
|
|
15043
|
+
"api",
|
|
15044
|
+
"graphql",
|
|
15045
|
+
"-f",
|
|
15046
|
+
`query=${GITHUB_REPLY_MUTATION}`,
|
|
15047
|
+
"-f",
|
|
15048
|
+
`threadId=${input.threadId}`,
|
|
15049
|
+
"-f",
|
|
15050
|
+
`body=${input.body}`
|
|
15051
|
+
]);
|
|
15052
|
+
}
|
|
15053
|
+
if (input.resolve) {
|
|
15054
|
+
await runChecked("gh", [
|
|
15055
|
+
"api",
|
|
15056
|
+
"graphql",
|
|
15057
|
+
"-f",
|
|
15058
|
+
`query=${GITHUB_RESOLVE_MUTATION}`,
|
|
15059
|
+
"-f",
|
|
15060
|
+
`threadId=${input.threadId}`
|
|
15061
|
+
]);
|
|
15062
|
+
}
|
|
15063
|
+
}
|
|
15064
|
+
async publish(event) {
|
|
15065
|
+
const pending = await this.pendingReview();
|
|
15066
|
+
if (!pending && event === "COMMENT")
|
|
15067
|
+
return;
|
|
15068
|
+
if (!pending && event === "REQUEST_CHANGES") {
|
|
15069
|
+
throw new Error("GitHub requires pending comments before publishing a request-changes review without a body.");
|
|
15070
|
+
}
|
|
15071
|
+
const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, this.number, pending?.id ?? "");
|
|
15072
|
+
await runChecked("gh", ["api", "--method", "POST", endpoint, "-f", `event=${event}`]);
|
|
15073
|
+
}
|
|
15074
|
+
async pendingReview() {
|
|
15075
|
+
const result = await runChecked("gh", [
|
|
15076
|
+
"api",
|
|
15077
|
+
`/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${this.number}/reviews`,
|
|
15078
|
+
"--jq",
|
|
15079
|
+
'[.[] | select(.state=="PENDING")] | last | {id: (.id | tostring), nodeId: .node_id}'
|
|
15080
|
+
]);
|
|
15081
|
+
if (!result.stdout.trim())
|
|
15082
|
+
return;
|
|
15083
|
+
let pending;
|
|
15084
|
+
try {
|
|
15085
|
+
pending = JSON.parse(result.stdout);
|
|
15086
|
+
} catch {
|
|
15087
|
+
throw new Error("Cannot parse the pending GitHub review identifier as JSON.");
|
|
15088
|
+
}
|
|
15089
|
+
if (!pending.id || !pending.nodeId)
|
|
15090
|
+
return;
|
|
15091
|
+
return { id: pending.id, nodeId: pending.nodeId };
|
|
15092
|
+
}
|
|
15093
|
+
}
|
|
15094
|
+
|
|
15095
|
+
class GitlabReviewBackend {
|
|
15096
|
+
vcs;
|
|
15097
|
+
number;
|
|
15098
|
+
kind = "remote";
|
|
15099
|
+
constructor(vcs, number) {
|
|
15100
|
+
this.vcs = vcs;
|
|
15101
|
+
this.number = number;
|
|
15102
|
+
}
|
|
15103
|
+
async stage(draft) {
|
|
15104
|
+
const endpoint = `${this.mergeRequestEndpoint()}/draft_notes`;
|
|
15105
|
+
if (draft.body.trim()) {
|
|
15106
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
15107
|
+
input: JSON.stringify({ note: draft.body })
|
|
15108
|
+
});
|
|
15109
|
+
}
|
|
15110
|
+
if (draft.comments.length === 0)
|
|
15111
|
+
return;
|
|
15112
|
+
const response = await runChecked("glab", ["api", this.mergeRequestEndpoint()]);
|
|
15113
|
+
const diffRefs = parseGitlabDiffRefs(response.stdout);
|
|
15114
|
+
for (const comment of draft.comments) {
|
|
15115
|
+
const payload = {
|
|
15116
|
+
note: comment.body,
|
|
15117
|
+
position: {
|
|
15118
|
+
...diffRefs,
|
|
15119
|
+
position_type: "text",
|
|
15120
|
+
new_path: comment.file,
|
|
15121
|
+
old_path: comment.file,
|
|
15122
|
+
new_line: comment.side === "LEFT" ? undefined : comment.line,
|
|
15123
|
+
old_line: comment.side === "LEFT" ? comment.line : undefined
|
|
15124
|
+
}
|
|
15125
|
+
};
|
|
15126
|
+
await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
|
|
15127
|
+
input: JSON.stringify(payload)
|
|
15128
|
+
});
|
|
15129
|
+
}
|
|
15130
|
+
}
|
|
15131
|
+
async readDraft() {
|
|
15132
|
+
const result = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
|
|
15133
|
+
let notes;
|
|
15134
|
+
try {
|
|
15135
|
+
notes = JSON.parse(result.stdout);
|
|
15136
|
+
} catch {
|
|
15137
|
+
throw new Error("Cannot parse GitLab draft notes as JSON.");
|
|
15138
|
+
}
|
|
15139
|
+
const comments = [];
|
|
15140
|
+
const body = [];
|
|
15141
|
+
for (const note of notes) {
|
|
15142
|
+
if (!note.note)
|
|
15143
|
+
continue;
|
|
15144
|
+
const file = note.position?.new_path ?? note.position?.old_path;
|
|
15145
|
+
const line = note.position?.new_line ?? note.position?.old_line;
|
|
15146
|
+
if (file && line) {
|
|
15147
|
+
comments.push({
|
|
15148
|
+
file,
|
|
15149
|
+
line,
|
|
15150
|
+
side: note.position?.new_line ? "RIGHT" : "LEFT",
|
|
15151
|
+
body: note.note
|
|
15152
|
+
});
|
|
15153
|
+
} else {
|
|
15154
|
+
body.push(note.note);
|
|
15155
|
+
}
|
|
15156
|
+
}
|
|
15157
|
+
return { comments, body: body.join(`
|
|
15158
|
+
|
|
15159
|
+
`) };
|
|
15160
|
+
}
|
|
15161
|
+
async listThreads() {
|
|
15162
|
+
const discussions = [];
|
|
15163
|
+
for (let page = 1;; page += 1) {
|
|
15164
|
+
const result = await runChecked("glab", [
|
|
15165
|
+
"api",
|
|
15166
|
+
`${this.mergeRequestEndpoint()}/discussions?per_page=100&page=${page}`
|
|
15167
|
+
]);
|
|
15168
|
+
let batch;
|
|
15169
|
+
try {
|
|
15170
|
+
batch = JSON.parse(result.stdout);
|
|
15171
|
+
} catch {
|
|
15172
|
+
throw new Error("Cannot parse GitLab review discussions as JSON.");
|
|
15173
|
+
}
|
|
15174
|
+
discussions.push(...batch);
|
|
15175
|
+
if (batch.length < 100)
|
|
15176
|
+
break;
|
|
15177
|
+
}
|
|
15178
|
+
return discussions.map((discussion) => {
|
|
15179
|
+
const notes = discussion.notes ?? [];
|
|
15180
|
+
const note = notes[0];
|
|
15181
|
+
const body = note?.body ?? "";
|
|
15182
|
+
return {
|
|
15183
|
+
id: discussion.id,
|
|
15184
|
+
file: note?.position?.new_path ?? note?.position?.old_path,
|
|
15185
|
+
line: note?.position?.new_line ?? note?.position?.old_line,
|
|
15186
|
+
body,
|
|
15187
|
+
author: note?.author?.username,
|
|
15188
|
+
resolved: Boolean(discussion.resolved),
|
|
15189
|
+
question: /\?\s*$/.test(body.trim()),
|
|
15190
|
+
replies: notes.slice(1).map((reply) => reply.body)
|
|
15191
|
+
};
|
|
15192
|
+
});
|
|
15193
|
+
}
|
|
15194
|
+
async reply(input) {
|
|
15195
|
+
const endpoint = `${this.mergeRequestEndpoint()}/discussions/${encodeURIComponent(input.threadId)}`;
|
|
15196
|
+
const threads = await this.listThreads();
|
|
15197
|
+
const existing = threads.find((thread) => thread.id === input.threadId);
|
|
15198
|
+
if (!existing?.replies?.includes(input.body)) {
|
|
15199
|
+
await runChecked("glab", ["api", "--method", "POST", `${endpoint}/notes`, "--input", "-"], {
|
|
15200
|
+
input: JSON.stringify({ body: input.body })
|
|
15201
|
+
});
|
|
15202
|
+
}
|
|
15203
|
+
if (input.resolve)
|
|
15204
|
+
await runChecked("glab", ["api", "--method", "PUT", `${endpoint}?resolved=true`]);
|
|
15205
|
+
}
|
|
15206
|
+
async publish(event) {
|
|
15207
|
+
assertReviewEventSupported(this.vcs.provider, event);
|
|
15208
|
+
const drafts = await runChecked("glab", ["api", `${this.mergeRequestEndpoint()}/draft_notes`]);
|
|
15209
|
+
if (hasGitlabDraftNotes(drafts.stdout)) {
|
|
15210
|
+
await runChecked("glab", ["api", "--method", "POST", `${this.mergeRequestEndpoint()}/draft_notes/bulk_publish`]);
|
|
15211
|
+
}
|
|
15212
|
+
if (event === "APPROVE") {
|
|
15213
|
+
await runChecked("glab", ["mr", "approve", String(this.number), "--repo", this.project()]);
|
|
15214
|
+
}
|
|
15215
|
+
}
|
|
15216
|
+
project() {
|
|
15217
|
+
return `${this.vcs.owner}/${this.vcs.repo}`;
|
|
15218
|
+
}
|
|
15219
|
+
mergeRequestEndpoint() {
|
|
15220
|
+
return `projects/${encodeURIComponent(this.project())}/merge_requests/${this.number}`;
|
|
15221
|
+
}
|
|
15222
|
+
}
|
|
15223
|
+
function assertReviewEventSupported(provider, event) {
|
|
15224
|
+
if (provider === "gitlab" && event === "REQUEST_CHANGES") {
|
|
15225
|
+
throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
|
|
15226
|
+
}
|
|
15227
|
+
}
|
|
15228
|
+
function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
|
|
15229
|
+
return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
|
|
15230
|
+
}
|
|
15231
|
+
function parseGitlabDiffRefs(input) {
|
|
15232
|
+
let data;
|
|
15233
|
+
try {
|
|
15234
|
+
data = JSON.parse(input);
|
|
15235
|
+
} catch {
|
|
15236
|
+
throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
|
|
15237
|
+
}
|
|
15238
|
+
const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
|
|
15239
|
+
if (!base_sha || !start_sha || !head_sha) {
|
|
15240
|
+
throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
|
|
15241
|
+
}
|
|
15242
|
+
return { base_sha, start_sha, head_sha };
|
|
15243
|
+
}
|
|
15244
|
+
function hasGitlabDraftNotes(input) {
|
|
15245
|
+
try {
|
|
15246
|
+
const data = JSON.parse(input);
|
|
15247
|
+
return Array.isArray(data) && data.length > 0;
|
|
15248
|
+
} catch {
|
|
15249
|
+
throw new Error("Cannot publish the GitLab review: the draft notes response was not valid JSON.");
|
|
15250
|
+
}
|
|
15251
|
+
}
|
|
15252
|
+
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}}}}}`;
|
|
15253
|
+
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}}}`;
|
|
15254
|
+
var GITHUB_REPLY_MUTATION = `mutation($threadId:ID!,$body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:$threadId,body:$body}){comment{id}}}`;
|
|
15255
|
+
var GITHUB_RESOLVE_MUTATION = `mutation($threadId:ID!){resolveReviewThread(input:{threadId:$threadId}){thread{isResolved}}}`;
|
|
15256
|
+
|
|
15257
|
+
// src/review-publication.ts
|
|
15258
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
15259
|
+
import { readFile as readFile6, rename, writeFile as writeFile3 } from "node:fs/promises";
|
|
15260
|
+
import { join as join9 } from "node:path";
|
|
15261
|
+
import { z as z5 } from "zod";
|
|
15262
|
+
var reviewPublicationStateSchema = z5.object({
|
|
15263
|
+
target: z5.string().optional(),
|
|
15264
|
+
comments: z5.array(z5.string()).default([]),
|
|
15265
|
+
replies: z5.array(z5.string()).default([]),
|
|
15266
|
+
overlayPath: z5.string().optional()
|
|
15267
|
+
});
|
|
15268
|
+
function reviewCommentFingerprint(comment) {
|
|
15269
|
+
return digest([comment.file, String(comment.line), comment.side ?? "RIGHT", comment.body].join("\x00"));
|
|
15270
|
+
}
|
|
15271
|
+
function reviewReplyFingerprint(threadId, body) {
|
|
15272
|
+
return digest(`${threadId}\x00${body}`);
|
|
15273
|
+
}
|
|
15274
|
+
function unpublishedReviewComments(comments, knownFingerprints) {
|
|
15275
|
+
return comments.filter((comment) => !knownFingerprints.has(reviewCommentFingerprint(comment)));
|
|
15276
|
+
}
|
|
15277
|
+
async function loadReviewPublicationState(cwd, vcs, number, homeDir) {
|
|
15278
|
+
const target = `${vcs.provider}:${vcs.owner}/${vcs.repo}#${number}`;
|
|
15279
|
+
const path = join9(await sessionsDir(cwd, homeDir), `review-publish-${digest(target).slice(0, 16)}.json`);
|
|
15280
|
+
try {
|
|
15281
|
+
const parsed = reviewPublicationStateSchema.parse(JSON.parse(await readFile6(path, "utf8")));
|
|
15282
|
+
return { path, state: { ...parsed, target } };
|
|
15283
|
+
} catch (error) {
|
|
15284
|
+
if (error.code === "ENOENT") {
|
|
15285
|
+
return { path, state: { target, comments: [], replies: [] } };
|
|
15286
|
+
}
|
|
15287
|
+
if (error instanceof SyntaxError || error instanceof z5.ZodError) {
|
|
15288
|
+
throw new Error(`Cannot parse review publication state: ${path}`);
|
|
15289
|
+
}
|
|
15290
|
+
throw error;
|
|
15291
|
+
}
|
|
15292
|
+
}
|
|
15293
|
+
async function saveReviewPublicationState(path, state) {
|
|
15294
|
+
const temp = `${path}.${process.pid}.tmp`;
|
|
15295
|
+
await writeFile3(temp, `${JSON.stringify(state, null, 2)}
|
|
15296
|
+
`, "utf8");
|
|
15297
|
+
await rename(temp, path);
|
|
15298
|
+
}
|
|
15299
|
+
function digest(value) {
|
|
15300
|
+
return createHash2("sha256").update(value).digest("hex");
|
|
15301
|
+
}
|
|
15302
|
+
|
|
15303
|
+
// src/templates.ts
|
|
15304
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
15305
|
+
import { homedir as homedir6 } from "node:os";
|
|
15306
|
+
import { join as join10, normalize } from "node:path";
|
|
15307
|
+
async function loadTemplate(name, options = {}) {
|
|
15308
|
+
const relative = templateRelativePath(name);
|
|
15309
|
+
const userPath = join10(options.homeDir ?? homedir6(), ".difflab", "diffpi", "templates", relative);
|
|
15310
|
+
const bundledPath = join10(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
15311
|
+
const user = await readOptionalFile(userPath);
|
|
15312
|
+
if (user !== undefined)
|
|
15313
|
+
return { name, path: userPath, source: "user", content: user };
|
|
15314
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
15315
|
+
if (bundled !== undefined)
|
|
15316
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
15317
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
15318
|
+
}
|
|
15319
|
+
function renderTemplate(content, variables) {
|
|
15320
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
15321
|
+
}
|
|
15322
|
+
function templateRelativePath(name) {
|
|
15323
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
15324
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
15325
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
15326
|
+
}
|
|
15327
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
15328
|
+
}
|
|
15329
|
+
async function readOptionalFile(path) {
|
|
15330
|
+
try {
|
|
15331
|
+
return await readFile7(path, "utf8");
|
|
15332
|
+
} catch (error) {
|
|
15333
|
+
if (error.code === "ENOENT")
|
|
15334
|
+
return;
|
|
15335
|
+
throw error;
|
|
15336
|
+
}
|
|
15337
|
+
}
|
|
15338
|
+
|
|
15339
|
+
// src/tools/review.ts
|
|
15340
|
+
var contextSchema = z6.object({
|
|
15341
|
+
cwd: z6.string().optional(),
|
|
15342
|
+
target: z6.string().optional(),
|
|
15343
|
+
workingTree: z6.boolean().optional()
|
|
15344
|
+
});
|
|
15345
|
+
var localSchema = contextSchema.extend({ local: z6.boolean().optional() });
|
|
15346
|
+
var openSchema = localSchema.extend({
|
|
15347
|
+
title: z6.string().optional(),
|
|
15348
|
+
intent: z6.string().optional(),
|
|
15349
|
+
base: z6.string().optional()
|
|
15350
|
+
});
|
|
15351
|
+
var submitSchema = localSchema.extend({
|
|
15352
|
+
findings: findingsSchema,
|
|
15353
|
+
overallIssues: z6.array(z6.string()).optional(),
|
|
15354
|
+
notVerified: z6.array(z6.string()).optional(),
|
|
15355
|
+
title: z6.string().optional()
|
|
15356
|
+
});
|
|
15357
|
+
var addCommentSchema = localSchema.extend({
|
|
15358
|
+
body: z6.string().min(1),
|
|
15359
|
+
file: z6.string().min(1),
|
|
15360
|
+
line: z6.number().int().positive(),
|
|
15361
|
+
side: z6.enum(["LEFT", "RIGHT"]).optional()
|
|
15362
|
+
});
|
|
15363
|
+
var respondSchema = localSchema.extend({
|
|
15364
|
+
threadId: z6.string().min(1),
|
|
15365
|
+
body: z6.string().min(1),
|
|
15366
|
+
question: z6.boolean().optional(),
|
|
15367
|
+
resolve: z6.boolean().optional()
|
|
15368
|
+
});
|
|
15369
|
+
var publishSchema = localSchema.extend({
|
|
15370
|
+
status: z6.enum(["COMMENT", "APPROVE", "REQUEST_CHANGES", "CLOSE"]).optional()
|
|
15371
|
+
});
|
|
15372
|
+
function parameters5(schema) {
|
|
15373
|
+
return z6.toJSONSchema(schema, { io: "input" });
|
|
15374
|
+
}
|
|
15375
|
+
function cwdOf(params) {
|
|
15376
|
+
return params.cwd ?? process.cwd();
|
|
15377
|
+
}
|
|
15378
|
+
function result(text, details = {}) {
|
|
15379
|
+
return { content: [{ type: "text", text }], details };
|
|
15380
|
+
}
|
|
15381
|
+
function modelRoute(ctx) {
|
|
15382
|
+
if (!ctx.model)
|
|
15383
|
+
throw new Error("Cannot record review provenance because Pi did not provide an active model route.");
|
|
15384
|
+
return `${ctx.model.provider}/${ctx.model.id}`;
|
|
15385
|
+
}
|
|
15386
|
+
function conventionalMergeGuard(subject) {
|
|
15387
|
+
return checkConventionalSubject(subject);
|
|
15388
|
+
}
|
|
15389
|
+
async function workingTreeDiff(cwd) {
|
|
15390
|
+
const tracked = await run("git", ["-C", cwd, "diff", "HEAD"], { capture: "unbounded" });
|
|
15391
|
+
if (tracked.code !== 0)
|
|
15392
|
+
throw new Error(tracked.stderr || "Cannot read tracked working-tree changes.");
|
|
15393
|
+
const untracked = await runChecked("git", ["-C", cwd, "ls-files", "--others", "--exclude-standard", "-z"]);
|
|
15394
|
+
const patches = [tracked.stdout];
|
|
15395
|
+
for (const file of untracked.stdout.split("\x00").filter(Boolean)) {
|
|
15396
|
+
const patch = await run("git", ["-C", cwd, "diff", "--no-index", "--", "/dev/null", file], {
|
|
15397
|
+
capture: "unbounded"
|
|
15398
|
+
});
|
|
15399
|
+
if (patch.code > 1)
|
|
15400
|
+
throw new Error(patch.stderr || `Cannot read untracked file diff: ${file}`);
|
|
15401
|
+
patches.push(patch.stdout);
|
|
15402
|
+
}
|
|
15403
|
+
return patches.filter(Boolean).join(`
|
|
15404
|
+
`);
|
|
15405
|
+
}
|
|
15406
|
+
function createReviewTools() {
|
|
15407
|
+
return [
|
|
15408
|
+
defineTool3({
|
|
15409
|
+
name: "review_context",
|
|
15410
|
+
label: "review context",
|
|
15411
|
+
description: "Orient to the target, backend, forge, environment, shared store, PR/MR, and tuicr session.",
|
|
15412
|
+
promptSnippet: "Call review_context first",
|
|
15413
|
+
promptGuidelines: ["Call this before every review workflow."],
|
|
15414
|
+
parameters: parameters5(localSchema),
|
|
15415
|
+
executionMode: "parallel",
|
|
15416
|
+
async execute(_id, input) {
|
|
15417
|
+
const params = localSchema.parse(input);
|
|
15418
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15419
|
+
const store = await ensureStore(review.cwd);
|
|
15420
|
+
const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
|
|
15421
|
+
const session = await resolveTuicrSession(review, params.workingTree);
|
|
15422
|
+
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
15423
|
+
const backend = params.local ? "tuicr" : review.forge ? review.vcs.provider : "tuicr";
|
|
15424
|
+
return result([
|
|
15425
|
+
`Backend: ${backend}`,
|
|
15426
|
+
`Forge: ${review.vcs.provider}${review.vcs.provider === "none" ? "" : ` (${review.vcs.owner}/${review.vcs.repo})`}`,
|
|
15427
|
+
`Branch: ${review.vcs.branch} → ${baseRef}`,
|
|
15428
|
+
`Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
|
|
15429
|
+
`Store: ${store.link} → ${store.dest}`,
|
|
15430
|
+
review.pr ? `PR/MR: #${review.pr.number} ${review.pr.url}` : "PR/MR: none",
|
|
15431
|
+
session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
|
|
15432
|
+
].join(`
|
|
15433
|
+
`), { ...review, env, store, session, baseRef, backend });
|
|
15434
|
+
}
|
|
15435
|
+
}),
|
|
15436
|
+
defineTool3({
|
|
15437
|
+
name: "review_open",
|
|
15438
|
+
label: "review open",
|
|
15439
|
+
description: "Create a draft PR/MR from the template registry, or launch a local tuicr target.",
|
|
15440
|
+
promptSnippet: "Call review_open to start",
|
|
15441
|
+
promptGuidelines: ["Use local to select tuicr as the review backend."],
|
|
15442
|
+
parameters: parameters5(openSchema),
|
|
15443
|
+
executionMode: "sequential",
|
|
15444
|
+
async execute(_id, input) {
|
|
15445
|
+
const params = openSchema.parse(input);
|
|
15446
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15447
|
+
await ensureStore(review.cwd);
|
|
15448
|
+
if (params.local || !review.forge)
|
|
15449
|
+
return launchLocalReview(review, params.workingTree);
|
|
15450
|
+
const base = params.base ?? await review.forge.defaultBranch();
|
|
15451
|
+
const template = await loadTemplate("review/draft-pr");
|
|
15452
|
+
const body = renderTemplate(template.content, {
|
|
15453
|
+
intent: params.intent ?? "<!-- Describe why this change is needed. -->",
|
|
15454
|
+
head: review.vcs.branch,
|
|
15455
|
+
base
|
|
15456
|
+
});
|
|
15457
|
+
const pr = await review.forge.createDraftPr({
|
|
15458
|
+
title: params.title ?? deriveTitle(review.vcs.branch),
|
|
15459
|
+
body,
|
|
15460
|
+
base,
|
|
15461
|
+
head: review.vcs.branch
|
|
15462
|
+
});
|
|
15463
|
+
return result(`Draft PR/MR created from ${template.source} template: ${pr.url}`, { pr, template });
|
|
15464
|
+
}
|
|
15465
|
+
}),
|
|
15466
|
+
defineTool3({
|
|
15467
|
+
name: "review_edit",
|
|
15468
|
+
label: "review edit",
|
|
15469
|
+
description: "Switch to the requested PR branch when necessary and open its existing tuicr session without generating comments.",
|
|
15470
|
+
promptSnippet: "Call review_edit for the local-only edit workflow",
|
|
15471
|
+
promptGuidelines: ["This tool never generates review findings."],
|
|
15472
|
+
parameters: parameters5(contextSchema),
|
|
15473
|
+
executionMode: "sequential",
|
|
15474
|
+
async execute(_id, input) {
|
|
15475
|
+
const params = contextSchema.parse(input);
|
|
15476
|
+
let review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15477
|
+
if (review.pr && review.pr.headRef !== review.vcs.branch) {
|
|
15478
|
+
await switchBranch(review.cwd, review.pr.headRef);
|
|
15479
|
+
review = await resolveReviewContext(review.cwd, params.target);
|
|
15480
|
+
}
|
|
15481
|
+
await ensureStore(review.cwd);
|
|
15482
|
+
return launchLocalReview(review, params.workingTree);
|
|
15483
|
+
}
|
|
15484
|
+
}),
|
|
15485
|
+
defineTool3({
|
|
15486
|
+
name: "review_diff",
|
|
15487
|
+
label: "review diff",
|
|
15488
|
+
description: "Fetch the target PR/MR diff or auto-detected local working-tree diff.",
|
|
15489
|
+
promptSnippet: "Call review_diff for the code under review",
|
|
15490
|
+
promptGuidelines: ["Ground findings in this diff."],
|
|
15491
|
+
parameters: parameters5(localSchema),
|
|
15492
|
+
executionMode: "parallel",
|
|
15493
|
+
async execute(_id, input) {
|
|
15494
|
+
const params = localSchema.parse(input);
|
|
15495
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15496
|
+
if (params.workingTree || !review.pr && (params.local || !review.forge)) {
|
|
15497
|
+
const diff = await workingTreeDiff(review.cwd);
|
|
15498
|
+
return result(diff || "No working-tree changes.", { diff, target: "local" });
|
|
15499
|
+
}
|
|
15500
|
+
if (!review.pr || !review.forge)
|
|
15501
|
+
return result("No PR/MR matches this remote review target.");
|
|
15502
|
+
const diff = await review.forge.prDiff(review.pr.number);
|
|
15503
|
+
return result(diff || "Empty diff.", { diff, pr: review.pr });
|
|
15504
|
+
}
|
|
15505
|
+
}),
|
|
15506
|
+
defineTool3({
|
|
15507
|
+
name: "review_gates",
|
|
15508
|
+
label: "review gates",
|
|
15509
|
+
description: "Run format, lint, test, conventional-subject, and available CI checks.",
|
|
15510
|
+
promptSnippet: "Call review_gates before submitting findings",
|
|
15511
|
+
promptGuidelines: ["Report skipped gates as skipped."],
|
|
15512
|
+
parameters: parameters5(localSchema),
|
|
15513
|
+
executionMode: "parallel",
|
|
15514
|
+
async execute(_id, input) {
|
|
15515
|
+
const params = localSchema.parse(input);
|
|
15516
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15517
|
+
const gates = await runMiseGates(review.cwd);
|
|
15518
|
+
const commit = await run("git", ["-C", review.cwd, "log", "-1", "--format=%s"]);
|
|
15519
|
+
const subject = commit.stdout.trim();
|
|
15520
|
+
if (subject)
|
|
15521
|
+
gates.push(checkConventionalSubject(subject));
|
|
15522
|
+
if (!params.local && review.pr && review.forge)
|
|
15523
|
+
gates.push(ciGate(await review.forge.prChecks(review.pr.number)));
|
|
15524
|
+
return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
|
|
15525
|
+
`), {
|
|
15526
|
+
results: gates
|
|
15527
|
+
});
|
|
15528
|
+
}
|
|
15529
|
+
}),
|
|
15530
|
+
defineTool3({
|
|
15531
|
+
name: "review_submit",
|
|
15532
|
+
label: "review submit",
|
|
15533
|
+
description: "Write the review artifact and stage comments in the selected local or remote backend.",
|
|
15534
|
+
promptSnippet: "Call review_submit with the findings JSON",
|
|
15535
|
+
promptGuidelines: ["Remote comments remain pending until review_publish."],
|
|
15536
|
+
parameters: parameters5(submitSchema),
|
|
15537
|
+
executionMode: "sequential",
|
|
15538
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
15539
|
+
const params = submitSchema.parse(input);
|
|
15540
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15541
|
+
const model = modelRoute(ctx);
|
|
15542
|
+
const findings = dedupeFindings(params.findings);
|
|
15543
|
+
const gates = await runMiseGates(review.cwd);
|
|
15544
|
+
const baseRef = review.pr?.baseRef ?? (review.forge ? await review.forge.defaultBranch() : "local");
|
|
15545
|
+
const artifact = await newReviewArtifactPath(review.cwd, params.workingTree || !review.pr ? "local" : await reviewTargetId(review));
|
|
15546
|
+
await writeFile4(artifact, renderReviewDoc({
|
|
15547
|
+
title: params.title ?? review.pr?.title ?? review.vcs.branch,
|
|
15548
|
+
number: review.pr?.number,
|
|
15549
|
+
url: review.pr?.url,
|
|
15550
|
+
model,
|
|
15551
|
+
headRef: review.vcs.branch,
|
|
15552
|
+
baseRef,
|
|
15553
|
+
findings,
|
|
15554
|
+
overallIssues: params.overallIssues ?? [],
|
|
15555
|
+
gates,
|
|
15556
|
+
notVerified: params.notVerified ?? []
|
|
15557
|
+
}), "utf8");
|
|
15558
|
+
const useLocalBackend = Boolean(params.local || !review.forge);
|
|
15559
|
+
const comments = toReviewComments(findings, useLocalBackend ? undefined : model);
|
|
15560
|
+
if (useLocalBackend) {
|
|
15561
|
+
const session = await resolveTuicrSession(review, params.workingTree);
|
|
15562
|
+
if (!session) {
|
|
15563
|
+
return result(`Review written: ${artifact}. Open tuicr, then call review_submit again to seed comments.`, {
|
|
15564
|
+
artifact,
|
|
15565
|
+
count: findings.length
|
|
15566
|
+
});
|
|
15567
|
+
}
|
|
15568
|
+
const backend = createLocalReviewBackend({
|
|
15569
|
+
session: session.path,
|
|
15570
|
+
artifactPath: artifact,
|
|
15571
|
+
author: localReviewAuthor(model)
|
|
15572
|
+
});
|
|
15573
|
+
await backend.stage({ comments, body: (params.overallIssues ?? []).join(`
|
|
15574
|
+
`) });
|
|
15575
|
+
return result(`Local review staged in tuicr: ${artifact}`, { artifact, count: findings.length, session });
|
|
15576
|
+
}
|
|
15577
|
+
if (!review.pr)
|
|
15578
|
+
return result(`Review written: ${artifact}. No PR/MR matches this remote target.`, { artifact });
|
|
15579
|
+
if (comments.length > 0) {
|
|
15580
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments, body: "" });
|
|
15581
|
+
}
|
|
15582
|
+
return result(`${comments.length > 0 ? "Pending review staged" : "Clean review recorded"} on #${review.pr.number}. Artifact: ${artifact}`, {
|
|
15583
|
+
artifact,
|
|
15584
|
+
pr: review.pr,
|
|
15585
|
+
count: findings.length
|
|
15586
|
+
});
|
|
15587
|
+
}
|
|
15588
|
+
}),
|
|
15589
|
+
defineTool3({
|
|
15590
|
+
name: "review_add_comment",
|
|
15591
|
+
label: "review add comment",
|
|
15592
|
+
description: "Add one provenance-marked comment through tuicr or the remote pending-review backend.",
|
|
15593
|
+
promptSnippet: "Call review_add_comment for incremental comments",
|
|
15594
|
+
promptGuidelines: ["Pass local=true when tuicr owns the draft."],
|
|
15595
|
+
parameters: parameters5(addCommentSchema),
|
|
15596
|
+
executionMode: "sequential",
|
|
15597
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
15598
|
+
const params = addCommentSchema.parse(input);
|
|
15599
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15600
|
+
const model = modelRoute(ctx);
|
|
15601
|
+
const useLocalBackend = Boolean(params.local || !review.forge);
|
|
15602
|
+
const comment = {
|
|
15603
|
+
file: params.file,
|
|
15604
|
+
line: params.line,
|
|
15605
|
+
side: params.side ?? "RIGHT",
|
|
15606
|
+
body: useLocalBackend ? params.body : withRemoteProvenance(params.body, model)
|
|
15607
|
+
};
|
|
15608
|
+
if (useLocalBackend) {
|
|
15609
|
+
const session = await resolveTuicrSession(review, params.workingTree);
|
|
15610
|
+
if (!session)
|
|
15611
|
+
return result("No matching tuicr session. Open review_edit or review_launch first.");
|
|
15612
|
+
const backend = createLocalReviewBackend({
|
|
15613
|
+
session: session.path,
|
|
15614
|
+
artifactPath: "",
|
|
15615
|
+
author: localReviewAuthor(model)
|
|
15616
|
+
});
|
|
15617
|
+
await backend.stage({ comments: [comment], body: "" });
|
|
15618
|
+
return result(`Comment added to tuicr session ${session.slug}.`, { session, comment });
|
|
15619
|
+
}
|
|
15620
|
+
if (!review.pr)
|
|
15621
|
+
return result("No PR/MR matches this remote review target.");
|
|
15622
|
+
await createRemoteReviewBackend(review.vcs, review.pr.number).stage({ comments: [comment], body: "" });
|
|
15623
|
+
return result(`Draft comment added to #${review.pr.number}.`, { pr: review.pr, comment });
|
|
15624
|
+
}
|
|
15625
|
+
}),
|
|
15626
|
+
defineTool3({
|
|
15627
|
+
name: "review_comments",
|
|
15628
|
+
label: "review comments",
|
|
15629
|
+
description: "Pull review threads or local tuicr comments and write a target-named artifact.",
|
|
15630
|
+
promptSnippet: "Call review_comments before addressing findings",
|
|
15631
|
+
promptGuidelines: ["Pass local=true to prepare the local reply overlay."],
|
|
15632
|
+
parameters: parameters5(localSchema),
|
|
15633
|
+
executionMode: "sequential",
|
|
15634
|
+
async execute(_id, input) {
|
|
15635
|
+
const params = localSchema.parse(input);
|
|
15636
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15637
|
+
let threads;
|
|
15638
|
+
if (!params.workingTree && review.pr && review.forge) {
|
|
15639
|
+
threads = await createRemoteReviewBackend(review.vcs, review.pr.number).listThreads();
|
|
15640
|
+
} else if (params.local || !review.forge) {
|
|
15641
|
+
const session = await resolveTuicrSession(review, params.workingTree);
|
|
15642
|
+
if (!session)
|
|
15643
|
+
return result("No matching tuicr session found.");
|
|
15644
|
+
const draft = toFindings(await readSession(session.path));
|
|
15645
|
+
threads = draft.comments.map((comment, index) => ({
|
|
15646
|
+
id: `local-${index + 1}`,
|
|
15647
|
+
file: comment.file,
|
|
15648
|
+
line: comment.line,
|
|
15649
|
+
body: comment.body,
|
|
15650
|
+
resolved: false,
|
|
15651
|
+
question: /\?\s*$/.test(comment.body.trim())
|
|
15652
|
+
}));
|
|
15653
|
+
} else {
|
|
15654
|
+
return result("No PR/MR matches this remote review target.");
|
|
15655
|
+
}
|
|
15656
|
+
const target = params.workingTree ? "local" : await reviewTargetId(review);
|
|
15657
|
+
const artifact = await newReviewArtifactPath(review.cwd, target);
|
|
15658
|
+
await writeFile4(artifact, renderThreadArtifact(review.pr?.title ?? review.vcs.branch, target, threads, {
|
|
15659
|
+
number: params.workingTree ? undefined : review.pr?.number,
|
|
15660
|
+
url: params.workingTree ? undefined : review.pr?.url
|
|
15661
|
+
}), "utf8");
|
|
15662
|
+
if (params.local && !params.workingTree && review.pr && review.forge) {
|
|
15663
|
+
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
15664
|
+
publication.state.overlayPath = artifact;
|
|
15665
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
15666
|
+
}
|
|
15667
|
+
return result(threads.map((thread) => `${thread.id} ${thread.file ?? "review"}:${thread.line ?? "-"} — ${thread.body}`).join(`
|
|
15668
|
+
`) || "No comments.", { artifact, threads });
|
|
15669
|
+
}
|
|
15670
|
+
}),
|
|
15671
|
+
defineTool3({
|
|
15672
|
+
name: "review_respond",
|
|
15673
|
+
label: "review respond",
|
|
15674
|
+
description: "Record a local overlay reply or post a provenance-marked remote thread reply.",
|
|
15675
|
+
promptSnippet: "Call review_respond after addressing a comment",
|
|
15676
|
+
promptGuidelines: ["Question replies remain unresolved."],
|
|
15677
|
+
parameters: parameters5(respondSchema),
|
|
15678
|
+
executionMode: "sequential",
|
|
15679
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
15680
|
+
const params = respondSchema.parse(input);
|
|
15681
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15682
|
+
const model = modelRoute(ctx);
|
|
15683
|
+
if (params.local) {
|
|
15684
|
+
const target = params.workingTree ? "local" : await reviewTargetId(review);
|
|
15685
|
+
const publication = !params.workingTree && review.pr && review.forge ? await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number) : undefined;
|
|
15686
|
+
const artifact = publication?.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
15687
|
+
if (!artifact)
|
|
15688
|
+
return result("No local review artifact. Run review_comments with local=true first.");
|
|
15689
|
+
const threads = await readThreadArtifact(artifact);
|
|
15690
|
+
const known = threads.find((thread) => thread.id === params.threadId);
|
|
15691
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
15692
|
+
const tuicrSession = await resolveTuicrSession(review, params.workingTree);
|
|
15693
|
+
const session = tuicrSession?.path ?? "";
|
|
15694
|
+
await createLocalReviewBackend({ session, artifactPath: artifact, author: localReviewAuthor(model) }).reply({
|
|
15695
|
+
threadId: params.threadId,
|
|
15696
|
+
body: withRemoteProvenance(params.body, model),
|
|
15697
|
+
resolve: false,
|
|
15698
|
+
question
|
|
15699
|
+
});
|
|
15700
|
+
return result(`Local reply recorded in ${artifact}.`, { artifact, question });
|
|
15701
|
+
}
|
|
15702
|
+
if (!review.pr || !review.forge)
|
|
15703
|
+
return result("No remote PR/MR for this reply.");
|
|
15704
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
15705
|
+
const remoteThreads = await remote.listThreads();
|
|
15706
|
+
const known = remoteThreads.find((thread) => thread.id === params.threadId);
|
|
15707
|
+
const question = known?.question === true || params.question === true || !known && params.question === undefined;
|
|
15708
|
+
const resolve = question ? false : params.resolve ?? true;
|
|
15709
|
+
await remote.reply({
|
|
15710
|
+
threadId: params.threadId,
|
|
15711
|
+
body: withRemoteProvenance(params.body, model),
|
|
15712
|
+
resolve,
|
|
15713
|
+
question
|
|
15714
|
+
});
|
|
15715
|
+
return result(`Replied to ${params.threadId}${resolve ? " and resolved it" : " and left it open"}.`, {
|
|
15716
|
+
pr: review.pr,
|
|
15717
|
+
resolve
|
|
15718
|
+
});
|
|
15719
|
+
}
|
|
15720
|
+
}),
|
|
15721
|
+
defineTool3({
|
|
15722
|
+
name: "review_publish",
|
|
15723
|
+
label: "review publish",
|
|
15724
|
+
description: "Promote local drafts when needed, publish pending review work, and apply the selected public status.",
|
|
15725
|
+
promptSnippet: "Call review_publish to make review work public",
|
|
15726
|
+
promptGuidelines: ["Statuses are COMMENT, APPROVE, REQUEST_CHANGES, or CLOSE."],
|
|
15727
|
+
parameters: parameters5(publishSchema),
|
|
15728
|
+
executionMode: "sequential",
|
|
15729
|
+
async execute(_id, input, _signal, _onUpdate, ctx) {
|
|
15730
|
+
const params = publishSchema.parse(input);
|
|
15731
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15732
|
+
if (!review.pr || !review.forge)
|
|
15733
|
+
return result("No remote PR/MR to publish.");
|
|
15734
|
+
return publishResolvedReview(review, params, modelRoute(ctx));
|
|
15735
|
+
}
|
|
15736
|
+
}),
|
|
15737
|
+
defineTool3({
|
|
15738
|
+
name: "review_merge",
|
|
15739
|
+
label: "review merge",
|
|
15740
|
+
description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
|
|
15741
|
+
promptSnippet: "Call review_merge only after review_publish APPROVE",
|
|
15742
|
+
promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
|
|
15743
|
+
parameters: parameters5(contextSchema.extend({ subject: z6.string().optional() })),
|
|
15744
|
+
executionMode: "sequential",
|
|
15745
|
+
async execute(_id, input) {
|
|
15746
|
+
const params = contextSchema.extend({ subject: z6.string().optional() }).parse(input);
|
|
15747
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15748
|
+
if (review.vcs.provider !== "github" || !review.forge)
|
|
15749
|
+
return result("review_merge currently supports GitHub only.");
|
|
15750
|
+
if (!review.pr)
|
|
15751
|
+
return result("No open PR/MR for this branch.");
|
|
15752
|
+
const subject = params.subject ?? review.pr.title;
|
|
15753
|
+
const guard = conventionalMergeGuard(subject);
|
|
15754
|
+
if (guard.status !== "pass")
|
|
15755
|
+
return result(`Merge blocked: ${guard.detail}`, { pr: review.pr, guard });
|
|
15756
|
+
await review.forge.mergePr(review.pr.number, subject);
|
|
15757
|
+
return result(`Merged #${review.pr.number} with subject: ${subject}.`, { pr: review.pr, guard });
|
|
15758
|
+
}
|
|
15759
|
+
}),
|
|
15760
|
+
defineTool3({
|
|
15761
|
+
name: "review_launch",
|
|
15762
|
+
label: "review launch",
|
|
15763
|
+
description: "Open the auto-detected tuicr target in a mux tab, configure Zed, or print the command.",
|
|
15764
|
+
promptSnippet: "Call review_launch for the interactive tuicr TUI",
|
|
15765
|
+
promptGuidelines: ["Show the returned command when launch cannot open a tab."],
|
|
15766
|
+
parameters: parameters5(contextSchema),
|
|
15767
|
+
executionMode: "sequential",
|
|
15768
|
+
async execute(_id, input) {
|
|
15769
|
+
const params = contextSchema.parse(input);
|
|
15770
|
+
const review = await resolveReviewContext(cwdOf(params), params.target);
|
|
15771
|
+
return launchLocalReview(review, params.workingTree);
|
|
15772
|
+
}
|
|
15773
|
+
})
|
|
15774
|
+
];
|
|
15775
|
+
}
|
|
15776
|
+
async function publishResolvedReview(review, params, model) {
|
|
15777
|
+
const status = params.status ?? "COMMENT";
|
|
15778
|
+
const event = status === "CLOSE" ? "COMMENT" : status;
|
|
15779
|
+
assertReviewEventSupported(review.vcs.provider, event);
|
|
15780
|
+
const remote = createRemoteReviewBackend(review.vcs, review.pr.number);
|
|
15781
|
+
const promotion = params.local ? await promoteLocalReview(review, remote, model, Boolean(params.workingTree)) : undefined;
|
|
15782
|
+
if (status !== "CLOSE" && review.pr.isDraft)
|
|
15783
|
+
await review.forge.markReady(review.pr.number);
|
|
15784
|
+
await remote.publish(event);
|
|
15785
|
+
if (promotion) {
|
|
15786
|
+
promotion.publication.state.comments = [
|
|
15787
|
+
...new Set([...promotion.publication.state.comments, ...promotion.commentFingerprints])
|
|
15788
|
+
];
|
|
15789
|
+
await saveReviewPublicationState(promotion.publication.path, promotion.publication.state);
|
|
15790
|
+
}
|
|
15791
|
+
if (status === "CLOSE")
|
|
15792
|
+
await review.forge.closePr(review.pr.number);
|
|
15793
|
+
const finalPr = await review.forge.viewPr(String(review.pr.number));
|
|
15794
|
+
const promotedComments = promotion?.promotedComments ?? 0;
|
|
15795
|
+
const promotedReplies = promotion?.promotedReplies ?? 0;
|
|
15796
|
+
return result(`Published #${review.pr.number} (${status}); promoted ${promotedComments} comments and ${promotedReplies} replies.`, { pr: finalPr ?? review.pr, status, promotedComments, promotedReplies });
|
|
15797
|
+
}
|
|
15798
|
+
async function promoteLocalReview(review, remote, model, workingTree) {
|
|
15799
|
+
const publication = await loadReviewPublicationState(review.cwd, review.vcs, review.pr.number);
|
|
15800
|
+
const session = await resolveTuicrSession(review, workingTree);
|
|
15801
|
+
if (!session)
|
|
15802
|
+
throw new Error("No matching tuicr session to publish.");
|
|
15803
|
+
const local = createLocalReviewBackend({
|
|
15804
|
+
session: session.path,
|
|
15805
|
+
artifactPath: "",
|
|
15806
|
+
author: localReviewAuthor(model)
|
|
15807
|
+
});
|
|
15808
|
+
const draft = await local.readDraft();
|
|
15809
|
+
const comments = draft.comments.map((comment) => ({
|
|
15810
|
+
...comment,
|
|
15811
|
+
body: withRemoteProvenance(comment.body, comment.author?.replace(/^Agent:\s*/, "") || model)
|
|
15812
|
+
}));
|
|
15813
|
+
const commentFingerprints = comments.map(reviewCommentFingerprint);
|
|
15814
|
+
const remoteDraft = await remote.readDraft();
|
|
15815
|
+
const known = new Set([...publication.state.comments, ...remoteDraft.comments.map(reviewCommentFingerprint)]);
|
|
15816
|
+
const unpublished = unpublishedReviewComments(comments, known);
|
|
15817
|
+
if (unpublished.length > 0)
|
|
15818
|
+
await remote.stage({ comments: unpublished, body: "" });
|
|
15819
|
+
const promotedReplies = await promoteLocalReplies(review, remote, publication, model, workingTree);
|
|
15820
|
+
return {
|
|
15821
|
+
publication,
|
|
15822
|
+
commentFingerprints,
|
|
15823
|
+
promotedComments: unpublished.length,
|
|
15824
|
+
promotedReplies
|
|
15825
|
+
};
|
|
15826
|
+
}
|
|
15827
|
+
async function promoteLocalReplies(review, remote, publication, model, workingTree) {
|
|
15828
|
+
const target = workingTree ? "local" : await reviewTargetId(review);
|
|
15829
|
+
const artifact = workingTree ? await latestThreadArtifact(review.cwd, review, target) : publication.state.overlayPath ?? await latestThreadArtifact(review.cwd, review, target);
|
|
15830
|
+
if (!artifact)
|
|
15831
|
+
return 0;
|
|
15832
|
+
if (!workingTree)
|
|
15833
|
+
publication.state.overlayPath = artifact;
|
|
15834
|
+
let count = 0;
|
|
15835
|
+
for (const thread of await readThreadArtifact(artifact)) {
|
|
15836
|
+
if (!thread.reply)
|
|
15837
|
+
continue;
|
|
15838
|
+
const body = withRemoteProvenance(thread.reply, model);
|
|
15839
|
+
const fingerprint = reviewReplyFingerprint(thread.id, body);
|
|
15840
|
+
if (publication.state.replies.includes(fingerprint))
|
|
15841
|
+
continue;
|
|
15842
|
+
await remote.reply({
|
|
15843
|
+
threadId: thread.id,
|
|
15844
|
+
body,
|
|
15845
|
+
resolve: !thread.question,
|
|
15846
|
+
question: thread.question
|
|
15847
|
+
});
|
|
15848
|
+
publication.state.replies.push(fingerprint);
|
|
15849
|
+
await saveReviewPublicationState(publication.path, publication.state);
|
|
15850
|
+
count += 1;
|
|
15851
|
+
}
|
|
15852
|
+
return count;
|
|
15853
|
+
}
|
|
15854
|
+
async function resolveReviewContext(cwd, target) {
|
|
15855
|
+
const vcs = await detectVcs(cwd);
|
|
15856
|
+
const forge = vcs.provider === "none" ? undefined : createForge(vcs);
|
|
15857
|
+
const requested = target ? normalizeTarget(target) : vcs.branch;
|
|
15858
|
+
const pr = forge ? await forge.viewPr(requested) : undefined;
|
|
15859
|
+
return { cwd, vcs, forge, pr };
|
|
15860
|
+
}
|
|
15861
|
+
function normalizeTarget(target) {
|
|
15862
|
+
return target.match(/\/(?:pull|merge_requests)\/(\d+)(?:\/|$)/)?.[1] ?? target;
|
|
15863
|
+
}
|
|
15864
|
+
function resolveTuicrSession(review, workingTree = false) {
|
|
15865
|
+
return resolveReviewSession(review.cwd, {
|
|
15866
|
+
branch: review.vcs.branch,
|
|
15867
|
+
workingTree,
|
|
15868
|
+
owner: review.vcs.provider === "none" ? undefined : review.vcs.owner,
|
|
15869
|
+
repo: review.vcs.provider === "none" ? undefined : review.vcs.repo,
|
|
15870
|
+
number: review.pr?.number
|
|
15871
|
+
});
|
|
15872
|
+
}
|
|
15873
|
+
async function launchLocalReview(review, workingTree) {
|
|
15874
|
+
const launched = await launch(review.cwd, workingTree ? undefined : review.pr?.number);
|
|
15875
|
+
const commandTarget = workingTree || !review.pr ? "working tree" : `PR/MR #${review.pr.number}`;
|
|
15876
|
+
return result(launched.launched ? `Opened ${commandTarget} in tuicr (${launched.via}).` : launched.instruction ?? `Run: ${launched.command}`, { launched, pr: review.pr, target: commandTarget });
|
|
15877
|
+
}
|
|
15878
|
+
async function switchBranch(cwd, branch) {
|
|
15879
|
+
const dirty = await runChecked("git", ["-C", cwd, "status", "--porcelain"]);
|
|
15880
|
+
if (dirty.stdout.trim())
|
|
15881
|
+
throw new Error(`Cannot switch to ${branch}: the current worktree has uncommitted changes.`);
|
|
15882
|
+
await runChecked("git", ["-C", cwd, "fetch", "origin", branch]);
|
|
15883
|
+
const local = await run("git", ["-C", cwd, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
15884
|
+
if (local.code === 0)
|
|
15885
|
+
await runChecked("git", ["-C", cwd, "switch", branch]);
|
|
15886
|
+
else
|
|
15887
|
+
await runChecked("git", ["-C", cwd, "switch", "--track", "-c", branch, `origin/${branch}`]);
|
|
15888
|
+
}
|
|
15889
|
+
async function reviewTargetId(review) {
|
|
15890
|
+
if (!review.pr)
|
|
15891
|
+
return "local";
|
|
15892
|
+
if (review.pr.headSha)
|
|
15893
|
+
return review.pr.headSha.slice(0, 12);
|
|
15894
|
+
if (review.pr.headRef === review.vcs.branch)
|
|
15895
|
+
return headSha(review.cwd);
|
|
15896
|
+
const remote = await run("git", ["-C", review.cwd, "ls-remote", "origin", `refs/heads/${review.pr.headRef}`]);
|
|
15897
|
+
return remote.stdout.trim().split(/\s+/)[0]?.slice(0, 12) || headSha(review.cwd);
|
|
15898
|
+
}
|
|
15899
|
+
async function headSha(cwd) {
|
|
15900
|
+
const result = await runChecked("git", ["-C", cwd, "rev-parse", "--short=12", "HEAD"]);
|
|
15901
|
+
return result.stdout.trim();
|
|
15902
|
+
}
|
|
15903
|
+
async function newReviewArtifactPath(cwd, target) {
|
|
15904
|
+
const dir = await reviewsDir(cwd);
|
|
15905
|
+
await mkdir3(dir, { recursive: true });
|
|
15906
|
+
return uniqueRecordPath(dir, reviewRecordName(target));
|
|
15907
|
+
}
|
|
15908
|
+
async function readThreadArtifact(path) {
|
|
15909
|
+
try {
|
|
15910
|
+
return parseThreadArtifact(await readFile8(path, "utf8"));
|
|
15911
|
+
} catch (error) {
|
|
15912
|
+
if (error.code === "ENOENT") {
|
|
15913
|
+
throw new Error(`Expected local reply overlay is missing: ${path}`);
|
|
15914
|
+
}
|
|
15915
|
+
throw error;
|
|
15916
|
+
}
|
|
15917
|
+
}
|
|
15918
|
+
async function latestThreadArtifact(cwd, review, target) {
|
|
15919
|
+
try {
|
|
15920
|
+
return await findLatestThreadArtifact(cwd, review, target);
|
|
15921
|
+
} catch (error) {
|
|
15922
|
+
throw new Error(`Cannot locate the latest review thread artifact for ${target}.`, { cause: error });
|
|
15923
|
+
}
|
|
15924
|
+
}
|
|
15925
|
+
async function findLatestThreadArtifact(cwd, review, target) {
|
|
15926
|
+
const dir = await reviewsDir(cwd);
|
|
15927
|
+
const suffix = reviewSlug(target) || "local";
|
|
15928
|
+
const names = await listReviewArtifactNames(dir);
|
|
15929
|
+
const candidates = await Promise.all(names.flatMap((name) => {
|
|
15930
|
+
if (!name.endsWith(".md"))
|
|
15931
|
+
return [];
|
|
15932
|
+
return [
|
|
15933
|
+
(async () => {
|
|
15934
|
+
const path = join11(dir, name);
|
|
15935
|
+
const info = await stat(path);
|
|
15936
|
+
return { path, name, content: await readFile8(path, "utf8"), modified: info.mtimeMs };
|
|
15937
|
+
})()
|
|
15938
|
+
];
|
|
15939
|
+
}));
|
|
15940
|
+
return candidates.filter(({ name, content }) => {
|
|
15941
|
+
if (!content.startsWith("<!-- diffpi-threads:"))
|
|
15942
|
+
return false;
|
|
15943
|
+
if (review.pr && target !== "local")
|
|
15944
|
+
return content.includes(`- PR/MR: #${review.pr.number}`);
|
|
15945
|
+
return artifactNameMatches(name, suffix);
|
|
15946
|
+
}).sort((a, b) => b.modified - a.modified)[0]?.path;
|
|
15947
|
+
}
|
|
15948
|
+
async function listReviewArtifactNames(dir) {
|
|
15949
|
+
try {
|
|
15950
|
+
return await readdir2(dir);
|
|
15951
|
+
} catch (error) {
|
|
15952
|
+
throw new Error(`Cannot read review artifacts in ${dir}.`, { cause: error });
|
|
15953
|
+
}
|
|
15954
|
+
}
|
|
15955
|
+
function deriveTitle(branch) {
|
|
15956
|
+
return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
|
|
15957
|
+
}
|
|
15958
|
+
function uniqueRecordPath(dir, base) {
|
|
15959
|
+
let path = join11(dir, `${base}.md`);
|
|
15960
|
+
let count = 2;
|
|
15961
|
+
while (existsSync3(path))
|
|
15962
|
+
path = join11(dir, `${base}-${count++}.md`);
|
|
15963
|
+
return path;
|
|
15964
|
+
}
|
|
15965
|
+
function artifactNameMatches(name, suffix) {
|
|
15966
|
+
if (!name.endsWith(".md"))
|
|
15967
|
+
return false;
|
|
15968
|
+
const stem = name.slice(0, -3);
|
|
15969
|
+
const marker = `-${suffix}`;
|
|
15970
|
+
const markerIndex = stem.lastIndexOf(marker);
|
|
15971
|
+
if (markerIndex < 0)
|
|
15972
|
+
return false;
|
|
15973
|
+
const tail = stem.slice(markerIndex + marker.length);
|
|
15974
|
+
return tail === "" || /^-\d+$/.test(tail);
|
|
15975
|
+
}
|
|
15976
|
+
|
|
13353
15977
|
// src/tools/setup.ts
|
|
13354
|
-
import { defineTool as
|
|
13355
|
-
import { z as
|
|
15978
|
+
import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
|
|
15979
|
+
import { z as z7 } from "zod";
|
|
13356
15980
|
|
|
13357
15981
|
// src/setup.ts
|
|
13358
|
-
import {
|
|
13359
|
-
import {
|
|
15982
|
+
import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
|
|
15983
|
+
import { readdir as readdir3, readFile as readFile11 } from "node:fs/promises";
|
|
15984
|
+
import { homedir as homedir10 } from "node:os";
|
|
15985
|
+
import { basename as basename5, join as join15 } from "node:path";
|
|
13360
15986
|
|
|
13361
15987
|
// src/mcp.ts
|
|
13362
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
13363
|
-
import { homedir as
|
|
13364
|
-
import { dirname as
|
|
15988
|
+
import { mkdir as mkdir4, readFile as readFile9, writeFile as writeFile5 } from "node:fs/promises";
|
|
15989
|
+
import { homedir as homedir7 } from "node:os";
|
|
15990
|
+
import { dirname as dirname5, join as join12 } from "node:path";
|
|
13365
15991
|
var mcp = {
|
|
13366
|
-
globalConfigPath(homeDir =
|
|
13367
|
-
return
|
|
15992
|
+
globalConfigPath(homeDir = homedir7()) {
|
|
15993
|
+
return join12(homeDir, ".config", "mcp", "mcp.json");
|
|
13368
15994
|
},
|
|
13369
15995
|
async serversEnsure(servers, options = {}) {
|
|
13370
15996
|
const path = options.path ?? mcp.globalConfigPath();
|
|
@@ -13377,8 +16003,8 @@ var mcp = {
|
|
|
13377
16003
|
const next = { ...current, mcpServers: nextServers };
|
|
13378
16004
|
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
13379
16005
|
if (changed && !options.dryRun) {
|
|
13380
|
-
await
|
|
13381
|
-
await
|
|
16006
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
16007
|
+
await writeFile5(path, `${JSON.stringify(next, null, 2)}
|
|
13382
16008
|
`, "utf8");
|
|
13383
16009
|
}
|
|
13384
16010
|
return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
|
|
@@ -13407,7 +16033,7 @@ function getParsedConfig(content, path) {
|
|
|
13407
16033
|
}
|
|
13408
16034
|
async function getOptionalFile(path) {
|
|
13409
16035
|
try {
|
|
13410
|
-
return await
|
|
16036
|
+
return await readFile9(path, "utf8");
|
|
13411
16037
|
} catch (error) {
|
|
13412
16038
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13413
16039
|
return;
|
|
@@ -13419,68 +16045,9 @@ function isRecord(value) {
|
|
|
13419
16045
|
}
|
|
13420
16046
|
|
|
13421
16047
|
// src/mise.ts
|
|
13422
|
-
import { mkdir as
|
|
13423
|
-
import { homedir as
|
|
13424
|
-
import { basename, dirname as
|
|
13425
|
-
|
|
13426
|
-
// src/process.ts
|
|
13427
|
-
import { constants } from "node:fs";
|
|
13428
|
-
import { access } from "node:fs/promises";
|
|
13429
|
-
import { delimiter, join as join4 } from "node:path";
|
|
13430
|
-
import { spawn as spawn2 } from "node:child_process";
|
|
13431
|
-
var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
|
|
13432
|
-
async function findExecutable(name) {
|
|
13433
|
-
if (name.includes("/")) {
|
|
13434
|
-
try {
|
|
13435
|
-
await access(name, constants.X_OK);
|
|
13436
|
-
return name;
|
|
13437
|
-
} catch {
|
|
13438
|
-
return;
|
|
13439
|
-
}
|
|
13440
|
-
}
|
|
13441
|
-
for (const directory of (process.env.PATH ?? "").split(delimiter)) {
|
|
13442
|
-
if (!directory)
|
|
13443
|
-
continue;
|
|
13444
|
-
const candidate = join4(directory, name);
|
|
13445
|
-
try {
|
|
13446
|
-
await access(candidate, constants.X_OK);
|
|
13447
|
-
return candidate;
|
|
13448
|
-
} catch {}
|
|
13449
|
-
}
|
|
13450
|
-
return;
|
|
13451
|
-
}
|
|
13452
|
-
function run(command, args, options = {}) {
|
|
13453
|
-
return new Promise((resolve, reject) => {
|
|
13454
|
-
const child = spawn2(command, args, {
|
|
13455
|
-
cwd: options.cwd,
|
|
13456
|
-
env: options.env ?? process.env,
|
|
13457
|
-
stdio: ["ignore", "pipe", "pipe"]
|
|
13458
|
-
});
|
|
13459
|
-
let stdout = "";
|
|
13460
|
-
let stderr = "";
|
|
13461
|
-
child.stdout.on("data", (chunk) => {
|
|
13462
|
-
stdout = appendBounded(stdout, chunk.toString());
|
|
13463
|
-
});
|
|
13464
|
-
child.stderr.on("data", (chunk) => {
|
|
13465
|
-
stderr = appendBounded(stderr, chunk.toString());
|
|
13466
|
-
});
|
|
13467
|
-
child.on("error", reject);
|
|
13468
|
-
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
13469
|
-
});
|
|
13470
|
-
}
|
|
13471
|
-
async function runChecked(command, args, options = {}) {
|
|
13472
|
-
const result = await run(command, args, options);
|
|
13473
|
-
if (result.code === 0)
|
|
13474
|
-
return result;
|
|
13475
|
-
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
13476
|
-
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`);
|
|
13477
|
-
}
|
|
13478
|
-
function appendBounded(current, next) {
|
|
13479
|
-
const combined = current + next;
|
|
13480
|
-
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
13481
|
-
}
|
|
13482
|
-
|
|
13483
|
-
// src/mise.ts
|
|
16048
|
+
import { mkdir as mkdir5, readFile as readFile10, writeFile as writeFile6 } from "node:fs/promises";
|
|
16049
|
+
import { homedir as homedir8 } from "node:os";
|
|
16050
|
+
import { basename as basename4, dirname as dirname6, join as join13 } from "node:path";
|
|
13484
16051
|
var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
|
|
13485
16052
|
var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
|
|
13486
16053
|
var mise = {
|
|
@@ -13488,11 +16055,11 @@ var mise = {
|
|
|
13488
16055
|
return findExecutable(name);
|
|
13489
16056
|
},
|
|
13490
16057
|
async install(options = {}) {
|
|
13491
|
-
const homeDir = options.homeDir ??
|
|
16058
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
13492
16059
|
const platform = options.platform ?? process.platform;
|
|
13493
16060
|
if (platform === "win32")
|
|
13494
16061
|
throw new Error("Automatic mise installation supports macOS and Linux only.");
|
|
13495
|
-
const installedPath =
|
|
16062
|
+
const installedPath = join13(homeDir, ".local", "bin", "mise");
|
|
13496
16063
|
if (options.dryRun)
|
|
13497
16064
|
return installedPath;
|
|
13498
16065
|
await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
|
|
@@ -13502,8 +16069,8 @@ var mise = {
|
|
|
13502
16069
|
return executable;
|
|
13503
16070
|
},
|
|
13504
16071
|
async hookEnsure(executable, options = {}) {
|
|
13505
|
-
const homeDir = options.homeDir ??
|
|
13506
|
-
const hook = getShellHook(
|
|
16072
|
+
const homeDir = options.homeDir ?? homedir8();
|
|
16073
|
+
const hook = getShellHook(basename4(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
|
|
13507
16074
|
const current = await getOptionalFile2(hook.path);
|
|
13508
16075
|
if (current.includes(MISE_HOOK_START))
|
|
13509
16076
|
return { path: hook.path, changed: false, planned: false };
|
|
@@ -13512,8 +16079,8 @@ var mise = {
|
|
|
13512
16079
|
const separator = current.length === 0 || current.endsWith(`
|
|
13513
16080
|
`) ? "" : `
|
|
13514
16081
|
`;
|
|
13515
|
-
await
|
|
13516
|
-
await
|
|
16082
|
+
await mkdir5(dirname6(hook.path), { recursive: true });
|
|
16083
|
+
await writeFile6(hook.path, `${current}${separator}${hook.content}`, "utf8");
|
|
13517
16084
|
return { path: hook.path, changed: true, planned: false };
|
|
13518
16085
|
},
|
|
13519
16086
|
async toolCheckGlobal(executable, tool, minimumVersion) {
|
|
@@ -13530,7 +16097,7 @@ var mise = {
|
|
|
13530
16097
|
async toolInstallLocal(executable, specification, cwd = process.cwd()) {
|
|
13531
16098
|
await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
|
|
13532
16099
|
},
|
|
13533
|
-
async toolUpdateAllGlobal(executable, homeDir =
|
|
16100
|
+
async toolUpdateAllGlobal(executable, homeDir = homedir8()) {
|
|
13534
16101
|
await runChecked(executable, ["upgrade"], { cwd: homeDir });
|
|
13535
16102
|
}
|
|
13536
16103
|
};
|
|
@@ -13539,7 +16106,7 @@ function getShellHook(shell, executable, homeDir) {
|
|
|
13539
16106
|
switch (shell.toLowerCase()) {
|
|
13540
16107
|
case "zsh":
|
|
13541
16108
|
return {
|
|
13542
|
-
path:
|
|
16109
|
+
path: join13(homeDir, ".zshrc"),
|
|
13543
16110
|
content: `${MISE_HOOK_START}
|
|
13544
16111
|
eval "$(${command} activate zsh)"
|
|
13545
16112
|
${MISE_HOOK_END}
|
|
@@ -13547,7 +16114,7 @@ ${MISE_HOOK_END}
|
|
|
13547
16114
|
};
|
|
13548
16115
|
case "fish":
|
|
13549
16116
|
return {
|
|
13550
|
-
path:
|
|
16117
|
+
path: join13(homeDir, ".config", "fish", "config.fish"),
|
|
13551
16118
|
content: `${MISE_HOOK_START}
|
|
13552
16119
|
${command} activate fish | source
|
|
13553
16120
|
${MISE_HOOK_END}
|
|
@@ -13556,7 +16123,7 @@ ${MISE_HOOK_END}
|
|
|
13556
16123
|
case "nu":
|
|
13557
16124
|
case "nushell":
|
|
13558
16125
|
return {
|
|
13559
|
-
path:
|
|
16126
|
+
path: join13(homeDir, ".config", "nushell", "config.nu"),
|
|
13560
16127
|
content: `${MISE_HOOK_START}
|
|
13561
16128
|
let mise_bin = ${command}
|
|
13562
16129
|
let mise_path = $nu.default-config-dir | path join mise.nu
|
|
@@ -13567,7 +16134,7 @@ ${MISE_HOOK_END}
|
|
|
13567
16134
|
};
|
|
13568
16135
|
case "xonsh":
|
|
13569
16136
|
return {
|
|
13570
|
-
path:
|
|
16137
|
+
path: join13(homeDir, ".xonshrc"),
|
|
13571
16138
|
content: `${MISE_HOOK_START}
|
|
13572
16139
|
execx($(${command} activate xonsh))
|
|
13573
16140
|
${MISE_HOOK_END}
|
|
@@ -13575,7 +16142,7 @@ ${MISE_HOOK_END}
|
|
|
13575
16142
|
};
|
|
13576
16143
|
case "elvish":
|
|
13577
16144
|
return {
|
|
13578
|
-
path:
|
|
16145
|
+
path: join13(homeDir, ".config", "elvish", "rc.elv"),
|
|
13579
16146
|
content: `${MISE_HOOK_START}
|
|
13580
16147
|
var mise: = (ns [&])
|
|
13581
16148
|
eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
|
|
@@ -13586,7 +16153,7 @@ ${MISE_HOOK_END}
|
|
|
13586
16153
|
case "pwsh":
|
|
13587
16154
|
case "powershell":
|
|
13588
16155
|
return {
|
|
13589
|
-
path:
|
|
16156
|
+
path: join13(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
|
|
13590
16157
|
content: `${MISE_HOOK_START}
|
|
13591
16158
|
(& ${command} activate pwsh) | Out-String | Invoke-Expression
|
|
13592
16159
|
${MISE_HOOK_END}
|
|
@@ -13595,7 +16162,7 @@ ${MISE_HOOK_END}
|
|
|
13595
16162
|
case "bash":
|
|
13596
16163
|
default:
|
|
13597
16164
|
return {
|
|
13598
|
-
path:
|
|
16165
|
+
path: join13(homeDir, ".bashrc"),
|
|
13599
16166
|
content: `${MISE_HOOK_START}
|
|
13600
16167
|
eval "$(${command} activate bash)"
|
|
13601
16168
|
${MISE_HOOK_END}
|
|
@@ -13634,7 +16201,7 @@ function isVersionAtLeast(version, minimumVersion) {
|
|
|
13634
16201
|
}
|
|
13635
16202
|
async function getOptionalFile2(path) {
|
|
13636
16203
|
try {
|
|
13637
|
-
return await
|
|
16204
|
+
return await readFile10(path, "utf8");
|
|
13638
16205
|
} catch (error) {
|
|
13639
16206
|
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
13640
16207
|
return "";
|
|
@@ -13646,79 +16213,86 @@ function getShellQuoted(value) {
|
|
|
13646
16213
|
}
|
|
13647
16214
|
|
|
13648
16215
|
// src/pi.ts
|
|
13649
|
-
import { mkdir as
|
|
13650
|
-
import { homedir as
|
|
13651
|
-
import { dirname as
|
|
16216
|
+
import { mkdir as mkdir6, writeFile as writeFile7 } from "node:fs/promises";
|
|
16217
|
+
import { homedir as homedir9 } from "node:os";
|
|
16218
|
+
import { dirname as dirname7, join as join14 } from "node:path";
|
|
13652
16219
|
var pi = {
|
|
13653
|
-
|
|
13654
|
-
|
|
13655
|
-
|
|
13656
|
-
|
|
13657
|
-
|
|
13658
|
-
|
|
13659
|
-
|
|
13660
|
-
|
|
16220
|
+
executableCheck: findPiExecutable,
|
|
16221
|
+
packageList: listPiPackages,
|
|
16222
|
+
packageCheck: hasPiPackage,
|
|
16223
|
+
packageInstall: installPiPackage,
|
|
16224
|
+
agentDir: resolvePiAgentDir,
|
|
16225
|
+
agentEnsure: ensurePiAgent,
|
|
16226
|
+
skillCheckGlobal: checkGlobalPiSkill,
|
|
16227
|
+
skillInstallGlobal: installGlobalPiSkills,
|
|
16228
|
+
configEnsure: ensurePiConfig
|
|
16229
|
+
};
|
|
16230
|
+
async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
|
|
16231
|
+
const path = join14(agentDir, "agents", filename);
|
|
16232
|
+
const currentText = await readTextIfExists(path);
|
|
16233
|
+
const changed = currentText !== content;
|
|
16234
|
+
if (changed && !dryRun) {
|
|
16235
|
+
await mkdir6(dirname7(path), { recursive: true });
|
|
16236
|
+
await writeFile7(path, content, "utf8");
|
|
16237
|
+
}
|
|
16238
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
16239
|
+
}
|
|
16240
|
+
async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join14(homedir9(), ".agents", "skills")) {
|
|
16241
|
+
const roots = [join14(agentDir, "skills"), sharedSkillsDir];
|
|
16242
|
+
for (const root of roots) {
|
|
16243
|
+
if (await readTextIfExists(join14(root, name, "SKILL.md")) !== undefined)
|
|
13661
16244
|
return true;
|
|
13662
|
-
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
13663
|
-
},
|
|
13664
|
-
async packageInstall(executable, source) {
|
|
13665
|
-
await runChecked(executable, ["install", source]);
|
|
13666
|
-
},
|
|
13667
|
-
agentDir(homeDir = homedir4()) {
|
|
13668
|
-
return getAgentDir(homeDir);
|
|
13669
|
-
},
|
|
13670
|
-
async skillCheckGlobal(name, agentDir = getAgentDir(), sharedSkillsDir = join6(homedir4(), ".agents", "skills")) {
|
|
13671
|
-
const roots = [join6(agentDir, "skills"), sharedSkillsDir];
|
|
13672
|
-
for (const root of roots) {
|
|
13673
|
-
if (await getOptionalFile3(join6(root, name, "SKILL.md")) !== undefined)
|
|
13674
|
-
return true;
|
|
13675
|
-
}
|
|
13676
|
-
return false;
|
|
13677
|
-
},
|
|
13678
|
-
async skillInstallGlobal(miseExecutable, source, names) {
|
|
13679
|
-
const selection = names.flatMap((name) => ["--skill", name]);
|
|
13680
|
-
await runChecked(miseExecutable, [
|
|
13681
|
-
"x",
|
|
13682
|
-
"node@22",
|
|
13683
|
-
"--",
|
|
13684
|
-
"npx",
|
|
13685
|
-
"-y",
|
|
13686
|
-
"skills",
|
|
13687
|
-
"add",
|
|
13688
|
-
source,
|
|
13689
|
-
...selection,
|
|
13690
|
-
"--global",
|
|
13691
|
-
"--agent",
|
|
13692
|
-
"pi",
|
|
13693
|
-
"--yes"
|
|
13694
|
-
]);
|
|
13695
|
-
},
|
|
13696
|
-
async configEnsure(path, update, dryRun = false) {
|
|
13697
|
-
const currentText = await getOptionalFile3(path);
|
|
13698
|
-
const current = getParsedObject(currentText, path);
|
|
13699
|
-
const next = update(current);
|
|
13700
|
-
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
13701
|
-
if (changed && !dryRun) {
|
|
13702
|
-
await mkdir3(dirname4(path), { recursive: true });
|
|
13703
|
-
await writeFile3(path, `${JSON.stringify(next, null, 2)}
|
|
13704
|
-
`, "utf8");
|
|
13705
|
-
}
|
|
13706
|
-
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
13707
16245
|
}
|
|
13708
|
-
|
|
13709
|
-
function getAgentDir(homeDir = homedir4()) {
|
|
13710
|
-
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join6(process.env.XDG_CONFIG_HOME, "pi") : join6(homeDir, ".pi", "agent"));
|
|
16246
|
+
return false;
|
|
13711
16247
|
}
|
|
13712
|
-
async function
|
|
13713
|
-
|
|
13714
|
-
|
|
13715
|
-
|
|
13716
|
-
|
|
13717
|
-
|
|
13718
|
-
|
|
16248
|
+
async function installGlobalPiSkills(miseExecutable, source, names) {
|
|
16249
|
+
const selection = names.flatMap((name) => ["--skill", name]);
|
|
16250
|
+
await runChecked(miseExecutable, [
|
|
16251
|
+
"x",
|
|
16252
|
+
"node@22",
|
|
16253
|
+
"--",
|
|
16254
|
+
"npx",
|
|
16255
|
+
"-y",
|
|
16256
|
+
"skills",
|
|
16257
|
+
"add",
|
|
16258
|
+
source,
|
|
16259
|
+
...selection,
|
|
16260
|
+
"--global",
|
|
16261
|
+
"--agent",
|
|
16262
|
+
"pi",
|
|
16263
|
+
"--yes"
|
|
16264
|
+
]);
|
|
16265
|
+
}
|
|
16266
|
+
async function ensurePiConfig(path, update, dryRun = false) {
|
|
16267
|
+
const currentText = await readTextIfExists(path);
|
|
16268
|
+
const current = parseJsonObject(currentText, path);
|
|
16269
|
+
const next = update(current);
|
|
16270
|
+
const changed = JSON.stringify(current) !== JSON.stringify(next);
|
|
16271
|
+
if (changed && !dryRun) {
|
|
16272
|
+
await mkdir6(dirname7(path), { recursive: true });
|
|
16273
|
+
await writeFile7(path, `${JSON.stringify(next, null, 2)}
|
|
16274
|
+
`, "utf8");
|
|
13719
16275
|
}
|
|
16276
|
+
return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
|
|
16277
|
+
}
|
|
16278
|
+
async function findPiExecutable() {
|
|
16279
|
+
return findExecutable("pi");
|
|
13720
16280
|
}
|
|
13721
|
-
function
|
|
16281
|
+
async function listPiPackages(executable) {
|
|
16282
|
+
return (await runChecked(executable, ["list"])).stdout;
|
|
16283
|
+
}
|
|
16284
|
+
function hasPiPackage(listOutput, source) {
|
|
16285
|
+
if (listOutput.includes(source))
|
|
16286
|
+
return true;
|
|
16287
|
+
return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
|
|
16288
|
+
}
|
|
16289
|
+
async function installPiPackage(executable, source) {
|
|
16290
|
+
await runChecked(executable, ["install", source]);
|
|
16291
|
+
}
|
|
16292
|
+
function resolvePiAgentDir(homeDir = homedir9()) {
|
|
16293
|
+
return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join14(process.env.XDG_CONFIG_HOME, "pi") : join14(homeDir, ".pi", "agent"));
|
|
16294
|
+
}
|
|
16295
|
+
function parseJsonObject(content, path) {
|
|
13722
16296
|
if (!content?.trim())
|
|
13723
16297
|
return {};
|
|
13724
16298
|
try {
|
|
@@ -13760,9 +16334,14 @@ var PI_SKILL_SOURCES = [
|
|
|
13760
16334
|
{ repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
|
|
13761
16335
|
];
|
|
13762
16336
|
var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
|
|
16337
|
+
var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
|
|
16338
|
+
var FORGE_DEPENDENCIES = {
|
|
16339
|
+
github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
|
|
16340
|
+
gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
|
|
16341
|
+
};
|
|
13763
16342
|
async function ensureMise(options = {}) {
|
|
13764
|
-
const homeDir = options.homeDir ??
|
|
13765
|
-
const current = await mise.executableCheck() ?? await mise.executableCheck(
|
|
16343
|
+
const homeDir = options.homeDir ?? homedir10();
|
|
16344
|
+
const current = await mise.executableCheck() ?? await mise.executableCheck(join15(homeDir, ".local", "bin", "mise"));
|
|
13766
16345
|
if (current)
|
|
13767
16346
|
return { executable: current, action: createSetupAction("mise", "ready", current) };
|
|
13768
16347
|
reportProgress(options, "Installing mise");
|
|
@@ -13791,7 +16370,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
|
|
|
13791
16370
|
async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
13792
16371
|
const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
|
|
13793
16372
|
const actions = [];
|
|
13794
|
-
|
|
16373
|
+
const dependencies = [...MISE_DEPENDENCIES];
|
|
16374
|
+
if (options.forge && options.forge !== "none")
|
|
16375
|
+
dependencies.push(FORGE_DEPENDENCIES[options.forge]);
|
|
16376
|
+
for (const dependency of dependencies) {
|
|
13795
16377
|
const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
|
|
13796
16378
|
if (installed) {
|
|
13797
16379
|
actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
|
|
@@ -13807,18 +16389,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
|
|
|
13807
16389
|
async function ensurePiPlugins(options = {}) {
|
|
13808
16390
|
const actions = await ensurePiPackages(PI_PACKAGES, options);
|
|
13809
16391
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
13810
|
-
const webSearch = await pi.configEnsure(
|
|
16392
|
+
const webSearch = await pi.configEnsure(join15(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
|
|
13811
16393
|
actions.push(getConfigSetupAction("web search settings", webSearch));
|
|
13812
|
-
const lsp = await pi.configEnsure(
|
|
16394
|
+
const lsp = await pi.configEnsure(join15(agentDir, "pi-lsp.json"), (config) => ({
|
|
13813
16395
|
...config,
|
|
13814
16396
|
progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
|
|
13815
16397
|
}), options.dryRun);
|
|
13816
16398
|
actions.push(getConfigSetupAction("pi-lsp settings", lsp));
|
|
13817
16399
|
return actions;
|
|
13818
16400
|
}
|
|
16401
|
+
async function ensurePiAgents(options = {}) {
|
|
16402
|
+
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
16403
|
+
const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
|
|
16404
|
+
const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
|
|
16405
|
+
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));
|
|
16406
|
+
const actions = [];
|
|
16407
|
+
for (const entry of entries) {
|
|
16408
|
+
const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
|
|
16409
|
+
const source = await readFile11(join15(bundledAgentsDir, entry.name), "utf8");
|
|
16410
|
+
const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
|
|
16411
|
+
const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
|
|
16412
|
+
actions.push(getConfigSetupAction(`pi agent ${id}`, result));
|
|
16413
|
+
}
|
|
16414
|
+
return actions;
|
|
16415
|
+
}
|
|
13819
16416
|
async function ensurePiSkills(miseExecutable, options = {}) {
|
|
13820
16417
|
const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
|
|
13821
|
-
const sharedSkillsDir =
|
|
16418
|
+
const sharedSkillsDir = join15(options.homeDir ?? homedir10(), ".agents", "skills");
|
|
13822
16419
|
const actions = [];
|
|
13823
16420
|
for (const source of PI_SKILL_SOURCES) {
|
|
13824
16421
|
const missing = [];
|
|
@@ -13862,6 +16459,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
|
|
|
13862
16459
|
} else if (options.issueTracker === "jira") {
|
|
13863
16460
|
servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
|
|
13864
16461
|
}
|
|
16462
|
+
if (options.forge === "github") {
|
|
16463
|
+
servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
|
|
16464
|
+
} else if (options.forge === "gitlab") {
|
|
16465
|
+
const host = (await detectVcs(projectDir)).host || "gitlab.com";
|
|
16466
|
+
servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
|
|
16467
|
+
}
|
|
13865
16468
|
const result = await mcp.serversEnsure(servers, {
|
|
13866
16469
|
dryRun: options.dryRun,
|
|
13867
16470
|
path: mcp.globalConfigPath(options.homeDir)
|
|
@@ -13875,13 +16478,81 @@ async function setupPi(options = {}) {
|
|
|
13875
16478
|
actions.push(await ensureMiseHooks(miseResult.executable, options));
|
|
13876
16479
|
actions.push(...await ensureMiseDeps(miseResult.executable, options));
|
|
13877
16480
|
actions.push(...await ensurePiPlugins(options));
|
|
16481
|
+
actions.push(...await ensurePiAgents(options));
|
|
13878
16482
|
actions.push(...await ensurePiSkills(miseResult.executable, options));
|
|
13879
16483
|
actions.push(...await ensureMcpAdapters(miseResult.executable, options));
|
|
16484
|
+
if (options.bindZedKey)
|
|
16485
|
+
actions.push(...await ensureZedIntegration(options));
|
|
13880
16486
|
return {
|
|
13881
16487
|
actions,
|
|
13882
|
-
restartPi: actions
|
|
16488
|
+
restartPi: setupRequiresRestart(actions)
|
|
13883
16489
|
};
|
|
13884
16490
|
}
|
|
16491
|
+
function setupRequiresRestart(actions) {
|
|
16492
|
+
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"));
|
|
16493
|
+
}
|
|
16494
|
+
function materializeAgentModels(content, agentId, config, availableModels) {
|
|
16495
|
+
const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
|
|
16496
|
+
const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
|
|
16497
|
+
const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
|
|
16498
|
+
let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
|
|
16499
|
+
let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
|
|
16500
|
+
if (availableModels) {
|
|
16501
|
+
for (const [index, preference] of preferences.entries()) {
|
|
16502
|
+
const match = findPreferredModel(availableModels, preference);
|
|
16503
|
+
if (!match)
|
|
16504
|
+
continue;
|
|
16505
|
+
selectedIndex = index;
|
|
16506
|
+
selectedModel = `${match.provider}/${match.id}`;
|
|
16507
|
+
break;
|
|
16508
|
+
}
|
|
16509
|
+
}
|
|
16510
|
+
const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
|
|
16511
|
+
return replaceAgentModelFields(content, selectedModel, fallbacks);
|
|
16512
|
+
}
|
|
16513
|
+
function replaceAgentModelFields(content, model, fallbacks) {
|
|
16514
|
+
const newline = content.includes(`\r
|
|
16515
|
+
`) ? `\r
|
|
16516
|
+
` : `
|
|
16517
|
+
`;
|
|
16518
|
+
const lines = content.replaceAll(`\r
|
|
16519
|
+
`, `
|
|
16520
|
+
`).split(`
|
|
16521
|
+
`);
|
|
16522
|
+
const closingDelimiter = lines.indexOf("---", 1);
|
|
16523
|
+
if (lines[0] !== "---" || closingDelimiter < 0)
|
|
16524
|
+
return content;
|
|
16525
|
+
const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
|
|
16526
|
+
if (model)
|
|
16527
|
+
frontmatter.push(`model: ${model}`);
|
|
16528
|
+
if (fallbacks.length > 0)
|
|
16529
|
+
frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
|
|
16530
|
+
return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
|
|
16531
|
+
}
|
|
16532
|
+
async function ensureZedIntegration(options = {}) {
|
|
16533
|
+
if (options.dryRun) {
|
|
16534
|
+
const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
|
|
16535
|
+
if (options.bindZedKey)
|
|
16536
|
+
actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
|
|
16537
|
+
return actions;
|
|
16538
|
+
}
|
|
16539
|
+
const actions = [];
|
|
16540
|
+
try {
|
|
16541
|
+
const task = await ensureZedReviewTask(options.homeDir);
|
|
16542
|
+
actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
|
|
16543
|
+
} catch (error) {
|
|
16544
|
+
actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
|
|
16545
|
+
}
|
|
16546
|
+
if (options.bindZedKey) {
|
|
16547
|
+
try {
|
|
16548
|
+
const key = await ensureZedReviewKeybinding(options.homeDir);
|
|
16549
|
+
actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
|
|
16550
|
+
} catch (error) {
|
|
16551
|
+
actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
|
|
16552
|
+
}
|
|
16553
|
+
}
|
|
16554
|
+
return actions;
|
|
16555
|
+
}
|
|
13885
16556
|
async function ensurePiPackages(packages, options) {
|
|
13886
16557
|
const executable = await pi.executableCheck();
|
|
13887
16558
|
if (!executable && !options.dryRun)
|
|
@@ -13903,6 +16574,10 @@ ${source}`;
|
|
|
13903
16574
|
}
|
|
13904
16575
|
return actions;
|
|
13905
16576
|
}
|
|
16577
|
+
function getTextList(value) {
|
|
16578
|
+
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
|
|
16579
|
+
return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
|
|
16580
|
+
}
|
|
13906
16581
|
function getConfigSetupAction(name, result) {
|
|
13907
16582
|
if (!result.changed)
|
|
13908
16583
|
return createSetupAction(name, "ready", result.path);
|
|
@@ -13921,11 +16596,13 @@ function getRecord(value) {
|
|
|
13921
16596
|
}
|
|
13922
16597
|
|
|
13923
16598
|
// src/tools/setup.ts
|
|
13924
|
-
var setupParametersSchema =
|
|
13925
|
-
issueTracker:
|
|
16599
|
+
var setupParametersSchema = z7.object({
|
|
16600
|
+
issueTracker: z7.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
|
|
16601
|
+
forge: z7.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
|
|
16602
|
+
bindZedKey: z7.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
|
|
13926
16603
|
});
|
|
13927
|
-
var setupParameters =
|
|
13928
|
-
var diffpiSetupTool =
|
|
16604
|
+
var setupParameters = z7.toJSONSchema(setupParametersSchema, { io: "input" });
|
|
16605
|
+
var diffpiSetupTool = defineTool4({
|
|
13929
16606
|
name: "diffpi_setup",
|
|
13930
16607
|
label: "diffpi setup",
|
|
13931
16608
|
description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
|
|
@@ -13938,11 +16615,14 @@ var diffpiSetupTool = defineTool2({
|
|
|
13938
16615
|
],
|
|
13939
16616
|
parameters: setupParameters,
|
|
13940
16617
|
executionMode: "sequential",
|
|
13941
|
-
async execute(_toolCallId, input, _signal, onUpdate) {
|
|
16618
|
+
async execute(_toolCallId, input, _signal, onUpdate, ctx) {
|
|
13942
16619
|
const params = setupParametersSchema.parse(input);
|
|
13943
16620
|
const result = await setupPi({
|
|
13944
16621
|
issueTracker: params.issueTracker,
|
|
16622
|
+
forge: params.forge,
|
|
16623
|
+
bindZedKey: params.bindZedKey,
|
|
13945
16624
|
installMiseHook: true,
|
|
16625
|
+
availableModels: ctx.modelRegistry.getAvailable(),
|
|
13946
16626
|
onProgress(message) {
|
|
13947
16627
|
onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
|
|
13948
16628
|
}
|
|
@@ -13950,7 +16630,7 @@ var diffpiSetupTool = defineTool2({
|
|
|
13950
16630
|
return formatResult(result, "Setup complete.");
|
|
13951
16631
|
}
|
|
13952
16632
|
});
|
|
13953
|
-
var diffpiValidateTool =
|
|
16633
|
+
var diffpiValidateTool = defineTool4({
|
|
13954
16634
|
name: "diffpi_validate",
|
|
13955
16635
|
label: "diffpi validate",
|
|
13956
16636
|
description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
|
|
@@ -13962,12 +16642,15 @@ var diffpiValidateTool = defineTool2({
|
|
|
13962
16642
|
],
|
|
13963
16643
|
parameters: setupParameters,
|
|
13964
16644
|
executionMode: "sequential",
|
|
13965
|
-
async execute(_toolCallId, input) {
|
|
16645
|
+
async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
|
|
13966
16646
|
const params = setupParametersSchema.parse(input);
|
|
13967
16647
|
const result = await setupPi({
|
|
13968
16648
|
issueTracker: params.issueTracker,
|
|
16649
|
+
forge: params.forge,
|
|
16650
|
+
bindZedKey: params.bindZedKey,
|
|
13969
16651
|
installMiseHook: true,
|
|
13970
|
-
dryRun: true
|
|
16652
|
+
dryRun: true,
|
|
16653
|
+
availableModels: ctx.modelRegistry.getAvailable()
|
|
13971
16654
|
});
|
|
13972
16655
|
const incomplete = result.actions.some((item) => item.status === "planned");
|
|
13973
16656
|
return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
|
|
@@ -13987,32 +16670,85 @@ ${lines.join(`
|
|
|
13987
16670
|
};
|
|
13988
16671
|
}
|
|
13989
16672
|
|
|
16673
|
+
// src/tools/templates.ts
|
|
16674
|
+
import { defineTool as defineTool5 } from "@earendil-works/pi-coding-agent";
|
|
16675
|
+
import { z as z8 } from "zod";
|
|
16676
|
+
var templateSchema = z8.object({
|
|
16677
|
+
name: z8.string().min(1),
|
|
16678
|
+
variables: z8.record(z8.string(), z8.string()).optional(),
|
|
16679
|
+
homeDir: z8.string().optional()
|
|
16680
|
+
});
|
|
16681
|
+
var diffpiTemplateTool = defineTool5({
|
|
16682
|
+
name: "diffpi_template",
|
|
16683
|
+
label: "diffpi template",
|
|
16684
|
+
description: "Load a bundled Diffpi template or a user override and render named variables.",
|
|
16685
|
+
promptSnippet: "Use diffpi_template for package workflow templates",
|
|
16686
|
+
promptGuidelines: ["User overrides live under ~/.difflab/diffpi/templates."],
|
|
16687
|
+
parameters: z8.toJSONSchema(templateSchema, { io: "input" }),
|
|
16688
|
+
executionMode: "parallel",
|
|
16689
|
+
async execute(_id, input) {
|
|
16690
|
+
const params = templateSchema.parse(input);
|
|
16691
|
+
const template = await loadTemplate(params.name, { homeDir: params.homeDir });
|
|
16692
|
+
const content = renderTemplate(template.content, params.variables ?? {});
|
|
16693
|
+
return {
|
|
16694
|
+
content: [{ type: "text", text: content }],
|
|
16695
|
+
details: { name: params.name, path: template.path, source: template.source }
|
|
16696
|
+
};
|
|
16697
|
+
}
|
|
16698
|
+
});
|
|
16699
|
+
|
|
13990
16700
|
// src/tools/index.ts
|
|
13991
|
-
function createPiTools(pi) {
|
|
13992
|
-
return [
|
|
16701
|
+
function createPiTools(pi, modes) {
|
|
16702
|
+
return [
|
|
16703
|
+
diffpiSetupTool,
|
|
16704
|
+
diffpiValidateTool,
|
|
16705
|
+
createDiffpiReloadTool(pi),
|
|
16706
|
+
diffpiTemplateTool,
|
|
16707
|
+
...createModeTools(modes),
|
|
16708
|
+
...createReviewTools()
|
|
16709
|
+
];
|
|
13993
16710
|
}
|
|
13994
16711
|
|
|
13995
16712
|
// extensions/index.ts
|
|
13996
16713
|
var RELOAD_COMMAND = "diffpi-reload";
|
|
13997
|
-
var
|
|
16714
|
+
var REVIEW_COMMAND = "review";
|
|
16715
|
+
var SKILL_ROUTING_GUIDANCE = `## Skill and tool routing
|
|
13998
16716
|
Use the docs-search skill before web search for library or API documentation.
|
|
13999
16717
|
Use docs-manage when the required documentation is absent or stale.
|
|
14000
|
-
Use fetch-url for a one-time read that does not belong in the documentation index
|
|
16718
|
+
Use fetch-url for a one-time read that does not belong in the documentation index.
|
|
16719
|
+
Use the context-mode skill for commands, tests, builds, logs, API responses, and other output that can be large.
|
|
16720
|
+
Use ctx_execute or ctx_execute_file to analyze that output, and use ctx_fetch_and_index with ctx_search for external documentation.`;
|
|
14001
16721
|
function difflabPiExtension(pi) {
|
|
14002
16722
|
rpiv_ask_user_question_default(pi);
|
|
16723
|
+
const modes = createModeController(pi);
|
|
14003
16724
|
pi.registerCommand(RELOAD_COMMAND, {
|
|
14004
16725
|
description: "Reload extensions, skills, prompts, themes, and context files",
|
|
14005
16726
|
handler: async (_args, ctx) => {
|
|
14006
16727
|
await ctx.reload();
|
|
14007
16728
|
}
|
|
14008
16729
|
});
|
|
14009
|
-
for (const tool of createPiTools(pi))
|
|
16730
|
+
for (const tool of createPiTools(pi, modes))
|
|
14010
16731
|
pi.registerTool(tool);
|
|
14011
|
-
pi.
|
|
14012
|
-
|
|
16732
|
+
pi.registerCommand(REVIEW_COMMAND, {
|
|
16733
|
+
description: "Code review: open, new, edit, address, publish, merge (add --local for tuicr)",
|
|
16734
|
+
handler: (args) => {
|
|
16735
|
+
const invocation = args.trim() || "help";
|
|
16736
|
+
pi.sendMessage({
|
|
16737
|
+
customType: "diffpi-review-command",
|
|
16738
|
+
display: false,
|
|
16739
|
+
content: `The user ran /review ${invocation}. Follow the review skill dispatcher. Call review_context first, then the matching review_* tools. Do not perform unrelated work.`
|
|
16740
|
+
}, { triggerTurn: true });
|
|
16741
|
+
return Promise.resolve();
|
|
16742
|
+
}
|
|
16743
|
+
});
|
|
16744
|
+
pi.on("session_start", async (_event, ctx) => modes.restore(ctx));
|
|
16745
|
+
pi.on("session_tree", async (_event, ctx) => modes.restore(ctx));
|
|
16746
|
+
pi.on("before_agent_start", (event) => {
|
|
16747
|
+
const defaultPrompt = `${event.systemPrompt}
|
|
14013
16748
|
|
|
14014
|
-
${
|
|
14015
|
-
|
|
16749
|
+
${SKILL_ROUTING_GUIDANCE}`;
|
|
16750
|
+
return { systemPrompt: modes.apply(defaultPrompt) };
|
|
16751
|
+
});
|
|
14016
16752
|
}
|
|
14017
16753
|
export {
|
|
14018
16754
|
difflabPiExtension as default
|