@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.
Files changed (33) hide show
  1. package/extensions/orchestrator/agents/code-reviewer.ts +21 -5
  2. package/extensions/orchestrator/agents/constraints.test.ts +39 -1
  3. package/extensions/orchestrator/agents/constraints.ts +63 -2
  4. package/extensions/orchestrator/agents/tool-routing.ts +30 -14
  5. package/extensions/orchestrator/ai-comment-cleanup.test.ts +89 -0
  6. package/extensions/orchestrator/ai-comment-cleanup.ts +73 -0
  7. package/extensions/orchestrator/command-handlers.test.ts +47 -0
  8. package/extensions/orchestrator/command-handlers.ts +43 -27
  9. package/extensions/orchestrator/config.test.ts +40 -1
  10. package/extensions/orchestrator/config.ts +13 -0
  11. package/extensions/orchestrator/context.ts +16 -0
  12. package/extensions/orchestrator/custom-footer.test.ts +91 -0
  13. package/extensions/orchestrator/custom-footer.ts +20 -20
  14. package/extensions/orchestrator/event-handlers.test.ts +315 -1
  15. package/extensions/orchestrator/event-handlers.ts +397 -201
  16. package/extensions/orchestrator/integration.test.ts +305 -9
  17. package/extensions/orchestrator/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -0
  18. package/extensions/orchestrator/orchestrator.ts +46 -58
  19. package/extensions/orchestrator/phases/brainstorm.ts +11 -7
  20. package/extensions/orchestrator/phases/machine.test.ts +36 -0
  21. package/extensions/orchestrator/phases/machine.ts +11 -1
  22. package/extensions/orchestrator/phases/review-task.test.ts +20 -0
  23. package/extensions/orchestrator/phases/review-task.ts +44 -16
  24. package/extensions/orchestrator/phases/review.test.ts +26 -0
  25. package/extensions/orchestrator/phases/review.ts +40 -3
  26. package/extensions/orchestrator/pp-menu.test.ts +207 -1
  27. package/extensions/orchestrator/pp-menu.ts +376 -378
  28. package/extensions/orchestrator/state.test.ts +9 -0
  29. package/extensions/orchestrator/state.ts +15 -0
  30. package/extensions/orchestrator/transition-controller.test.ts +100 -0
  31. package/extensions/orchestrator/transition-controller.ts +61 -3
  32. package/package.json +1 -1
  33. package/AGENTS.md +0 -28
@@ -107,8 +107,10 @@ export function brainstormSystemPrompt(taskType: TaskType, taskDescription: stri
107
107
  "",
108
108
  registerReposInstruction,
109
109
  "",
110
- "Your job is to produce USER_REQUEST.md and RESEARCH.md — complete enough that",
111
- "downstream agents can work without re-exploring the codebase or re-interviewing the user.",
110
+ "This is a clarify + research + DESIGN phase. Your job is to produce USER_REQUEST.md and RESEARCH.md — complete enough that",
111
+ "downstream agents can work without re-exploring the codebase or re-interviewing the user. That means not just describing what",
112
+ "exists, but exploring the design space: weigh the viable approaches and their tradeoffs so the plan phase inherits a clear",
113
+ "direction rather than an open-ended problem.",
112
114
  "",
113
115
  "# Steps:",
114
116
  "1. Clarify requirements with the user if anything is ambiguous",
@@ -119,9 +121,11 @@ export function brainstormSystemPrompt(taskType: TaskType, taskDescription: stri
119
121
  " - lsp documentSymbol, goToDefinition, findReferences, goToImplementation, hover",
120
122
  " - cbm_trace: trace call chains for dependency understanding",
121
123
  " - ast_search: find structural patterns across the codebase",
122
- "4. Ask the user follow-up questions as needed",
123
- "5. Write findings into RESEARCH.md as results come back — don't wait for all subagents",
124
- "6. Keep USER_REQUEST.md current: update it whenever the user's request changes or clarifies, so it reflects what the user actually wants don't write it once and leave it stale",
124
+ "4. Explore design options: identify the viable approaches, weigh their tradeoffs, and land on a recommended direction (capture the reasoning in RESEARCH.md / an artifact)",
125
+ "5. Ask the user follow-up questions as needed",
126
+ "6. Actively drive every Open Question to resolution chase down answers via research or by asking the user; the Open Questions section should be empty (or every entry marked DECIDED) before you hand off, not a passive backlog",
127
+ "7. Write findings into RESEARCH.md as results come back — don't wait for all subagents",
128
+ "8. Keep USER_REQUEST.md current: update it whenever the user's request changes or clarifies, so it reflects what the user actually wants — don't write it once and leave it stale",
125
129
  "",
126
130
  "Produce two files:",
127
131
  `- ${taskDir}/USER_REQUEST.md — MUST follow this exact structure:`,
@@ -140,14 +144,14 @@ export function brainstormSystemPrompt(taskType: TaskType, taskDescription: stri
140
144
  " - MUST: <hard requirements discovered from code>",
141
145
  " - RISK: <things that could break>",
142
146
  " ## Open Questions",
143
- " <Unresolved items needing user input. Omit section if none.>",
147
+ " <Unresolved items needing user input. Drive these to resolution before advancing — omit the section only when genuinely none remain.>",
144
148
  "",
145
149
  "These files are validated programmatically. Missing sections or unexpected sections will be rejected.",
146
150
  "Use pp_write_state_file / pp_edit_state_file (NOT the generic write/edit) for .pp state files — they keep the output compact and validate structure.",
147
151
  "",
148
152
  "# Optional: focused analysis artifacts",
149
153
  `You may also write additional analysis files to ${taskDir}/artifacts/<name>.md`,
150
- "for deep dives on specific topics (e.g. architecture analysis, API comparison, risk assessment).",
154
+ "for deep dives on specific topics (e.g. architecture analysis, API comparison, design-option/tradeoff analysis, risk assessment).",
151
155
  "Each artifact must start with # <Title>. Content is freeform. These are reviewed alongside USER_REQUEST.md and RESEARCH.md.",
152
156
  "Do NOT duplicate content already in RESEARCH.md — artifacts are for supplementary deep dives.",
153
157
  ].join("\n");
@@ -297,6 +297,42 @@ Fix bug.
297
297
  expect(validateExitCriteria(pass, "debug", "debug")).toEqual({ ok: true });
298
298
  });
