@ilya-lesikov/pi-pi 0.8.0 → 0.9.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/extensions/orchestrator/agents/code-reviewer.ts +21 -5
- package/extensions/orchestrator/agents/constraints.test.ts +39 -1
- package/extensions/orchestrator/agents/constraints.ts +63 -2
- package/extensions/orchestrator/agents/tool-routing.ts +30 -14
- package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
- package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
- package/extensions/orchestrator/command-handlers.test.ts +47 -0
- package/extensions/orchestrator/command-handlers.ts +43 -27
- package/extensions/orchestrator/config.test.ts +40 -1
- package/extensions/orchestrator/config.ts +13 -0
- package/extensions/orchestrator/context.ts +16 -0
- package/extensions/orchestrator/custom-footer.test.ts +91 -0
- package/extensions/orchestrator/custom-footer.ts +20 -20
- package/extensions/orchestrator/event-handlers.test.ts +315 -1
- package/extensions/orchestrator/event-handlers.ts +397 -201
- package/extensions/orchestrator/integration.test.ts +305 -9
- package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
- package/extensions/orchestrator/orchestrator.ts +46 -58
- package/extensions/orchestrator/phases/brainstorm.ts +11 -7
- package/extensions/orchestrator/phases/machine.test.ts +36 -0
- package/extensions/orchestrator/phases/machine.ts +11 -1
- package/extensions/orchestrator/phases/review-task.test.ts +20 -0
- package/extensions/orchestrator/phases/review-task.ts +44 -16
- package/extensions/orchestrator/phases/review.test.ts +26 -0
- package/extensions/orchestrator/phases/review.ts +40 -3
- package/extensions/orchestrator/pp-menu.test.ts +207 -1
- package/extensions/orchestrator/pp-menu.ts +376 -378
- package/extensions/orchestrator/state.test.ts +9 -0
- package/extensions/orchestrator/state.ts +15 -0
- package/extensions/orchestrator/transition-controller.test.ts +100 -0
- package/extensions/orchestrator/transition-controller.ts +61 -3
- package/package.json +1 -1
- package/AGENTS.md +0 -28
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { tmpdir } from "os";
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
3
2
|
import { resolve, basename, join, relative, dirname, isAbsolute, sep } from "path";
|
|
4
3
|
import { validateUserRequest, validateResearch, validateArtifact } from "./validate-artifacts.js";
|
|
5
4
|
import { Type } from "@sinclair/typebox";
|
|
@@ -25,8 +24,9 @@ import { registerCbmTools } from "./cbm.js";
|
|
|
25
24
|
import { registerExaTools } from "./exa.js";
|
|
26
25
|
import { registerAstSearchTool } from "./ast-search.js";
|
|
27
26
|
import { SUBAGENT_SESSION_KEY } from "./index.js";
|
|
28
|
-
import { registerCommandHandlers } from "./command-handlers.js";
|
|
27
|
+
import { registerCommandHandlers, runAfterImplementForActive } from "./command-handlers.js";
|
|
29
28
|
import { registerStateFileTools } from "./pp-state-tools.js";
|
|
29
|
+
import { isAiCommentOnlyChange } from "./ai-comment-cleanup.js";
|
|
30
30
|
import { handleMainRateLimit, handleSubagentRateLimit, isRateLimitError, isSdkRetryableError } from "./rate-limit-fallback.js";
|
|
31
31
|
import { setExtensionOnlyMode, unregisterAgentDefinitions } from "./agents/registry.js";
|
|
32
32
|
import { resolveModel, getModelInfo, updateRegistryFromAvailableModels } from "./model-registry.js";
|
|
@@ -34,11 +34,11 @@ import { spawnPlanners, spawnPlanReviewers } from "./phases/planning.js";
|
|
|
34
34
|
import { spawnCodeReviewers } from "./phases/review.js";
|
|
35
35
|
import { spawnBrainstormReviewers } from "./phases/brainstorm.js";
|
|
36
36
|
import { reviewPassUnanimousApprove } from "./phases/verdict.js";
|
|
37
|
-
import { validateExitCriteria } from "./phases/machine.js";
|
|
37
|
+
import { nextPhase, validateExitCriteria } from "./phases/machine.js";
|
|
38
38
|
import { openPlannotator, waitForPlannotatorResult, cancelPendingPlannotatorWait } from "./plannotator.js";
|
|
39
39
|
import { advanceBanner } from "./messages.js";
|
|
40
40
|
import { Orchestrator, type ActiveTask } from "./orchestrator.js";
|
|
41
|
-
import { createCustomFooter, setFooterContext, setFooterTracker } from "./custom-footer.js";
|
|
41
|
+
import { createCustomFooter, setFooterContext, setFooterTracker, setFooterOrchestrator } from "./custom-footer.js";
|
|
42
42
|
import { createUsageTracker, dumpUsageSummary, loadUsageSummary, isSubscriptionRouted, type UsageTracker } from "./usage-tracker.js";
|
|
43
43
|
import { askUser, isCancel } from "../../3p/pi-ask-user/index.js";
|
|
44
44
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
@@ -121,15 +121,21 @@ export async function detectDefaultBranch(orchestrator: Orchestrator, repos: Rep
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
export async function selectOption(ctx: any, question: string, options: string[]): Promise<string | undefined> {
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
124
|
+
const orchestrator = Orchestrator.current;
|
|
125
|
+
if (orchestrator) orchestrator.interactivePromptOpen = true;
|
|
126
|
+
try {
|
|
127
|
+
const result = await askUser(ctx, {
|
|
128
|
+
question,
|
|
129
|
+
options,
|
|
130
|
+
allowFreeform: false,
|
|
131
|
+
allowComment: false,
|
|
132
|
+
allowMultiple: false,
|
|
133
|
+
});
|
|
134
|
+
if (!result || isCancel(result) || result.kind !== "selection") return undefined;
|
|
135
|
+
return result.selections[0];
|
|
136
|
+
} finally {
|
|
137
|
+
if (orchestrator) orchestrator.interactivePromptOpen = false;
|
|
138
|
+
}
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
function resolveReviewers(
|
|
@@ -318,7 +324,9 @@ export async function enterReviewCycle(
|
|
|
318
324
|
orchestrator.active.state.reviewCycle = null;
|
|
319
325
|
orchestrator.active.state.step = "llm_work";
|
|
320
326
|
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
321
|
-
|
|
327
|
+
// Implement/review-phase Plannotator is driven entirely by the menu's per-repo
|
|
328
|
+
// interleaved loop (pp-menu runPlannotatorCursor); it never enters this path.
|
|
329
|
+
return "Plannotator code review runs per-repo from the /pp Review menu. Open /pp → Review → Review in Plannotator.";
|
|
322
330
|
}
|
|
323
331
|
|
|
324
332
|
const phase = orchestrator.active.state.phase;
|
|
@@ -411,6 +419,14 @@ export async function stopTask(orchestrator: Orchestrator): Promise<string> {
|
|
|
411
419
|
return `Task "${desc}" stopped. Use /pp → Resume to continue.`;
|
|
412
420
|
}
|
|
413
421
|
|
|
422
|
+
// A review is LIVE when its cycle exists and reviewers are still being awaited.
|
|
423
|
+
// Guards the menu/autonomous callers against re-spawning reviewers on top of a
|
|
424
|
+
// running cycle (the "6 agents" double-spawn). NOT "a cycle object exists": a
|
|
425
|
+
// completed cycle is finalized (and nulled) legitimately before the next pass.
|
|
426
|
+
export function isReviewCycleLive(task: ActiveTask): boolean {
|
|
427
|
+
return !!task.state.reviewCycle && task.state.reviewCycle.step === "await_reviewers";
|
|
428
|
+
}
|
|
429
|
+
|
|
414
430
|
export function finalizeReviewCycle(task: ActiveTask): void {
|
|
415
431
|
if (!task.state.reviewCycle) return;
|
|
416
432
|
const kind = task.state.reviewCycle.kind;
|
|
@@ -448,14 +464,176 @@ export function finalizeReviewCycleAutonomous(task: ActiveTask): void {
|
|
|
448
464
|
saveTask(task.dir, task.state);
|
|
449
465
|
}
|
|
450
466
|
|
|
467
|
+
export function registerOrchestratorToolsForTest(orchestrator: Orchestrator): void {
|
|
468
|
+
registerOrchestratorTools(orchestrator);
|
|
469
|
+
}
|
|
470
|
+
|
|
451
471
|
function registerOrchestratorTools(orchestrator: Orchestrator): void {
|
|
452
472
|
registerRepoTool(orchestrator);
|
|
473
|
+
registerCheckoutPrHeadTool(orchestrator);
|
|
453
474
|
registerPhaseCompleteTool(orchestrator);
|
|
454
475
|
registerCommitTool(orchestrator);
|
|
455
|
-
registerSpecifyReviewsTool(orchestrator);
|
|
456
476
|
registerStateFileTools(orchestrator);
|
|
457
477
|
}
|
|
458
478
|
|
|
479
|
+
// Extension-side PR-head checkout for the review phase. Performed here (never via the
|
|
480
|
+
// agent prompt) so REVIEW_READONLY_CONSTRAINT stays truthful — the agent never runs
|
|
481
|
+
// `git checkout` itself. Lands the repo on its PR head under strict safety rules (no stash,
|
|
482
|
+
// no --force, no branch switch, no worktree, no detached checkout): when the tree is clean
|
|
483
|
+
// and on the PR head's branch but merely behind, it fast-forwards the branch to the head
|
|
484
|
+
// (`git merge --ff-only`, which never detaches, never rewrites, and refuses a non-ff move).
|
|
485
|
+
// A dirty tree, a different branch, or a non-fast-forwardable divergence all HALT with a
|
|
486
|
+
// message for the user. Checking HEAD's SHA first makes this re-entrant and detached-safe.
|
|
487
|
+
export async function checkoutPrHead(
|
|
488
|
+
orchestrator: Orchestrator,
|
|
489
|
+
repoPath: string,
|
|
490
|
+
headRefName: string,
|
|
491
|
+
headRefOid: string,
|
|
492
|
+
): Promise<{ ok: boolean; message: string }> {
|
|
493
|
+
const pi = orchestrator.pi;
|
|
494
|
+
const name = (headRefName ?? "").trim();
|
|
495
|
+
const oid = (headRefOid ?? "").trim();
|
|
496
|
+
if (!name && !oid) {
|
|
497
|
+
return { ok: true, message: `No PR head provided for ${repoPath}; skipping (non-PR review scope).` };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const run = (args: string[]) => pi.exec("git", args, { cwd: repoPath, timeout: 15000 });
|
|
501
|
+
const target = `${name || oid}${name && oid ? ` @ ${oid}` : ""}`;
|
|
502
|
+
|
|
503
|
+
let status;
|
|
504
|
+
try {
|
|
505
|
+
status = await run(["status", "--porcelain"]);
|
|
506
|
+
} catch (err: any) {
|
|
507
|
+
return { ok: false, message: `Cannot inspect ${repoPath}: ${err?.message ?? "git status failed"}` };
|
|
508
|
+
}
|
|
509
|
+
if (status.code !== 0) {
|
|
510
|
+
return { ok: false, message: `Cannot inspect ${repoPath}: ${status.stderr?.trim() || status.stdout?.trim() || "git status failed"}` };
|
|
511
|
+
}
|
|
512
|
+
if (status.stdout.trim()) {
|
|
513
|
+
return {
|
|
514
|
+
ok: false,
|
|
515
|
+
message:
|
|
516
|
+
`HALT: ${repoPath} has uncommitted changes. Commit or stash them, then bring the repo to its PR head (${target}) and ask me to continue.\n\n${status.stdout.trim()}`,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (!oid) {
|
|
521
|
+
return { ok: true, message: `${repoPath}: no PR head commit provided; left as-is (clean tree).` };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let head;
|
|
525
|
+
try {
|
|
526
|
+
head = await run(["rev-parse", "HEAD"]);
|
|
527
|
+
} catch (err: any) {
|
|
528
|
+
return { ok: false, message: `Cannot read HEAD of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
|
|
529
|
+
}
|
|
530
|
+
if (head.stdout.trim() === oid) {
|
|
531
|
+
return { ok: true, message: `${repoPath} is on PR head ${oid}.` };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
let branch;
|
|
535
|
+
try {
|
|
536
|
+
branch = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
537
|
+
} catch (err: any) {
|
|
538
|
+
return { ok: false, message: `Cannot read current branch of ${repoPath}: ${err?.message ?? "git rev-parse failed"}` };
|
|
539
|
+
}
|
|
540
|
+
const currentBranch = branch.stdout.trim();
|
|
541
|
+
if (currentBranch === "HEAD") {
|
|
542
|
+
return {
|
|
543
|
+
ok: false,
|
|
544
|
+
message:
|
|
545
|
+
`HALT: ${repoPath} has a detached HEAD, not at the PR head commit ${oid}. ` +
|
|
546
|
+
`Check out the PR head branch${name ? ` "${name}"` : ""} at ${oid}, then ask me to continue. I will not move HEAD for you.`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (name && currentBranch !== name) {
|
|
550
|
+
return {
|
|
551
|
+
ok: false,
|
|
552
|
+
message:
|
|
553
|
+
`HALT: ${repoPath} is on "${currentBranch}", not the PR head branch "${name}" (target commit ${oid}). ` +
|
|
554
|
+
"Check out that branch at its PR head, then ask me to continue. I will not switch branches for you.",
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// Clean tree, on the PR head's branch, but behind: advance the branch to the head with a
|
|
559
|
+
// fast-forward-only merge. This moves the branch ref without detaching, forcing, or stashing,
|
|
560
|
+
// and fails safely if the branch has diverged from the PR head.
|
|
561
|
+
let ff;
|
|
562
|
+
try {
|
|
563
|
+
ff = await run(["merge", "--ff-only", oid]);
|
|
564
|
+
} catch (err: any) {
|
|
565
|
+
return { ok: false, message: `Fast-forward of ${repoPath} to PR head ${oid} failed: ${err?.message ?? "git merge failed"}` };
|
|
566
|
+
}
|
|
567
|
+
if (ff.code !== 0) {
|
|
568
|
+
return {
|
|
569
|
+
ok: false,
|
|
570
|
+
message:
|
|
571
|
+
`HALT: ${repoPath} (branch "${currentBranch}") cannot fast-forward to PR head ${oid} — it has diverged. ` +
|
|
572
|
+
`Reconcile it with the PR head, then ask me to continue. I will not force-move or rebase it for you.` +
|
|
573
|
+
`${ff.stderr?.trim() ? `\n\n${ff.stderr.trim()}` : ""}`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// `git merge --ff-only <ancestor>` returns exit 0 ("Already up to date") WITHOUT moving HEAD
|
|
578
|
+
// when the branch is already ahead of the PR head, so a zero exit is not proof we landed on it.
|
|
579
|
+
// Re-read HEAD and HALT if the branch carries commits beyond the PR head.
|
|
580
|
+
let after;
|
|
581
|
+
try {
|
|
582
|
+
after = await run(["rev-parse", "HEAD"]);
|
|
583
|
+
} catch (err: any) {
|
|
584
|
+
return { ok: false, message: `Cannot confirm HEAD of ${repoPath} after fast-forward: ${err?.message ?? "git rev-parse failed"}` };
|
|
585
|
+
}
|
|
586
|
+
if (after.stdout.trim() !== oid) {
|
|
587
|
+
return {
|
|
588
|
+
ok: false,
|
|
589
|
+
message:
|
|
590
|
+
`HALT: ${repoPath} (branch "${currentBranch}") is ahead of the PR head ${oid} — it has commits the PR head does not. ` +
|
|
591
|
+
"Push them to the PR (or check out the exact PR head), then ask me to continue. I will not move HEAD backwards for you.",
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return { ok: true, message: `${repoPath} fast-forwarded to PR head ${oid}.` };
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function registerCheckoutPrHeadTool(orchestrator: Orchestrator): void {
|
|
598
|
+
const pi = orchestrator.pi;
|
|
599
|
+
|
|
600
|
+
pi.registerTool({
|
|
601
|
+
name: "pp_checkout_pr_head",
|
|
602
|
+
label: "pi-pi",
|
|
603
|
+
description:
|
|
604
|
+
"Land a registered repository on its PR head commit before reviewing it. " +
|
|
605
|
+
"Call this ONLY for a PR-scoped review, once per repo, AFTER you have resolved the PR " +
|
|
606
|
+
"(e.g. via `gh pr view --json headRefName,headRefOid`). Do NOT call it for a branch, " +
|
|
607
|
+
"commit-range, or uncommitted-changes review. The extension does this safely (no worktree, " +
|
|
608
|
+
"no stash, no --force, no branch switch, no detached checkout): a clean repo already on the " +
|
|
609
|
+
"PR head succeeds; a clean repo on the PR head's branch but behind is fast-forwarded to the " +
|
|
610
|
+
"head; a dirty tree, a different branch, or a diverged branch HALTS with a message for you to " +
|
|
611
|
+
"relay to the user. You never run `git checkout` yourself.",
|
|
612
|
+
parameters: Type.Object({
|
|
613
|
+
repoPath: Type.String({ description: "Absolute path to the registered repository" }),
|
|
614
|
+
headRefName: Type.String({ description: "PR head branch name (headRefName)" }),
|
|
615
|
+
headRefOid: Type.String({ description: "PR head commit SHA (headRefOid)" }),
|
|
616
|
+
}),
|
|
617
|
+
async execute(_toolCallId, params: any) {
|
|
618
|
+
if (!orchestrator.active) {
|
|
619
|
+
return { content: [{ type: "text" as const, text: "No active task." }], isError: true as const, details: {} };
|
|
620
|
+
}
|
|
621
|
+
const pathInput = typeof params.repoPath === "string" ? params.repoPath.trim() : "";
|
|
622
|
+
if (!pathInput) {
|
|
623
|
+
return { content: [{ type: "text" as const, text: "Missing repoPath." }], isError: true as const, details: {} };
|
|
624
|
+
}
|
|
625
|
+
const normalized = normalizeRepoPath(pathInput);
|
|
626
|
+
const repos = orchestrator.active.state.repos ?? [];
|
|
627
|
+
const registered = repos.find((repo) => repo.path === normalized);
|
|
628
|
+
if (!registered) {
|
|
629
|
+
return { content: [{ type: "text" as const, text: `Repository is not registered: ${params.repoPath}` }], isError: true as const, details: {} };
|
|
630
|
+
}
|
|
631
|
+
const result = await checkoutPrHead(orchestrator, registered.path, params.headRefName ?? "", params.headRefOid ?? "");
|
|
632
|
+
return { content: [{ type: "text" as const, text: result.message }], isError: result.ok ? undefined : (true as const), details: {} };
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
|
|
459
637
|
function registerRepoTool(orchestrator: Orchestrator): void {
|
|
460
638
|
const pi = orchestrator.pi;
|
|
461
639
|
|
|
@@ -595,190 +773,6 @@ function registerRepoTool(orchestrator: Orchestrator): void {
|
|
|
595
773
|
});
|
|
596
774
|
}
|
|
597
775
|
|
|
598
|
-
async function openCodeReviewDirect(
|
|
599
|
-
orchestrator: Orchestrator,
|
|
600
|
-
payload: Record<string, unknown>,
|
|
601
|
-
): Promise<{ approved: boolean; feedback?: string } | { error: string }> {
|
|
602
|
-
const { opened, reviewId } = await openPlannotator(orchestrator.pi, "code-review", payload);
|
|
603
|
-
if (!opened) {
|
|
604
|
-
return { error: "Plannotator not available" };
|
|
605
|
-
}
|
|
606
|
-
let result: { approved: boolean; feedback?: string; error?: string };
|
|
607
|
-
try {
|
|
608
|
-
result = await waitForPlannotatorResult(orchestrator, reviewId, null);
|
|
609
|
-
} catch (err) {
|
|
610
|
-
return { error: err instanceof Error ? err.message : "Plannotator review failed" };
|
|
611
|
-
}
|
|
612
|
-
if (result.error) {
|
|
613
|
-
return { error: result.error };
|
|
614
|
-
}
|
|
615
|
-
return { approved: result.approved, feedback: result.feedback };
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
function registerSpecifyReviewsTool(orchestrator: Orchestrator): void {
|
|
619
|
-
const pi = orchestrator.pi;
|
|
620
|
-
|
|
621
|
-
pi.registerTool({
|
|
622
|
-
name: "pp_specify_reviews",
|
|
623
|
-
label: "pi-pi",
|
|
624
|
-
description:
|
|
625
|
-
"Specify which repositories and commit ranges to open in Plannotator for code review. " +
|
|
626
|
-
"Called when the user requests a Plannotator code review. " +
|
|
627
|
-
"Plannotator will open sequentially for each entry. Results are returned after all reviews complete. " +
|
|
628
|
-
"Range supports both 'base..HEAD' and 'base..target' (non-HEAD target is reviewed via temporary worktree checkout).",
|
|
629
|
-
parameters: Type.Object({
|
|
630
|
-
reviews: Type.Array(Type.Object({
|
|
631
|
-
cwd: Type.String({ description: "Absolute path to the git repository" }),
|
|
632
|
-
range: Type.String({ description: "Git commit range, e.g. 'origin/main..HEAD', 'abc123..def456', 'HEAD~3..HEAD'" }),
|
|
633
|
-
})),
|
|
634
|
-
}),
|
|
635
|
-
async execute(_toolCallId, params: any, _signal, _onUpdate, ctx) {
|
|
636
|
-
if (orchestrator.active && getEffectiveMode(orchestrator.active.state) === "autonomous") {
|
|
637
|
-
return { content: [{ type: "text" as const, text: "Plannotator review is not available in autonomous mode. Continue with automated reviews via pp_phase_complete." }], isError: true as const, details: {} };
|
|
638
|
-
}
|
|
639
|
-
if (!params.reviews || params.reviews.length === 0) {
|
|
640
|
-
return { content: [{ type: "text" as const, text: "No reviews specified." }], isError: true as const, details: {} };
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const results: string[] = [];
|
|
644
|
-
let hasNeedsChanges = false;
|
|
645
|
-
for (const review of params.reviews) {
|
|
646
|
-
ctx.ui?.setWorkingMessage?.(`Waiting for Plannotator review: ${review.range}…`);
|
|
647
|
-
const range = String(review.range ?? "").trim();
|
|
648
|
-
let compareBase = range;
|
|
649
|
-
let compareTarget = "HEAD";
|
|
650
|
-
const rangeSeparatorIdx = range.indexOf("..");
|
|
651
|
-
if (rangeSeparatorIdx >= 0) {
|
|
652
|
-
compareBase = range.slice(0, rangeSeparatorIdx).trim();
|
|
653
|
-
compareTarget = range.slice(rangeSeparatorIdx + 2).trim() || "HEAD";
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
if (!compareBase) {
|
|
657
|
-
results.push(`${review.cwd} (${review.range}): Invalid range (missing base).`);
|
|
658
|
-
continue;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
let reviewCwd = review.cwd;
|
|
662
|
-
let tempWorktreePath: string | null = null;
|
|
663
|
-
let tempWorktreeParent: string | null = null;
|
|
664
|
-
let setupError: string | null = null;
|
|
665
|
-
|
|
666
|
-
if (compareTarget !== "HEAD") {
|
|
667
|
-
tempWorktreeParent = mkdtempSync(join(tmpdir(), "pi-pi-review-worktree-"));
|
|
668
|
-
tempWorktreePath = join(tempWorktreeParent, "checkout");
|
|
669
|
-
try {
|
|
670
|
-
const addResult = await pi.exec("git", ["worktree", "add", "--detach", tempWorktreePath, compareTarget], {
|
|
671
|
-
cwd: review.cwd,
|
|
672
|
-
timeout: 20000,
|
|
673
|
-
});
|
|
674
|
-
if (addResult.code !== 0) {
|
|
675
|
-
setupError = addResult.stderr?.trim() || addResult.stdout?.trim() || "Failed to prepare temporary worktree";
|
|
676
|
-
} else {
|
|
677
|
-
reviewCwd = tempWorktreePath;
|
|
678
|
-
}
|
|
679
|
-
} catch (error: any) {
|
|
680
|
-
setupError = error?.message ?? String(error);
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
if (setupError) {
|
|
685
|
-
results.push(`${review.cwd} (${review.range}): ${setupError}`);
|
|
686
|
-
if (tempWorktreePath) {
|
|
687
|
-
try {
|
|
688
|
-
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
689
|
-
cwd: review.cwd,
|
|
690
|
-
timeout: 20000,
|
|
691
|
-
});
|
|
692
|
-
} catch {}
|
|
693
|
-
}
|
|
694
|
-
if (tempWorktreePath) {
|
|
695
|
-
try {
|
|
696
|
-
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
697
|
-
} catch {}
|
|
698
|
-
}
|
|
699
|
-
if (tempWorktreeParent) {
|
|
700
|
-
try {
|
|
701
|
-
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
702
|
-
} catch {}
|
|
703
|
-
}
|
|
704
|
-
continue;
|
|
705
|
-
}
|
|
706
|
-
|
|
707
|
-
const result = await openCodeReviewDirect(orchestrator, {
|
|
708
|
-
cwd: reviewCwd,
|
|
709
|
-
diffType: "branch",
|
|
710
|
-
defaultBranch: compareBase,
|
|
711
|
-
});
|
|
712
|
-
|
|
713
|
-
if (tempWorktreePath) {
|
|
714
|
-
try {
|
|
715
|
-
await pi.exec("git", ["worktree", "remove", "--force", tempWorktreePath], {
|
|
716
|
-
cwd: review.cwd,
|
|
717
|
-
timeout: 20000,
|
|
718
|
-
});
|
|
719
|
-
} catch {}
|
|
720
|
-
try {
|
|
721
|
-
rmSync(tempWorktreePath, { recursive: true, force: true });
|
|
722
|
-
} catch {}
|
|
723
|
-
if (tempWorktreeParent) {
|
|
724
|
-
try {
|
|
725
|
-
rmSync(tempWorktreeParent, { recursive: true, force: true });
|
|
726
|
-
} catch {}
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
if ("error" in result) {
|
|
731
|
-
results.push(`${review.cwd} (${review.range}): ${result.error}`);
|
|
732
|
-
} else {
|
|
733
|
-
const status = result.approved ? "APPROVED" : "NEEDS_CHANGES";
|
|
734
|
-
if (!result.approved) hasNeedsChanges = true;
|
|
735
|
-
const feedback = result.feedback ? `\nFeedback: ${result.feedback}` : "";
|
|
736
|
-
results.push(`${review.cwd} (${review.range}): ${status}${feedback}`);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
ctx.ui?.setWorkingMessage?.();
|
|
740
|
-
|
|
741
|
-
const summary = results.join("\n\n");
|
|
742
|
-
|
|
743
|
-
if (hasNeedsChanges) {
|
|
744
|
-
if (orchestrator.active) {
|
|
745
|
-
orchestrator.active.state.step = "llm_work";
|
|
746
|
-
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
747
|
-
}
|
|
748
|
-
return {
|
|
749
|
-
content: [{ type: "text" as const, text: `Plannotator review complete.\n\n${summary}\n\nAddress the user's feedback. If the feedback contains questions, answer them. If it requests changes, make the changes. Then call pp_phase_complete when done.` }],
|
|
750
|
-
details: {},
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
ctx.ui?.setWorkingMessage?.("Waiting for user input…");
|
|
755
|
-
try {
|
|
756
|
-
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
757
|
-
const text = await showActiveTaskMenu(orchestrator, ctx, `Plannotator review complete.\n\n${summary}`, "tool");
|
|
758
|
-
// Deliberate user ESC: stop the turn cleanly (mirror ask_user), no
|
|
759
|
-
// reminder text that would start a new LLM turn.
|
|
760
|
-
if (text === USER_CANCELLED) {
|
|
761
|
-
ctx.abort?.();
|
|
762
|
-
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
763
|
-
}
|
|
764
|
-
// A transition may have started while the menu was open. The controller
|
|
765
|
-
// is the source of truth; abort the agent's pending turn so it doesn't
|
|
766
|
-
// race the transition. (Interactive-UX abort — stays local, not routed.)
|
|
767
|
-
if (!orchestrator.transitionController.isRunning()) {
|
|
768
|
-
ctx.abort?.();
|
|
769
|
-
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
770
|
-
}
|
|
771
|
-
if (!text) {
|
|
772
|
-
return { content: [{ type: "text" as const, text: "User dismissed the menu. Wait for the user's next message. When you resume work, update USER_REQUEST.md and RESEARCH.md with any new findings before calling pp_phase_complete." }], details: {} };
|
|
773
|
-
}
|
|
774
|
-
return { content: [{ type: "text" as const, text }], details: {} };
|
|
775
|
-
} finally {
|
|
776
|
-
ctx.ui?.setWorkingMessage?.();
|
|
777
|
-
}
|
|
778
|
-
},
|
|
779
|
-
});
|
|
780
|
-
}
|
|
781
|
-
|
|
782
776
|
function loadPhaseReviewOutputs(taskDir: string, phase: string, pass: number): { name: string; content: string }[] {
|
|
783
777
|
if (phase === "brainstorm") return loadBrainstormReviewOutputs(taskDir, pass);
|
|
784
778
|
if (phase === "plan") return loadPlanReviewOutputs(taskDir, pass);
|
|
@@ -948,6 +942,33 @@ function registerPhaseCompleteTool(orchestrator: Orchestrator): void {
|
|
|
948
942
|
}
|
|
949
943
|
}
|
|
950
944
|
|
|
945
|
+
// Terminal handoff (#1): an autonomous IMPLEMENT phase whose next phase is
|
|
946
|
+
// "done" should NOT auto-complete. Run afterImplement (transitionToNextPhase
|
|
947
|
+
// is skipped here, so run it explicitly — once), then open the GUIDED
|
|
948
|
+
// implement menu and wait, instead of tearing the task down.
|
|
949
|
+
if (phase === "implement" && nextPhase(orchestrator.active.type, phase) === "done") {
|
|
950
|
+
if (!orchestrator.active.state.afterImplementRan) {
|
|
951
|
+
const afterError = runAfterImplementForActive(orchestrator);
|
|
952
|
+
if (afterError) {
|
|
953
|
+
return { content: [{ type: "text" as const, text: `${afterError}\n\nDo NOT wait for the user — fix these and call pp_phase_complete again.` }], details: {} };
|
|
954
|
+
}
|
|
955
|
+
orchestrator.active.state.afterImplementRan = true;
|
|
956
|
+
saveTask(orchestrator.active.dir, orchestrator.active.state);
|
|
957
|
+
}
|
|
958
|
+
ctx.ui.setWorkingMessage?.("Waiting for user approval…");
|
|
959
|
+
try {
|
|
960
|
+
const { showActiveTaskMenu, USER_CANCELLED } = await import("./pp-menu.js");
|
|
961
|
+
const text = await showActiveTaskMenu(orchestrator, ctx, params.summary, "tool", true);
|
|
962
|
+
if (text === USER_CANCELLED) {
|
|
963
|
+
ctx.abort?.();
|
|
964
|
+
return { content: [{ type: "text" as const, text: "" }], details: {} };
|
|
965
|
+
}
|
|
966
|
+
return { content: [{ type: "text" as const, text: text ?? "" }], details: {} };
|
|
967
|
+
} finally {
|
|
968
|
+
ctx.ui.setWorkingMessage?.();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
951
972
|
const plannerPreset = orchestrator.active.state.autonomousConfig?.phases?.plan?.plannerPreset;
|
|
952
973
|
const result = await orchestrator.transitionToNextPhase(ctx, plannerPreset);
|
|
953
974
|
if (!result.ok) {
|
|
@@ -1022,31 +1043,49 @@ function registerMainTraceHooks(orchestrator: Orchestrator): void {
|
|
|
1022
1043
|
tracer?.traceMain("turn_end", { turnIndex: event.turnIndex, message: event.message, toolResults: event.toolResults });
|
|
1023
1044
|
});
|
|
1024
1045
|
pi.on("message_start", async (event) => {
|
|
1046
|
+
markMainTurnActivity(orchestrator);
|
|
1025
1047
|
const tracer = getTracer();
|
|
1026
1048
|
tracer?.traceMain("message_start", { turnIndex: tracer.turnIndex, message: event.message });
|
|
1027
1049
|
});
|
|
1028
1050
|
pi.on("message_update", async (event) => {
|
|
1051
|
+
markMainTurnActivity(orchestrator);
|
|
1029
1052
|
const tracer = getTracer();
|
|
1030
1053
|
tracer?.traceMain("message_update", { turnIndex: tracer.turnIndex, assistantMessageEvent: event.assistantMessageEvent });
|
|
1031
1054
|
});
|
|
1032
1055
|
pi.on("message_end", async (event) => {
|
|
1056
|
+
markMainTurnActivity(orchestrator);
|
|
1033
1057
|
const tracer = getTracer();
|
|
1034
1058
|
tracer?.traceMain("message_end", { turnIndex: tracer.turnIndex, message: event.message });
|
|
1035
1059
|
});
|
|
1036
1060
|
pi.on("tool_execution_start", async (event) => {
|
|
1061
|
+
markMainTurnActivity(orchestrator);
|
|
1037
1062
|
const tracer = getTracer();
|
|
1038
1063
|
tracer?.traceMain("tool_execution_start", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args });
|
|
1039
1064
|
});
|
|
1040
1065
|
pi.on("tool_execution_update", async (event) => {
|
|
1066
|
+
markMainTurnActivity(orchestrator);
|
|
1041
1067
|
const tracer = getTracer();
|
|
1042
1068
|
tracer?.traceMain("tool_execution_update", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, args: event.args, partialResult: event.partialResult });
|
|
1043
1069
|
});
|
|
1044
1070
|
pi.on("tool_execution_end", async (event) => {
|
|
1071
|
+
markMainTurnActivity(orchestrator);
|
|
1045
1072
|
const tracer = getTracer();
|
|
1046
1073
|
tracer?.traceMain("tool_execution_end", { turnIndex: tracer.turnIndex, toolCallId: event.toolCallId, toolName: event.toolName, result: event.result, isError: event.isError });
|
|
1047
1074
|
});
|
|
1048
1075
|
}
|
|
1049
1076
|
|
|
1077
|
+
// Main-turn watchdog activity helpers (BUG-2). Kept module-level so they can be
|
|
1078
|
+
// called from the several existing main-session handlers without registering a
|
|
1079
|
+
// second handler per event.
|
|
1080
|
+
function markMainTurnActivity(orchestrator: Orchestrator): void {
|
|
1081
|
+
orchestrator.mainTurnLastActivity = Date.now();
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function endMainTurn(orchestrator: Orchestrator): void {
|
|
1085
|
+
orchestrator.mainTurnInFlight = false;
|
|
1086
|
+
orchestrator.mainTurnRecovering = false;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1050
1089
|
export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
1051
1090
|
const pi = orchestrator.pi;
|
|
1052
1091
|
|
|
@@ -1077,6 +1116,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1077
1116
|
// the subagent branch, so these only ever see root-session events.
|
|
1078
1117
|
pi.on("agent_end", async (_event, ctx) => {
|
|
1079
1118
|
if (ctx) orchestrator.lastCtx = ctx;
|
|
1119
|
+
endMainTurn(orchestrator);
|
|
1080
1120
|
orchestrator.transitionController.onAgentEnd();
|
|
1081
1121
|
});
|
|
1082
1122
|
pi.on("session_compact", async (_event, ctx) => {
|
|
@@ -1084,6 +1124,65 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1084
1124
|
orchestrator.transitionController.onSessionCompact();
|
|
1085
1125
|
});
|
|
1086
1126
|
|
|
1127
|
+
// BUG-2: main-turn stall watchdog. A main turn can start (turn_start / manual
|
|
1128
|
+
// "continue" / SDK auto-retry) and then wedge with no terminal turn_end, so the
|
|
1129
|
+
// session sits "Working…" forever with nothing to recover it (the SDK-retryable
|
|
1130
|
+
// deferral below arms nothing, and startStaleAgentWatchdog only watches
|
|
1131
|
+
// subagents). Activity is marked from the existing main-session handlers
|
|
1132
|
+
// (turn_start below, tool_call, tool_result, turn_end) so we do NOT register a
|
|
1133
|
+
// second handler per event (the runtime allows it, but keeping one owner per
|
|
1134
|
+
// event is simpler and test-mock-friendly). When a turn is in flight with NO
|
|
1135
|
+
// activity beyond the configured threshold, abort + recover via the idle-gated
|
|
1136
|
+
// single-send path so recovery can't itself race into "Agent is already
|
|
1137
|
+
// processing".
|
|
1138
|
+
const startMainTurnWatchdog = () => {
|
|
1139
|
+
if (orchestrator.mainTurnTimer) return;
|
|
1140
|
+
orchestrator.mainTurnTimer = setInterval(() => {
|
|
1141
|
+
if (!orchestrator.active) {
|
|
1142
|
+
clearInterval(orchestrator.mainTurnTimer!);
|
|
1143
|
+
orchestrator.mainTurnTimer = null;
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
if (!orchestrator.mainTurnInFlight || orchestrator.mainTurnRecovering) return;
|
|
1147
|
+
// Only a genuinely stuck turn is a target: an idle session (turn already
|
|
1148
|
+
// settled), an in-progress transition, or an await_* step is not a stall.
|
|
1149
|
+
if (orchestrator.transitionController.isTransitioning()) return;
|
|
1150
|
+
const step = orchestrator.active.state.step;
|
|
1151
|
+
if (step === "await_planners" || step === "await_reviewers") return;
|
|
1152
|
+
// A turn parked on an open user-facing dialogue (ask_user / the /pp menu /
|
|
1153
|
+
// any interactive selectOption / the rate-limit fallback switch) is
|
|
1154
|
+
// user-gated, not wedged — do not abort it out from under the user.
|
|
1155
|
+
if (orchestrator.interactivePromptOpen || orchestrator.subFallbackDialogPending) return;
|
|
1156
|
+
const staleMs = orchestrator.config.performance.internals.mainTurnStale;
|
|
1157
|
+
if (Date.now() - orchestrator.mainTurnLastActivity <= staleMs) return;
|
|
1158
|
+
|
|
1159
|
+
orchestrator.mainTurnRecovering = true;
|
|
1160
|
+
const taskToken = orchestrator.activeTaskToken;
|
|
1161
|
+
const phase = orchestrator.active.state.phase;
|
|
1162
|
+
getLogger().warn({ s: "watchdog", phase, staleMs }, "main turn wedged — aborting and recovering");
|
|
1163
|
+
orchestrator.lastCtx?.ui?.notify?.(
|
|
1164
|
+
`Main turn stalled with no activity for ${Math.round(staleMs / 1000)}s — recovering.`,
|
|
1165
|
+
"warning",
|
|
1166
|
+
);
|
|
1167
|
+
try {
|
|
1168
|
+
orchestrator.transitionController.abortMainAgent(orchestrator.lastCtx?.abort?.bind(orchestrator.lastCtx));
|
|
1169
|
+
} catch {}
|
|
1170
|
+
orchestrator.mainTurnInFlight = false;
|
|
1171
|
+
orchestrator.sendUserMessageWhenIdle(
|
|
1172
|
+
`[PI-PI] The previous turn stalled without completing. Continue working on the current phase (${phase}).`,
|
|
1173
|
+
taskToken,
|
|
1174
|
+
);
|
|
1175
|
+
}, 30000);
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
1179
|
+
if (ctx) orchestrator.lastCtx = ctx;
|
|
1180
|
+
orchestrator.mainTurnInFlight = true;
|
|
1181
|
+
orchestrator.mainTurnRecovering = false;
|
|
1182
|
+
markMainTurnActivity(orchestrator);
|
|
1183
|
+
startMainTurnWatchdog();
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1087
1186
|
// Expose the event-driven planner-completion check so initial plan-entry
|
|
1088
1187
|
// spawns (in command-handlers / orchestrator) can wire it as their onSettled
|
|
1089
1188
|
// safety net (the deleted poller's former role). checkPlannerCompletion is a
|
|
@@ -1203,6 +1302,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1203
1302
|
}
|
|
1204
1303
|
|
|
1205
1304
|
trackSubagentEvent(data, "created");
|
|
1305
|
+
// Nudge acceptance/suppression is driven from the root `Agent` tool-call observation
|
|
1306
|
+
// (main-initiated by construction), NOT here — this event also fires for lineage-less
|
|
1307
|
+
// nested spawns, which must not count as the main agent following a nudge.
|
|
1308
|
+
|
|
1206
1309
|
startStaleAgentWatchdog();
|
|
1207
1310
|
const mgr = (globalThis as any)[Symbol.for("pi-subagents:manager")];
|
|
1208
1311
|
mgr?.refreshWidget?.(orchestrator.lastCtx?.ui);
|
|
@@ -1789,6 +1892,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1789
1892
|
(globalThis as any)[USAGE_TRACKER_KEY] = tracker;
|
|
1790
1893
|
setFooterContext(ctx);
|
|
1791
1894
|
setFooterTracker(tracker);
|
|
1895
|
+
setFooterOrchestrator(orchestrator);
|
|
1792
1896
|
ctx.ui.setFooter(createCustomFooter);
|
|
1793
1897
|
}
|
|
1794
1898
|
|
|
@@ -1874,6 +1978,30 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1874
1978
|
if (orchestrator.transitionController.gateAgentStart(() => ctx.abort())) {
|
|
1875
1979
|
return;
|
|
1876
1980
|
}
|
|
1981
|
+
|
|
1982
|
+
// Stale continuation-nudge guard. A `[PI-PI] Continue the <phase> phase…`
|
|
1983
|
+
// nudge is a followUp queued at turn_end; by the time it is delivered the
|
|
1984
|
+
// phase may have advanced (plan→implement) or the task may have changed
|
|
1985
|
+
// (old plan-task → new plan-task). Re-validate against the phase/token
|
|
1986
|
+
// captured at generation time and abort the turn on any mismatch — a stale
|
|
1987
|
+
// nudge is not genuine user re-engagement, so this runs BEFORE the no-active
|
|
1988
|
+
// early return and does NOT clear the nudge/error guards. Checked only for
|
|
1989
|
+
// the tracked "Continue the <phase> phase" shape; other [PI-PI] handoffs are
|
|
1990
|
+
// untouched.
|
|
1991
|
+
const nudgeMeta = orchestrator.pendingNudges.get(event.prompt ?? "");
|
|
1992
|
+
if (nudgeMeta !== undefined) {
|
|
1993
|
+
orchestrator.pendingNudges.delete(event.prompt ?? "");
|
|
1994
|
+
const livePhase = orchestrator.active?.state.phase;
|
|
1995
|
+
if (!orchestrator.active || nudgeMeta.phase !== livePhase || nudgeMeta.taskToken !== orchestrator.activeTaskToken) {
|
|
1996
|
+
getLogger().debug(
|
|
1997
|
+
{ s: "hook", hook: "before_agent_start", nudgePhase: nudgeMeta.phase, livePhase, nudgeToken: nudgeMeta.taskToken, liveToken: orchestrator.activeTaskToken },
|
|
1998
|
+
"dropping stale continuation nudge",
|
|
1999
|
+
);
|
|
2000
|
+
ctx.abort();
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1877
2005
|
if (!orchestrator.active || orchestrator.active.state.phase === "done") return;
|
|
1878
2006
|
|
|
1879
2007
|
// Clear the nudge guard ONLY on a genuine user re-engagement. Controller-
|
|
@@ -1907,6 +2035,11 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1907
2035
|
const projectContext = systemSnippets
|
|
1908
2036
|
? ["<project_context>", systemSnippets, "</project_context>"].join("\n")
|
|
1909
2037
|
: "";
|
|
2038
|
+
const agentsMdPath = join(orchestrator.cwd, "AGENTS.md");
|
|
2039
|
+
const agentsMd =
|
|
2040
|
+
orchestrator.config.general.injectAgentsMd && existsSync(agentsMdPath)
|
|
2041
|
+
? [`<agents_md source="${agentsMdPath}">`, readFileSync(agentsMdPath, "utf-8"), "</agents_md>"].join("\n")
|
|
2042
|
+
: "";
|
|
1910
2043
|
const checklistLine =
|
|
1911
2044
|
phase === "implement" ? "Keep the plan checklist current: mark each item done (- [ ] → - [x]) as you complete it." : "";
|
|
1912
2045
|
const taskBlock = [
|
|
@@ -1925,6 +2058,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1925
2058
|
TOOLS_BLOCK,
|
|
1926
2059
|
DELEGATION_BLOCK,
|
|
1927
2060
|
projectContext,
|
|
2061
|
+
agentsMd,
|
|
1928
2062
|
taskBlock,
|
|
1929
2063
|
]
|
|
1930
2064
|
.filter(Boolean)
|
|
@@ -1936,6 +2070,7 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1936
2070
|
});
|
|
1937
2071
|
|
|
1938
2072
|
pi.on("tool_call", async (event, _ctx) => {
|
|
2073
|
+
markMainTurnActivity(orchestrator);
|
|
1939
2074
|
getLogger().debug({ s: "hook", hook: "tool_call", tool: event.toolName }, "tool call");
|
|
1940
2075
|
if (event.toolName === "ask_user" && orchestrator.active) {
|
|
1941
2076
|
if (getEffectivePhaseMode(orchestrator.active.state) === "autonomous") {
|
|
@@ -1993,11 +2128,49 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
1993
2128
|
if (fileName === "config.json" && isPathInside(ppDir, resolvedPath)) {
|
|
1994
2129
|
return { block: true, reason: "config.json is managed by the user, not the LLM" };
|
|
1995
2130
|
}
|
|
2131
|
+
|
|
2132
|
+
// Review-phase read-only exception is scoped to AI_COMMENT markers: the main
|
|
2133
|
+
// agent may only insert/remove `AI_COMMENT:` markers in source files. Enforce
|
|
2134
|
+
// it at the gate (the constraint is otherwise prompt-level only): a source
|
|
2135
|
+
// write/edit is allowed only when it changes nothing but AI_COMMENT markers.
|
|
2136
|
+
// Publishing findings as file comments goes through this path.
|
|
2137
|
+
if (
|
|
2138
|
+
orchestrator.active?.state.phase === "review" &&
|
|
2139
|
+
!isPathInside(ppDir, resolvedPath)
|
|
2140
|
+
) {
|
|
2141
|
+
const denial = {
|
|
2142
|
+
block: true as const,
|
|
2143
|
+
reason: "Review phase is read-only except for inserting/removing AI_COMMENT: markers. This edit changes non-AI_COMMENT content — record the finding instead of applying a fix.",
|
|
2144
|
+
};
|
|
2145
|
+
if (event.toolName === "edit") {
|
|
2146
|
+
const edits = Array.isArray((event.input as any)?.edits) ? (event.input as any).edits : [];
|
|
2147
|
+
const legacy = event.input as any;
|
|
2148
|
+
const allEdits = [
|
|
2149
|
+
...edits,
|
|
2150
|
+
...(typeof legacy?.oldText === "string" && typeof legacy?.newText === "string" ? [{ oldText: legacy.oldText, newText: legacy.newText }] : []),
|
|
2151
|
+
];
|
|
2152
|
+
for (const e of allEdits) {
|
|
2153
|
+
if (typeof e?.oldText !== "string" || typeof e?.newText !== "string") continue;
|
|
2154
|
+
if (!isAiCommentOnlyChange(e.oldText, e.newText)) return denial;
|
|
2155
|
+
}
|
|
2156
|
+
} else {
|
|
2157
|
+
// Anchoring inserts markers into EXISTING code; creating a new source
|
|
2158
|
+
// file is never a valid AI_COMMENT-only operation.
|
|
2159
|
+
if (!existsSync(resolvedPath)) return denial;
|
|
2160
|
+
const newContent = typeof (event.input as any)?.content === "string" ? (event.input as any).content : "";
|
|
2161
|
+
let before = "";
|
|
2162
|
+
try {
|
|
2163
|
+
before = readFileSync(resolvedPath, "utf-8");
|
|
2164
|
+
} catch {}
|
|
2165
|
+
if (!isAiCommentOnlyChange(before, newContent)) return denial;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
1996
2168
|
}
|
|
1997
2169
|
return;
|
|
1998
2170
|
});
|
|
1999
2171
|
|
|
2000
2172
|
pi.on("tool_result", async (event, ctx) => {
|
|
2173
|
+
markMainTurnActivity(orchestrator);
|
|
2001
2174
|
// ESC in an ask_user dialogue must stop the LLM's turn (treat ESC as
|
|
2002
2175
|
// "stop, I want to type"). Only a deliberate user cancel aborts — timeout
|
|
2003
2176
|
// and programmatic signal-aborts carry a non-"user" reason and must not.
|
|
@@ -2066,6 +2239,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2066
2239
|
orchestrator.active.modifiedFiles.add(resolvedWrite);
|
|
2067
2240
|
orchestrator.active.state.modifiedFiles = [...orchestrator.active.modifiedFiles];
|
|
2068
2241
|
orchestrator.active.state.reviewApprovedClean = false;
|
|
2242
|
+
// New implement edits invalidate a prior afterImplement run (e.g. fixes made
|
|
2243
|
+
// via the guided menu after the autonomous terminal handoff), so the hooks
|
|
2244
|
+
// re-run and validate the final code on the next completion.
|
|
2245
|
+
orchestrator.active.state.afterImplementRan = false;
|
|
2069
2246
|
try { saveTask(orchestrator.active.dir, orchestrator.active.state); } catch {}
|
|
2070
2247
|
|
|
2071
2248
|
const repos = orchestrator.active.state.repos ?? [];
|
|
@@ -2126,10 +2303,21 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2126
2303
|
// so this works whether or not `active` is still set (done cleanup nulls it).
|
|
2127
2304
|
if (transitioning) {
|
|
2128
2305
|
const summary = orchestrator.transitionController.currentSummary() || "Phase transition in progress.";
|
|
2306
|
+
// Task-boundary discard: keep NO verbatim transcript beyond the summary so
|
|
2307
|
+
// the finished/replaced task cannot leak into the next task. Point
|
|
2308
|
+
// firstKeptEntryId at the newest branch entry (nothing before it is kept
|
|
2309
|
+
// verbatim). Phase transitions keep the default recent window so
|
|
2310
|
+
// legitimate context carries forward.
|
|
2311
|
+
const branchEntries = (event as any).branchEntries as Array<{ id: string }> | undefined;
|
|
2312
|
+
const lastEntryId = branchEntries && branchEntries.length > 0 ? branchEntries[branchEntries.length - 1].id : undefined;
|
|
2313
|
+
const firstKeptEntryId =
|
|
2314
|
+
orchestrator.transitionController.isDiscardTransition() && lastEntryId
|
|
2315
|
+
? lastEntryId
|
|
2316
|
+
: event.preparation.firstKeptEntryId;
|
|
2129
2317
|
return {
|
|
2130
2318
|
compaction: {
|
|
2131
2319
|
summary,
|
|
2132
|
-
firstKeptEntryId
|
|
2320
|
+
firstKeptEntryId,
|
|
2133
2321
|
tokensBefore: event.preparation.tokensBefore,
|
|
2134
2322
|
},
|
|
2135
2323
|
};
|
|
@@ -2159,6 +2347,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2159
2347
|
});
|
|
2160
2348
|
|
|
2161
2349
|
pi.on("turn_end", async (event, ctx) => {
|
|
2350
|
+
// The turn produced a terminal event — it is no longer wedged. If the SDK
|
|
2351
|
+
// auto-retries a retryable error it will emit a fresh turn_start, which
|
|
2352
|
+
// re-arms the watchdog.
|
|
2353
|
+
endMainTurn(orchestrator);
|
|
2162
2354
|
const msg = event.message as any;
|
|
2163
2355
|
const usageTracker = getUsageTracker();
|
|
2164
2356
|
if (usageTracker && msg?.usage) {
|
|
@@ -2362,6 +2554,10 @@ export function registerEventHandlers(orchestrator: Orchestrator): void {
|
|
|
2362
2554
|
const nudge = isAutonomous
|
|
2363
2555
|
? `[PI-PI] Continue the ${phase} phase. ${phaseConstraint(phase as Phase)} If the phase's objectives are met, call pp_phase_complete now; otherwise call the next tool. Do NOT apologize or reply with text only — respond with a tool call.`
|
|
2364
2556
|
: `[PI-PI] Continue the ${phase} phase where you left off.`;
|
|
2557
|
+
// Record the nudge's phase/task at generation time so a delivery that lands
|
|
2558
|
+
// after the phase advanced or the task changed can be dropped (the queued
|
|
2559
|
+
// followUp string itself carries no token).
|
|
2560
|
+
orchestrator.pendingNudges.set(nudge, { phase: phase as Phase, taskToken: orchestrator.activeTaskToken });
|
|
2365
2561
|
orchestrator.safeSendUserMessage(nudge);
|
|
2366
2562
|
});
|
|
2367
2563
|
|