@difflab/pi 0.1.0 → 0.2.0-rc.202609170717.9cb91d1

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.
Files changed (59) hide show
  1. package/README.md +32 -1
  2. package/agents/diffpi-autonomous.md +15 -0
  3. package/agents/diffpi-copilot.md +22 -0
  4. package/agents/diffpi-orchestrator.md +23 -0
  5. package/agents/diffpi-planner.md +15 -0
  6. package/agents/diffpi-reviewer.md +27 -0
  7. package/agents/diffpi-tutor.md +20 -0
  8. package/agents/diffpi-worker.md +19 -0
  9. package/dist/assets.d.ts +2 -0
  10. package/dist/assets.d.ts.map +1 -0
  11. package/dist/config.d.ts +28 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/environment.d.ts +40 -0
  14. package/dist/environment.d.ts.map +1 -0
  15. package/dist/extensions/index.js +2054 -182
  16. package/dist/forge.d.ts +48 -0
  17. package/dist/forge.d.ts.map +1 -0
  18. package/dist/fsx.d.ts +6 -0
  19. package/dist/fsx.d.ts.map +1 -0
  20. package/dist/gates.d.ts +12 -0
  21. package/dist/gates.d.ts.map +1 -0
  22. package/dist/index.d.ts +19 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1528 -149
  25. package/dist/modes.d.ts +59 -0
  26. package/dist/modes.d.ts.map +1 -0
  27. package/dist/pi.d.ts +21 -5
  28. package/dist/pi.d.ts.map +1 -1
  29. package/dist/process.d.ts +2 -0
  30. package/dist/process.d.ts.map +1 -1
  31. package/dist/review.d.ts +57 -0
  32. package/dist/review.d.ts.map +1 -0
  33. package/dist/setup.d.ts +11 -0
  34. package/dist/setup.d.ts.map +1 -1
  35. package/dist/store.d.ts +15 -0
  36. package/dist/store.d.ts.map +1 -0
  37. package/dist/tools/index.d.ts +5 -2
  38. package/dist/tools/index.d.ts.map +1 -1
  39. package/dist/tools/index.js +1763 -171
  40. package/dist/tools/modes.d.ts +4 -0
  41. package/dist/tools/modes.d.ts.map +1 -0
  42. package/dist/tools/review.d.ts +7 -0
  43. package/dist/tools/review.d.ts.map +1 -0
  44. package/dist/tools/setup.d.ts.map +1 -1
  45. package/dist/tuicr.d.ts +43 -0
  46. package/dist/tuicr.d.ts.map +1 -0
  47. package/dist/zed.d.ts +11 -0
  48. package/dist/zed.d.ts.map +1 -0
  49. package/package.json +2 -1
  50. package/skills/diffpi-setup/SKILL.md +15 -1
  51. package/skills/mode/SKILL.md +38 -0
  52. package/skills/review/SKILL.md +13 -0
  53. package/skills/review/references/workflows/address.md +6 -0
  54. package/skills/review/references/workflows/complete.md +6 -0
  55. package/skills/review/references/workflows/help.md +12 -0
  56. package/skills/review/references/workflows/merge.md +6 -0
  57. package/skills/review/references/workflows/new.md +6 -0
  58. package/skills/review/references/workflows/open.md +6 -0
  59. package/skills/review/references/workflows/publish.md +5 -0