299
299
 
300
+ it("validates review phase — requires artifacts and an ANCHORS-bearing final_pass file", () => {
301
+ const missing = makeTempDir();
302
+ expect(validateExitCriteria(missing, "review", "review")).toEqual({
303
+ ok: false,
304
+ reason: "USER_REQUEST.md does not exist or is empty",
305
+ });
306
+
307
+ const noAnchors = makeTempDir();
308
+ writeFileSync(join(noAnchors, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
309
+ writeFileSync(join(noAnchors, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
310
+ const res1 = validateExitCriteria(noAnchors, "review", "review");
311
+ expect(res1.ok).toBe(false);
312
+ expect((res1 as { reason: string }).reason).toContain("ANCHORS");
313
+
314
+ const emptyAnchorsFile = makeTempDir();
315
+ writeFileSync(join(emptyAnchorsFile, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
316
+ writeFileSync(join(emptyAnchorsFile, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
317
+ mkdirSync(join(emptyAnchorsFile, "code-reviews"), { recursive: true });
318
+ writeFileSync(join(emptyAnchorsFile, "code-reviews", "1_final_pass-1.md"), "no anchors here", "utf-8");
319
+ expect(validateExitCriteria(emptyAnchorsFile, "review", "review").ok).toBe(false);
320
+
321
+ const zeroFindings = makeTempDir();
322
+ writeFileSync(join(zeroFindings, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
323
+ writeFileSync(join(zeroFindings, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
324
+ mkdirSync(join(zeroFindings, "code-reviews"), { recursive: true });
325
+ writeFileSync(join(zeroFindings, "code-reviews", "1_final_pass-1.md"), "# Review\nANCHORS: (none)\n", "utf-8");
326
+ expect(validateExitCriteria(zeroFindings, "review", "review")).toEqual({ ok: true });
327
+
328
+ const withFindings = makeTempDir();
329
+ writeFileSync(join(withFindings, "USER_REQUEST.md"), VALID_USER_REQUEST, "utf-8");
330
+ writeFileSync(join(withFindings, "RESEARCH.md"), VALID_RESEARCH, "utf-8");
331
+ mkdirSync(join(withFindings, "code-reviews"), { recursive: true });
332
+ writeFileSync(join(withFindings, "code-reviews", "1_final_pass-2.md"), "ANCHORS:\n- src/a.ts:12 must fix\n", "utf-8");
333
+ expect(validateExitCriteria(withFindings, "review", "review")).toEqual({ ok: true });
334
+ });
335
+
300
336
  it("returns unknown phase error for done", () => {
301
337
  const dir = makeTempDir();
302
338
  expect(validateExitCriteria(dir, "implement", "done")).toEqual({
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from "fs";
2
2
  import { join } from "path";
3
3
  import type { TaskType, Phase } from "../state.js";
4
- import { getLatestSynthesizedPlan } from "../context.js";
4
+ import { getLatestSynthesizedPlan, hasFinalPassAnchors } from "../context.js";
5
5
  import { validatePlan, validateResearch, validateUserRequest } from "../validate-artifacts.js";
6
6
 
7
7
  const USER_REQUEST_TEMPLATE = [
@@ -156,6 +156,16 @@ export function validateExitCriteria(
156
156
  };
157
157
  }
158
158
 
159
+ if (!hasFinalPassAnchors(taskDir)) {
160
+ return {
161
+ ok: false,
162
+ reason:
163
+ "No ANCHORS-bearing final review file exists. Write the review findings to " +
164
+ "`code-reviews/*_final_pass-*.md` with an `ANCHORS:` block (use `ANCHORS: (none)` " +
165
+ "if there are no findings) before completing the review.",
166
+ };
167
+ }
168
+
159
169
  return { ok: true };
160
170
  }
161
171
 
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reviewSystemPrompt } from "./review-task.js";
3
+
4
+ describe("review-task reviewSystemPrompt", () => {
5
+ it("mandates writing an ANCHORS-bearing final_pass file via the generic write tool", () => {
6
+ const prompt = reviewSystemPrompt("/tmp/task", "/tmp/cwd");
7
+ expect(prompt).toContain("/tmp/task/code-reviews/<unix-epoch-seconds>_final_pass-1.md");
8
+ expect(prompt).toContain("ANCHORS:");
9
+ expect(prompt).toContain("<relative/path/from/repo/root>:<line> — <one-line finding>");
10
+ expect(prompt).toContain("(none)");
11
+ expect(prompt).toContain("GENERIC write tool");
12
+ });
13
+
14
+ it("still captures the structured USER_REQUEST.md / RESEARCH.md deliverables", () => {
15
+ const prompt = reviewSystemPrompt("/tmp/task", "/tmp/cwd");
16
+ expect(prompt).toContain("USER_REQUEST.md");
17
+ expect(prompt).toContain("RESEARCH.md");
18
+ expect(prompt).toContain("pp_write_state_file");
19
+ });
20
+ });
@@ -4,31 +4,59 @@ export function reviewSystemPrompt(taskDir: string, cwd: string): string {
4
4
  "",
5
5
  `First, register all git repositories you'll work in using pp_register_repo (including the root: ${cwd}). For each, determine the base branch by examining the current branch and remote tracking.`,
6
6
  "",
7
- "You are reviewing code changes. USER_REQUEST.md describes what to review.",
8
- "Read it first to understand the scope.",
7
+ "You are reviewing code changes. The user will describe what to review in their first chat message",
8
+ "(e.g. a branch, a commit range, uncommitted changes, or a GitHub PR URL). There is NO pre-seeded",
9
+ "USER_REQUEST.md — you must capture the user's request and your findings into the state files yourself.",
10
+ "If the scope is ambiguous, ask the user before diving in.",
11
+ "",
12
+ "If the review scope is a GitHub PR: for each registered repo, resolve the PR with `gh pr view --json number,headRefName,headRefOid,url`, then call pp_checkout_pr_head with that repo's `headRefName`/`headRefOid` to land it on the PR head BEFORE analyzing it. If the tool HALTS (dirty tree, different branch, or diverged branch), relay its message to the user and wait for them to resolve it before continuing. For a branch, commit-range, or uncommitted-changes review, do NOT call pp_checkout_pr_head.",
9
13
  "",
10
14
  "Use available tools to analyze the changes:",
11
15
  "- git diff, git log, git show for examining commits and diffs",
12
16
  "- read, lsp, grep, find for understanding the code",
13
17
  "- gh pr view for GitHub PR context if a PR URL is mentioned",
18
+ "- cbm_search/cbm_search_code, lsp goToDefinition/findReferences/hover, cbm_trace, ast_search for deeper analysis",
14
19
  "",
15
20
  "Delegate to subagents where useful (see the delegation guidance in your system prompt) — spawn multiple explores in parallel for broad searches.",
16
21
  "",
17
- "Write your findings:",
18
- `- ${taskDir}/USER_REQUEST.md — update with a clear problem statement of issues found`,
19
- `- ${taskDir}/RESEARCH.md — detailed technical analysis`,
20
- "",
21
- "USER_REQUEST.md format:",
22
- "- # User Request",
23
- "- ## Problem",
24
- "- ## Constraints",
25
- "",
26
- "RESEARCH.md format:",
27
- "- ## Affected Code",
28
- "- ## Architecture Context",
29
- "- ## Constraints & Edge Cases",
30
- "- ## Open Questions (optional)",
22
+ "Produce two files:",
23
+ `- ${taskDir}/USER_REQUEST.md — MUST follow this exact structure:`,
24
+ " # User Request",
25
+ " <1-3 sentence distillation of what the user asked you to review>",
26
+ " ## Problem",
27
+ " <What is being reviewed and why, in the user's words. PR/issue link if provided.>",
28
+ " ## Constraints",
29
+ " <Boundaries the user explicitly stated. Only user-stated info, no agent findings.>",
30
+ `- ${taskDir}/RESEARCH.md — MUST follow this exact structure:`,
31
+ " ## Affected Code",
32
+ " <file:symbol — one-line role, per line>",
33
+ " ## Architecture Context",
34
+ " <Dense bullets. How the changed pieces connect. Sub-group by subsystem for complex reviews.>",
35
+ " ## Constraints & Edge Cases",
36
+ " - MUST: <hard requirements the change must satisfy>",
37
+ " - RISK: <bugs, regressions, edge cases the change could break>",
38
+ " ## Open Questions",
39
+ " <Low-confidence concerns or items needing user input. Omit section if none.>",
40
+ "",
41
+ "These files are validated programmatically. Missing sections or unexpected sections will be rejected.",
42
+ "Use pp_write_state_file / pp_edit_state_file (NOT the generic write/edit) for these .pp state files — they keep the output compact and validate structure.",
43
+ "Keep USER_REQUEST.md current: update it whenever the user clarifies the review scope, so it never goes stale.",
44
+ "",
45
+ "# Optional: focused analysis artifacts",
46
+ `You may also write additional analysis files to ${taskDir}/artifacts/<name>.md`,
47
+ "for deep dives on specific findings (e.g. a subtle bug, a design concern, a risk assessment).",
48
+ "Each artifact must start with # <Title>. Content is freeform. These are reviewed alongside USER_REQUEST.md and RESEARCH.md.",
49
+ "Do NOT duplicate content already in RESEARCH.md — artifacts are for supplementary deep dives.",
31
50
  "",
32
51
  "Focus on: correctness, edge cases, style consistency, missing tests, potential bugs.",
52
+ "You are investigating and documenting findings — you do NOT apply fixes in this phase.",
53
+ "",
54
+ "# Required: findings file with an ANCHORS block",
55
+ `When you finish reviewing, you MUST write your findings to ${taskDir}/code-reviews/<unix-epoch-seconds>_final_pass-1.md (use the GENERIC write tool, NOT pp_write_state_file — code-reviews/ is not a managed state path; the <unix-epoch-seconds> prefix, e.g. the output of \`date +%s\`, keeps it ordered against automated review-cycle files). Create the code-reviews/ directory if it does not exist.`,
56
+ "That file MUST include a machine-readable `ANCHORS:` block for the accepted findings — one line per finding in EXACTLY this format (the /pp Publish step consumes these lines):",
57
+ "ANCHORS:",
58
+ "<relative/path/from/repo/root>:<line> — <one-line finding>",
59
+ "Use the real file:line for each finding — do NOT invent locations. Write `ANCHORS:` followed by `(none)` if there are no anchorable accepted findings.",
60
+ "This is a normal deliverable of every review, independent of any automated review cycle: without it, publishing has nothing to anchor.",
33
61
  ].join("\n");
34
62
  }
@@ -45,6 +45,32 @@ describe("reviewSystemPrompt apply_feedback wording", () => {
45
45
  expect(impl).toContain("Implement the fixes");
46
46
  });
47
47
 
48
+ it("standalone review phase omits the fix-plan/implement/afterImplement tail", () => {
49
+ const prompt = reviewSystemPrompt("/tmp/task", 1, "review", "guided");
50
+ expect(prompt).toContain("REVIEW CYCLE");
51
+ expect(prompt).toContain("standalone review");
52
+ expect(prompt).not.toContain("Create a fix plan");
53
+ expect(prompt).not.toContain("Implement the fixes");
54
+ expect(prompt).not.toContain("Run afterImplement commands");
55
+ expect(prompt).toContain("code-reviews/");
56
+ });
57
+
58
+ it("standalone review synthesis emits an ANCHORS block and defers publishing to the user", () => {
59
+ const prompt = reviewSystemPrompt("/tmp/task", 1, "review", "guided");
60
+ expect(prompt).toContain("ANCHORS:");
61
+ expect(prompt).toContain("Do NOT publish findings now");
62
+ expect(prompt).not.toContain("do NOT call `gh` yourself");
63
+ });
64
+
65
+ it("review-cycle synthesis does NOT embed the Review Summary schema (delivered via the closing block)", () => {
66
+ // The schema is injected once by constraintsBlock -> closingBlockInstruction("review"),
67
+ // which is always prepended to the phase prompt; repeating it here would duplicate it.
68
+ for (const mode of ["guided", "autonomous"] as const) {
69
+ const prompt = reviewSystemPrompt("/tmp/task", 1, "review", mode);
70
+ expect(prompt).not.toContain("## Review Summary");
71
+ }
72
+ });
73
+
48
74
  it("points each phase at the directory its reviewers actually write to", () => {
49
75
  // plan reviewers write to plan-reviews (planning.ts) and outputs load from
50
76
  // plan-reviews (context.ts); the prompt must match, not code-reviews.
@@ -49,6 +49,40 @@ export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string
49
49
  ].join("\n");
50
50
  }
51
51
 
52
+ // A standalone review task's "review" phase has nothing to implement: the output
53
+ // is the synthesized findings. So it must NOT get the "create a fix plan /
54
+ // implement / run afterImplement" tail that implement-phase review uses. The user
55
+ // publishes findings (as file comments and/or GitHub PR comments) via the /pp
56
+ // "Publish" menu after synthesis — which consumes the ANCHORS: block below.
57
+ if (phase === "review") {
58
+ return [
59
+ `[PI-PI — REVIEW CYCLE (pass ${pass})]`,
60
+ "",
61
+ "Reviewer outputs are ready.",
62
+ `Read them from ${reviewsDir}/ and synthesize the findings.`,
63
+ "",
64
+ "You are a SYNTHESIZER: merge the reviewer outputs. Do NOT write your own review from scratch.",
65
+ `- Do NOT create the ${reviewsDirName}/ directory yourself — the extension manages it.`,
66
+ "- This is a standalone review: do NOT create a fix plan, implement fixes, run afterImplement commands, or commit.",
67
+ "- Do NOT publish findings now (no source edits, no `gh`); the user triggers publishing from the /pp menu.",
68
+ "",
69
+ "# Your job (in this order):",
70
+ `1. Read ALL reviewer outputs from ${reviewsDir}/`,
71
+ `2. Synthesize into ${reviewsDir}/<unix-epoch-seconds>_final_pass-${pass}.md (prefix with the current Unix epoch seconds, e.g. \`date +%s\`, so the file orders chronologically)`,
72
+ "3. Present the synthesis to the user",
73
+ "",
74
+ "In the synthesized final-review file you MUST include a machine-readable `ANCHORS:` block for the accepted findings — one line per finding in EXACTLY this format (a later publish step consumes these lines):",
75
+ "ANCHORS:",
76
+ "<relative/path/from/repo/root>:<line> — <one-line finding>",
77
+ "Use the `ANCHORS:` blocks the reviewers emitted as the source of file:line — do NOT invent locations. Write `ANCHORS:` followed by `(none)` if there are no anchorable accepted findings.",
78
+ "",
79
+ "PRIVACY: phrase every finding as a self-contained observation about the code. Do NOT reference private or internal details, `the ticket`, issue trackers, or internal design docs — these findings are published as PR/file comments. Say what is wrong in the code, not that it violates a private document's goal.",
80
+ // The Review Summary schema is delivered by the standardized closing block
81
+ // (constraints.ts closingBlockInstruction, gated to phase==="review"), which is
82
+ // always prepended to this prompt — so it is intentionally NOT repeated here.
83
+ ].join("\n");
84
+ }
85
+
52
86
  // In autonomous plan/implement the phase does NOT complete until the agent
53
87
  // re-calls pp_phase_complete: that is what finalizes the pass and advances the
54
88
  // phase. Telling it to "present to the user" and wait causes the plan phase to
@@ -102,7 +136,7 @@ export function reviewSystemPrompt(taskDir: string, pass: number, phase?: string
102
136
  "",
103
137
  "# Your job (in this order):",
104
138
  `1. Read ALL reviewer outputs from ${reviewsDir}/`,
105
- `2. Synthesize into ${reviewsDir}/<timestamp>_final_pass-${pass}.md`,
139
+ `2. Synthesize into ${reviewsDir}/<unix-epoch-seconds>_final_pass-${pass}.md (prefix with the current Unix epoch seconds, e.g. \`date +%s\`, so the file orders chronologically)`,
106
140
  finalStep,
107
141
  ...tail,
108
142
  ].join("\n");
@@ -132,8 +166,11 @@ export async function spawnCodeReviewers(
132
166
 
133
167
  const userRequest = readFileSync(urPath, "utf-8");
134
168
  const research = readFileSync(resPath, "utf-8");
135
- const synthesizedPlan = getLatestSynthesizedPlan(taskDir);
136
- if (!synthesizedPlan) {
169
+ // A standalone review task (phase "review") has no synthesized plan by design —
170
+ // reviewers assess the diff against USER_REQUEST.md/RESEARCH.md instead. The
171
+ // plan is only required where one legitimately exists (the implement phase).
172
+ const synthesizedPlan = phase === "review" ? undefined : (getLatestSynthesizedPlan(taskDir) ?? undefined);
173
+ if (phase !== "review" && !synthesizedPlan) {
137
174
  send(
138
175
  { customType: "pp-code-reviews-error", content: "Cannot start code review: no synthesized plan found.", display: true },
139
176
  "context",
@@ -1,11 +1,34 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { join } from "path";
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "fs";
4
+ import { tmpdir } from "os";
3
5
  import { getDefaultConfig, GLOBAL_CONFIG_PATH, parseDuration } from "./config.js";
4
6
  import * as configModule from "./config.js";
5
7
  import * as flantInfra from "./flant-infra.js";
6
- import { formatDuration, formatSourceTags, getConfigSourceInfo, pickMaxReviewPasses, showUsage } from "./pp-menu.js";
8
+ import { formatDuration, formatSourceTags, getConfigSourceInfo, pickMaxReviewPasses, publishGuard, publishFileCommentsBanner, publishPrCommentsBanner, showActiveTaskMenu, showUsage } from "./pp-menu.js";
7
9
  import { createUsageTracker } from "./usage-tracker.js";
8
10
 
11
+ // Drives showActiveTaskMenu's submenu navigation by scripting selectOption answers.
12
+ const askQueue: string[] = [];
13
+ const askQuestions: string[] = [];
14
+ vi.mock("../../3p/pi-ask-user/index.js", () => ({
15
+ isCancel: (r: any) => r?.__cancel === true,
16
+ askUser: vi.fn(async (_ctx: any, opts: any) => {
17
+ askQuestions.push(opts.question);
18
+ const next = askQueue.shift();
19
+ if (next === undefined || next === "__ESC__") return { __cancel: true, reason: "user" };
20
+ return { kind: "selection", selections: [next] };
21
+ }),
22
+ }));
23
+
24
+ // Scripts per-repo Plannotator outcomes for the #3a interleaved cursor tests.
25
+ const plannotatorResults: Array<{ approved: boolean; feedback?: string }> = [];
26
+ vi.mock("./plannotator.js", () => ({
27
+ cancelPendingPlannotatorWait: () => {},
28
+ openPlannotator: vi.fn(async () => ({ opened: true, reviewId: "rev" })),
29
+ waitForPlannotatorResult: vi.fn(async () => plannotatorResults.shift() ?? { approved: true }),
30
+ }));
31
+
9
32
  const USAGE_TRACKER_SYMBOL = Symbol.for("pi-pi:usage-tracker");
10
33
 
11
34
  afterEach(() => {
@@ -205,3 +228,186 @@ describe("pickMaxReviewPasses", () => {
205
228
  expect(await pickMaxReviewPasses(makeInputCtx([""]), 3)).toBeNull();
206
229
  });
207
230
  });
231
+
232
+ describe("publishGuard", () => {
233
+ let taskDir: string;
234
+
235
+ afterEach(() => {
236
+ if (taskDir) rmSync(taskDir, { recursive: true, force: true });
237
+ });
238
+
239
+ function makeTask(): string {
240
+ taskDir = mkdtempSync(join(tmpdir(), "pp-guard-"));
241
+ return taskDir;
242
+ }
243
+
244
+ it("blocks publishing when no review has run (no code-reviews dir)", () => {
245
+ const dir = makeTask();
246
+ const msg = publishGuard(dir);
247
+ expect(msg).toBeTruthy();
248
+ expect(msg).toContain("review pass");
249
+ });
250
+
251
+ it("blocks publishing when a final_pass file exists but carries no ANCHORS block", () => {
252
+ const dir = makeTask();
253
+ mkdirSync(join(dir, "code-reviews"));
254
+ writeFileSync(join(dir, "code-reviews", "20260101-000000_final_pass-1.md"), "# Findings\n\nSome prose without anchors.\n");
255
+ expect(publishGuard(dir)).toBeTruthy();
256
+ });
257
+
258
+ it("allows publishing when the latest final_pass file has an ANCHORS block", () => {
259
+ const dir = makeTask();
260
+ mkdirSync(join(dir, "code-reviews"));
261
+ writeFileSync(join(dir, "code-reviews", "20260101-000000_final_pass-1.md"), "ANCHORS:\nsrc/a.ts:10 — bug\n");
262
+ expect(publishGuard(dir)).toBeUndefined();
263
+ });
264
+
265
+ it("resolves the newest final_pass file by name and honors its ANCHORS block", () => {
266
+ const dir = makeTask();
267
+ mkdirSync(join(dir, "code-reviews"));
268
+ writeFileSync(join(dir, "code-reviews", "20260101-000000_final_pass-1.md"), "ANCHORS:\nsrc/a.ts:10 — bug\n");
269
+ writeFileSync(join(dir, "code-reviews", "20260102-000000_final_pass-2.md"), "# Findings\n\nno anchors here\n");
270
+ expect(publishGuard(dir)).toBeTruthy();
271
+ });
272
+ });
273
+
274
+ describe("publish banners", () => {
275
+ it("PR banner posts one bundled COMMENT review per repo, pre-validated against the diff", () => {
276
+ const banner = publishPrCommentsBanner("/tmp/task");
277
+ expect(banner).toContain("pulls/<number>/reviews");
278
+ expect(banner).toContain("event=COMMENT");
279
+ expect(banner).not.toContain("event=APPROVE");
280
+ expect(banner).toContain("never APPROVE or REQUEST_CHANGES");
281
+ expect(banner).toContain("PRE-VALIDATE");
282
+ expect(banner).toContain("all-or-nothing");
283
+ expect(banner).toContain("Findings not anchorable to the diff:");
284
+ expect(banner).toContain("(generated by pi-pi)");
285
+ });
286
+
287
+ it("PR banner idempotency matches on path+line+footer+body", () => {
288
+ const banner = publishPrCommentsBanner("/tmp/task");
289
+ expect(banner).toContain("footer, AND body text all match");
290
+ expect(banner).toContain("Two distinct findings on the same line are NOT duplicates");
291
+ });
292
+
293
+ it("both banners carry the privacy instruction", () => {
294
+ for (const banner of [publishPrCommentsBanner("/tmp/task"), publishFileCommentsBanner("/tmp/task")]) {
295
+ expect(banner).toContain("PRIVACY:");
296
+ expect(banner).toContain("the ticket");
297
+ expect(banner).toContain("self-contained");
298
+ }
299
+ });
300
+ });
301
+
302
+ describe("showActiveTaskMenu Publish/Next Back navigation (#6)", () => {
303
+ let taskDir: string;
304
+
305
+ afterEach(() => {
306
+ askQueue.length = 0;
307
+ askQuestions.length = 0;
308
+ if (taskDir) rmSync(taskDir, { recursive: true, force: true });
309
+ });
310
+
311
+ function makeReviewOrchestrator(): any {
312
+ taskDir = mkdtempSync(join(tmpdir(), "pp-nav-"));
313
+ return {
314
+ active: {
315
+ type: "review",
316
+ dir: taskDir,
317
+ state: { phase: "review", step: "llm_work", mode: "guided" },
318
+ },
319
+ transitionController: {
320
+ isRunning: () => false,
321
+ abortMainAgent: () => {},
322
+ },
323
+ cancelPendingRetry: () => {},
324
+ abortAllSubagents: () => {},
325
+ };
326
+ }
327
+
328
+ const ctx = { ui: { notify: () => {} }, waitForIdle: async () => {}, abort: () => {} };
329
+
330
+ it("Publish 'Back' re-renders the Next submenu instead of the top-level menu", async () => {
331
+ const orchestrator = makeReviewOrchestrator();
332
+ // /pp -> Next -> Publish -> Back (should return to Next) -> Back (to top-level) -> Back (exit).
333
+ askQueue.push("Next", "Publish", "Back", "Back", "Back");
334
+ const result = await showActiveTaskMenu(orchestrator, ctx, "/pp", "tool");
335
+ expect(result).toBe("");
336
+ // After Publish's Back we must see the "Next" submenu rendered again before the
337
+ // top-level menu reappears: questions = [top, Next, Publish, Next(again), top].
338
+ const nextRenders = askQuestions.filter((q) => q === "Next").length;
339
+ expect(nextRenders).toBe(2);
340
+ expect(askQuestions[askQuestions.length - 1]).toContain("/pp");
341
+ });
342
+
343
+ it("Next 'Back' returns straight to the top-level menu", async () => {
344
+ const orchestrator = makeReviewOrchestrator();
345
+ askQueue.push("Next", "Back", "Back");
346
+ const result = await showActiveTaskMenu(orchestrator, ctx, "/pp", "tool");
347
+ expect(result).toBe("");
348
+ expect(askQuestions.filter((q) => q === "Next").length).toBe(1);
349
+ // Top-level rendered twice (initial + after Next's Back), Next once.
350
+ expect(askQuestions.filter((q) => q.startsWith("/pp")).length).toBe(2);
351
+ });
352
+
353
+ it("Review submenu 'Editor review' Back returns to Review, not the top-level menu (#3d)", async () => {
354
+ const orchestrator = makeReviewOrchestrator();
355
+ // /pp -> Review -> Review on my own -> Editor review Back (should return to
356
+ // Review) -> Review Back (to top-level) -> top-level Back (exit).
357
+ askQueue.push("Review", "Review on my own", "Back", "Back", "Back");
358
+ const result = await showActiveTaskMenu(orchestrator, ctx, "/pp", "tool");
359
+ expect(result).toBe("");
360
+ // Review submenu rendered twice (initial + after the Editor-review Back).
361
+ expect(askQuestions.filter((q) => q === "Review").length).toBe(2);
362
+ // Top-level rendered twice (initial + after Review's explicit Back).
363
+ expect(askQuestions.filter((q) => q.startsWith("/pp")).length).toBe(2);
364
+ });
365
+
366
+ it("resumes the per-repo Plannotator cursor and interleaves fixes (#3a)", async () => {
367
+ const orchestrator = makeReviewOrchestrator();
368
+ orchestrator.active.type = "implement";
369
+ orchestrator.active.state.phase = "implement";
370
+ orchestrator.active.state.repos = [
371
+ { path: "/repo/a", isRoot: true },
372
+ { path: "/repo/b", isRoot: false },
373
+ ];
374
+ orchestrator.active.state.plannotatorCursor = { repoPaths: ["/repo/a", "/repo/b"], index: 0 };
375
+ orchestrator.pi = { exec: vi.fn(async () => ({ code: 0, stdout: "", stderr: "" })) };
376
+ orchestrator.config = getDefaultConfig();
377
+
378
+ // First repo: choose a diff scope, Plannotator returns NEEDS_CHANGES → the
379
+ // menu exits with a work instruction and the cursor advances to repo b.
380
+ plannotatorResults.push({ approved: false, feedback: "fix the thing" });
381
+ askQueue.push("Uncommitted changes");
382
+ const first = await showActiveTaskMenu(orchestrator, ctx, "/pp", "tool");
383
+ expect(first).toContain("Plannotator requested changes");
384
+ expect(first).toContain("fix the thing");
385
+ expect(orchestrator.active.state.plannotatorCursor).toEqual({ repoPaths: ["/repo/a", "/repo/b"], index: 1 });
386
+
387
+ // Next /pp resumes at repo b; APPROVED → cursor is cleared and the loop ends,
388
+ // falling through to the normal menu (top-level Back exits).
389
+ plannotatorResults.push({ approved: true });
390
+ askQueue.push("Uncommitted changes", "Back");
391
+ const second = await showActiveTaskMenu(orchestrator, ctx, "/pp", "tool");
392
+ expect(second).toBe("");
393
+ expect(orchestrator.active.state.plannotatorCursor).toBeUndefined();
394
+ });
395
+
396
+ it("blocks a second review while one is already running (#3b)", async () => {
397
+ const orchestrator = makeReviewOrchestrator();
398
+ orchestrator.active.state.reviewCycle = { kind: "auto", step: "await_reviewers", pass: 1 };
399
+ // isRunning() true so the top-level "Review" option is offered.
400
+ orchestrator.transitionController.isRunning = () => true;
401
+ let notified = "";
402
+ const notifyCtx = { ui: { notify: (t: string) => { notified = t; } }, waitForIdle: async () => {}, abort: () => {} };
403
+ // Pick Review (blocked → notify + back to top-level menu), then Back to exit.
404
+ askQueue.push("Review", "Back");
405
+ const result = await showActiveTaskMenu(orchestrator, notifyCtx, "/pp", "tool");
406
+ expect(result).toBe("");
407
+ expect(notified).toBe("A review is already running");
408
+ // The live cycle is untouched (not finalized/nulled) and the top-level menu
409
+ // re-rendered rather than /pp exiting.
410
+ expect(orchestrator.active.state.reviewCycle).toEqual({ kind: "auto", step: "await_reviewers", pass: 1 });
411
+ expect(askQuestions.filter((q) => q.startsWith("/pp")).length).toBe(2);
412
+ });
413
+ });