@ilya-lesikov/pi-pi 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/3p/pi-plannotator/apps/pi-extension/README.md +6 -5
- package/3p/pi-plannotator/apps/pi-extension/plannotator-browser.ts +25 -10
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.code-review.test.ts +172 -0
- package/3p/pi-plannotator/apps/pi-extension/plannotator-events.ts +50 -12
- package/3p/pi-plannotator/apps/pi-extension/server/project.test.ts +45 -0
- package/3p/pi-plannotator/apps/pi-extension/server/project.ts +7 -6
- package/3p/pi-plannotator/apps/pi-extension/server/serverReview.ts +41 -25
- package/3p/pi-subagents/src/agent-manager.ts +34 -1
- package/3p/pi-subagents/src/agent-runner.ts +66 -33
- package/3p/pi-subagents/src/index.ts +3 -38
- package/3p/pi-subagents/src/types.ts +4 -0
- package/extensions/orchestrator/agents/advisor.ts +35 -0
- package/extensions/orchestrator/agents/brainstorm-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/code-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/constraints.test.ts +44 -0
- package/extensions/orchestrator/agents/constraints.ts +3 -0
- package/extensions/orchestrator/agents/deep-debugger.ts +35 -0
- package/extensions/orchestrator/agents/plan-reviewer.ts +7 -7
- package/extensions/orchestrator/agents/planner.ts +9 -8
- package/extensions/orchestrator/agents/prompts.test.ts +120 -0
- package/extensions/orchestrator/agents/reviewer.ts +48 -0
- package/extensions/orchestrator/agents/task.ts +6 -20
- package/extensions/orchestrator/agents/tool-routing.ts +23 -1
- package/extensions/orchestrator/command-handlers.ts +1 -1
- package/extensions/orchestrator/config.ts +5 -2
- package/extensions/orchestrator/context.test.ts +54 -0
- package/extensions/orchestrator/context.ts +65 -2
- package/extensions/orchestrator/event-handlers.test.ts +97 -1
- package/extensions/orchestrator/event-handlers.ts +176 -43
- package/extensions/orchestrator/flant-infra.test.ts +25 -0
- package/extensions/orchestrator/flant-infra.ts +91 -0
- package/extensions/orchestrator/index.ts +1 -1
- package/extensions/orchestrator/integration.test.ts +107 -12
- package/extensions/orchestrator/messages.test.ts +30 -0
- package/extensions/orchestrator/messages.ts +6 -0
- package/extensions/orchestrator/model-registry.test.ts +48 -1
- package/extensions/orchestrator/model-registry.ts +43 -1
- package/extensions/orchestrator/orchestrator.test.ts +113 -0
- package/extensions/orchestrator/orchestrator.ts +151 -2
- package/extensions/orchestrator/phases/brainstorm.ts +9 -20
- package/extensions/orchestrator/phases/implementation.test.ts +11 -0
- package/extensions/orchestrator/phases/implementation.ts +4 -6
- package/extensions/orchestrator/phases/planning.test.ts +16 -0
- package/extensions/orchestrator/phases/planning.ts +11 -4
- package/extensions/orchestrator/phases/review-task.ts +1 -4
- package/extensions/orchestrator/phases/review.test.ts +8 -0
- package/extensions/orchestrator/phases/review.ts +4 -3
- package/extensions/orchestrator/plannotator.ts +9 -6
- package/extensions/orchestrator/pp-menu.ts +168 -54
- package/extensions/orchestrator/pp-state-tools.test.ts +192 -0
- package/extensions/orchestrator/pp-state-tools.ts +249 -0
- package/extensions/orchestrator/rate-limit-fallback-flow.test.ts +128 -0
- package/extensions/orchestrator/rate-limit-fallback.test.ts +98 -0
- package/extensions/orchestrator/rate-limit-fallback.ts +243 -0
- package/extensions/orchestrator/test-helpers.ts +4 -1
- package/extensions/orchestrator/usage-tracker.ts +5 -1
- package/extensions/orchestrator/validate-artifacts.test.ts +20 -0
- package/extensions/orchestrator/validate-artifacts.ts +2 -2
- package/package.json +1 -1
|
@@ -27,7 +27,8 @@ import {
|
|
|
27
27
|
} from "./context.js";
|
|
28
28
|
import { detectDefaultBranch, enterReviewCycle, finalizeReviewCycle } from "./event-handlers.js";
|
|
29
29
|
import { Orchestrator } from "./orchestrator.js";
|
|
30
|
-
import { cancelPendingPlannotatorWait } from "./plannotator.js";
|
|
30
|
+
import { cancelPendingPlannotatorWait, openPlannotator, waitForPlannotatorResult } from "./plannotator.js";
|
|
31
|
+
import { advanceBanner } from "./messages.js";
|
|
31
32
|
import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
|
|
32
33
|
import { spawnCodeReviewers } from "./phases/review.js";
|
|
33
34
|
import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
|
|
@@ -225,6 +226,44 @@ async function pickCommitForRepo(orchestrator: Orchestrator, ctx: any, repo: Rep
|
|
|
225
226
|
return pickedHash || null;
|
|
226
227
|
}
|
|
227
228
|
|
|
229
|
+
async function repoHasReviewableChanges(
|
|
230
|
+
orchestrator: Orchestrator,
|
|
231
|
+
repo: RepoInfo,
|
|
232
|
+
base: string,
|
|
233
|
+
): Promise<{ changed: boolean; error?: string }> {
|
|
234
|
+
const run = async (args: string[]): Promise<{ out: string; failed: boolean }> => {
|
|
235
|
+
try {
|
|
236
|
+
const res = await orchestrator.pi.exec("git", args, { cwd: repo.path, timeout: 5000 });
|
|
237
|
+
if (res.code !== 0) return { out: "", failed: true };
|
|
238
|
+
return { out: res.stdout, failed: false };
|
|
239
|
+
} catch {
|
|
240
|
+
return { out: "", failed: true };
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const committed = await run(["diff", "--name-only", `${base}...HEAD`]);
|
|
245
|
+
const unstaged = await run(["diff", "--name-only"]);
|
|
246
|
+
const staged = await run(["diff", "--cached", "--name-only"]);
|
|
247
|
+
const status = await run(["status", "--porcelain"]);
|
|
248
|
+
|
|
249
|
+
const hasCommitted = committed.out.trim().length > 0;
|
|
250
|
+
const hasUnstaged = unstaged.out.trim().length > 0;
|
|
251
|
+
const hasStaged = staged.out.trim().length > 0;
|
|
252
|
+
const hasUntracked = status.out.split("\n").some((line) => line.startsWith("??"));
|
|
253
|
+
const changed = hasCommitted || hasUnstaged || hasStaged || hasUntracked;
|
|
254
|
+
|
|
255
|
+
if (changed) return { changed: true };
|
|
256
|
+
|
|
257
|
+
// No change detected — but a failed probe (e.g. a missing/invalid base ref)
|
|
258
|
+
// could be masking real changes, so surface it rather than silently hiding
|
|
259
|
+
// the repo.
|
|
260
|
+
if (committed.failed || unstaged.failed || staged.failed || status.failed) {
|
|
261
|
+
return { changed: false, error: "git status/diff failed" };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return { changed: false };
|
|
265
|
+
}
|
|
266
|
+
|
|
228
267
|
interface RepoPrContext {
|
|
229
268
|
repoPath: string;
|
|
230
269
|
prUrl: string | null;
|
|
@@ -252,6 +291,11 @@ function showStatus(orchestrator: Orchestrator, ctx: any): void {
|
|
|
252
291
|
}
|
|
253
292
|
|
|
254
293
|
async function abortCurrentWork(orchestrator: Orchestrator, ctx: any): Promise<void> {
|
|
294
|
+
// Clear pi-pi's own delayed post-error retry (timer + ESC interrupt). Unlike
|
|
295
|
+
// pause/finish, abortCurrentWork does NOT go through cleanupActive/
|
|
296
|
+
// resetTaskScopedState, so without this a scheduled retry would survive the
|
|
297
|
+
// abort and re-nudge seconds later.
|
|
298
|
+
orchestrator.cancelPendingRetry();
|
|
255
299
|
orchestrator.abortAllSubagents();
|
|
256
300
|
orchestrator.transitionController.abortMainAgent(ctx.abort?.bind(ctx));
|
|
257
301
|
await ctx.waitForIdle?.();
|
|
@@ -795,6 +839,9 @@ function collectRoleAssignments(config: Partial<PiPiConfig> | null): string[] {
|
|
|
795
839
|
add("agents.subagents.simple.explore", config.agents?.subagents?.simple?.explore?.model);
|
|
796
840
|
add("agents.subagents.simple.librarian", config.agents?.subagents?.simple?.librarian?.model);
|
|
797
841
|
add("agents.subagents.simple.task", config.agents?.subagents?.simple?.task?.model);
|
|
842
|
+
add("agents.subagents.simple.advisor", config.agents?.subagents?.simple?.advisor?.model);
|
|
843
|
+
add("agents.subagents.simple.deep-debugger", config.agents?.subagents?.simple?.["deep-debugger"]?.model);
|
|
844
|
+
add("agents.subagents.simple.reviewer", config.agents?.subagents?.simple?.reviewer?.model);
|
|
798
845
|
return out;
|
|
799
846
|
}
|
|
800
847
|
|
|
@@ -814,6 +861,7 @@ function flantStatusText(settings: FlantSettings): string {
|
|
|
814
861
|
lines.push(
|
|
815
862
|
`Personal subscription: on (${subActive ? `active — pp-flant-anthropic-sub, ${providers.anthropic} models` : "inactive"})`,
|
|
816
863
|
);
|
|
864
|
+
lines.push(`Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`);
|
|
817
865
|
if (!subActive) {
|
|
818
866
|
if (!hasOAuth) lines.push(" - missing Claude OAuth token (run pi /login for Anthropic)");
|
|
819
867
|
if (!hasGatewayKey) lines.push(" - missing gateway key (set LLM_API_KEY or FLANT_API_KEY)");
|
|
@@ -849,15 +897,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
|
|
|
849
897
|
while (true) {
|
|
850
898
|
const settings = loadFlantSettings();
|
|
851
899
|
const enableLabel = `Enable: ${settings.enabled ? "ON" : "OFF"}`;
|
|
852
|
-
const options: OptionInput[] = [
|
|
900
|
+
const options: OptionInput[] = [
|
|
901
|
+
{ title: enableLabel, description: "Turn the Flant AI model providers on or off" },
|
|
902
|
+
];
|
|
853
903
|
const subscriptionLabel = `Personal Claude subscription: ${settings.subscription ? "ON" : "OFF"}`;
|
|
854
904
|
if (settings.enabled) {
|
|
855
905
|
options.push(
|
|
856
|
-
|
|
857
|
-
`
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
906
|
+
{ title: subscriptionLabel, description: "Route Claude roles through your personal Claude subscription instead of the gateway" },
|
|
907
|
+
{ title: `Auto-update on startup: ${settings.autoUpdate ? "ON" : "OFF"}`, description: "Refresh the available model list automatically each time pi starts" },
|
|
908
|
+
{ title: `Cache period: ${settings.cacheTTLDays} ${settings.cacheTTLDays === 1 ? "day" : "days"}`, description: "How long the fetched model list is reused before it is refreshed" },
|
|
909
|
+
);
|
|
910
|
+
if (settings.subscription) {
|
|
911
|
+
options.push({ title: `Rate-limit switch-back check: every ${settings.switchBackIntervalMinutes} min`, description: "How often to retry your subscription after it was rate-limited and traffic fell back to the gateway" });
|
|
912
|
+
}
|
|
913
|
+
options.push(
|
|
914
|
+
{ title: "Update now", description: "Fetch the latest model list from Flant right away" },
|
|
915
|
+
{ title: "Current status", description: "Show the current Flant configuration, providers, and model counts" },
|
|
861
916
|
);
|
|
862
917
|
}
|
|
863
918
|
options.push({ title: "Back", description: "Return to the previous menu" });
|
|
@@ -920,6 +975,22 @@ async function showFlantInfraMenu(orchestrator: Orchestrator, ctx: any): Promise
|
|
|
920
975
|
continue;
|
|
921
976
|
}
|
|
922
977
|
|
|
978
|
+
if (choice.startsWith("Rate-limit switch-back check:")) {
|
|
979
|
+
const selected = await selectOption(ctx, "Switch-back check interval", [
|
|
980
|
+
{ title: "15 min", description: "Probe the subscription limit every 15 minutes" },
|
|
981
|
+
{ title: "30 min", description: "Default — probe every 30 minutes" },
|
|
982
|
+
{ title: "60 min", description: "Probe hourly" },
|
|
983
|
+
{ title: "120 min", description: "Probe every two hours" },
|
|
984
|
+
{ title: "Back", description: "Return to the previous menu" },
|
|
985
|
+
]);
|
|
986
|
+
if (!selected || selected === "Back") continue;
|
|
987
|
+
const mins = Number(selected.split(" ")[0]);
|
|
988
|
+
if (!Number.isFinite(mins) || mins <= 0) continue;
|
|
989
|
+
saveFlantSettings({ ...settings, switchBackIntervalMinutes: mins });
|
|
990
|
+
ctx.ui.notify(`Switch-back check interval set to ${mins} min.`, "info");
|
|
991
|
+
continue;
|
|
992
|
+
}
|
|
993
|
+
|
|
923
994
|
if (choice.startsWith("Cache period:")) {
|
|
924
995
|
const selected = await selectOption(ctx, "Cache period", [
|
|
925
996
|
{ title: "1 day", description: "Refresh model metadata daily" },
|
|
@@ -1199,6 +1270,9 @@ const SUBAGENT_ROLES: Array<{ role: AgentRole; label: string; description: strin
|
|
|
1199
1270
|
{ role: "explore", label: "Explore", description: "agents.subagents.simple.explore" },
|
|
1200
1271
|
{ role: "librarian", label: "Librarian", description: "agents.subagents.simple.librarian" },
|
|
1201
1272
|
{ role: "task", label: "Task", description: "agents.subagents.simple.task" },
|
|
1273
|
+
{ role: "advisor", label: "Advisor", description: "agents.subagents.simple.advisor" },
|
|
1274
|
+
{ role: "deep-debugger", label: "Deep debugger", description: "agents.subagents.simple.deep-debugger" },
|
|
1275
|
+
{ role: "reviewer", label: "Reviewer", description: "agents.subagents.simple.reviewer" },
|
|
1202
1276
|
];
|
|
1203
1277
|
|
|
1204
1278
|
const PRESET_GROUP_ITEMS: Array<{ group: PresetGroup; label: string }> = [
|
|
@@ -1924,7 +1998,7 @@ async function showPresetVariantEditor(
|
|
|
1924
1998
|
continue;
|
|
1925
1999
|
}
|
|
1926
2000
|
if (choice.startsWith("Enabled:")) {
|
|
1927
|
-
await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"]);
|
|
2001
|
+
await showBooleanSetting(orchestrator, ctx, "Enabled", [...variantPath, "enabled"], "Make this agent available for use", "Disable this agent so it is not used");
|
|
1928
2002
|
continue;
|
|
1929
2003
|
}
|
|
1930
2004
|
const confirm = await selectOption(ctx, "Confirm delete?", [
|
|
@@ -2065,8 +2139,8 @@ async function showPresetEnabledSetting(
|
|
|
2065
2139
|
const yesTitle = withTags("Yes", formatSourceTags(true, info));
|
|
2066
2140
|
const noTitle = withTags("No", formatSourceTags(false, info));
|
|
2067
2141
|
const choice = await selectOption(ctx, "Enabled", [
|
|
2068
|
-
yesTitle,
|
|
2069
|
-
noTitle,
|
|
2142
|
+
{ title: yesTitle, description: "Make this preset available for selection" },
|
|
2143
|
+
{ title: noTitle, description: "Hide this preset so it can no longer be selected" },
|
|
2070
2144
|
opt("Back", "Return to the previous menu"),
|
|
2071
2145
|
]);
|
|
2072
2146
|
if (!choice || choice === "Back") return;
|
|
@@ -2247,7 +2321,7 @@ async function showAfterEditCommands(orchestrator: Orchestrator, ctx: any): Prom
|
|
|
2247
2321
|
continue;
|
|
2248
2322
|
}
|
|
2249
2323
|
if (commandChoice.startsWith("Enabled:")) {
|
|
2250
|
-
await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
|
|
2324
|
+
await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
|
|
2251
2325
|
continue;
|
|
2252
2326
|
}
|
|
2253
2327
|
if (commandChoice === "Triggers") {
|
|
@@ -2364,7 +2438,7 @@ async function showAfterImplementCommands(orchestrator: Orchestrator, ctx: any):
|
|
|
2364
2438
|
continue;
|
|
2365
2439
|
}
|
|
2366
2440
|
if (commandChoice.startsWith("Enabled:")) {
|
|
2367
|
-
await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"]);
|
|
2441
|
+
await showBooleanSetting(orchestrator, ctx, "Enabled", [...commandPath, "enabled"], "Run this command when its triggers fire", "Keep this command configured but stop running it");
|
|
2368
2442
|
continue;
|
|
2369
2443
|
}
|
|
2370
2444
|
const confirm = await selectOption(ctx, "Confirm delete?", [
|
|
@@ -2487,14 +2561,16 @@ async function showBooleanSetting(
|
|
|
2487
2561
|
ctx: any,
|
|
2488
2562
|
title: string,
|
|
2489
2563
|
keyPath: string[],
|
|
2564
|
+
yesDescription = "Turn this setting on",
|
|
2565
|
+
noDescription = "Turn this setting off",
|
|
2490
2566
|
): Promise<void> {
|
|
2491
2567
|
while (true) {
|
|
2492
2568
|
const info = getConfigSourceInfo(orchestrator, keyPath);
|
|
2493
2569
|
const yesTitle = withTags("Yes", formatSourceTags(true, info));
|
|
2494
2570
|
const noTitle = withTags("No", formatSourceTags(false, info));
|
|
2495
2571
|
const choice = await selectOption(ctx, title, [
|
|
2496
|
-
yesTitle,
|
|
2497
|
-
noTitle,
|
|
2572
|
+
{ title: yesTitle, description: yesDescription },
|
|
2573
|
+
{ title: noTitle, description: noDescription },
|
|
2498
2574
|
...buildResetOptions(orchestrator, keyPath),
|
|
2499
2575
|
opt("Back", "Return to the previous menu"),
|
|
2500
2576
|
]);
|
|
@@ -2516,14 +2592,16 @@ async function showInvertedBooleanSetting(
|
|
|
2516
2592
|
ctx: any,
|
|
2517
2593
|
title: string,
|
|
2518
2594
|
keyPath: string[],
|
|
2595
|
+
yesDescription = "Turn this setting on",
|
|
2596
|
+
noDescription = "Turn this setting off",
|
|
2519
2597
|
): Promise<void> {
|
|
2520
2598
|
while (true) {
|
|
2521
2599
|
const info = getConfigSourceInfo(orchestrator, keyPath);
|
|
2522
2600
|
const yesTitle = withTags("Yes", formatSourceTags(false, info));
|
|
2523
2601
|
const noTitle = withTags("No", formatSourceTags(true, info));
|
|
2524
2602
|
const choice = await selectOption(ctx, title, [
|
|
2525
|
-
yesTitle,
|
|
2526
|
-
noTitle,
|
|
2603
|
+
{ title: yesTitle, description: yesDescription },
|
|
2604
|
+
{ title: noTitle, description: noDescription },
|
|
2527
2605
|
...buildResetOptions(orchestrator, keyPath),
|
|
2528
2606
|
opt("Back", "Return to the previous menu"),
|
|
2529
2607
|
]);
|
|
@@ -2541,15 +2619,15 @@ async function showInvertedBooleanSetting(
|
|
|
2541
2619
|
}
|
|
2542
2620
|
|
|
2543
2621
|
async function showLogLevelSetting(orchestrator: Orchestrator, ctx: any): Promise<void> {
|
|
2544
|
-
const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string }> = [
|
|
2545
|
-
{ value: "debug", label: "Debug" },
|
|
2546
|
-
{ value: "info", label: "Info" },
|
|
2547
|
-
{ value: "warn", label: "Warning" },
|
|
2548
|
-
{ value: "error", label: "Error" },
|
|
2622
|
+
const levels: Array<{ value: PiPiConfig["general"]["logLevel"]; label: string; description: string }> = [
|
|
2623
|
+
{ value: "debug", label: "Debug", description: "Log everything, including detailed diagnostics for troubleshooting" },
|
|
2624
|
+
{ value: "info", label: "Info", description: "Log normal activity plus warnings and errors" },
|
|
2625
|
+
{ value: "warn", label: "Warning", description: "Log only warnings and errors" },
|
|
2626
|
+
{ value: "error", label: "Error", description: "Log only errors" },
|
|
2549
2627
|
];
|
|
2550
2628
|
while (true) {
|
|
2551
2629
|
const info = getConfigSourceInfo(orchestrator, ["general", "logLevel"]);
|
|
2552
|
-
const options: OptionInput[] = levels.map((entry) => withTags(entry.label, formatSourceTags(entry.value, info)));
|
|
2630
|
+
const options: OptionInput[] = levels.map((entry) => ({ title: withTags(entry.label, formatSourceTags(entry.value, info)), description: entry.description }));
|
|
2553
2631
|
options.push(...buildResetOptions(orchestrator, ["general", "logLevel"]));
|
|
2554
2632
|
options.push(opt("Back", "Return to the previous menu"));
|
|
2555
2633
|
const choice = await selectOption(ctx, "Log level", options);
|
|
@@ -2579,11 +2657,11 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
|
|
|
2579
2657
|
]);
|
|
2580
2658
|
if (!choice || choice === "Back") return BACK;
|
|
2581
2659
|
if (choice.startsWith("Commit automatically:")) {
|
|
2582
|
-
await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"]);
|
|
2660
|
+
await showBooleanSetting(orchestrator, ctx, "Commit automatically", ["general", "autoCommit"], "Commit changes automatically as work progresses", "Leave committing to you");
|
|
2583
2661
|
continue;
|
|
2584
2662
|
}
|
|
2585
2663
|
if (choice.startsWith("Ignore configs from other repos:")) {
|
|
2586
|
-
await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"]);
|
|
2664
|
+
await showInvertedBooleanSetting(orchestrator, ctx, "Ignore configs from other repos", ["general", "loadExtraRepoConfigs"], "Use only the root repo config and ignore configs from other registered repos", "Also load configs from the other registered repos");
|
|
2587
2665
|
continue;
|
|
2588
2666
|
}
|
|
2589
2667
|
if (choice.startsWith("Log level:")) {
|
|
@@ -2591,7 +2669,7 @@ async function showGeneralSettings(orchestrator: Orchestrator, ctx: any): Promis
|
|
|
2591
2669
|
continue;
|
|
2592
2670
|
}
|
|
2593
2671
|
if (choice.startsWith("Tracing:")) {
|
|
2594
|
-
await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"]);
|
|
2672
|
+
await showBooleanSetting(orchestrator, ctx, "Tracing", ["general", "tracing"], "Capture full session traces to .pp/logs/traces/", "Do not record session traces");
|
|
2595
2673
|
continue;
|
|
2596
2674
|
}
|
|
2597
2675
|
await showFlantInfraMenu(orchestrator, ctx);
|
|
@@ -3056,30 +3134,24 @@ async function openCodeReviewInPlannotator(
|
|
|
3056
3134
|
if (payload.diffType) requestPayload.diffType = payload.diffType;
|
|
3057
3135
|
if (payload.defaultBranch) requestPayload.defaultBranch = payload.defaultBranch;
|
|
3058
3136
|
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
? response.result.feedback
|
|
3078
|
-
: undefined;
|
|
3079
|
-
resolve({ status: approved ? "approved" : "needs_changes", feedback });
|
|
3080
|
-
},
|
|
3081
|
-
});
|
|
3082
|
-
});
|
|
3137
|
+
const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", requestPayload);
|
|
3138
|
+
if (!opened) {
|
|
3139
|
+
return { status: "error", error: "Plannotator is not available." };
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
let result: { approved: boolean; feedback?: string; error?: string };
|
|
3143
|
+
try {
|
|
3144
|
+
result = await waitForPlannotatorResult(orchestrator, reviewId, null);
|
|
3145
|
+
} catch (err) {
|
|
3146
|
+
return { status: "error", error: err instanceof Error ? err.message : "Plannotator review failed." };
|
|
3147
|
+
}
|
|
3148
|
+
if (result.error) {
|
|
3149
|
+
return { status: "error", error: result.error };
|
|
3150
|
+
}
|
|
3151
|
+
const feedback = typeof result.feedback === "string" && result.feedback.trim().length > 0
|
|
3152
|
+
? result.feedback
|
|
3153
|
+
: undefined;
|
|
3154
|
+
return { status: result.approved ? "approved" : "needs_changes", feedback };
|
|
3083
3155
|
}
|
|
3084
3156
|
|
|
3085
3157
|
async function startReviewTask(
|
|
@@ -3435,7 +3507,7 @@ export async function showActiveTaskMenu(
|
|
|
3435
3507
|
summary: string,
|
|
3436
3508
|
mode: MenuMode = "command",
|
|
3437
3509
|
): Promise<string> {
|
|
3438
|
-
const continueMessage = "User wants to continue. Run /pp when ready to advance.";
|
|
3510
|
+
const continueMessage = advanceBanner("[PI-PI] User wants to continue. Run /pp when ready to advance.");
|
|
3439
3511
|
|
|
3440
3512
|
while (true) {
|
|
3441
3513
|
if (!orchestrator.active) return "No active task.";
|
|
@@ -3575,7 +3647,9 @@ export async function showActiveTaskMenu(
|
|
|
3575
3647
|
if (hasPlannotator) {
|
|
3576
3648
|
reviewOptions.push(opt("Review in Plannotator", phase === "plan" ? "Open plan review in browser" : "Open code diff review in browser"));
|
|
3577
3649
|
}
|
|
3578
|
-
reviewOptions.push(opt("Review on my own",
|
|
3650
|
+
reviewOptions.push(opt("Review on my own", phase === "implement"
|
|
3651
|
+
? "Review in your editor: mark spots with AI_REVIEW: comments, then have the agent address them"
|
|
3652
|
+
: "Review manually, then continue"));
|
|
3579
3653
|
reviewOptions.push(opt("Back", "Return to the previous menu"));
|
|
3580
3654
|
|
|
3581
3655
|
const reviewChoice = await selectOption(ctx, "Review", reviewOptions);
|
|
@@ -3591,8 +3665,23 @@ export async function showActiveTaskMenu(
|
|
|
3591
3665
|
if (handled.continueLoop) continue;
|
|
3592
3666
|
return handled.text ?? text;
|
|
3593
3667
|
}
|
|
3594
|
-
const
|
|
3668
|
+
const allRepos = getRegisteredRepos(orchestrator);
|
|
3595
3669
|
const summaries: string[] = [];
|
|
3670
|
+
const repos: RepoInfo[] = [];
|
|
3671
|
+
for (const repo of allRepos) {
|
|
3672
|
+
const base = await detectDefaultBranch(orchestrator, allRepos, repo.path);
|
|
3673
|
+
const { changed, error } = await repoHasReviewableChanges(orchestrator, repo, base);
|
|
3674
|
+
if (error) {
|
|
3675
|
+
summaries.push(`${formatRepoLabel(repo)}: ERROR — ${error}`);
|
|
3676
|
+
repos.push(repo);
|
|
3677
|
+
} else if (changed) {
|
|
3678
|
+
repos.push(repo);
|
|
3679
|
+
}
|
|
3680
|
+
}
|
|
3681
|
+
if (repos.length === 0) {
|
|
3682
|
+
ctx.ui.notify("No registered repositories have changes to review.", "info");
|
|
3683
|
+
continue;
|
|
3684
|
+
}
|
|
3596
3685
|
let stopReviewing = false;
|
|
3597
3686
|
|
|
3598
3687
|
for (const repo of repos) {
|
|
@@ -3661,10 +3750,35 @@ export async function showActiveTaskMenu(
|
|
|
3661
3750
|
if (reviewChoice === "Review on my own") {
|
|
3662
3751
|
if (phase === "plan") {
|
|
3663
3752
|
setStep(orchestrator, "synthesize");
|
|
3664
|
-
|
|
3753
|
+
return continueMessage;
|
|
3754
|
+
}
|
|
3755
|
+
if (phase !== "implement") {
|
|
3665
3756
|
setStep(orchestrator, "llm_work");
|
|
3757
|
+
return continueMessage;
|
|
3666
3758
|
}
|
|
3667
|
-
|
|
3759
|
+
|
|
3760
|
+
const gate = await selectOption(ctx, "Editor review", [
|
|
3761
|
+
opt("Done", "I've added AI_REVIEW: markers and saved my files"),
|
|
3762
|
+
opt("Skip markers", "Continue without the marker workflow"),
|
|
3763
|
+
opt("Back", "Return to the review menu"),
|
|
3764
|
+
]);
|
|
3765
|
+
if (!gate || gate === "Back") continue;
|
|
3766
|
+
setStep(orchestrator, "llm_work");
|
|
3767
|
+
if (gate === "Skip markers") {
|
|
3768
|
+
return continueMessage;
|
|
3769
|
+
}
|
|
3770
|
+
return advanceBanner(
|
|
3771
|
+
"[PI-PI] The user reviewed the changes in their editor and left inline `AI_REVIEW:` markers " +
|
|
3772
|
+
"(inside each file's native comment syntax, e.g. `// AI_REVIEW: ...`, `# AI_REVIEW: ...`, " +
|
|
3773
|
+
"`<!-- AI_REVIEW: ... -->`).\n\n" +
|
|
3774
|
+
"For each registered repo, enumerate the CHANGED files only — union of `git diff --name-only <base>...HEAD`, " +
|
|
3775
|
+
"`git diff --name-only`, `git diff --cached --name-only`, and untracked-non-ignored files from `git status --porcelain` " +
|
|
3776
|
+
"(base = each repo's configured base branch) — and search WITHIN those files for `AI_REVIEW:`. Do NOT grep the whole " +
|
|
3777
|
+
"worktree (avoid vendored/generated/node_modules and historical markers).\n\n" +
|
|
3778
|
+
"For each marker: address the request, then remove that marker in the SAME edit. After a pass, re-scan the changed " +
|
|
3779
|
+
"files and repeat until no `AI_REVIEW:` markers remain. Then verify your work and report what you changed per marker. " +
|
|
3780
|
+
"When complete, call pp_phase_complete.",
|
|
3781
|
+
);
|
|
3668
3782
|
}
|
|
3669
3783
|
|
|
3670
3784
|
const reviewPreset = await pickPreset(ctx, orchestrator, getReviewPresetGroup(phase), "Review preset");
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
|
|
6
|
+
vi.mock("./log.js", () => ({
|
|
7
|
+
getLogger: () => ({ debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
import { registerStateFileTools } from "./pp-state-tools.js";
|
|
11
|
+
|
|
12
|
+
const tempDirs: string[] = [];
|
|
13
|
+
|
|
14
|
+
function makeTempCwd(): string {
|
|
15
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-pi-state-tools-"));
|
|
16
|
+
tempDirs.push(dir);
|
|
17
|
+
return dir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
for (const dir of tempDirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
interface RegisteredTool {
|
|
25
|
+
name: string;
|
|
26
|
+
execute: (id: string, params: any) => Promise<any>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function setup() {
|
|
30
|
+
const cwd = makeTempCwd();
|
|
31
|
+
const taskDir = join(cwd, ".pp", "state", "brainstorm", "abc_brainstorm");
|
|
32
|
+
mkdirSync(join(taskDir, "artifacts"), { recursive: true });
|
|
33
|
+
mkdirSync(join(taskDir, "plans"), { recursive: true });
|
|
34
|
+
|
|
35
|
+
const tools = new Map<string, RegisteredTool>();
|
|
36
|
+
const orchestrator: any = {
|
|
37
|
+
cwd,
|
|
38
|
+
active: { dir: taskDir },
|
|
39
|
+
pi: { registerTool: (t: RegisteredTool) => tools.set(t.name, t) },
|
|
40
|
+
};
|
|
41
|
+
registerStateFileTools(orchestrator);
|
|
42
|
+
return { cwd, taskDir, orchestrator, tools };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function textOf(result: any): string {
|
|
46
|
+
return (result.content ?? []).map((c: any) => c.text).join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const VALID_RESEARCH = [
|
|
50
|
+
"## Affected Code",
|
|
51
|
+
"- foo.ts:bar — does a thing",
|
|
52
|
+
"## Architecture Context",
|
|
53
|
+
"- how it connects",
|
|
54
|
+
"## Constraints & Edge Cases",
|
|
55
|
+
"- MUST: keep it working",
|
|
56
|
+
].join("\n");
|
|
57
|
+
|
|
58
|
+
describe("pp_write_state_file", () => {
|
|
59
|
+
it("creates a valid RESEARCH.md and returns compact output (no diff)", async () => {
|
|
60
|
+
const { taskDir, tools } = setup();
|
|
61
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
|
|
62
|
+
expect(res.isError).toBeFalsy();
|
|
63
|
+
expect(res.details).toEqual({});
|
|
64
|
+
expect(textOf(res)).toMatch(/^Created RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
|
|
65
|
+
expect(textOf(res)).not.toContain("Affected Code");
|
|
66
|
+
expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toBe(VALID_RESEARCH);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("reports 'Updated' on overwrite", async () => {
|
|
70
|
+
const { tools } = setup();
|
|
71
|
+
await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: VALID_RESEARCH });
|
|
72
|
+
const res = await tools.get("pp_write_state_file")!.execute("2", { path: "RESEARCH.md", content: VALID_RESEARCH + "\n- more" });
|
|
73
|
+
expect(textOf(res)).toMatch(/^Updated RESEARCH\.md/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects invalid RESEARCH.md structure without writing", async () => {
|
|
77
|
+
const { taskDir, tools } = setup();
|
|
78
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "RESEARCH.md", content: "## Wrong Section\nx" });
|
|
79
|
+
expect(res.isError).toBe(true);
|
|
80
|
+
expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
|
|
81
|
+
expect(() => readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toThrow();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("accepts an artifact starting with a top-level heading", async () => {
|
|
85
|
+
const { tools } = setup();
|
|
86
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "artifacts/design.md", content: "# Design\n\nbody" });
|
|
87
|
+
expect(res.isError).toBeFalsy();
|
|
88
|
+
expect(textOf(res)).toMatch(/artifacts\/design\.md/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects a path escaping the task dir", async () => {
|
|
92
|
+
const { tools } = setup();
|
|
93
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "../../../evil.md", content: "# x" });
|
|
94
|
+
expect(res.isError).toBe(true);
|
|
95
|
+
expect(textOf(res)).toMatch(/escapes the active task directory/);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("rejects a non-.md file", async () => {
|
|
99
|
+
const { tools } = setup();
|
|
100
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "notes.txt", content: "x" });
|
|
101
|
+
expect(res.isError).toBe(true);
|
|
102
|
+
expect(textOf(res)).toMatch(/Only \.md/);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects managed review outputs and non-synthesized plans", async () => {
|
|
106
|
+
const { tools } = setup();
|
|
107
|
+
for (const p of [
|
|
108
|
+
"code-reviews/1_gpt.md",
|
|
109
|
+
"brainstorm-reviews/1_gemini.md",
|
|
110
|
+
"plan-reviews/1_opus.md",
|
|
111
|
+
"plans/1_gpt.md",
|
|
112
|
+
"scratch.md",
|
|
113
|
+
"artifacts/nested/deep.md",
|
|
114
|
+
]) {
|
|
115
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: p, content: "# x" });
|
|
116
|
+
expect(res.isError, `expected ${p} to be rejected`).toBe(true);
|
|
117
|
+
expect(textOf(res)).toMatch(/Not an editable state file/);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("allows the synthesized plan", async () => {
|
|
122
|
+
const { tools } = setup();
|
|
123
|
+
const plan = [
|
|
124
|
+
"# Plan",
|
|
125
|
+
"## Scope",
|
|
126
|
+
"Do the thing.",
|
|
127
|
+
"## Checklist",
|
|
128
|
+
"- [ ] item — Done when: it works",
|
|
129
|
+
].join("\n");
|
|
130
|
+
const res = await tools.get("pp_write_state_file")!.execute("1", { path: "plans/123_synthesized.md", content: plan });
|
|
131
|
+
expect(res.isError).toBeFalsy();
|
|
132
|
+
expect(textOf(res)).toMatch(/plans\/123_synthesized\.md/);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("pp_edit_state_file", () => {
|
|
137
|
+
beforeEach(() => {});
|
|
138
|
+
|
|
139
|
+
it("replaces a unique span and returns compact output", async () => {
|
|
140
|
+
const { taskDir, tools } = setup();
|
|
141
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
142
|
+
const res = await tools.get("pp_edit_state_file")!.execute("1", {
|
|
143
|
+
path: "RESEARCH.md",
|
|
144
|
+
oldText: "does a thing",
|
|
145
|
+
newText: "does a different thing",
|
|
146
|
+
});
|
|
147
|
+
expect(res.isError).toBeFalsy();
|
|
148
|
+
expect(textOf(res)).toMatch(/^Updated RESEARCH\.md \(\+\d+\/-\d+ lines\)$/);
|
|
149
|
+
expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("does a different thing");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("errors when oldText is not found", async () => {
|
|
153
|
+
const { taskDir, tools } = setup();
|
|
154
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
155
|
+
const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "nope", newText: "x" });
|
|
156
|
+
expect(res.isError).toBe(true);
|
|
157
|
+
expect(textOf(res)).toMatch(/not found/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("errors on ambiguous oldText unless replaceAll", async () => {
|
|
161
|
+
const { taskDir, tools } = setup();
|
|
162
|
+
// Use an artifact (an allowed, unstructured-body state file) with duplicate spans.
|
|
163
|
+
writeFileSync(join(taskDir, "artifacts", "dup.md"), "# Dup\nx\nx\n", "utf-8");
|
|
164
|
+
const ambiguous = await tools.get("pp_edit_state_file")!.execute("1", { path: "artifacts/dup.md", oldText: "x", newText: "y" });
|
|
165
|
+
expect(ambiguous.isError).toBe(true);
|
|
166
|
+
expect(textOf(ambiguous)).toMatch(/matches 2 locations/);
|
|
167
|
+
|
|
168
|
+
const all = await tools.get("pp_edit_state_file")!.execute("2", { path: "artifacts/dup.md", oldText: "x", newText: "y", replaceAll: true });
|
|
169
|
+
expect(all.isError).toBeFalsy();
|
|
170
|
+
expect(readFileSync(join(taskDir, "artifacts", "dup.md"), "utf-8")).toBe("# Dup\ny\ny\n");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("errors when the file does not exist", async () => {
|
|
174
|
+
const { tools } = setup();
|
|
175
|
+
const res = await tools.get("pp_edit_state_file")!.execute("1", { path: "RESEARCH.md", oldText: "a", newText: "b" });
|
|
176
|
+
expect(res.isError).toBe(true);
|
|
177
|
+
expect(textOf(res)).toMatch(/does not exist/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("rejects an edit that breaks structure without writing", async () => {
|
|
181
|
+
const { taskDir, tools } = setup();
|
|
182
|
+
writeFileSync(join(taskDir, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
|
|
183
|
+
const res = await tools.get("pp_edit_state_file")!.execute("1", {
|
|
184
|
+
path: "RESEARCH.md",
|
|
185
|
+
oldText: "## Affected Code",
|
|
186
|
+
newText: "## Renamed Section",
|
|
187
|
+
});
|
|
188
|
+
expect(res.isError).toBe(true);
|
|
189
|
+
expect(textOf(res)).toContain("RESEARCH.md structure is invalid");
|
|
190
|
+
expect(readFileSync(join(taskDir, "RESEARCH.md"), "utf-8")).toContain("## Affected Code");
|
|
191
|
+
});
|
|
192
|
+
});
|