@@ -13323,11 +13323,376 @@ 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 resolveBundledAgentsDir(moduleUrl = import.meta.url) {
13340
+ const moduleDir = dirname2(fileURLToPath(moduleUrl));
13341
+ const candidates = [
13342
+ join3(moduleDir, "agents"),
13343
+ join3(moduleDir, "..", "agents"),
13344
+ join3(moduleDir, "..", "..", "agents")
13345
+ ];
13346
+ return candidates.find((path) => existsSync2(path)) ?? candidates[1];
13347
+ }
13348
+
13349
+ // src/config.ts
13350
+ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
13351
+ import { z } from "zod";
13352
+ import { homedir as homedir2 } from "node:os";
13353
+ import { join as join4 } from "node:path";
13354
+
13355
+ // src/fsx.ts
13356
+ import { readdir, readFile } from "node:fs/promises";
13357
+ async function readDirectoryIfExists(path) {
13358
+ try {
13359
+ return await readdir(path, { withFileTypes: true });
13360
+ } catch (error) {
13361
+ if (isMissingPath(error))
13362
+ return [];
13363
+ throw error;
13364
+ }
13365
+ }
13366
+ async function readTextIfExists(path) {
13367
+ try {
13368
+ return await readFile(path, "utf8");
13369
+ } catch (error) {
13370
+ if (isMissingPath(error))
13371
+ return;
13372
+ throw error;
13373
+ }
13374
+ }
13375
+ function isMissingPath(error) {
13376
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
13377
+ }
13378
+
13379
+ // src/config.ts
13380
+ var modelReferenceSchema = z.string().trim().min(1);
13381
+ var agentConfigSchema = z.object({
13382
+ models: z.array(modelReferenceSchema).optional()
13383
+ }).strict();
13384
+ var diffpiConfigSchema = z.object({
13385
+ agents: z.record(z.string(), agentConfigSchema).optional()
13386
+ }).strict();
13387
+ function diffpiConfigPaths(homeDir = homedir2()) {
13388
+ const directory = join4(homeDir, ".difflab", "diffpi");
13389
+ return {
13390
+ yaml: join4(directory, "config.yaml"),
13391
+ json: join4(directory, "config.json")
13392
+ };
13393
+ }
13394
+ async function loadDiffpiConfig(options = {}) {
13395
+ const paths = diffpiConfigPaths(options.homeDir);
13396
+ for (const [format, path] of [
13397
+ ["yaml", paths.yaml],
13398
+ ["json", paths.json]
13399
+ ]) {
13400
+ const content = await readTextIfExists(path);
13401
+ if (content === undefined)
13402
+ continue;
13403
+ try {
13404
+ const value = format === "yaml" ? parseYamlConfig(content) : JSON.parse(content);
13405
+ return { config: diffpiConfigSchema.parse(value ?? {}), path };
13406
+ } catch (error) {
13407
+ const reason = error instanceof Error ? error.message : String(error);
13408
+ throw new Error(`Invalid Diffpi config at ${path}: ${reason}`, { cause: error });
13409
+ }
13410
+ }
13411
+ return { config: {} };
13412
+ }
13413
+ function resolveAgentModelPreferences(agentId, profilePreferences, config) {
13414
+ const override = config.agents?.[agentId];
13415
+ if (override && Object.hasOwn(override, "models"))
13416
+ return [...override.models ?? []];
13417
+ return [...profilePreferences];
13418
+ }
13419
+ function findPreferredModel(models, preference) {
13420
+ const normalizedPreference = normalizeModelReference(preference);
13421
+ const exactReference = models.find((model) => normalizeModelReference(`${model.provider}/${model.id}`) === normalizedPreference);
13422
+ if (exactReference)
13423
+ return exactReference;
13424
+ const idPreference = preference.includes("/") ? preference.slice(preference.indexOf("/") + 1) : preference;
13425
+ const normalizedIdPreference = normalizeModelReference(idPreference);
13426
+ const exactId = models.find((model) => normalizeModelReference(model.id) === normalizedIdPreference);
13427
+ if (exactId)
13428
+ return exactId;
13429
+ const preferenceTokens = normalizedIdPreference.split("-").filter(Boolean);
13430
+ return models.find((model) => {
13431
+ const modelTokens = new Set(normalizeModelReference(model.id).split("-").filter(Boolean));
13432
+ return preferenceTokens.every((token) => modelTokens.has(token));
13433
+ });
13434
+ }
13435
+ function parseYamlConfig(content) {
13436
+ const document = content.replace(/^\uFEFF/, "").replace(/^---[^\S\r\n]*(?:#.*)?(?:\r?\n|$)/, "");
13437
+ return parseFrontmatter(`---
13438
+ ${document}
13439
+ ---
13440
+ `).frontmatter;
13441
+ }
13442
+ function normalizeModelReference(value) {
13443
+ return value.toLowerCase().replace(/^~/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
13444
+ }
13445
+
13446
+ // src/modes.ts
13447
+ async function discoverAgentModes(options) {
13448
+ const agentDir = options.agentDir ?? getAgentDir();
13449
+ const homeDir = options.homeDir ?? homedir3();
13450
+ const modes = new Map;
13451
+ const diagnostics = [];
13452
+ await loadAgentModes(options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR, "diffpi agent", modes, diagnostics);
13453
+ await loadAgentModes(join5(agentDir, "agents"), "user agent", modes, diagnostics);
13454
+ if (options.includeSkills) {
13455
+ await loadSkillModes(join5(homeDir, ".agents", "skills"), "user skill", modes, diagnostics);
13456
+ await loadSkillModes(join5(agentDir, "skills"), "pi user skill", modes, diagnostics);
13457
+ }
13458
+ if (options.projectTrusted === true) {
13459
+ if (options.includeSkills) {
13460
+ await loadSkillModes(join5(options.cwd, ".agents", "skills"), "project skill", modes, diagnostics);
13461
+ await loadSkillModes(join5(options.cwd, ".pi", "skills"), "pi project skill", modes, diagnostics);
13462
+ }
13463
+ await loadAgentModes(join5(options.cwd, ".agents", "agents"), "project agent", modes, diagnostics);
13464
+ await loadAgentModes(join5(options.cwd, ".pi", "agents"), "pi project agent", modes, diagnostics);
13465
+ }
13466
+ const userConfig = await loadDiffpiConfig({ homeDir });
13467
+ const configuredModes = [...modes.values()].map((mode) => ({
13468
+ ...mode,
13469
+ modelPreferences: resolveAgentModelPreferences(mode.id, mode.modelPreferences, userConfig.config)
13470
+ }));
13471
+ return {
13472
+ modes: configuredModes.sort((left, right) => left.id.localeCompare(right.id)),
13473
+ diagnostics
13474
+ };
13475
+ }
13476
+ function resolveAgentMode(modes, requested) {
13477
+ const name = requested.trim();
13478
+ if (!name)
13479
+ return { ok: false, message: "Agent name is required." };
13480
+ const exact = modes.find((mode) => mode.id === name);
13481
+ if (exact)
13482
+ return { ok: true, active: exact, message: `Active inline agent: ${exact.id}.` };
13483
+ const lowerName = name.toLowerCase();
13484
+ const matches = modes.filter((mode) => mode.id.toLowerCase() === lowerName);
13485
+ if (matches.length === 1) {
13486
+ const active = matches[0];
13487
+ return { ok: true, active, message: `Active inline agent: ${active.id}.` };
13488
+ }
13489
+ if (matches.length > 1) {
13490
+ return {
13491
+ ok: false,
13492
+ message: `Inline agent "${name}" is ambiguous. Use one of: ${matches.map((mode) => mode.id).join(", ")}.`
13493
+ };
13494
+ }
13495
+ return { ok: false, message: `Unknown inline agent "${name}". Run /skill:mode or diffpi_modes_list.` };
13496
+ }
13497
+ function createModeController(pi, options = {}) {
13498
+ let active;
13499
+ let baseline;
13500
+ const updateStatus = (ctx) => {
13501
+ ctx.ui.setStatus(MODE_STATUS_KEY, active ? `mode: ${active.id}` : undefined);
13502
+ };
13503
+ const list = (ctx, listOptions = {}) => discoverAgentModes({
13504
+ cwd: ctx.cwd,
13505
+ agentDir: options.agentDir,
13506
+ bundledAgentsDir: options.bundledAgentsDir,
13507
+ homeDir: options.homeDir,
13508
+ projectTrusted: ctx.isProjectTrusted(),
13509
+ includeSkills: listOptions.includeSkills
13510
+ });
13511
+ return {
13512
+ list,
13513
+ async set(agent, ctx) {
13514
+ const catalog = await list(ctx, { includeSkills: agent.includes(":") });
13515
+ const result = resolveAgentMode(catalog.modes, agent);
13516
+ if (!result.ok || !result.active)
13517
+ return result;
13518
+ baseline ??= captureRuntime(pi, ctx);
13519
+ if (active && baseline)
13520
+ await restoreRuntime(pi, baseline, ctx);
13521
+ active = result.active;
13522
+ const runtimeMessage = await applyModeRuntime(pi, active, ctx);
13523
+ pi.appendEntry(MODE_STATE_ENTRY, { active, baseline });
13524
+ updateStatus(ctx);
13525
+ return { ...result, message: `${result.message} ${runtimeMessage}` };
13526
+ },
13527
+ async unset(ctx) {
13528
+ if (!active)
13529
+ return { ok: true, message: "Inline agent is already clear." };
13530
+ if (baseline)
13531
+ await restoreRuntime(pi, baseline, ctx);
13532
+ active = undefined;
13533
+ pi.appendEntry(MODE_STATE_ENTRY, { active: null });
13534
+ baseline = undefined;
13535
+ updateStatus(ctx);
13536
+ return { ok: true, message: "Inline agent cleared. The previous model, thinking, tools, and prompt resume." };
13537
+ },
13538
+ async restore(ctx) {
13539
+ const previousActive = active;
13540
+ const previousBaseline = baseline;
13541
+ const entry = [...ctx.sessionManager.getBranch()].reverse().find((candidate) => candidate.type === "custom" && candidate.customType === MODE_STATE_ENTRY);
13542
+ const restored = entry?.data?.active;
13543
+ const restoredBaseline = entry?.data?.baseline;
13544
+ if (isAgentModeSnapshot(restored)) {
13545
+ active = restored;
13546
+ baseline = isModeBaseline(restoredBaseline) ? restoredBaseline : previousBaseline;
13547
+ await applyModeRuntime(pi, active, ctx);
13548
+ } else {
13549
+ if (previousActive && previousBaseline)
13550
+ pi.setActiveTools(previousBaseline.tools);
13551
+ active = undefined;
13552
+ baseline = undefined;
13553
+ }
13554
+ updateStatus(ctx);
13555
+ },
13556
+ apply(systemPrompt) {
13557
+ if (!active)
13558
+ return systemPrompt;
13559
+ if (active.promptStrategy === "replace")
13560
+ return active.systemPrompt;
13561
+ return `${systemPrompt}
13562
+
13563
+ ## Active inline agent: ${active.label}
13564
+
13565
+ ${active.systemPrompt}`;
13566
+ },
13567
+ getActive() {
13568
+ return active;
13569
+ }
13570
+ };
13571
+ }
13572
+ var MODE_STATE_ENTRY = "diffpi-mode-state";
13573
+ var MODE_STATUS_KEY = "diffpi-mode";
13574
+ var MODE_CONTROL_TOOLS = ["ask_user_question", "diffpi_modes_list", "diffpi_modes_set", "diffpi_modes_unset"];
13575
+ var BUNDLED_AGENTS_DIR = resolveBundledAgentsDir();
13576
+ var THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
13577
+ async function applyModeRuntime(pi, mode, ctx) {
13578
+ let selectedModel;
13579
+ if (mode.modelPreferences.length > 0) {
13580
+ const scoped = ctx.scopedModels.length > 0 ? ctx.scopedModels.map((entry) => entry.model) : undefined;
13581
+ const availableModels = scoped ?? ctx.modelRegistry.getAvailable();
13582
+ for (const preference of mode.modelPreferences) {
13583
+ const model = findPreferredModel(availableModels, preference);
13584
+ if (model && await pi.setModel(model)) {
13585
+ selectedModel = `${model.provider}/${model.id}`;
13586
+ break;
13587
+ }
13588
+ }
13589
+ }
13590
+ if (mode.thinkingLevel)
13591
+ pi.setThinkingLevel(mode.thinkingLevel);
13592
+ if (mode.tools.length > 0) {
13593
+ const availableTools = new Set(pi.getAllTools().map((tool) => tool.name));
13594
+ const selectedTools = [...new Set([...mode.tools, ...MODE_CONTROL_TOOLS])].filter((tool) => availableTools.has(tool));
13595
+ if (selectedTools.length > 0)
13596
+ pi.setActiveTools(selectedTools);
13597
+ }
13598
+ const parts = [];
13599
+ if (mode.modelPreferences.length > 0) {
13600
+ parts.push(selectedModel ? `Model: ${selectedModel}.` : "No preferred model was available; kept the current model.");
13601
+ }
13602
+ if (mode.thinkingLevel)
13603
+ parts.push(`Thinking: ${mode.thinkingLevel}.`);
13604
+ if (mode.tools.length > 0)
13605
+ parts.push("Applied the profile tool set.");
13606
+ return parts.join(" ") || "The profile changes the prompt only.";
13607
+ }
13608
+ async function restoreRuntime(pi, state, ctx) {
13609
+ if (state.model) {
13610
+ const model = ctx.modelRegistry.find(state.model.provider, state.model.id);
13611
+ if (model)
13612
+ await pi.setModel(model);
13613
+ }
13614
+ pi.setThinkingLevel(state.thinkingLevel);
13615
+ pi.setActiveTools(state.tools);
13616
+ }
13617
+ function captureRuntime(pi, ctx) {
13618
+ return {
13619
+ model: ctx.model ? { provider: ctx.model.provider, id: ctx.model.id } : undefined,
13620
+ thinkingLevel: pi.getThinkingLevel(),
13621
+ tools: pi.getActiveTools()
13622
+ };
13623
+ }
13624
+ async function loadSkillModes(skillsDir, source, modes, diagnostics) {
13625
+ const entries = await readDirectoryIfExists(skillsDir);
13626
+ for (const entry of entries.filter((item) => item.isDirectory()).sort((a, b) => a.name.localeCompare(b.name))) {
13627
+ await loadAgentModes(join5(skillsDir, entry.name, "agents"), `${source} ${entry.name}`, modes, diagnostics, entry.name);
13628
+ }
13629
+ }
13630
+ async function loadAgentModes(directory, source, modes, diagnostics, skillName) {
13631
+ const entries = await readDirectoryIfExists(directory);
13632
+ for (const entry of entries.filter((item) => item.isFile() && item.name.endsWith(".md")).sort((a, b) => a.name.localeCompare(b.name))) {
13633
+ const path = join5(directory, entry.name);
13634
+ try {
13635
+ const content = await readFile2(path, "utf8");
13636
+ const { frontmatter, body } = parseFrontmatter2(content.startsWith("\uFEFF") ? content.slice(1) : content);
13637
+ if (frontmatter.enabled === false || frontmatter.inline === false)
13638
+ continue;
13639
+ const name = getFrontmatterText(frontmatter.name) ?? basename(path, extname(path));
13640
+ const systemPrompt = body.trim();
13641
+ if (!name || name.includes(":") || !systemPrompt) {
13642
+ diagnostics.push(`Skipped ${path}: agent name must not contain ":" and prompt body is required.`);
13643
+ continue;
13644
+ }
13645
+ const id = skillName ? `${skillName}:${name}` : name;
13646
+ modes.set(id, {
13647
+ id,
13648
+ label: getFrontmatterText(frontmatter.display_name) ?? name,
13649
+ description: getFrontmatterText(frontmatter.description) ?? `Inline agent from ${basename(path)}`,
13650
+ systemPrompt,
13651
+ promptStrategy: frontmatter.prompt_mode === "append" ? "append" : "replace",
13652
+ modelPreferences: [
13653
+ ...getFrontmatterList(frontmatter.model),
13654
+ ...getFrontmatterList(frontmatter.model_fallbacks)
13655
+ ],
13656
+ thinkingLevel: getThinkingLevel(frontmatter.thinking),
13657
+ tools: getFrontmatterList(frontmatter.tools),
13658
+ source,
13659
+ sourcePath: path
13660
+ });
13661
+ } catch (error) {
13662
+ diagnostics.push(`Skipped ${path}: ${error instanceof Error ? error.message : String(error)}`);
13663
+ }
13664
+ }
13665
+ }
13666
+ function getFrontmatterText(value) {
13667
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
13668
+ }
13669
+ function getFrontmatterList(value) {
13670
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
13671
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
13672
+ }
13673
+ function getThinkingLevel(value) {
13674
+ const level = getFrontmatterText(value);
13675
+ return level && THINKING_LEVELS.has(level) ? level : undefined;
13676
+ }
13677
+ function isAgentModeSnapshot(value) {
13678
+ if (!value || typeof value !== "object")
13679
+ return false;
13680
+ const candidate = value;
13681
+ 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";
13682
+ }
13683
+ function isModeBaseline(value) {
13684
+ if (!value || typeof value !== "object")
13685
+ return false;
13686
+ const candidate = value;
13687
+ const model = candidate.model;
13688
+ 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");
13689
+ }
13690
+
13326
13691
  // src/tools/reload.ts
13327
13692
  import { defineTool } from "@earendil-works/pi-coding-agent";
13328
- import { z } from "zod";
13329
- var reloadParametersSchema = z.object({});
13330
- var reloadParameters = z.toJSONSchema(reloadParametersSchema, { io: "input" });
13693
+ import { z as z2 } from "zod";
13694
+ var reloadParametersSchema = z2.object({});
13695
+ var reloadParameters = z2.toJSONSchema(reloadParametersSchema, { io: "input" });
13331
13696
  function createDiffpiReloadTool(pi) {
13332
13697
  return defineTool({
13333
13698
  name: "diffpi_reload",
@@ -13350,83 +13715,119 @@ function createDiffpiReloadTool(pi) {
13350
13715
  });
13351
13716
  }
13352
13717
 
13353
- // src/tools/setup.ts
13718
+ // src/tools/modes.ts
13354
13719
  import { defineTool as defineTool2 } from "@earendil-works/pi-coding-agent";
13355
- import { z as z2 } from "zod";
13356
-
13357
- // src/setup.ts
13358
- import { homedir as homedir5 } from "node:os";
13359
- import { join as join7 } from "node:path";
13360
-
13361
- // src/mcp.ts
13362
- import { mkdir, readFile, writeFile } from "node:fs/promises";
13363
- import { homedir as homedir2 } from "node:os";
13364
- import { dirname as dirname2, join as join3 } from "node:path";
13365
- var mcp = {
13366
- globalConfigPath(homeDir = homedir2()) {
13367
- return join3(homeDir, ".config", "mcp", "mcp.json");
13368
- },
13369
- async serversEnsure(servers, options = {}) {
13370
- const path = options.path ?? mcp.globalConfigPath();
13371
- const currentText = await getOptionalFile(path);
13372
- const current = getParsedConfig(currentText, path);
13373
- const nextServers = { ...current.mcpServers };
13374
- for (const [name, entry] of Object.entries(servers)) {
13375
- nextServers[name] = mergeEntry(nextServers[name], entry);
13376
- }
13377
- const next = { ...current, mcpServers: nextServers };
13378
- const changed = JSON.stringify(current) !== JSON.stringify(next);
13379
- if (changed && !options.dryRun) {
13380
- await mkdir(dirname2(path), { recursive: true });
13381
- await writeFile(path, `${JSON.stringify(next, null, 2)}
13382
- `, "utf8");
13383
- }
13384
- return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
13385
- }
13386
- };
13387
- function mergeEntry(current, required) {
13388
- const merged = { ...current, ...required };
13389
- if (current?.env || required.env)
13390
- merged.env = { ...current?.env, ...required.env };
13391
- return merged;
13720
+ import { z as z3 } from "zod";
13721
+ var emptyParametersSchema = z3.object({});
13722
+ var emptyParameters = z3.toJSONSchema(emptyParametersSchema, { io: "input" });
13723
+ var listParametersSchema = z3.object({
13724
+ includeSkills: z3.boolean().optional().describe("Include skill-owned agents using skill:agent ids.")
13725
+ });
13726
+ var listParameters = z3.toJSONSchema(listParametersSchema, { io: "input" });
13727
+ var setParametersSchema = z3.object({
13728
+ agent: z3.string().trim().min(1).describe("Inline agent id from diffpi_modes_list.")
13729
+ });
13730
+ var setParameters = z3.toJSONSchema(setParametersSchema, { io: "input" });
13731
+ function createModeTools(controller) {
13732
+ return [
13733
+ defineTool2({
13734
+ name: "diffpi_modes_list",
13735
+ label: "diffpi modes list",
13736
+ description: "List inline agents shared with the subagent plugin, optionally including skill-owned agents.",
13737
+ promptSnippet: "List inline agents before selecting one when the requested agent is unclear",
13738
+ promptGuidelines: [
13739
+ "Call diffpi_modes_list when the user asks which inline agents are available.",
13740
+ "Set includeSkills to true only when the user asks for skill agents or runs /skill:mode --include-skills.",
13741
+ "Inline mode applies the profile prompt, first available preferred model, thinking level, and available tools."
13742
+ ],
13743
+ parameters: listParameters,
13744
+ executionMode: "parallel",
13745
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
13746
+ const params = listParametersSchema.parse(input);
13747
+ const catalog = await controller.list(ctx, { includeSkills: params.includeSkills });
13748
+ return {
13749
+ content: [{ type: "text", text: formatCatalog(catalog, controller.getActive()?.id) }],
13750
+ details: { active: controller.getActive()?.id, catalog }
13751
+ };
13752
+ }
13753
+ }),
13754
+ defineTool2({
13755
+ name: "diffpi_modes_set",
13756
+ label: "diffpi modes set",
13757
+ description: "Set a validated available agent as the inline behavioral agent for subsequent chat turns.",
13758
+ promptSnippet: "Set the inline behavioral agent only after the user chooses one",
13759
+ promptGuidelines: [
13760
+ "Call diffpi_modes_set only after the user explicitly selects an agent.",
13761
+ "Use the exact skill:agent id for a skill-owned agent.",
13762
+ "The selected prompt takes effect on the next model turn."
13763
+ ],
13764
+ parameters: setParameters,
13765
+ executionMode: "sequential",
13766
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
13767
+ const params = setParametersSchema.parse(input);
13768
+ const result = await controller.set(params.agent, ctx);
13769
+ if (!result.ok)
13770
+ throw new Error(result.message);
13771
+ return {
13772
+ content: [{ type: "text", text: `${result.message} The prompt takes effect on the next turn.` }],
13773
+ details: { active: result.active }
13774
+ };
13775
+ }
13776
+ }),
13777
+ defineTool2({
13778
+ name: "diffpi_modes_unset",
13779
+ label: "diffpi modes unset",
13780
+ description: "Clear the inline behavioral agent and restore default Pi prompting for subsequent turns.",
13781
+ promptSnippet: "Clear the inline agent when the user asks for default behavior",
13782
+ promptGuidelines: [
13783
+ "Call diffpi_modes_unset only when the user explicitly asks to clear the active inline agent."
13784
+ ],
13785
+ parameters: emptyParameters,
13786
+ executionMode: "sequential",
13787
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
13788
+ emptyParametersSchema.parse(input);
13789
+ const result = await controller.unset(ctx);
13790
+ return {
13791
+ content: [{ type: "text", text: result.message }],
13792
+ details: { active: controller.getActive()?.id }
13793
+ };
13794
+ }
13795
+ })
13796
+ ];
13392
13797
  }
13393
- function getParsedConfig(content, path) {
13394
- if (!content?.trim())
13395
- return { mcpServers: {} };
13396
- try {
13397
- const value = JSON.parse(content);
13398
- if (!isRecord(value))
13399
- throw new Error("not an object");
13400
- const servers = value.mcpServers;
13401
- if (servers !== undefined && !isRecord(servers))
13402
- throw new Error("mcpServers is not an object");
13403
- return { ...value, mcpServers: servers ?? {} };
13404
- } catch {
13405
- throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
13798
+ function formatCatalog(catalog, active) {
13799
+ const lines = [`Active inline agent: ${active ?? "default"}.`, "", "Available inline agents:"];
13800
+ for (const mode of catalog.modes) {
13801
+ const runtime = [mode.modelPreferences[0], mode.thinkingLevel].filter(Boolean).join(", ");
13802
+ lines.push(`- ${mode.id} [${mode.promptStrategy}${runtime ? `; ${runtime}` : ""}] — ${sanitize(mode.description)} (${mode.source})`);
13406
13803
  }
13407
- }
13408
- async function getOptionalFile(path) {
13409
- try {
13410
- return await readFile(path, "utf8");
13411
- } catch (error) {
13412
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
13413
- return;
13414
- throw error;
13804
+ if (catalog.diagnostics.length > 0) {
13805
+ lines.push("", "Skipped agent files:");
13806
+ for (const diagnostic of catalog.diagnostics)
13807
+ lines.push(`- ${sanitize(diagnostic)}`);
13415
13808
  }
13809
+ lines.push("", "Inline mode applies the profile prompt, preferred available model, thinking level, and tool set.");
13810
+ return lines.join(`
13811
+ `);
13416
13812
  }
13417
- function isRecord(value) {
13418
- return value !== null && typeof value === "object" && !Array.isArray(value);
13813
+ function sanitize(value) {
13814
+ return value.replace(/[\r\n\t]+/g, " ").replace(/\s+/g, " ").trim();
13419
13815
  }
13420
13816
 
13421
- // src/mise.ts
13422
- import { mkdir as mkdir2, readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
13423
- import { homedir as homedir3 } from "node:os";
13424
- import { basename, dirname as dirname3, join as join5 } from "node:path";
13817
+ // src/tools/review.ts
13818
+ import { existsSync as existsSync4 } from "node:fs";
13819
+ import { mkdir as mkdir3, writeFile as writeFile2 } from "node:fs/promises";
13820
+ import { join as join10 } from "node:path";
13821
+ import { defineTool as defineTool3 } from "@earendil-works/pi-coding-agent";
13822
+ import { z as z5 } from "zod";
13823
+
13824
+ // src/environment.ts
13825
+ import { basename as basename2 } from "node:path";
13425
13826
 
13426
13827
  // src/process.ts
13427
13828
  import { constants } from "node:fs";
13428
13829
  import { access } from "node:fs/promises";
13429
- import { delimiter, join as join4 } from "node:path";
13830
+ import { delimiter, join as join6 } from "node:path";
13430
13831
  import { spawn as spawn2 } from "node:child_process";
13431
13832
  var MAX_CAPTURED_OUTPUT_LENGTH = 65536;
13432
13833
  async function findExecutable(name) {
@@ -13441,7 +13842,7 @@ async function findExecutable(name) {
13441
13842
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
13442
13843
  if (!directory)
13443
13844
  continue;
13444
- const candidate = join4(directory, name);
13845
+ const candidate = join6(directory, name);
13445
13846
  try {
13446
13847
  await access(candidate, constants.X_OK);
13447
13848
  return candidate;
@@ -13454,18 +13855,35 @@ function run(command, args, options = {}) {
13454
13855
  const child = spawn2(command, args, {
13455
13856
  cwd: options.cwd,
13456
13857
  env: options.env ?? process.env,
13457
- stdio: ["ignore", "pipe", "pipe"]
13858
+ stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"]
13458
13859
  });
13459
13860
  let stdout = "";
13460
13861
  let stderr = "";
13461
- child.stdout.on("data", (chunk) => {
13462
- stdout = appendBounded(stdout, chunk.toString());
13862
+ const stdoutChunks = [];
13863
+ const stderrChunks = [];
13864
+ const unbounded = options.capture === "unbounded";
13865
+ child.stdout?.on("data", (chunk) => {
13866
+ const text = chunk.toString();
13867
+ if (unbounded)
13868
+ stdoutChunks.push(text);
13869
+ else
13870
+ stdout = appendBounded(stdout, text);
13463
13871
  });
13464
- child.stderr.on("data", (chunk) => {
13465
- stderr = appendBounded(stderr, chunk.toString());
13872
+ child.stderr?.on("data", (chunk) => {
13873
+ const text = chunk.toString();
13874
+ if (unbounded)
13875
+ stderrChunks.push(text);
13876
+ else
13877
+ stderr = appendBounded(stderr, text);
13466
13878
  });
13467
13879
  child.on("error", reject);
13468
- child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
13880
+ child.on("close", (code) => resolve({
13881
+ code: code ?? 1,
13882
+ stdout: unbounded ? stdoutChunks.join("") : stdout,
13883
+ stderr: unbounded ? stderrChunks.join("") : stderr
13884
+ }));
13885
+ if (options.input !== undefined && child.stdin)
13886
+ child.stdin.end(options.input);
13469
13887
  });
13470
13888
  }
13471
13889
  async function runChecked(command, args, options = {}) {
@@ -13480,7 +13898,1320 @@ function appendBounded(current, next) {
13480
13898
  return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
13481
13899
  }
13482
13900
 
13901
+ // src/zed.ts
13902
+ import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
13903
+ import { homedir as homedir4 } from "node:os";
13904
+ import { dirname as dirname3, join as join7 } from "node:path";
13905
+ var ZED_REVIEW_TASK_NAME = "diffpi: tuicr review";
13906
+ var REVIEW_KEYBINDING = "cmd-alt-r";
13907
+ var REVIEW_TASK = {
13908
+ label: ZED_REVIEW_TASK_NAME,
13909
+ command: "tuicr",
13910
+ args: ["-w"],
13911
+ cwd: "$ZED_WORKTREE_ROOT",
13912
+ use_new_terminal: true,
13913
+ reveal: "always"
13914
+ };
13915
+ function zedTasksPath(homeDir = homedir4()) {
13916
+ return join7(homeDir, ".config", "zed", "tasks.json");
13917
+ }
13918
+ function zedKeymapPath(homeDir = homedir4()) {
13919
+ return join7(homeDir, ".config", "zed", "keymap.json");
13920
+ }
13921
+ async function ensureZedReviewTask(homeDir = homedir4()) {
13922
+ const path = zedTasksPath(homeDir);
13923
+ const currentText = await readOptional(path);
13924
+ const tasks = parseJsonArray(currentText, path);
13925
+ const index = tasks.findIndex((task) => task.label === ZED_REVIEW_TASK_NAME);
13926
+ const next = [...tasks];
13927
+ if (index >= 0)
13928
+ next[index] = { ...tasks[index], ...REVIEW_TASK };
13929
+ else
13930
+ next.push(REVIEW_TASK);
13931
+ const changed = JSON.stringify(tasks) !== JSON.stringify(next);
13932
+ if (changed)
13933
+ await writeJson(path, next);
13934
+ return { path, changed, existed: currentText !== undefined };
13935
+ }
13936
+ async function ensureZedReviewKeybinding(homeDir = homedir4()) {
13937
+ const path = zedKeymapPath(homeDir);
13938
+ const currentText = await readOptional(path);
13939
+ const entries = parseJsonArray(currentText, path);
13940
+ const alreadyBound = entries.some((entry) => Object.values(entry.bindings ?? {}).some((action) => Array.isArray(action) && action[0] === "task::Spawn" && bindsReviewTask(action[1])));
13941
+ if (alreadyBound)
13942
+ return { path, changed: false, existed: currentText !== undefined };
13943
+ const next = [
13944
+ ...entries,
13945
+ { context: "Workspace", bindings: { [REVIEW_KEYBINDING]: ["task::Spawn", { task_name: ZED_REVIEW_TASK_NAME }] } }
13946
+ ];
13947
+ await writeJson(path, next);
13948
+ return { path, changed: true, existed: currentText !== undefined };
13949
+ }
13950
+ function bindsReviewTask(payload) {
13951
+ return typeof payload === "object" && payload !== null && payload.task_name === ZED_REVIEW_TASK_NAME;
13952
+ }
13953
+ async function readOptional(path) {
13954
+ try {
13955
+ return await readFile3(path, "utf8");
13956
+ } catch (error) {
13957
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
13958
+ return;
13959
+ throw error;
13960
+ }
13961
+ }
13962
+ function parseJsonArray(content, path) {
13963
+ if (!content?.trim())
13964
+ return [];
13965
+ let value;
13966
+ try {
13967
+ value = JSON.parse(content);
13968
+ } catch {
13969
+ throw new Error(`Cannot safely edit ${path}: not strict JSON (it may contain JSONC comments).`);
13970
+ }
13971
+ if (!Array.isArray(value))
13972
+ throw new Error(`Expected a JSON array in ${path}.`);
13973
+ return value;
13974
+ }
13975
+ async function writeJson(path, value) {
13976
+ await mkdir(dirname3(path), { recursive: true });
13977
+ await writeFile(path, `${JSON.stringify(value, null, 2)}
13978
+ `, "utf8");
13979
+ }
13980
+
13981
+ // src/environment.ts
13982
+ function detectIde(env = process.env) {
13983
+ const program = (env.TERM_PROGRAM ?? "").toLowerCase();
13984
+ if (env.ZED_TERM === "true" || program === "zed")
13985
+ return "zed";
13986
+ if (env.CURSOR_TRACE_ID || program === "cursor")
13987
+ return "cursor";
13988
+ if (env.WINDSURF_ENV || program === "windsurf")
13989
+ return "windsurf";
13990
+ if (env.TERMINAL_EMULATOR?.toLowerCase().includes("jetbrains"))
13991
+ return "jetbrains";
13992
+ if (env.VSCODE_PID || env.VSCODE_GIT_IPC_HANDLE || program === "vscode")
13993
+ return "vscode";
13994
+ return "unknown";
13995
+ }
13996
+ function detectMux(env = process.env) {
13997
+ if (env.ZELLIJ || env.ZELLIJ_SESSION_NAME)
13998
+ return "zellij";
13999
+ if (env.TMUX)
14000
+ return "tmux";
14001
+ if (env.STY)
14002
+ return "screen";
14003
+ return "none";
14004
+ }
14005
+ function detectShell(env = process.env) {
14006
+ return env.SHELL ? basename2(env.SHELL) : "unknown";
14007
+ }
14008
+ async function detectVcs(cwd) {
14009
+ const root = (await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"])).stdout.trim() || cwd;
14010
+ const branch = (await run("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"])).stdout.trim();
14011
+ const remote = (await run("git", ["-C", root, "remote", "get-url", "origin"])).stdout.trim();
14012
+ return { ...parseRemote(remote), branch, root };
14013
+ }
14014
+ function parseRemote(remote) {
14015
+ const empty = { provider: "none", host: "", owner: "", repo: "" };
14016
+ if (!remote)
14017
+ return empty;
14018
+ const scp = remote.match(/^[^@]+@([^:]+):(.+?)(?:\.git)?$/);
14019
+ const url = remote.match(/^[a-z]+:\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/i);
14020
+ const match = scp ?? url;
14021
+ if (!match)
14022
+ return empty;
14023
+ const host = match[1];
14024
+ const segments = match[2].split("/").filter(Boolean);
14025
+ if (segments.length < 2)
14026
+ return { ...empty, host };
14027
+ const repo = segments.at(-1) ?? "";
14028
+ const owner = segments.slice(0, -1).join("/");
14029
+ const provider = /github/i.test(host) ? "github" : /gitlab/i.test(host) ? "gitlab" : "none";
14030
+ return { provider, host, owner, repo };
14031
+ }
14032
+ async function openInNewTab(command, opts) {
14033
+ const env = opts.env ?? process.env;
14034
+ const name = opts.name ?? "review";
14035
+ const printable = command.join(" ");
14036
+ const mux = detectMux(env);
14037
+ if (mux !== "none") {
14038
+ const opened = await openMuxTab(mux, command, opts.cwd, name, printable);
14039
+ if (opened)
14040
+ return opened;
14041
+ }
14042
+ if (detectIde(env) === "zed") {
14043
+ try {
14044
+ await ensureZedReviewTask(opts.homeDir);
14045
+ return {
14046
+ launched: false,
14047
+ configured: true,
14048
+ via: "zed-task",
14049
+ command: printable,
14050
+ taskName: ZED_REVIEW_TASK_NAME,
14051
+ instruction: `Run the Zed task "${ZED_REVIEW_TASK_NAME}".`
14052
+ };
14053
+ } catch {}
14054
+ }
14055
+ return { launched: false, via: "print", command: printable };
14056
+ }
14057
+ async function openMuxTab(mux, command, cwd, name, printable) {
14058
+ if (mux === "zellij" && await findExecutable("zellij")) {
14059
+ const result = await run("zellij", ["action", "new-tab", "--cwd", cwd, "--name", name, "--", ...command]);
14060
+ if (result.code === 0)
14061
+ return { launched: true, via: "zellij", command: printable };
14062
+ const fallback = await run("zellij", ["run", "--cwd", cwd, "--name", name, "--", ...command]);
14063
+ if (fallback.code === 0)
14064
+ return { launched: true, via: "zellij-run", command: printable };
14065
+ }
14066
+ if (mux === "tmux" && await findExecutable("tmux")) {
14067
+ const result = await run("tmux", ["new-window", "-c", cwd, "-n", name, printable]);
14068
+ if (result.code === 0)
14069
+ return { launched: true, via: "tmux", command: printable };
14070
+ }
14071
+ if (mux === "screen" && await findExecutable("screen")) {
14072
+ const result = await run("screen", screenWindowArgs(command, cwd, name));
14073
+ if (result.code === 0)
14074
+ return { launched: true, via: "screen", command: printable };
14075
+ }
14076
+ return;
14077
+ }
14078
+ function screenWindowArgs(command, cwd, name) {
14079
+ return ["-X", "screen", "-t", name, "sh", "-lc", 'cd -- "$1" && shift && exec "$@"', "sh", cwd, ...command];
14080
+ }
14081
+
14082
+ // src/forge.ts
14083
+ function createForge(vcs) {
14084
+ if (vcs.provider === "github")
14085
+ return new GithubForge(vcs);
14086
+ if (vcs.provider === "gitlab")
14087
+ return new GitlabForge(vcs);
14088
+ throw new Error("No forge detected from the git remote. Use --local for an offline review.");
14089
+ }
14090
+
14091
+ class GithubForge {
14092
+ vcs;
14093
+ provider = "github";
14094
+ constructor(vcs) {
14095
+ this.vcs = vcs;
14096
+ }
14097
+ repoFlag() {
14098
+ return ["--repo", `${this.vcs.owner}/${this.vcs.repo}`];
14099
+ }
14100
+ async createDraftPr(options) {
14101
+ const args = [
14102
+ "pr",
14103
+ "create",
14104
+ ...this.repoFlag(),
14105
+ "--title",
14106
+ options.title,
14107
+ "--body",
14108
+ options.body,
14109
+ "--base",
14110
+ options.base,
14111
+ "--head",
14112
+ options.head
14113
+ ];
14114
+ if (options.draft !== false)
14115
+ args.push("--draft");
14116
+ await runChecked("gh", args);
14117
+ const ref = await this.viewPr(options.head);
14118
+ if (!ref)
14119
+ throw new Error("Draft PR created but could not be resolved.");
14120
+ return ref;
14121
+ }
14122
+ async viewPr(idOrBranch) {
14123
+ const args = [
14124
+ "pr",
14125
+ "view",
14126
+ idOrBranch,
14127
+ ...this.repoFlag(),
14128
+ "--json",
14129
+ "number,title,url,isDraft,baseRefName,headRefName"
14130
+ ];
14131
+ const result = await run("gh", args);
14132
+ if (result.code !== 0) {
14133
+ if (isConfirmedMissingChange("github", result.stderr || result.stdout))
14134
+ return;
14135
+ throw commandFailure("gh", args, result);
14136
+ }
14137
+ if (!result.stdout.trim())
14138
+ throw new Error("GitHub returned an empty pull request response.");
14139
+ let data;
14140
+ try {
14141
+ data = JSON.parse(result.stdout);
14142
+ } catch {
14143
+ throw new Error("Cannot parse the GitHub pull request response as JSON.");
14144
+ }
14145
+ 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") {
14146
+ throw new Error("GitHub returned an invalid pull request response.");
14147
+ }
14148
+ return {
14149
+ number: data.number,
14150
+ title: data.title,
14151
+ url: data.url,
14152
+ isDraft: data.isDraft,
14153
+ baseRef: data.baseRefName,
14154
+ headRef: data.headRefName
14155
+ };
14156
+ }
14157
+ async defaultBranch() {
14158
+ const result = await runChecked("gh", [
14159
+ "repo",
14160
+ "view",
14161
+ `${this.vcs.owner}/${this.vcs.repo}`,
14162
+ "--json",
14163
+ "defaultBranchRef",
14164
+ "--jq",
14165
+ ".defaultBranchRef.name"
14166
+ ]);
14167
+ return requireBranchName(result.stdout, "GitHub");
14168
+ }
14169
+ async prDiff(id) {
14170
+ return (await runChecked("gh", ["pr", "diff", String(id), ...this.repoFlag()], { capture: "unbounded" })).stdout;
14171
+ }
14172
+ async prChecks(id) {
14173
+ return (await run("gh", ["pr", "checks", String(id), ...this.repoFlag()])).stdout;
14174
+ }
14175
+ async createPendingReview(id, comments, body) {
14176
+ const payload = {
14177
+ body,
14178
+ comments: comments.map((comment) => ({
14179
+ path: comment.file,
14180
+ line: comment.line,
14181
+ side: comment.side ?? "RIGHT",
14182
+ body: comment.body
14183
+ }))
14184
+ };
14185
+ await runChecked("gh", ["api", "--method", "POST", `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`, "--input", "-"], { input: JSON.stringify(payload) });
14186
+ }
14187
+ async submitReview(id, event, body) {
14188
+ const pending = await runChecked("gh", [
14189
+ "api",
14190
+ `/repos/${this.vcs.owner}/${this.vcs.repo}/pulls/${id}/reviews`,
14191
+ "--jq",
14192
+ '[.[] | select(.state=="PENDING")] | last | .id'
14193
+ ]);
14194
+ const reviewId = pending.stdout.trim();
14195
+ const endpoint = githubReviewSubmissionEndpoint(this.vcs.owner, this.vcs.repo, id, reviewId);
14196
+ const args = ["api", "--method", "POST", endpoint, "-f", `event=${event}`];
14197
+ if (body.trim())
14198
+ args.push("-f", `body=${body}`);
14199
+ await runChecked("gh", args);
14200
+ }
14201
+ async markReady(id) {
14202
+ await runChecked("gh", ["pr", "ready", String(id), ...this.repoFlag()]);
14203
+ }
14204
+ async closePr(id, comment) {
14205
+ const args = ["pr", "close", String(id), ...this.repoFlag()];
14206
+ if (comment)
14207
+ args.push("--comment", comment);
14208
+ await runChecked("gh", args);
14209
+ }
14210
+ }
14211
+
14212
+ class GitlabForge {
14213
+ vcs;
14214
+ provider = "gitlab";
14215
+ constructor(vcs) {
14216
+ this.vcs = vcs;
14217
+ }
14218
+ project() {
14219
+ return `${this.vcs.owner}/${this.vcs.repo}`;
14220
+ }
14221
+ async createDraftPr(options) {
14222
+ await runChecked("glab", [
14223
+ "mr",
14224
+ "create",
14225
+ "--repo",
14226
+ this.project(),
14227
+ "--title",
14228
+ `Draft: ${options.title}`,
14229
+ "--description",
14230
+ options.body,
14231
+ "--target-branch",
14232
+ options.base,
14233
+ "--source-branch",
14234
+ options.head,
14235
+ "--yes"
14236
+ ]);
14237
+ const ref = await this.viewPr(options.head);
14238
+ if (!ref)
14239
+ throw new Error("Draft MR created but could not be resolved.");
14240
+ return ref;
14241
+ }
14242
+ async viewPr(idOrBranch) {
14243
+ const args = ["mr", "view", idOrBranch, "--repo", this.project(), "--output", "json"];
14244
+ const result = await run("glab", args);
14245
+ if (result.code !== 0) {
14246
+ if (isConfirmedMissingChange("gitlab", result.stderr || result.stdout))
14247
+ return;
14248
+ throw commandFailure("glab", args, result);
14249
+ }
14250
+ if (!result.stdout.trim())
14251
+ throw new Error("GitLab returned an empty merge request response.");
14252
+ let data;
14253
+ try {
14254
+ data = JSON.parse(result.stdout);
14255
+ } catch {
14256
+ throw new Error("Cannot parse the GitLab merge request response as JSON.");
14257
+ }
14258
+ if (typeof data.iid !== "number" || typeof data.title !== "string" || typeof data.web_url !== "string" || typeof data.target_branch !== "string" || typeof data.source_branch !== "string") {
14259
+ throw new Error("GitLab returned an invalid merge request response.");
14260
+ }
14261
+ return {
14262
+ number: data.iid,
14263
+ title: data.title,
14264
+ url: data.web_url,
14265
+ isDraft: Boolean(data.draft ?? data.work_in_progress),
14266
+ baseRef: data.target_branch,
14267
+ headRef: data.source_branch
14268
+ };
14269
+ }
14270
+ async defaultBranch() {
14271
+ const result = await runChecked("glab", [
14272
+ "api",
14273
+ `projects/${encodeURIComponent(this.project())}`,
14274
+ "--jq",
14275
+ ".default_branch"
14276
+ ]);
14277
+ return requireBranchName(result.stdout, "GitLab");
14278
+ }
14279
+ async prDiff(id) {
14280
+ return (await runChecked("glab", ["mr", "diff", String(id), "--repo", this.project()], { capture: "unbounded" })).stdout;
14281
+ }
14282
+ async prChecks() {
14283
+ return (await run("glab", ["ci", "status", "--repo", this.project()])).stdout;
14284
+ }
14285
+ async createPendingReview(id, comments, body) {
14286
+ const endpoint = `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`;
14287
+ if (body.trim()) {
14288
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
14289
+ input: JSON.stringify({ note: body })
14290
+ });
14291
+ }
14292
+ if (comments.length === 0)
14293
+ return;
14294
+ const response = await runChecked("glab", [
14295
+ "api",
14296
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}`
14297
+ ]);
14298
+ const diffRefs = parseGitlabDiffRefs(response.stdout);
14299
+ for (const comment of comments) {
14300
+ const payload = {
14301
+ note: comment.body,
14302
+ position: {
14303
+ ...diffRefs,
14304
+ position_type: "text",
14305
+ new_path: comment.file,
14306
+ old_path: comment.file,
14307
+ new_line: comment.side === "LEFT" ? undefined : comment.line,
14308
+ old_line: comment.side === "LEFT" ? comment.line : undefined
14309
+ }
14310
+ };
14311
+ await runChecked("glab", ["api", "--method", "POST", endpoint, "--input", "-"], {
14312
+ input: JSON.stringify(payload)
14313
+ });
14314
+ }
14315
+ }
14316
+ async submitReview(id, event, body) {
14317
+ assertReviewEventSupported(this.provider, event);
14318
+ const drafts = await runChecked("glab", [
14319
+ "api",
14320
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes`
14321
+ ]);
14322
+ const hasDrafts = hasGitlabDraftNotes(drafts.stdout);
14323
+ if (hasDrafts) {
14324
+ await runChecked("glab", [
14325
+ "api",
14326
+ "--method",
14327
+ "POST",
14328
+ `projects/${encodeURIComponent(this.project())}/merge_requests/${id}/draft_notes/bulk_publish`
14329
+ ]);
14330
+ } else if (body.trim()) {
14331
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", body]);
14332
+ }
14333
+ if (event === "APPROVE")
14334
+ await runChecked("glab", ["mr", "approve", String(id), "--repo", this.project()]);
14335
+ }
14336
+ async markReady(id) {
14337
+ await runChecked("glab", ["mr", "update", String(id), "--repo", this.project(), "--ready"]);
14338
+ }
14339
+ async closePr(id, comment) {
14340
+ if (comment)
14341
+ await runChecked("glab", ["mr", "note", String(id), "--repo", this.project(), "--message", comment]);
14342
+ await runChecked("glab", ["mr", "close", String(id), "--repo", this.project()]);
14343
+ }
14344
+ }
14345
+ function assertReviewEventSupported(provider, event) {
14346
+ if (provider === "gitlab" && event === "REQUEST_CHANGES") {
14347
+ throw new Error("GitLab does not support REQUEST_CHANGES reviews; post a comment or reject the merge request manually.");
14348
+ }
14349
+ }
14350
+ function isConfirmedMissingChange(provider, output) {
14351
+ const message = output.toLowerCase();
14352
+ if (provider === "github") {
14353
+ return message.includes("no pull requests found for branch") || message.includes("could not find pull request") || message.includes("could not resolve to a pullrequest");
14354
+ }
14355
+ if (provider === "gitlab") {
14356
+ return message.includes("no open merge request") || /failed to get open merge request/.test(message) && /404(?: not found)?/.test(message);
14357
+ }
14358
+ return false;
14359
+ }
14360
+ function commandFailure(command, args, result) {
14361
+ const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
14362
+ return new Error(`${command} ${args.join(" ")} failed: ${detail}`);
14363
+ }
14364
+ function requireBranchName(output, provider) {
14365
+ const branch = output.trim();
14366
+ if (!branch || branch === "null")
14367
+ throw new Error(`${provider} did not return a default branch.`);
14368
+ return branch;
14369
+ }
14370
+ function githubReviewSubmissionEndpoint(owner, repo, id, pendingReviewId) {
14371
+ return pendingReviewId ? `/repos/${owner}/${repo}/pulls/${id}/reviews/${pendingReviewId}/events` : `/repos/${owner}/${repo}/pulls/${id}/reviews`;
14372
+ }
14373
+ function parseGitlabDiffRefs(input) {
14374
+ let data;
14375
+ try {
14376
+ data = JSON.parse(input);
14377
+ } catch {
14378
+ throw new Error("Cannot create positioned GitLab draft notes: the merge request response was not valid JSON.");
14379
+ }
14380
+ const { base_sha, start_sha, head_sha } = data.diff_refs ?? {};
14381
+ if (!base_sha || !start_sha || !head_sha) {
14382
+ throw new Error("Cannot create positioned GitLab draft notes: merge request diff refs are unavailable.");
14383
+ }
14384
+ return { base_sha, start_sha, head_sha };
14385
+ }
14386
+ function hasGitlabDraftNotes(input) {
14387
+ try {
14388
+ const data = JSON.parse(input);
14389
+ return Array.isArray(data) && data.length > 0;
14390
+ } catch {
14391
+ throw new Error("Cannot complete GitLab review: the draft notes response was not valid JSON.");
14392
+ }
14393
+ }
14394
+
14395
+ // src/gates.ts
14396
+ var CONVENTIONAL_COMMIT = /^(feat|fix|perf|refactor|docs|chore|test|build|ci|style|revert)(\([^)]+\))?!?: .+/;
14397
+ var MISE_GATES = ["format:check", "lint", "test"];
14398
+ function checkConventionalSubject(subject) {
14399
+ const trimmed = subject.trim();
14400
+ const ok = CONVENTIONAL_COMMIT.test(trimmed);
14401
+ return {
14402
+ name: "conventional-subject",
14403
+ status: ok ? "pass" : "warn",
14404
+ detail: ok ? trimmed : `not a conventional-commit subject: "${trimmed}"`
14405
+ };
14406
+ }
14407
+ async function runMiseGates(cwd) {
14408
+ const tasks = await discoverMiseTasks(cwd);
14409
+ const results = [];
14410
+ for (const gate of MISE_GATES) {
14411
+ const targets = tasks.get(gate) ?? [];
14412
+ if (targets.length === 0) {
14413
+ results.push({ name: gate, status: "skip", detail: "no mise recipe" });
14414
+ continue;
14415
+ }
14416
+ const invocations = targets.flatMap((target, index) => index === 0 ? [target] : [":::", target]);
14417
+ const result = await run("mise", ["run", ...invocations], { cwd });
14418
+ results.push({
14419
+ name: gate,
14420
+ status: result.code === 0 ? "pass" : "fail",
14421
+ detail: result.code === 0 ? "clean" : (result.stderr.trim() || result.stdout.trim()).slice(-400)
14422
+ });
14423
+ }
14424
+ return results;
14425
+ }
14426
+ function ciGate(checksOutput) {
14427
+ const text = checksOutput.toLowerCase();
14428
+ if (!text.trim())
14429
+ return { name: "ci", status: "skip", detail: "no CI output" };
14430
+ if (/\bfail|error\b/.test(text))
14431
+ return { name: "ci", status: "warn", detail: "CI failing" };
14432
+ if (/\bpending|in progress|queued\b/.test(text))
14433
+ return { name: "ci", status: "warn", detail: "CI pending" };
14434
+ return { name: "ci", status: "pass", detail: "CI green" };
14435
+ }
14436
+ async function discoverMiseTasks(cwd) {
14437
+ const result = await run("mise", ["tasks", "--json", "--all"], { cwd });
14438
+ if (result.code !== 0)
14439
+ return new Map;
14440
+ return parseMiseTasks(result.stdout);
14441
+ }
14442
+ function parseMiseTasks(input) {
14443
+ let tasks;
14444
+ try {
14445
+ tasks = JSON.parse(input);
14446
+ } catch {
14447
+ return new Map;
14448
+ }
14449
+ if (!Array.isArray(tasks))
14450
+ return new Map;
14451
+ const found = new Map;
14452
+ for (const gate of MISE_GATES) {
14453
+ const targets = tasks.flatMap((task) => {
14454
+ if (typeof task.name !== "string")
14455
+ return [];
14456
+ return task.name === gate || task.name.endsWith(`:${gate}`) || task.aliases?.includes(gate) ? [task.name] : [];
14457
+ });
14458
+ if (targets.length > 0)
14459
+ found.set(gate, [...new Set(targets)]);
14460
+ }
14461
+ return found;
14462
+ }
14463
+
14464
+ // src/review.ts
14465
+ import { join as join8 } from "node:path";
14466
+ import { z as z4 } from "zod";
14467
+ var severitySchema = z4.enum(["BLOCKING", "CONSIDER", "NOTE"]);
14468
+ var findingSchema = z4.object({
14469
+ file: z4.string().min(1),
14470
+ line: z4.number().int().nonnegative(),
14471
+ severity: severitySchema,
14472
+ body: z4.string().min(1),
14473
+ reference: z4.string().optional().default("")
14474
+ });
14475
+ var findingsSchema = z4.array(findingSchema);
14476
+ function reviewSlug(input) {
14477
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 50);
14478
+ }
14479
+ function mmddyy(date = new Date) {
14480
+ const mm = String(date.getMonth() + 1).padStart(2, "0");
14481
+ const dd = String(date.getDate()).padStart(2, "0");
14482
+ const yy = String(date.getFullYear() % 100).padStart(2, "0");
14483
+ return `${mm}${dd}${yy}`;
14484
+ }
14485
+ function reviewRecordName(branch, date = new Date) {
14486
+ return `${mmddyy(date)}-${reviewSlug(branch)}`;
14487
+ }
14488
+ function dedupeFindings(findings) {
14489
+ const rank = { BLOCKING: 3, CONSIDER: 2, NOTE: 1 };
14490
+ const byKey = new Map;
14491
+ for (const finding of findings) {
14492
+ const key = `${finding.file}:${finding.line}`;
14493
+ const existing = byKey.get(key);
14494
+ if (!existing || rank[finding.severity] > rank[existing.severity])
14495
+ byKey.set(key, finding);
14496
+ }
14497
+ return [...byKey.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || rank[b.severity] - rank[a.severity]);
14498
+ }
14499
+ function toReviewComments(findings) {
14500
+ return findings.filter((finding) => finding.line > 0).map((finding) => ({
14501
+ file: finding.file,
14502
+ line: finding.line,
14503
+ side: "RIGHT",
14504
+ body: renderCommentBody(finding)
14505
+ }));
14506
+ }
14507
+ function renderReviewDoc(input) {
14508
+ const anchored = dedupeFindings(input.findings).filter((finding) => finding.line > 0);
14509
+ const lines = [`# Review: ${input.title}`, "", "## Metadata"];
14510
+ if (input.number !== undefined)
14511
+ lines.push(`- **PR/MR**: #${input.number}${input.url ? ` — ${input.url}` : ""}`);
14512
+ if (input.author)
14513
+ lines.push(`- **Author**: ${input.author}`);
14514
+ if (input.headRef && input.baseRef)
14515
+ lines.push(`- **Branch**: ${input.headRef} → ${input.baseRef}`);
14516
+ if (input.additions !== undefined)
14517
+ lines.push(`- **Stats**: +${input.additions} -${input.deletions ?? 0} across ${input.changedFiles ?? 0} files`);
14518
+ lines.push(`- **Reviewed**: ${input.timestamp ?? new Date().toISOString()}`, "");
14519
+ if (input.overallIssues.length > 0) {
14520
+ lines.push("## Overall issues", "");
14521
+ for (const issue of input.overallIssues)
14522
+ lines.push(`- ${issue}`);
14523
+ lines.push("");
14524
+ }
14525
+ lines.push("## Verification", "");
14526
+ for (const gate of input.gates)
14527
+ lines.push(`- ${gate.name}: ${gate.status} — ${gate.detail}`);
14528
+ lines.push("");
14529
+ if (input.notVerified.length > 0) {
14530
+ lines.push("## What was NOT verified", "");
14531
+ for (const item of input.notVerified)
14532
+ lines.push(`- ${item}`);
14533
+ lines.push("");
14534
+ }
14535
+ lines.push("## Inline Comments", "");
14536
+ for (const finding of anchored) {
14537
+ lines.push(`### ${finding.file}:${finding.line} — ${finding.severity}`, "", finding.body, "");
14538
+ if (finding.reference)
14539
+ lines.push(`> **Reference:** ${finding.reference}`, "");
14540
+ lines.push("---", "");
14541
+ }
14542
+ return `${lines.join(`
14543
+ `).trimEnd()}
14544
+ `;
14545
+ }
14546
+ function reviewWorkingDir(storeReviewsDir, slug) {
14547
+ return join8(storeReviewsDir, slug);
14548
+ }
14549
+ function renderCommentBody(finding) {
14550
+ const prefix = finding.severity === "BLOCKING" ? "**BLOCKING** " : "";
14551
+ const reference = finding.reference ? `
14552
+
14553
+ > **Reference:** ${finding.reference}` : "";
14554
+ return `${prefix}${finding.body}${reference}`;
14555
+ }
14556
+
14557
+ // src/store.ts
14558
+ import { createHash } from "node:crypto";
14559
+ import { existsSync as existsSync3 } from "node:fs";
14560
+ import { mkdir as mkdir2, realpath, symlink } from "node:fs/promises";
14561
+ import { homedir as homedir5 } from "node:os";
14562
+ import { isAbsolute as isAbsolute2, join as join9, resolve as resolve2 } from "node:path";
14563
+ var STORE_LINK = join9(".pi", "diffpi");
14564
+ async function gitToplevel(cwd) {
14565
+ const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
14566
+ const top = result.stdout.trim();
14567
+ return result.code === 0 && top ? top : resolve2(cwd);
14568
+ }
14569
+ async function computeProjectSlug(cwd) {
14570
+ const root = await gitToplevel(cwd);
14571
+ const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
14572
+ const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
14573
+ const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
14574
+ const common = commonResult.stdout.trim();
14575
+ let commonPath = root;
14576
+ if (commonResult.code === 0 && common) {
14577
+ const resolvedCommon = isAbsolute2(common) ? common : join9(root, common);
14578
+ commonPath = resolve2(resolvedCommon);
14579
+ }
14580
+ const canonicalCommon = await canonicalPath(commonPath);
14581
+ const identity = remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${canonicalCommon}`;
14582
+ const name = remote ? repositoryName(remote) : basename3(resolve2(canonicalCommon, "..")) || basename3(root);
14583
+ const readable = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
14584
+ const digest = createHash("sha256").update(identity).digest("hex").slice(0, 12);
14585
+ return `${readable}-${digest}`;
14586
+ }
14587
+ function storeGlobalRoot(homeDir = homedir5()) {
14588
+ return join9(homeDir, ".difflab", "diffpi", "projects");
14589
+ }
14590
+ async function ensureStore(cwd, homeDir = homedir5()) {
14591
+ const root = await gitToplevel(cwd);
14592
+ const slug = await computeProjectSlug(root);
14593
+ const dest = join9(storeGlobalRoot(homeDir), slug);
14594
+ const link = join9(root, STORE_LINK);
14595
+ if (existsSync3(link))
14596
+ return { slug, root, dest, link, linked: true };
14597
+ await mkdir2(dest, { recursive: true });
14598
+ await mkdir2(join9(root, ".pi"), { recursive: true });
14599
+ await symlink(dest, link);
14600
+ return { slug, root, dest, link, linked: true };
14601
+ }
14602
+ async function storeDir(cwd, homeDir = homedir5()) {
14603
+ return (await ensureStore(cwd, homeDir)).dest;
14604
+ }
14605
+ async function reviewsDir(cwd, homeDir = homedir5()) {
14606
+ const dir = join9(await storeDir(cwd, homeDir), "reviews");
14607
+ await mkdir2(dir, { recursive: true });
14608
+ return dir;
14609
+ }
14610
+ async function canonicalPath(path) {
14611
+ try {
14612
+ return await realpath(path);
14613
+ } catch {
14614
+ return resolve2(path);
14615
+ }
14616
+ }
14617
+ function normalizeRemote(remote) {
14618
+ return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
14619
+ }
14620
+ function repositoryName(remote) {
14621
+ const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
14622
+ return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
14623
+ }
14624
+ function basename3(path) {
14625
+ const parts = resolve2(path).split(/[/\\]/).filter(Boolean);
14626
+ return parts.at(-1) ?? "";
14627
+ }
14628
+
14629
+ // src/tuicr.ts
14630
+ import { readFile as readFile4, realpath as realpath2 } from "node:fs/promises";
14631
+ import { resolve as resolve3 } from "node:path";
14632
+ async function listSessions(repo = ".") {
14633
+ const result = await run("tuicr", ["review", "list", "--repo", repo]);
14634
+ if (result.code !== 0 || !result.stdout.trim())
14635
+ return [];
14636
+ let raw;
14637
+ try {
14638
+ raw = JSON.parse(result.stdout);
14639
+ } catch {
14640
+ return [];
14641
+ }
14642
+ return raw.map((entry) => ({
14643
+ slug: entry.slug,
14644
+ kind: entry.kind,
14645
+ path: entry.path,
14646
+ updatedAt: entry.updated_at,
14647
+ commentCount: entry.comment_count,
14648
+ anchor: entry.anchor,
14649
+ active: entry.active
14650
+ }));
14651
+ }
14652
+ async function resolveSession(cwd, branch) {
14653
+ return findMatchingSession(await listSessions(cwd), cwd, branch);
14654
+ }
14655
+ async function findMatchingSession(sessions, cwd, branch) {
14656
+ const repository = await canonicalPath2(await gitToplevel(cwd));
14657
+ for (const session of sessions) {
14658
+ if (session.kind !== "local")
14659
+ continue;
14660
+ try {
14661
+ const data = await readSession(session.path);
14662
+ if (data.branch_name !== branch || !data.repo_path)
14663
+ continue;
14664
+ if (await canonicalPath2(data.repo_path) === repository)
14665
+ return session;
14666
+ } catch {}
14667
+ }
14668
+ return;
14669
+ }
14670
+ async function readSession(path) {
14671
+ const content = await readFile4(path, "utf8");
14672
+ try {
14673
+ return JSON.parse(content);
14674
+ } catch {
14675
+ throw new Error(`Cannot parse tuicr session JSON: ${path}`);
14676
+ }
14677
+ }
14678
+ async function launch(cwd) {
14679
+ return openInNewTab(["tuicr", "-w"], { cwd, name: "tuicr" });
14680
+ }
14681
+ function toFindings(session) {
14682
+ const comments = [];
14683
+ const bodyParts = (session.review_comments ?? []).map((comment) => comment.content);
14684
+ for (const [file, entry] of Object.entries(session.files ?? {})) {
14685
+ const fileComments = (entry.file_comments ?? []).map((comment) => comment.content);
14686
+ if (fileComments.length > 0)
14687
+ bodyParts.push(`File: ${file}
14688
+
14689
+ ${fileComments.join(`
14690
+
14691
+ `)}`);
14692
+ for (const [lineKey, lineComments] of Object.entries(entry.line_comments ?? {})) {
14693
+ const line = Number.parseInt(lineKey, 10);
14694
+ if (!Number.isFinite(line))
14695
+ continue;
14696
+ for (const lineComment of lineComments) {
14697
+ comments.push({ file, line, side: lineComment.side === "old" ? "LEFT" : "RIGHT", body: lineComment.content });
14698
+ }
14699
+ }
14700
+ }
14701
+ return {
14702
+ comments,
14703
+ body: bodyParts.join(`
14704
+
14705
+ `)
14706
+ };
14707
+ }
14708
+ async function canonicalPath2(path) {
14709
+ try {
14710
+ return await realpath2(path);
14711
+ } catch {
14712
+ return resolve3(path);
14713
+ }
14714
+ }
14715
+
14716
+ // src/tools/review.ts
14717
+ var contextSchema = z5.object({ cwd: z5.string().optional() });
14718
+ var localSchema = contextSchema.extend({ local: z5.boolean().optional() });
14719
+ var openSchema = localSchema.extend({ title: z5.string().optional(), base: z5.string().optional() });
14720
+ var submitSchema = localSchema.extend({
14721
+ findings: findingsSchema,
14722
+ overallIssues: z5.array(z5.string()).optional(),
14723
+ notVerified: z5.array(z5.string()).optional(),
14724
+ title: z5.string().optional()
14725
+ });
14726
+ var completeSchema = contextSchema.extend({
14727
+ action: z5.enum(["accept", "reject", "close", "local"]),
14728
+ comment: z5.string().optional()
14729
+ });
14730
+ var eventSchema = localSchema.extend({ event: z5.enum(["APPROVE", "REQUEST_CHANGES", "COMMENT"]).optional() });
14731
+ var respondSchema = contextSchema.extend({
14732
+ body: z5.string().min(1),
14733
+ file: z5.string().optional(),
14734
+ line: z5.number().int().positive().optional()
14735
+ });
14736
+ function parameters5(schema) {
14737
+ return z5.toJSONSchema(schema, { io: "input" });
14738
+ }
14739
+ function cwdOf(params) {
14740
+ return params.cwd ?? process.cwd();
14741
+ }
14742
+ function result(text, details = {}) {
14743
+ return { content: [{ type: "text", text }], details };
14744
+ }
14745
+ function conventionalMergeGuard(subject) {
14746
+ return checkConventionalSubject(subject);
14747
+ }
14748
+ function assertGitHubMergeReady(input) {
14749
+ let data;
14750
+ try {
14751
+ data = JSON.parse(input);
14752
+ } catch {
14753
+ throw new Error("Merge blocked: GitHub readiness response was not valid JSON.");
14754
+ }
14755
+ const blockers = [];
14756
+ if (data.state !== "OPEN")
14757
+ blockers.push(`pull request state is ${data.state ?? "unknown"}`);
14758
+ if (data.isDraft)
14759
+ blockers.push("pull request is still a draft");
14760
+ if (data.reviewDecision !== "APPROVED") {
14761
+ blockers.push(`review decision is ${data.reviewDecision || "not approved"}`);
14762
+ }
14763
+ if (data.mergeStateStatus !== "CLEAN") {
14764
+ blockers.push(`merge state is ${data.mergeStateStatus ?? "unknown"}`);
14765
+ }
14766
+ for (const check of data.statusCheckRollup ?? []) {
14767
+ const name = check.name ?? check.context ?? "unnamed check";
14768
+ if (check.__typename === "CheckRun") {
14769
+ if (check.status !== "COMPLETED")
14770
+ blockers.push(`${name} is ${check.status?.toLowerCase() ?? "pending"}`);
14771
+ else if (!["SUCCESS", "SKIPPED", "NEUTRAL"].includes(check.conclusion ?? "")) {
14772
+ blockers.push(`${name} concluded ${(check.conclusion ?? "unknown").toLowerCase()}`);
14773
+ }
14774
+ } else if (check.state !== "SUCCESS") {
14775
+ blockers.push(`${name} is ${(check.state ?? "pending").toLowerCase()}`);
14776
+ }
14777
+ }
14778
+ if (blockers.length > 0)
14779
+ throw new Error(`Merge blocked: ${blockers.join("; ")}.`);
14780
+ }
14781
+ function reviewSubmissionBody(body) {
14782
+ return body.trim() || "Inline comments only.";
14783
+ }
14784
+ function createReviewTools() {
14785
+ return [
14786
+ defineTool3({
14787
+ name: "review_context",
14788
+ label: "review context",
14789
+ description: "Read-only orientation for the current forge, environment, store, branch, PR, and tuicr session.",
14790
+ promptSnippet: "Call review_context first",
14791
+ promptGuidelines: ["Call this before every review workflow."],
14792
+ parameters: parameters5(contextSchema),
14793
+ executionMode: "parallel",
14794
+ async execute(_id, input) {
14795
+ const params = contextSchema.parse(input);
14796
+ const cwd = cwdOf(params);
14797
+ const vcs = await detectVcs(cwd);
14798
+ const store = await ensureStore(cwd);
14799
+ const env = { ide: detectIde(), mux: detectMux(), shell: detectShell() };
14800
+ const forge = vcs.provider === "none" ? undefined : createForge(vcs);
14801
+ const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
14802
+ const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
14803
+ const session = await resolveSession(cwd, vcs.branch);
14804
+ return result([
14805
+ `Forge: ${vcs.provider}${vcs.provider === "none" ? "" : ` (${vcs.owner}/${vcs.repo})`}`,
14806
+ `Branch: ${vcs.branch} → ${baseRef}`,
14807
+ `Env: ide=${env.ide} mux=${env.mux} shell=${env.shell}`,
14808
+ `Store: ${store.link} → ${store.dest}`,
14809
+ pr ? `PR/MR: #${pr.number} ${pr.url}` : "PR/MR: none",
14810
+ session ? `tuicr: ${session.slug} (${session.commentCount} comments)` : "tuicr: none"
14811
+ ].join(`
14812
+ `), { vcs, env, store, pr, session, baseRef });
14813
+ }
14814
+ }),
14815
+ defineTool3({
14816
+ name: "review_open",
14817
+ label: "review open",
14818
+ description: "Create a draft PR/MR, or launch a local tuicr review.",
14819
+ promptSnippet: "Call review_open to start",
14820
+ promptGuidelines: ["Use local for the offline tuicr flow."],
14821
+ parameters: parameters5(openSchema),
14822
+ executionMode: "sequential",
14823
+ async execute(_id, input) {
14824
+ const params = openSchema.parse(input);
14825
+ const cwd = cwdOf(params);
14826
+ await ensureStore(cwd);
14827
+ const vcs = await detectVcs(cwd);
14828
+ if (params.local || vcs.provider === "none") {
14829
+ const launched = await launch(cwd);
14830
+ const record = join10(await reviewsDir(cwd), `${reviewRecordName(vcs.branch)}.md`);
14831
+ if (!existsSync4(record))
14832
+ await writeFile2(record, `# Local review: ${vcs.branch}
14833
+ `, "utf8");
14834
+ return result(launched.launched ? `Opened tuicr (${launched.via}). Record: ${record}` : `${launched.instruction ?? `Run: ${launched.command}`}
14835
+ Record: ${record}`, { launched, record });
14836
+ }
14837
+ const forge = createForge(vcs);
14838
+ const pr = await forge.createDraftPr({
14839
+ title: params.title ?? deriveTitle(vcs.branch),
14840
+ body: "<!-- fill in intent, changes, validation -->",
14841
+ base: params.base ?? await forge.defaultBranch(),
14842
+ head: vcs.branch
14843
+ });
14844
+ return result(`Draft PR/MR created: ${pr.url}`, { pr });
14845
+ }
14846
+ }),
14847
+ defineTool3({
14848
+ name: "review_diff",
14849
+ label: "review diff",
14850
+ description: "Fetch the forge diff or the local working-tree diff.",
14851
+ promptSnippet: "Call review_diff for the code under review",
14852
+ promptGuidelines: ["Ground findings in this diff."],
14853
+ parameters: parameters5(localSchema),
14854
+ executionMode: "parallel",
14855
+ async execute(_id, input) {
14856
+ const params = localSchema.parse(input);
14857
+ const cwd = cwdOf(params);
14858
+ const vcs = await detectVcs(cwd);
14859
+ if (params.local || vcs.provider === "none") {
14860
+ const diff = await run("git", ["-C", cwd, "diff", "HEAD"]);
14861
+ return result(diff.stdout || "No working-tree changes.", { diff: diff.stdout });
14862
+ }
14863
+ const pr = await createForge(vcs).viewPr(vcs.branch);
14864
+ if (!pr)
14865
+ return result("No open PR/MR. Run review_open first.");
14866
+ const diff = await createForge(vcs).prDiff(pr.number);
14867
+ return result(diff || "Empty diff.", { diff, pr });
14868
+ }
14869
+ }),
14870
+ defineTool3({
14871
+ name: "review_gates",
14872
+ label: "review gates",
14873
+ description: "Run format, lint, test, conventional-subject, and available CI checks.",
14874
+ promptSnippet: "Call review_gates before submitting findings",
14875
+ promptGuidelines: ["Report skipped gates as skipped."],
14876
+ parameters: parameters5(localSchema),
14877
+ executionMode: "parallel",
14878
+ async execute(_id, input) {
14879
+ const params = localSchema.parse(input);
14880
+ const cwd = cwdOf(params);
14881
+ const vcs = await detectVcs(cwd);
14882
+ const gates = await runMiseGates(cwd);
14883
+ const subject = (await run("git", ["-C", cwd, "log", "-1", "--format=%s"])).stdout.trim();
14884
+ if (subject)
14885
+ gates.push(checkConventionalSubject(subject));
14886
+ if (!params.local && vcs.provider !== "none") {
14887
+ const forge = createForge(vcs);
14888
+ const pr = await forge.viewPr(vcs.branch);
14889
+ if (pr)
14890
+ gates.push(ciGate(await forge.prChecks(pr.number)));
14891
+ }
14892
+ return result(gates.map((gate) => `- ${gate.name}: ${gate.status} — ${gate.detail}`).join(`
14893
+ `), {
14894
+ results: gates
14895
+ });
14896
+ }
14897
+ }),
14898
+ defineTool3({
14899
+ name: "review_submit",
14900
+ label: "review submit",
14901
+ description: "Render findings to the shared review store and create a pending forge review unless local.",
14902
+ promptSnippet: "Call review_submit with the findings JSON",
14903
+ promptGuidelines: ["Use concrete file and line values."],
14904
+ parameters: parameters5(submitSchema),
14905
+ executionMode: "sequential",
14906
+ async execute(_id, input) {
14907
+ const params = submitSchema.parse(input);
14908
+ const cwd = cwdOf(params);
14909
+ await ensureStore(cwd);
14910
+ const vcs = await detectVcs(cwd);
14911
+ const findings = dedupeFindings(params.findings);
14912
+ const slug = reviewSlug(params.title ?? vcs.branch) || "review";
14913
+ const dir = reviewWorkingDir(await reviewsDir(cwd), slug);
14914
+ await mkdir3(dir, { recursive: true });
14915
+ const gates = await runMiseGates(cwd);
14916
+ const forge = !params.local && vcs.provider !== "none" ? createForge(vcs) : undefined;
14917
+ const pr = forge ? await forge.viewPr(vcs.branch) : undefined;
14918
+ const baseRef = pr?.baseRef ?? (forge ? await forge.defaultBranch() : "local");
14919
+ const docPath = join10(dir, "new-review.md");
14920
+ await writeFile2(docPath, renderReviewDoc({
14921
+ title: params.title ?? vcs.branch,
14922
+ headRef: vcs.branch,
14923
+ baseRef,
14924
+ findings,
14925
+ overallIssues: params.overallIssues ?? [],
14926
+ gates,
14927
+ notVerified: params.notVerified ?? []
14928
+ }), "utf8");
14929
+ if (!forge)
14930
+ return result(`Local review written: ${docPath}`, { docPath, count: findings.length });
14931
+ if (!pr)
14932
+ return result(`No open PR/MR. Review written: ${docPath}`, { docPath });
14933
+ await forge.createPendingReview(pr.number, toReviewComments(findings), reviewSubmissionBody((params.overallIssues ?? []).join(`
14934
+ `)));
14935
+ return result(`Pending review posted to #${pr.number}. Doc: ${docPath}`, {
14936
+ docPath,
14937
+ pr,
14938
+ count: findings.length
14939
+ });
14940
+ }
14941
+ }),
14942
+ defineTool3({
14943
+ name: "review_comments",
14944
+ label: "review comments",
14945
+ description: "Read unresolved local tuicr comments. Forge thread retrieval is delegated to the forge MCP when available.",
14946
+ promptSnippet: "Call review_comments before addressing findings",
14947
+ promptGuidelines: ["Use forge MCP thread tools when the review is remote."],
14948
+ parameters: parameters5(localSchema),
14949
+ executionMode: "parallel",
14950
+ async execute(_id, input) {
14951
+ const params = localSchema.parse(input);
14952
+ const cwd = cwdOf(params);
14953
+ const vcs = await detectVcs(cwd);
14954
+ const session = await resolveSession(cwd, vcs.branch);
14955
+ if (!session)
14956
+ return result("No tuicr session found.");
14957
+ const normalized = toFindings(await readSession(session.path));
14958
+ return result(normalized.comments.map((comment) => `${comment.file}:${comment.line} — ${comment.body}`).join(`
14959
+ `) || "No comments.", { session, comments: normalized.comments });
14960
+ }
14961
+ }),
14962
+ defineTool3({
14963
+ name: "review_respond",
14964
+ label: "review respond",
14965
+ description: "Append a response to the local review record; remote responses should use the forge MCP thread tool.",
14966
+ promptSnippet: "Call review_respond after addressing a local comment",
14967
+ promptGuidelines: ["For remote reviews, prefer the forge MCP response and resolve tools."],
14968
+ parameters: parameters5(respondSchema),
14969
+ executionMode: "sequential",
14970
+ async execute(_id, input) {
14971
+ const params = respondSchema.parse(input);
14972
+ const cwd = cwdOf(params);
14973
+ const path = join10(await reviewsDir(cwd), `${reviewRecordName((await detectVcs(cwd)).branch)}.md`);
14974
+ await mkdir3(join10(path, ".."), { recursive: true });
14975
+ await writeFile2(path, `
14976
+ ## Response${params.file ? ` — ${params.file}:${params.line ?? 1}` : ""}
14977
+
14978
+ ${params.body}
14979
+ `, { encoding: "utf8", flag: "a" });
14980
+ return result(`Response recorded: ${path}`, { path });
14981
+ }
14982
+ }),
14983
+ defineTool3({
14984
+ name: "review_publish",
14985
+ label: "review publish",
14986
+ description: "Mark a draft ready and submit its pending forge review, optionally publishing a local tuicr session.",
14987
+ promptSnippet: "Call review_publish to publish",
14988
+ promptGuidelines: ["Pass local to publish a tuicr session first."],
14989
+ parameters: parameters5(eventSchema),
14990
+ executionMode: "sequential",
14991
+ async execute(_id, input) {
14992
+ const params = eventSchema.parse(input);
14993
+ const cwd = cwdOf(params);
14994
+ const vcs = await detectVcs(cwd);
14995
+ if (vcs.provider === "none")
14996
+ return result("No forge detected; local review remains in the shared store.");
14997
+ const forge = createForge(vcs);
14998
+ const pr = await forge.viewPr(vcs.branch);
14999
+ if (!pr)
15000
+ return result("No open PR/MR for this branch.");
15001
+ const event = params.event ?? "COMMENT";
15002
+ assertReviewEventSupported(forge.provider, event);
15003
+ let body = "";
15004
+ if (params.local) {
15005
+ const session = await resolveSession(cwd, vcs.branch);
15006
+ if (!session)
15007
+ return result("No tuicr session to publish.");
15008
+ const normalized = toFindings(await readSession(session.path));
15009
+ body = reviewSubmissionBody(normalized.body);
15010
+ await forge.createPendingReview(pr.number, normalized.comments, body);
15011
+ }
15012
+ if (pr.isDraft)
15013
+ await forge.markReady(pr.number);
15014
+ await forge.submitReview(pr.number, event, body);
15015
+ return result(`Published #${pr.number} (${event}).`, { pr, event });
15016
+ }
15017
+ }),
15018
+ defineTool3({
15019
+ name: "review_complete",
15020
+ label: "review complete",
15021
+ description: "Approve, request changes, close, or archive a review. This never merges.",
15022
+ promptSnippet: "Call review_complete to finish without merging",
15023
+ promptGuidelines: ["Use review_merge separately for merging."],
15024
+ parameters: parameters5(completeSchema),
15025
+ executionMode: "sequential",
15026
+ async execute(_id, input) {
15027
+ const params = completeSchema.parse(input);
15028
+ const cwd = cwdOf(params);
15029
+ const vcs = await detectVcs(cwd);
15030
+ if (params.action === "local") {
15031
+ const source = await resolveSession(cwd, vcs.branch);
15032
+ const dest = uniqueRecordPath(await reviewsDir(cwd), reviewRecordName(vcs.branch));
15033
+ if (source) {
15034
+ const normalized = toFindings(await readSession(source.path));
15035
+ await writeFile2(dest, `# Completed review: ${vcs.branch}
15036
+
15037
+ ${normalized.body}
15038
+
15039
+ ${normalized.comments.map((comment) => `- ${comment.file}:${comment.line} — ${comment.body}`).join(`
15040
+ `)}
15041
+ `, "utf8");
15042
+ } else
15043
+ await writeFile2(dest, `# Completed review: ${vcs.branch}
15044
+ `, "utf8");
15045
+ return result(`Local review archived: ${dest}`, { dest });
15046
+ }
15047
+ if (vcs.provider === "none")
15048
+ return result("No forge detected. Use action=local for a local review.");
15049
+ const forge = createForge(vcs);
15050
+ const pr = await forge.viewPr(vcs.branch);
15051
+ if (!pr)
15052
+ return result("No open PR/MR for this branch.");
15053
+ if (params.action === "accept") {
15054
+ if (pr.isDraft)
15055
+ await forge.markReady(pr.number);
15056
+ await forge.submitReview(pr.number, "APPROVE", params.comment ?? "Approved.");
15057
+ return result(`Approved #${pr.number}. Merge separately with review_merge.`, { pr });
15058
+ }
15059
+ if (params.action === "reject") {
15060
+ await forge.submitReview(pr.number, "REQUEST_CHANGES", params.comment ?? "Requesting changes.");
15061
+ return result(`Requested changes on #${pr.number}.`, { pr });
15062
+ }
15063
+ await forge.closePr(pr.number, params.comment);
15064
+ return result(`Closed #${pr.number}.`, { pr });
15065
+ }
15066
+ }),
15067
+ defineTool3({
15068
+ name: "review_merge",
15069
+ label: "review merge",
15070
+ description: "Squash-merge an approved GitHub PR after checking its conventional subject.",
15071
+ promptSnippet: "Call review_merge only after review_complete accept",
15072
+ promptGuidelines: ["This is intentionally GitHub-only until GitLab merge support is added."],
15073
+ parameters: parameters5(contextSchema.extend({ subject: z5.string().optional() })),
15074
+ executionMode: "sequential",
15075
+ async execute(_id, input) {
15076
+ const params = contextSchema.extend({ subject: z5.string().optional() }).parse(input);
15077
+ const cwd = cwdOf(params);
15078
+ const vcs = await detectVcs(cwd);
15079
+ if (vcs.provider !== "github")
15080
+ return result("review_merge currently supports GitHub only.");
15081
+ const pr = await createForge(vcs).viewPr(vcs.branch);
15082
+ if (!pr)
15083
+ return result("No open PR/MR for this branch.");
15084
+ const subject = params.subject ?? pr.title;
15085
+ const guard = conventionalMergeGuard(subject);
15086
+ if (guard.status !== "pass") {
15087
+ return result(`Merge blocked: ${guard.detail}`, { pr, guard });
15088
+ }
15089
+ const readiness = await runChecked("gh", [
15090
+ "pr",
15091
+ "view",
15092
+ String(pr.number),
15093
+ "--repo",
15094
+ `${vcs.owner}/${vcs.repo}`,
15095
+ "--json",
15096
+ "isDraft,state,reviewDecision,mergeStateStatus,statusCheckRollup"
15097
+ ], { capture: "unbounded" });
15098
+ assertGitHubMergeReady(readiness.stdout);
15099
+ await runChecked("gh", [
15100
+ "pr",
15101
+ "merge",
15102
+ String(pr.number),
15103
+ "--repo",
15104
+ `${vcs.owner}/${vcs.repo}`,
15105
+ "--squash",
15106
+ "--subject",
15107
+ subject
15108
+ ]);
15109
+ return result(`Merged #${pr.number} with subject: ${subject}.`, { pr, guard });
15110
+ }
15111
+ }),
15112
+ defineTool3({
15113
+ name: "review_launch",
15114
+ label: "review launch",
15115
+ description: "Open tuicr in a mux tab, configure its Zed task, or print the command.",
15116
+ promptSnippet: "Call review_launch for the interactive tuicr TUI",
15117
+ promptGuidelines: ["Show the returned command when launch cannot open a tab."],
15118
+ parameters: parameters5(contextSchema),
15119
+ executionMode: "sequential",
15120
+ async execute(_id, input) {
15121
+ const params = contextSchema.parse(input);
15122
+ const launchResult = await launch(cwdOf(params));
15123
+ return result(launchResult.launched ? `Opened tuicr (${launchResult.via}).` : launchResult.instruction ?? `Run: ${launchResult.command}`, {
15124
+ launch: launchResult
15125
+ });
15126
+ }
15127
+ })
15128
+ ];
15129
+ }
15130
+ function deriveTitle(branch) {
15131
+ return branch.replace(/^(feature|feat|fix|bug|chore)\//, "").replace(/^eng-\d+-/i, "").replace(/[-_]+/g, " ").replace(/^\w/, (char) => char.toUpperCase());
15132
+ }
15133
+ function uniqueRecordPath(dir, base) {
15134
+ let path = join10(dir, `${base}.md`);
15135
+ let count = 2;
15136
+ while (existsSync4(path))
15137
+ path = join10(dir, `${base}-${count++}.md`);
15138
+ return path;
15139
+ }
15140
+
15141
+ // src/tools/setup.ts
15142
+ import { defineTool as defineTool4 } from "@earendil-works/pi-coding-agent";
15143
+ import { z as z6 } from "zod";
15144
+
15145
+ // src/setup.ts
15146
+ import { parseFrontmatter as parseFrontmatter3 } from "@earendil-works/pi-coding-agent";
15147
+ import { readdir as readdir2, readFile as readFile7 } from "node:fs/promises";
15148
+ import { homedir as homedir9 } from "node:os";
15149
+ import { basename as basename5, join as join14 } from "node:path";
15150
+
15151
+ // src/mcp.ts
15152
+ import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile3 } from "node:fs/promises";
15153
+ import { homedir as homedir6 } from "node:os";
15154
+ import { dirname as dirname4, join as join11 } from "node:path";
15155
+ var mcp = {
15156
+ globalConfigPath(homeDir = homedir6()) {
15157
+ return join11(homeDir, ".config", "mcp", "mcp.json");
15158
+ },
15159
+ async serversEnsure(servers, options = {}) {
15160
+ const path = options.path ?? mcp.globalConfigPath();
15161
+ const currentText = await getOptionalFile(path);
15162
+ const current = getParsedConfig(currentText, path);
15163
+ const nextServers = { ...current.mcpServers };
15164
+ for (const [name, entry] of Object.entries(servers)) {
15165
+ nextServers[name] = mergeEntry(nextServers[name], entry);
15166
+ }
15167
+ const next = { ...current, mcpServers: nextServers };
15168
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
15169
+ if (changed && !options.dryRun) {
15170
+ await mkdir4(dirname4(path), { recursive: true });
15171
+ await writeFile3(path, `${JSON.stringify(next, null, 2)}
15172
+ `, "utf8");
15173
+ }
15174
+ return { path, changed, existed: currentText !== undefined, planned: changed && options.dryRun === true };
15175
+ }
15176
+ };
15177
+ function mergeEntry(current, required) {
15178
+ const merged = { ...current, ...required };
15179
+ if (current?.env || required.env)
15180
+ merged.env = { ...current?.env, ...required.env };
15181
+ return merged;
15182
+ }
15183
+ function getParsedConfig(content, path) {
15184
+ if (!content?.trim())
15185
+ return { mcpServers: {} };
15186
+ try {
15187
+ const value = JSON.parse(content);
15188
+ if (!isRecord(value))
15189
+ throw new Error("not an object");
15190
+ const servers = value.mcpServers;
15191
+ if (servers !== undefined && !isRecord(servers))
15192
+ throw new Error("mcpServers is not an object");
15193
+ return { ...value, mcpServers: servers ?? {} };
15194
+ } catch {
15195
+ throw new Error(`Expected a valid pi-mcp-adapter configuration in ${path}.`);
15196
+ }
15197
+ }
15198
+ async function getOptionalFile(path) {
15199
+ try {
15200
+ return await readFile5(path, "utf8");
15201
+ } catch (error) {
15202
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
15203
+ return;
15204
+ throw error;
15205
+ }
15206
+ }
15207
+ function isRecord(value) {
15208
+ return value !== null && typeof value === "object" && !Array.isArray(value);
15209
+ }
15210
+
13483
15211
  // src/mise.ts
15212
+ import { mkdir as mkdir5, readFile as readFile6, writeFile as writeFile4 } from "node:fs/promises";
15213
+ import { homedir as homedir7 } from "node:os";
15214
+ import { basename as basename4, dirname as dirname5, join as join12 } from "node:path";
13484
15215
  var MISE_HOOK_START = "# >>> @difflab/pi mise >>>";
13485
15216
  var MISE_HOOK_END = "# <<< @difflab/pi mise <<<";
13486
15217
  var mise = {
@@ -13488,11 +15219,11 @@ var mise = {
13488
15219
  return findExecutable(name);
13489
15220
  },
13490
15221
  async install(options = {}) {
13491
- const homeDir = options.homeDir ?? homedir3();
15222
+ const homeDir = options.homeDir ?? homedir7();
13492
15223
  const platform = options.platform ?? process.platform;
13493
15224
  if (platform === "win32")
13494
15225
  throw new Error("Automatic mise installation supports macOS and Linux only.");
13495
- const installedPath = join5(homeDir, ".local", "bin", "mise");
15226
+ const installedPath = join12(homeDir, ".local", "bin", "mise");
13496
15227
  if (options.dryRun)
13497
15228
  return installedPath;
13498
15229
  await runChecked("sh", ["-c", "curl -fsSL https://mise.run | sh"]);
@@ -13502,8 +15233,8 @@ var mise = {
13502
15233
  return executable;
13503
15234
  },
13504
15235
  async hookEnsure(executable, options = {}) {
13505
- const homeDir = options.homeDir ?? homedir3();
13506
- const hook = getShellHook(basename(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
15236
+ const homeDir = options.homeDir ?? homedir7();
15237
+ const hook = getShellHook(basename4(options.shell ?? process.env.SHELL ?? ""), executable, homeDir);
13507
15238
  const current = await getOptionalFile2(hook.path);
13508
15239
  if (current.includes(MISE_HOOK_START))
13509
15240
  return { path: hook.path, changed: false, planned: false };
@@ -13512,8 +15243,8 @@ var mise = {
13512
15243
  const separator = current.length === 0 || current.endsWith(`
13513
15244
  `) ? "" : `
13514
15245
  `;
13515
- await mkdir2(dirname3(hook.path), { recursive: true });
13516
- await writeFile2(hook.path, `${current}${separator}${hook.content}`, "utf8");
15246
+ await mkdir5(dirname5(hook.path), { recursive: true });
15247
+ await writeFile4(hook.path, `${current}${separator}${hook.content}`, "utf8");
13517
15248
  return { path: hook.path, changed: true, planned: false };
13518
15249
  },
13519
15250
  async toolCheckGlobal(executable, tool, minimumVersion) {
@@ -13530,7 +15261,7 @@ var mise = {
13530
15261
  async toolInstallLocal(executable, specification, cwd = process.cwd()) {
13531
15262
  await runChecked(executable, ["use", "--path", cwd, specification], { cwd });
13532
15263
  },
13533
- async toolUpdateAllGlobal(executable, homeDir = homedir3()) {
15264
+ async toolUpdateAllGlobal(executable, homeDir = homedir7()) {
13534
15265
  await runChecked(executable, ["upgrade"], { cwd: homeDir });
13535
15266
  }
13536
15267
  };
@@ -13539,7 +15270,7 @@ function getShellHook(shell, executable, homeDir) {
13539
15270
  switch (shell.toLowerCase()) {
13540
15271
  case "zsh":
13541
15272
  return {
13542
- path: join5(homeDir, ".zshrc"),
15273
+ path: join12(homeDir, ".zshrc"),
13543
15274
  content: `${MISE_HOOK_START}
13544
15275
  eval "$(${command} activate zsh)"
13545
15276
  ${MISE_HOOK_END}
@@ -13547,7 +15278,7 @@ ${MISE_HOOK_END}
13547
15278
  };
13548
15279
  case "fish":
13549
15280
  return {
13550
- path: join5(homeDir, ".config", "fish", "config.fish"),
15281
+ path: join12(homeDir, ".config", "fish", "config.fish"),
13551
15282
  content: `${MISE_HOOK_START}
13552
15283
  ${command} activate fish | source
13553
15284
  ${MISE_HOOK_END}
@@ -13556,7 +15287,7 @@ ${MISE_HOOK_END}
13556
15287
  case "nu":
13557
15288
  case "nushell":
13558
15289
  return {
13559
- path: join5(homeDir, ".config", "nushell", "config.nu"),
15290
+ path: join12(homeDir, ".config", "nushell", "config.nu"),
13560
15291
  content: `${MISE_HOOK_START}
13561
15292
  let mise_bin = ${command}
13562
15293
  let mise_path = $nu.default-config-dir | path join mise.nu
@@ -13567,7 +15298,7 @@ ${MISE_HOOK_END}
13567
15298
  };
13568
15299
  case "xonsh":
13569
15300
  return {
13570
- path: join5(homeDir, ".xonshrc"),
15301
+ path: join12(homeDir, ".xonshrc"),
13571
15302
  content: `${MISE_HOOK_START}
13572
15303
  execx($(${command} activate xonsh))
13573
15304
  ${MISE_HOOK_END}
@@ -13575,7 +15306,7 @@ ${MISE_HOOK_END}
13575
15306
  };
13576
15307
  case "elvish":
13577
15308
  return {
13578
- path: join5(homeDir, ".config", "elvish", "rc.elv"),
15309
+ path: join12(homeDir, ".config", "elvish", "rc.elv"),
13579
15310
  content: `${MISE_HOOK_START}
13580
15311
  var mise: = (ns [&])
13581
15312
  eval (${command} activate elvish | slurp) &ns=$mise: &on-end={|ns| set mise: = $ns }
@@ -13586,7 +15317,7 @@ ${MISE_HOOK_END}
13586
15317
  case "pwsh":
13587
15318
  case "powershell":
13588
15319
  return {
13589
- path: join5(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
15320
+ path: join12(homeDir, ".config", "powershell", "Microsoft.PowerShell_profile.ps1"),
13590
15321
  content: `${MISE_HOOK_START}
13591
15322
  (& ${command} activate pwsh) | Out-String | Invoke-Expression
13592
15323
  ${MISE_HOOK_END}
@@ -13595,7 +15326,7 @@ ${MISE_HOOK_END}
13595
15326
  case "bash":
13596
15327
  default:
13597
15328
  return {
13598
- path: join5(homeDir, ".bashrc"),
15329
+ path: join12(homeDir, ".bashrc"),
13599
15330
  content: `${MISE_HOOK_START}
13600
15331
  eval "$(${command} activate bash)"
13601
15332
  ${MISE_HOOK_END}
@@ -13634,7 +15365,7 @@ function isVersionAtLeast(version, minimumVersion) {
13634
15365
  }
13635
15366
  async function getOptionalFile2(path) {
13636
15367
  try {
13637
- return await readFile2(path, "utf8");
15368
+ return await readFile6(path, "utf8");
13638
15369
  } catch (error) {
13639
15370
  if (error instanceof Error && "code" in error && error.code === "ENOENT")
13640
15371
  return "";
@@ -13646,79 +15377,86 @@ function getShellQuoted(value) {
13646
15377
  }
13647
15378
 
13648
15379
  // src/pi.ts
13649
- import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
13650
- import { homedir as homedir4 } from "node:os";
13651
- import { dirname as dirname4, join as join6 } from "node:path";
15380
+ import { mkdir as mkdir6, writeFile as writeFile5 } from "node:fs/promises";
15381
+ import { homedir as homedir8 } from "node:os";
15382
+ import { dirname as dirname6, join as join13 } from "node:path";
13652
15383
  var pi = {
13653
- async executableCheck() {
13654
- return findExecutable("pi");
13655
- },
13656
- async packageList(executable) {
13657
- return (await runChecked(executable, ["list"])).stdout;
13658
- },
13659
- packageCheck(listOutput, source) {
13660
- if (listOutput.includes(source))
15384
+ executableCheck: findPiExecutable,
15385
+ packageList: listPiPackages,
15386
+ packageCheck: hasPiPackage,
15387
+ packageInstall: installPiPackage,
15388
+ agentDir: resolvePiAgentDir,
15389
+ agentEnsure: ensurePiAgent,
15390
+ skillCheckGlobal: checkGlobalPiSkill,
15391
+ skillInstallGlobal: installGlobalPiSkills,
15392
+ configEnsure: ensurePiConfig
15393
+ };
15394
+ async function ensurePiAgent(filename, content, agentDir = resolvePiAgentDir(), dryRun = false) {
15395
+ const path = join13(agentDir, "agents", filename);
15396
+ const currentText = await readTextIfExists(path);
15397
+ const changed = currentText !== content;
15398
+ if (changed && !dryRun) {
15399
+ await mkdir6(dirname6(path), { recursive: true });
15400
+ await writeFile5(path, content, "utf8");
15401
+ }
15402
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
15403
+ }
15404
+ async function checkGlobalPiSkill(name, agentDir = resolvePiAgentDir(), sharedSkillsDir = join13(homedir8(), ".agents", "skills")) {
15405
+ const roots = [join13(agentDir, "skills"), sharedSkillsDir];
15406
+ for (const root of roots) {
15407
+ if (await readTextIfExists(join13(root, name, "SKILL.md")) !== undefined)
13661
15408
  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
15409
  }
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"));
15410
+ return false;
13711
15411
  }
13712
- async function getOptionalFile3(path) {
13713
- try {
13714
- return await readFile3(path, "utf8");
13715
- } catch (error) {
13716
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
13717
- return;
13718
- throw error;
15412
+ async function installGlobalPiSkills(miseExecutable, source, names) {
15413
+ const selection = names.flatMap((name) => ["--skill", name]);
15414
+ await runChecked(miseExecutable, [
15415
+ "x",
15416
+ "node@22",
15417
+ "--",
15418
+ "npx",
15419
+ "-y",
15420
+ "skills",
15421
+ "add",
15422
+ source,
15423
+ ...selection,
15424
+ "--global",
15425
+ "--agent",
15426
+ "pi",
15427
+ "--yes"
15428
+ ]);
15429
+ }
15430
+ async function ensurePiConfig(path, update, dryRun = false) {
15431
+ const currentText = await readTextIfExists(path);
15432
+ const current = parseJsonObject(currentText, path);
15433
+ const next = update(current);
15434
+ const changed = JSON.stringify(current) !== JSON.stringify(next);
15435
+ if (changed && !dryRun) {
15436
+ await mkdir6(dirname6(path), { recursive: true });
15437
+ await writeFile5(path, `${JSON.stringify(next, null, 2)}
15438
+ `, "utf8");
13719
15439
  }
15440
+ return { path, changed, existed: currentText !== undefined, planned: changed && dryRun };
15441
+ }
15442
+ async function findPiExecutable() {
15443
+ return findExecutable("pi");
15444
+ }
15445
+ async function listPiPackages(executable) {
15446
+ return (await runChecked(executable, ["list"])).stdout;
15447
+ }
15448
+ function hasPiPackage(listOutput, source) {
15449
+ if (listOutput.includes(source))
15450
+ return true;
15451
+ return source.startsWith("https://") && listOutput.includes(source.slice("https://".length));
15452
+ }
15453
+ async function installPiPackage(executable, source) {
15454
+ await runChecked(executable, ["install", source]);
15455
+ }
15456
+ function resolvePiAgentDir(homeDir = homedir8()) {
15457
+ return process.env.PI_CODING_AGENT_DIR ?? (process.env.XDG_CONFIG_HOME ? join13(process.env.XDG_CONFIG_HOME, "pi") : join13(homeDir, ".pi", "agent"));
13720
15458
  }
13721
- function getParsedObject(content, path) {
15459
+ function parseJsonObject(content, path) {
13722
15460
  if (!content?.trim())
13723
15461
  return {};
13724
15462
  try {
@@ -13760,9 +15498,14 @@ var PI_SKILL_SOURCES = [
13760
15498
  { repository: "AminBlg/SimpleEnglish", skills: ["simple-english"] }
13761
15499
  ];
13762
15500
  var MCP_ADAPTER_PACKAGE = "npm:pi-mcp-adapter";
15501
+ var BUNDLED_AGENTS_DIR2 = resolveBundledAgentsDir();
15502
+ var FORGE_DEPENDENCIES = {
15503
+ github: { name: "gh", tool: "gh", spec: "gh@latest", minimumVersion: undefined },
15504
+ gitlab: { name: "glab", tool: "glab", spec: "glab@latest", minimumVersion: undefined }
15505
+ };
13763
15506
  async function ensureMise(options = {}) {
13764
- const homeDir = options.homeDir ?? homedir5();
13765
- const current = await mise.executableCheck() ?? await mise.executableCheck(join7(homeDir, ".local", "bin", "mise"));
15507
+ const homeDir = options.homeDir ?? homedir9();
15508
+ const current = await mise.executableCheck() ?? await mise.executableCheck(join14(homeDir, ".local", "bin", "mise"));
13766
15509
  if (current)
13767
15510
  return { executable: current, action: createSetupAction("mise", "ready", current) };
13768
15511
  reportProgress(options, "Installing mise");
@@ -13791,7 +15534,10 @@ async function ensureMiseHooks(miseExecutable, options = {}) {
13791
15534
  async function ensureMiseDeps(miseExecutable, options = {}) {
13792
15535
  const canRunMise = Boolean(await mise.executableCheck(miseExecutable));
13793
15536
  const actions = [];
13794
- for (const dependency of MISE_DEPENDENCIES) {
15537
+ const dependencies = [...MISE_DEPENDENCIES];
15538
+ if (options.forge && options.forge !== "none")
15539
+ dependencies.push(FORGE_DEPENDENCIES[options.forge]);
15540
+ for (const dependency of dependencies) {
13795
15541
  const installed = canRunMise && await mise.toolCheckGlobal(miseExecutable, dependency.tool, dependency.minimumVersion);
13796
15542
  if (installed) {
13797
15543
  actions.push(createSetupAction(dependency.name, "ready", dependency.spec));
@@ -13807,18 +15553,33 @@ async function ensureMiseDeps(miseExecutable, options = {}) {
13807
15553
  async function ensurePiPlugins(options = {}) {
13808
15554
  const actions = await ensurePiPackages(PI_PACKAGES, options);
13809
15555
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
13810
- const webSearch = await pi.configEnsure(join7(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
15556
+ const webSearch = await pi.configEnsure(join14(agentDir, "web-search.json"), (config) => ({ ...config, workflow: "auto-summary" }), options.dryRun);
13811
15557
  actions.push(getConfigSetupAction("web search settings", webSearch));
13812
- const lsp = await pi.configEnsure(join7(agentDir, "pi-lsp.json"), (config) => ({
15558
+ const lsp = await pi.configEnsure(join14(agentDir, "pi-lsp.json"), (config) => ({
13813
15559
  ...config,
13814
15560
  progressive: { ...getRecord(config.progressive), enabled: true, inject: "none" }
13815
15561
  }), options.dryRun);
13816
15562
  actions.push(getConfigSetupAction("pi-lsp settings", lsp));
13817
15563
  return actions;
13818
15564
  }
15565
+ async function ensurePiAgents(options = {}) {
15566
+ const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
15567
+ const bundledAgentsDir = options.bundledAgentsDir ?? BUNDLED_AGENTS_DIR2;
15568
+ const userConfig = await loadDiffpiConfig({ homeDir: options.homeDir });
15569
+ const entries = (await readdir2(bundledAgentsDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.startsWith("diffpi-") && entry.name.endsWith(".md")).sort((left, right) => left.name.localeCompare(right.name));
15570
+ const actions = [];
15571
+ for (const entry of entries) {
15572
+ const id = basename5(entry.name, ".md").replace(/^diffpi-/, "");
15573
+ const source = await readFile7(join14(bundledAgentsDir, entry.name), "utf8");
15574
+ const content = materializeAgentModels(source, id, userConfig.config, options.availableModels);
15575
+ const result = await pi.agentEnsure(entry.name, content, agentDir, options.dryRun);
15576
+ actions.push(getConfigSetupAction(`pi agent ${id}`, result));
15577
+ }
15578
+ return actions;
15579
+ }
13819
15580
  async function ensurePiSkills(miseExecutable, options = {}) {
13820
15581
  const agentDir = options.agentDir ?? pi.agentDir(options.homeDir);
13821
- const sharedSkillsDir = join7(options.homeDir ?? homedir5(), ".agents", "skills");
15582
+ const sharedSkillsDir = join14(options.homeDir ?? homedir9(), ".agents", "skills");
13822
15583
  const actions = [];
13823
15584
  for (const source of PI_SKILL_SOURCES) {
13824
15585
  const missing = [];
@@ -13862,6 +15623,12 @@ async function ensureMcpAdapters(miseExecutable, options = {}) {
13862
15623
  } else if (options.issueTracker === "jira") {
13863
15624
  servers.atlassian = { url: "https://mcp.atlassian.com/v1/mcp", auth: "oauth", protocolVersion: "auto" };
13864
15625
  }
15626
+ if (options.forge === "github") {
15627
+ servers.github = { url: "https://api.githubcopilot.com/mcp/", auth: "oauth", protocolVersion: "auto" };
15628
+ } else if (options.forge === "gitlab") {
15629
+ const host = (await detectVcs(projectDir)).host || "gitlab.com";
15630
+ servers.gitlab = { url: `https://${host}/api/v4/mcp`, auth: "oauth", protocolVersion: "auto" };
15631
+ }
13865
15632
  const result = await mcp.serversEnsure(servers, {
13866
15633
  dryRun: options.dryRun,
13867
15634
  path: mcp.globalConfigPath(options.homeDir)
@@ -13875,13 +15642,81 @@ async function setupPi(options = {}) {
13875
15642
  actions.push(await ensureMiseHooks(miseResult.executable, options));
13876
15643
  actions.push(...await ensureMiseDeps(miseResult.executable, options));
13877
15644
  actions.push(...await ensurePiPlugins(options));
15645
+ actions.push(...await ensurePiAgents(options));
13878
15646
  actions.push(...await ensurePiSkills(miseResult.executable, options));
13879
15647
  actions.push(...await ensureMcpAdapters(miseResult.executable, options));
15648
+ if (options.bindZedKey)
15649
+ actions.push(...await ensureZedIntegration(options));
13880
15650
  return {
13881
15651
  actions,
13882
- restartPi: actions.some((item) => (item.status === "installed" || item.status === "updated") && (item.name.startsWith("pi package ") || item.name.startsWith("pi skill ") || item.name === "MCP configuration" || item.name === "web search settings" || item.name === "pi-lsp settings"))
15652
+ restartPi: setupRequiresRestart(actions)
13883
15653
  };
13884
15654
  }
15655
+ function setupRequiresRestart(actions) {
15656
+ 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"));
15657
+ }
15658
+ function materializeAgentModels(content, agentId, config, availableModels) {
15659
+ const { frontmatter } = parseFrontmatter3(content.startsWith("\uFEFF") ? content.slice(1) : content);
15660
+ const profilePreferences = [...getTextList(frontmatter.model), ...getTextList(frontmatter.model_fallbacks)];
15661
+ const preferences = resolveAgentModelPreferences(agentId, profilePreferences, config);
15662
+ let selectedIndex = availableModels === undefined && preferences.length > 0 ? 0 : -1;
15663
+ let selectedModel = selectedIndex === 0 ? preferences[0] : undefined;
15664
+ if (availableModels) {
15665
+ for (const [index, preference] of preferences.entries()) {
15666
+ const match = findPreferredModel(availableModels, preference);
15667
+ if (!match)
15668
+ continue;
15669
+ selectedIndex = index;
15670
+ selectedModel = `${match.provider}/${match.id}`;
15671
+ break;
15672
+ }
15673
+ }
15674
+ const fallbacks = preferences.filter((_preference, index) => index !== selectedIndex);
15675
+ return replaceAgentModelFields(content, selectedModel, fallbacks);
15676
+ }
15677
+ function replaceAgentModelFields(content, model, fallbacks) {
15678
+ const newline = content.includes(`\r
15679
+ `) ? `\r
15680
+ ` : `
15681
+ `;
15682
+ const lines = content.replaceAll(`\r
15683
+ `, `
15684
+ `).split(`
15685
+ `);
15686
+ const closingDelimiter = lines.indexOf("---", 1);
15687
+ if (lines[0] !== "---" || closingDelimiter < 0)
15688
+ return content;
15689
+ const frontmatter = lines.slice(1, closingDelimiter).filter((line) => !/^model(?:_fallbacks)?:/.test(line));
15690
+ if (model)
15691
+ frontmatter.push(`model: ${model}`);
15692
+ if (fallbacks.length > 0)
15693
+ frontmatter.push(`model_fallbacks: ${fallbacks.join(", ")}`);
15694
+ return ["---", ...frontmatter, "---", ...lines.slice(closingDelimiter + 1)].join(newline);
15695
+ }
15696
+ async function ensureZedIntegration(options = {}) {
15697
+ if (options.dryRun) {
15698
+ const actions = [createSetupAction("Zed review task", "planned", "tasks.json")];
15699
+ if (options.bindZedKey)
15700
+ actions.push(createSetupAction("Zed review keybinding", "planned", "keymap.json"));
15701
+ return actions;
15702
+ }
15703
+ const actions = [];
15704
+ try {
15705
+ const task = await ensureZedReviewTask(options.homeDir);
15706
+ actions.push(createSetupAction("Zed review task", task.changed ? "installed" : "ready", task.path));
15707
+ } catch (error) {
15708
+ actions.push(createSetupAction("Zed review task", "skipped", error instanceof Error ? error.message : String(error)));
15709
+ }
15710
+ if (options.bindZedKey) {
15711
+ try {
15712
+ const key = await ensureZedReviewKeybinding(options.homeDir);
15713
+ actions.push(createSetupAction("Zed review keybinding", key.changed ? "installed" : "ready", key.path));
15714
+ } catch (error) {
15715
+ actions.push(createSetupAction("Zed review keybinding", "skipped", error instanceof Error ? error.message : String(error)));
15716
+ }
15717
+ }
15718
+ return actions;
15719
+ }
13885
15720
  async function ensurePiPackages(packages, options) {
13886
15721
  const executable = await pi.executableCheck();
13887
15722
  if (!executable && !options.dryRun)
@@ -13903,6 +15738,10 @@ ${source}`;
13903
15738
  }
13904
15739
  return actions;
13905
15740
  }
15741
+ function getTextList(value) {
15742
+ const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
15743
+ return values.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean);
15744
+ }
13906
15745
  function getConfigSetupAction(name, result) {
13907
15746
  if (!result.changed)
13908
15747
  return createSetupAction(name, "ready", result.path);
@@ -13921,11 +15760,13 @@ function getRecord(value) {
13921
15760
  }
13922
15761
 
13923
15762
  // src/tools/setup.ts
13924
- var setupParametersSchema = z2.object({
13925
- issueTracker: z2.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira.")
15763
+ var setupParametersSchema = z6.object({
15764
+ issueTracker: z6.enum(["none", "linear", "jira"]).default("none").describe("Issue tracker MCP server to configure. Use none unless the user explicitly selects Linear or Jira."),
15765
+ forge: z6.enum(["none", "github", "gitlab"]).default("none").describe("Forge to configure for /review. Installs gh or glab and registers its MCP server."),
15766
+ bindZedKey: z6.boolean().default(false).describe("Opt in to a Zed keybinding for the tuicr review task.")
13926
15767
  });
13927
- var setupParameters = z2.toJSONSchema(setupParametersSchema, { io: "input" });
13928
- var diffpiSetupTool = defineTool2({
15768
+ var setupParameters = z6.toJSONSchema(setupParametersSchema, { io: "input" });
15769
+ var diffpiSetupTool = defineTool4({
13929
15770
  name: "diffpi_setup",
13930
15771
  label: "diffpi setup",
13931
15772
  description: "Install or repair the @difflab/pi environment. This mutates user-level tool installations and configuration files.",
@@ -13938,11 +15779,14 @@ var diffpiSetupTool = defineTool2({
13938
15779
  ],
13939
15780
  parameters: setupParameters,
13940
15781
  executionMode: "sequential",
13941
- async execute(_toolCallId, input, _signal, onUpdate) {
15782
+ async execute(_toolCallId, input, _signal, onUpdate, ctx) {
13942
15783
  const params = setupParametersSchema.parse(input);
13943
15784
  const result = await setupPi({
13944
15785
  issueTracker: params.issueTracker,
15786
+ forge: params.forge,
15787
+ bindZedKey: params.bindZedKey,
13945
15788
  installMiseHook: true,
15789
+ availableModels: ctx.modelRegistry.getAvailable(),
13946
15790
  onProgress(message) {
13947
15791
  onUpdate?.({ content: [{ type: "text", text: message }], details: {} });
13948
15792
  }
@@ -13950,7 +15794,7 @@ var diffpiSetupTool = defineTool2({
13950
15794
  return formatResult(result, "Setup complete.");
13951
15795
  }
13952
15796
  });
13953
- var diffpiValidateTool = defineTool2({
15797
+ var diffpiValidateTool = defineTool4({
13954
15798
  name: "diffpi_validate",
13955
15799
  label: "diffpi validate",
13956
15800
  description: "Inspect the @difflab/pi environment without installing software or changing configuration files.",
@@ -13962,12 +15806,15 @@ var diffpiValidateTool = defineTool2({
13962
15806
  ],
13963
15807
  parameters: setupParameters,
13964
15808
  executionMode: "sequential",
13965
- async execute(_toolCallId, input) {
15809
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
13966
15810
  const params = setupParametersSchema.parse(input);
13967
15811
  const result = await setupPi({
13968
15812
  issueTracker: params.issueTracker,
15813
+ forge: params.forge,
15814
+ bindZedKey: params.bindZedKey,
13969
15815
  installMiseHook: true,
13970
- dryRun: true
15816
+ dryRun: true,
15817
+ availableModels: ctx.modelRegistry.getAvailable()
13971
15818
  });
13972
15819
  const incomplete = result.actions.some((item) => item.status === "planned");
13973
15820
  return formatResult(result, incomplete ? "Setup is incomplete." : "Setup is ready.");
@@ -13988,31 +15835,56 @@ ${lines.join(`
13988
15835
  }
13989
15836
 
13990
15837
  // src/tools/index.ts
13991
- function createPiTools(pi) {
13992
- return [diffpiSetupTool, diffpiValidateTool, createDiffpiReloadTool(pi)];
15838
+ function createPiTools(pi, modes) {
15839
+ return [
15840
+ diffpiSetupTool,
15841
+ diffpiValidateTool,
15842
+ createDiffpiReloadTool(pi),
15843
+ ...createModeTools(modes),
15844
+ ...createReviewTools()
15845
+ ];
13993
15846
  }
13994
15847
 
13995
15848
  // extensions/index.ts
13996
15849
  var RELOAD_COMMAND = "diffpi-reload";
13997
- var DOCS_ROUTING_GUIDANCE = `## Documentation routing
15850
+ var REVIEW_COMMAND = "review";
15851
+ var SKILL_ROUTING_GUIDANCE = `## Skill and tool routing
13998
15852
  Use the docs-search skill before web search for library or API documentation.
13999
15853
  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.`;
15854
+ Use fetch-url for a one-time read that does not belong in the documentation index.
15855
+ Use the context-mode skill for commands, tests, builds, logs, API responses, and other output that can be large.
15856
+ Use ctx_execute or ctx_execute_file to analyze that output, and use ctx_fetch_and_index with ctx_search for external documentation.`;
14001
15857
  function difflabPiExtension(pi) {
14002
15858
  rpiv_ask_user_question_default(pi);
15859
+ const modes = createModeController(pi);
14003
15860
  pi.registerCommand(RELOAD_COMMAND, {
14004
15861
  description: "Reload extensions, skills, prompts, themes, and context files",
14005
15862
  handler: async (_args, ctx) => {
14006
15863
  await ctx.reload();
14007
15864
  }
14008
15865
  });
14009
- for (const tool of createPiTools(pi))
15866
+ for (const tool of createPiTools(pi, modes))
14010
15867
  pi.registerTool(tool);
14011
- pi.on("before_agent_start", (event) => ({
14012
- systemPrompt: `${event.systemPrompt}
15868
+ pi.registerCommand(REVIEW_COMMAND, {
15869
+ description: "Code review: open, new, address, publish, complete, merge (add --local for tuicr)",
15870
+ handler: (args) => {
15871
+ const invocation = args.trim() || "help";
15872
+ pi.sendMessage({
15873
+ customType: "diffpi-review-command",
15874
+ display: false,
15875
+ 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.`
15876
+ }, { triggerTurn: true });
15877
+ return Promise.resolve();
15878
+ }
15879
+ });
15880
+ pi.on("session_start", async (_event, ctx) => modes.restore(ctx));
15881
+ pi.on("session_tree", async (_event, ctx) => modes.restore(ctx));
15882
+ pi.on("before_agent_start", (event) => {
15883
+ const defaultPrompt = `${event.systemPrompt}
14013
15884
 
14014
- ${DOCS_ROUTING_GUIDANCE}`
14015
- }));
15885
+ ${SKILL_ROUTING_GUIDANCE}`;
15886
+ return { systemPrompt: modes.apply(defaultPrompt) };
15887
+ });
14016
15888
  }
14017
15889
  export {
14018
15890
  difflabPiExtension as default