@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
@@ -8,7 +8,7 @@ import { TOOLS_BLOCK, ALL_CBM_TOOLS, EXA_TOOLS, PRINCIPLES_BLOCK } from "./tool-
8
8
  export function createCodeReviewerAgent(
9
9
  variant: string,
10
10
  variants: Record<string, VariantConfig>,
11
- taskArtifacts: { userRequest: string; research: string; synthesizedPlan: string; manifest?: { title: string; path: string }[] },
11
+ taskArtifacts: { userRequest: string; research: string; synthesizedPlan?: string; manifest?: { title: string; path: string }[] },
12
12
  outputPath: string,
13
13
  contextDirs: string[],
14
14
  phase?: string,
@@ -21,6 +21,9 @@ export function createCodeReviewerAgent(
21
21
  const contextFiles = loadAllContextFiles(contextDirs, "codeReviewer", "system", phase, getModelInfo(variantConfig.model));
22
22
  const contextBlock = contextFiles.map((f) => f.content).join("\n\n");
23
23
  const repoContext = buildRepoContext(repos);
24
+ // A standalone review task (phase "review") has no synthesized plan: review the
25
+ // diff against USER_REQUEST.md/RESEARCH.md, not an implementation plan.
26
+ const hasPlan = typeof taskArtifacts.synthesizedPlan === "string" && taskArtifacts.synthesizedPlan.trim().length > 0;
24
27
 
25
28
  return {
26
29
  frontmatter: {
@@ -53,11 +56,15 @@ export function createCodeReviewerAgent(
53
56
  "3. Read changed files for full context",
54
57
  "4. Run lsp diagnostics on changed files",
55
58
  "5. Use lsp findReferences to check callers of modified functions",
56
- "6. Check the implementation against the plan",
59
+ hasPlan
60
+ ? "6. Check the implementation against the plan"
61
+ : "6. Check the changes against USER_REQUEST.md and RESEARCH.md (there is no implementation plan for a standalone review)",
57
62
  "",
58
63
  "Review criteria:",
59
64
  "- Bugs: logic errors, off-by-ones, null handling, race conditions",
60
- "- Correctness: does it match the plan and user request?",
65
+ hasPlan
66
+ ? "- Correctness: does it match the plan and user request?"
67
+ : "- Correctness: does it match the user request and the reviewed scope?",
61
68
  "- Quality: error handling, edge cases, type safety",
62
69
  "- Missing: untested paths, unhandled errors, incomplete implementations",
63
70
  "",
@@ -81,6 +88,16 @@ export function createCodeReviewerAgent(
81
88
  "- MINOR: (nice to have)",
82
89
  "- OPEN QUESTIONS: (low-confidence concerns, speculative follow-ups)",
83
90
  "",
91
+ "After the findings, include a machine-readable ANCHORS block so the synthesizer can place",
92
+ "the findings at their exact locations (in source AI_COMMENT markers and/or GitHub PR line comments).",
93
+ "Emit one line per actionable finding (CRITICAL/MAJOR/MINOR), in this EXACT format:",
94
+ "ANCHORS:",
95
+ "<relative/path/from/repo/root>:<line> — <severity>: <one-line finding>",
96
+ "Rules:",
97
+ "- Use the repo-relative path (as `git diff` shows it) and a single 1-based line number on the NEW side of the diff.",
98
+ "- One finding per line; omit findings you cannot pin to a concrete file:line (keep those in OPEN QUESTIONS).",
99
+ "- If there are no actionable findings, write `ANCHORS:` followed by `(none)`.",
100
+ "",
84
101
  "You may spawn ONLY explore/librarian subagents (subagent_type is REQUIRED — calls without it are rejected):",
85
102
  '- Agent(subagent_type="explore", ...) — codebase research. Prefer this for most lookups. Fast and cheap.',
86
103
  '- Agent(subagent_type="librarian", ...) — external docs, library APIs, web research.',
@@ -97,8 +114,7 @@ export function createCodeReviewerAgent(
97
114
  "=== RESEARCH ===",
98
115
  taskArtifacts.research,
99
116
  "",
100
- "=== SYNTHESIZED PLAN ===",
101
- taskArtifacts.synthesizedPlan,
117
+ ...(hasPlan ? ["=== SYNTHESIZED PLAN ===", taskArtifacts.synthesizedPlan as string, ""] : []),
102
118
  ...(repoContext ? [repoContext] : []),
103
119
  "",
104
120
  formatManifestBlock(taskArtifacts.manifest ?? []),
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { completionLine, constraintsBlock } from "./constraints.js";
2
+ import { closingBlockInstruction, completionLine, constraintsBlock } from "./constraints.js";
3
3
 
4
4
  describe("completionLine", () => {
5
5
  it("guided plan and implement instruct calling pp_phase_complete on completion", () => {
@@ -28,6 +28,44 @@ describe("completionLine", () => {
28
28
  expect(completionLine(phase, "autonomous")).toContain("call pp_phase_complete");
29
29
  }
30
30
  });
31
+
32
+ it("guided handoff phases require the standardized closing block", () => {
33
+ for (const phase of ["brainstorm", "review", "debug"] as const) {
34
+ const line = completionLine(phase, "guided");
35
+ expect(line).toContain("the standardized block");
36
+ expect(line).toContain("Advance via the /pp menu");
37
+ }
38
+ });
39
+
40
+ it("autonomous phases do not emit the prose closing block", () => {
41
+ expect(completionLine("brainstorm", "autonomous")).not.toContain("Advance via the /pp menu");
42
+ });
43
+ });
44
+
45
+ describe("closingBlockInstruction", () => {
46
+ it("spells out the exact block with a blank line between summary and advance, no rules", () => {
47
+ const block = closingBlockInstruction("brainstorm");
48
+ expect(block).toContain("Advance via the /pp menu to move into plan");
49
+ expect(block).not.toContain("────");
50
+ expect(block).toContain("/pp");
51
+ expect(block).toContain("✅ <one-sentence summary of what this phase produced>\n\n▶ Advance via the /pp menu to move into plan.");
52
+ });
53
+
54
+ it("prepends the Review Summary schema ONLY for the review phase, before the closing block", () => {
55
+ const review = closingBlockInstruction("review");
56
+ expect(review).toContain("## Review Summary");
57
+ expect(review).toContain("| # | Severity | Location | Finding |");
58
+ expect(review).toContain("BLOCKER");
59
+ // The ✅/▶ lines must remain the final lines of the block.
60
+ expect(review.trimEnd().endsWith("▶ Advance via the /pp menu to move into plan.")).toBe(true);
61
+ expect(review.indexOf("## Review Summary")).toBeLessThan(review.indexOf("✅ <one-sentence summary"));
62
+ });
63
+
64
+ it("does NOT emit the Review Summary schema for brainstorm or debug closes", () => {
65
+ for (const phase of ["brainstorm", "debug"] as const) {
66
+ expect(closingBlockInstruction(phase)).not.toContain("## Review Summary");
67
+ }
68
+ });
31
69
  });
32
70
 
33
71
  describe("constraintsBlock", () => {
@@ -3,6 +3,14 @@ import type { Phase, TaskMode } from "../state.js";
3
3
  const READONLY_CONSTRAINT =
4
4
  "You MUST NOT edit, create, or delete any project file (source, tests, config, docs) — only files under .pp/state/ may be written — and you MUST NOT run state-changing shell commands. If you find a fix worth making, record it in your output; do NOT apply it here.";
5
5
 
6
+ // The review phase is read-only EXCEPT for publishing findings: the main agent may
7
+ // insert or remove `AI_COMMENT:` markers in source files (file comments), and may
8
+ // run `gh` to post/read GitHub PR line comments AND bundled pull-request reviews
9
+ // (PR comments) — and nothing else (no fixes, no other edits, no other state-changing
10
+ // commands). This is the reviewer→user mirror of the user→reviewer `AI_REVIEW:` markers.
11
+ const REVIEW_READONLY_CONSTRAINT =
12
+ "You MUST NOT edit, create, or delete any project file (source, tests, config, docs) — only files under .pp/state/ may be written — and you MUST NOT run state-changing shell commands, WITH ONE EXCEPTION: when publishing review findings you MAY insert or remove `AI_COMMENT:` markers in source files (in each file's native comment syntax) and MAY run `gh` to post or read GitHub PR comments — including line comments and bundled pull-request reviews (a review body plus its line comments) — and nothing else. Do NOT apply fixes or make any other source change. If you find a fix worth making, record it in your output.";
13
+
6
14
  const IMPLEMENT_CONSTRAINT =
7
15
  "Implement only the approved plan. Do NOT add scope or change plan items without recording why in the plan. If the same fix fails 3 times, stop and re-plan — do NOT keep retrying the same approach.";
8
16
 
@@ -16,9 +24,62 @@ export function isReadOnlyPhase(phase: Phase): boolean {
16
24
  export function phaseConstraint(phase: Phase): string {
17
25
  if (phase === "implement") return IMPLEMENT_CONSTRAINT;
18
26
  if (phase === "quick") return QUICK_CONSTRAINT;
27
+ if (phase === "review") return REVIEW_READONLY_CONSTRAINT;
19
28
  return READONLY_CONSTRAINT;
20
29
  }
21
30
 
31
+ // Guided phases that stop and hand back to the user (brainstorm, review, debug) end their turn
32
+ // with prose rather than a tool call. To keep that handoff consistent, the model must close with
33
+ // this exact block. NEXT_PHASE_LABEL supplies the phase the /pp menu advances into.
34
+ const NEXT_PHASE_LABEL: Partial<Record<Phase, string>> = {
35
+ brainstorm: "plan",
36
+ review: "plan",
37
+ debug: "plan",
38
+ };
39
+
40
+ // The structured summary a review must print when it finishes, on BOTH review finish paths
41
+ // (the review-cycle synthesis in review.ts and the ordinary review-task close here). Counts
42
+ // reconcile with the `ANCHORS:` block: when it is `(none)`, Anchored=0 and every finding is
43
+ // Un-anchorable. Location is `file:line`, or `file:—` for un-anchorable findings.
44
+ export const REVIEW_SUMMARY_SCHEMA = [
45
+ "## Review Summary",
46
+ "",
47
+ "Scope: <repos/PR/branch/range + change size>",
48
+ "",
49
+ "| Repo | PR | Findings | Anchored | Un-anchorable |",
50
+ "|------|----|----------|----------|---------------|",
51
+ "| <owner/repo> | #<n> | <total> | <in-diff> | <not-in-diff> |",
52
+ "",
53
+ "### Findings",
54
+ "| # | Severity | Location | Finding |",
55
+ "|---|----------|----------|---------|",
56
+ "| 1 | BLOCKER | <file:line or file:—> | <one line> |",
57
+ "",
58
+ "Next step: /pp → Next → Publish to post these as GitHub PR comments or file comments.",
59
+ ].join("\n");
60
+
61
+ // Instruction wrapping REVIEW_SUMMARY_SCHEMA: severity vocabulary is fixed and the summary
62
+ // degrades gracefully to a single-repo / no-PR row when only one repo (or no PR) was resolved.
63
+ export const reviewSummaryInstruction = [
64
+ "Before the closing block, print a structured Review Summary in EXACTLY this shape (fill in the values; severity is one of BLOCKER / MAJOR / MINOR, uppercase):",
65
+ "",
66
+ REVIEW_SUMMARY_SCHEMA,
67
+ "",
68
+ "Counts MUST reconcile with the `ANCHORS:` block: an `(none)` block means Anchored=0 and every finding is Un-anchorable. Fill the table from whatever repos/PR you resolved; degrade to a single-repo / no-PR row when there is only one repo or no PR.",
69
+ ].join("\n");
70
+
71
+ export function closingBlockInstruction(phase: Phase): string {
72
+ const next = NEXT_PHASE_LABEL[phase] ?? "the next phase";
73
+ const summary = phase === "review" ? reviewSummaryInstruction + "\n\n" : "";
74
+ return [
75
+ summary +
76
+ "End that turn with EXACTLY this block, verbatim, as the final lines of your message (fill the summary line with one sentence; change nothing else):",
77
+ "✅ <one-sentence summary of what this phase produced>",
78
+ "",
79
+ `▶ Advance via the /pp menu to move into ${next}.`,
80
+ ].join("\n");
81
+ }
82
+
22
83
  export function completionLine(phase: Phase, mode: TaskMode): string {
23
84
  if (phase === "quick") {
24
85
  return "When the user's request is complete, call pp_phase_complete. Do NOT stop and wait for the user before then.";
@@ -27,12 +88,12 @@ export function completionLine(phase: Phase, mode: TaskMode): string {
27
88
  return "There is no user driving this phase. The moment its work is complete, call pp_phase_complete — do NOT pause, ask for confirmation, or wait for input. Never end a turn with prose: every turn ends in a tool call.";
28
89
  }
29
90
  if (phase === "brainstorm") {
30
- return "This is a conversation. Do NOT call pp_phase_complete yourself — keep going until the user ends it or advances via the /pp menu.";
91
+ return "This is a conversation. Do NOT call pp_phase_complete yourself — keep going until the user ends it or advances via the /pp menu. When you have delivered a complete answer and are handing back for the user to advance, close with the standardized block. " + closingBlockInstruction(phase);
31
92
  }
32
93
  if (phase === "plan" || phase === "implement") {
33
94
  return "When you judge this phase complete, call pp_phase_complete — the extension opens the advance gate for the user to review and confirm. Do NOT instead stop and ask the user to run /pp manually.";
34
95
  }
35
- return "When the work is complete, stop and let the user review and advance it via the /pp menu. Do NOT advance on your own or call pp_phase_complete unprompted.";
96
+ return "When the work is complete, stop and let the user review and advance it via the /pp menu. Do NOT advance on your own or call pp_phase_complete unprompted. Close with the standardized block. " + closingBlockInstruction(phase);
36
97
  }
37
98
 
38
99
  const PHASE_IDENTITY: Record<string, string> = {
@@ -32,6 +32,12 @@ const TOOL_ROUTING_BODY = [
32
32
  "Pass the base branch (the branch this work will be merged into). Must call at the start of each task " +
33
33
  "before doing any work.",
34
34
  "",
35
+ "**pp_checkout_pr_head** (review phase, PR-scoped only): after resolving a repo's PR (e.g. `gh pr view " +
36
+ "--json headRefName,headRefOid`), call this once per repo to land it on its PR head before reviewing. " +
37
+ "The extension fast-forwards a clean branch to the head; if the tree is dirty, on a different branch, or " +
38
+ "diverged it HALTS and returns a message to relay to the user. Do NOT call it for a " +
39
+ "branch/commit-range/uncommitted-changes review, and never run `git checkout` yourself.",
40
+ "",
35
41
  "Find code by concept or behavior:",
36
42
  "- Multi-repo: cbm_search, cbm_search_code, cbm_trace, cbm_changes accept optional project_path (absolute repo path). If omitted, they use the root project.",
37
43
  "- cbm_search: natural-language search (query='deploy release chart')",
@@ -82,19 +88,29 @@ export const TOOLS_BLOCK = ["<tools>", ...TOOL_ROUTING_BODY, "</tools>"].join("\
82
88
  // agent prompt in every phase. Registry-consistent lowercase agent names.
83
89
  export const DELEGATION_BLOCK = [
84
90
  "<delegation>",
85
- "Prefer delegating over doing wide or deep work yourself subagents run in parallel, dig",
86
- "deeper, and preserve your context. subagent_type is REQUIRED (calls without it are rejected).",
87
- "",
88
- '- Find or locate code ("where is X", "how does Y connect", map a flow) → explore',
89
- "- External library / API / documentation knowledge → librarian",
90
- '- Judgment call (design tradeoff, "is this correct", "why is this broken") → advisor',
91
- "- Root-cause a test or build that keeps failing after a quick attempt deep-debugger",
92
- "- A parallel, self-contained implementation subtask task",
93
- "- A code review of your changes — ONLY when the user explicitly asks for one reviewer",
94
- "",
95
- "explore FINDS; advisor JUDGES. Spawn several explores in parallel for broad searches.",
96
- "Don't use advisor for lookups, or deep-debugger for trivial errors. deep-debugger diagnoses",
97
- "only it must NOT write the actual fix. Do NOT spawn reviewer unless the user explicitly",
98
- "asks for a review (the automatic review panel already covers implement/review phases).",
91
+ "Prefer delegating over doing wide or deep work yourself. Subagents run in parallel, dig",
92
+ "deeper, and keep YOUR context clean. subagent_type is REQUIRED (calls without it are rejected).",
93
+ "",
94
+ "USE a subagent when:",
95
+ '- Locating code / mapping a flow ("where is X", "how does Y connect") → explore',
96
+ "- External library / API / framework knowledge → librarian",
97
+ '- A judgment call (design tradeoff, "is this correct", "why is this broken") advisor',
98
+ "- A test/build that keeps failing after one real attempt deep-debugger",
99
+ "- A self-contained, parallelizable implementation slice task",
100
+ "- A code review of your changes — ONLY when the user explicitly asks → reviewer",
101
+ "",
102
+ "If a task is broad or multi-part (investigate/analyze/refactor/audit/migrate a system,",
103
+ "subsystem, flow, or the codebase anything not pinned to one known file/symbol), OPEN with",
104
+ "2–3 parallel `explore` subagents before reading code yourself. Use 4+ only for audits,",
105
+ "migrations, or multi-repo/system work. Give each explore an orthogonal prompt (no duplicate",
106
+ "mapping). explore FINDS; advisor JUDGES.",
107
+ "",
108
+ "Do NOT delegate when:",
109
+ "- You already know the exact file/symbol — just read/edit it directly.",
110
+ "- The task is a single narrow change or a trivial lookup (delegation overhead > work).",
111
+ "- You are mid-edit on a file you understand — keep going; don't spawn to \"map\" it.",
112
+ "- deep-debugger for a trivial/obvious error, or advisor for a plain lookup.",
113
+ "deep-debugger diagnoses only — it must NOT write the actual fix. Do NOT spawn reviewer unless",
114
+ "the user explicitly asks (the automatic review panel already covers implement/review phases).",
99
115
  "</delegation>",
100
116
  ].join("\n");
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { stripAiCommentsFromContent, isAiCommentOnlyChange } from "./ai-comment-cleanup.js";
3
+
4
+ describe("stripAiCommentsFromContent", () => {
5
+ it("drops full-line AI_COMMENT markers in various comment syntaxes", () => {
6
+ const input = [
7
+ "const x = 1;",
8
+ "// AI_COMMENT: this is wrong",
9
+ " # AI_COMMENT: python style",
10
+ "<!-- AI_COMMENT: markdown -->",
11
+ "-- AI_COMMENT: sql style",
12
+ "const y = 2;",
13
+ ].join("\n");
14
+ const { content, removed } = stripAiCommentsFromContent(input);
15
+ expect(removed).toBe(4);
16
+ expect(content).toBe(["const x = 1;", "const y = 2;"].join("\n"));
17
+ });
18
+
19
+ it("strips a trailing AI_COMMENT marker but keeps the code", () => {
20
+ const input = "const z = 3; // AI_COMMENT: off-by-one?";
21
+ const { content, removed } = stripAiCommentsFromContent(input);
22
+ expect(removed).toBe(1);
23
+ expect(content).toBe("const z = 3;");
24
+ });
25
+
26
+ it("leaves prose that merely mentions the token untouched", () => {
27
+ const input = "This paragraph explains what AI_COMMENT: means in docs.";
28
+ const { content, removed } = stripAiCommentsFromContent(input);
29
+ expect(removed).toBe(0);
30
+ expect(content).toBe(input);
31
+ });
32
+
33
+ it("is a no-op when no token present", () => {
34
+ const input = "line one\nline two";
35
+ expect(stripAiCommentsFromContent(input)).toEqual({ content: input, removed: 0 });
36
+ });
37
+
38
+ it("does NOT corrupt a marker that lives inside a string literal", () => {
39
+ const cases = [
40
+ `const s = "// AI_COMMENT: example";`,
41
+ `const AI_COMMENT_MARKER_SYNTAX = "// AI_COMMENT: ...";`,
42
+ `log('# AI_COMMENT: note');`,
43
+ "const t = `<!-- AI_COMMENT: x -->`;",
44
+ ];
45
+ for (const input of cases) {
46
+ expect(stripAiCommentsFromContent(input)).toEqual({ content: input, removed: 0 });
47
+ }
48
+ });
49
+
50
+ it("strips a real trailing marker even when the code has an earlier closed string", () => {
51
+ const input = `url = "http://x"; // AI_COMMENT: note`;
52
+ const { content, removed } = stripAiCommentsFromContent(input);
53
+ expect(removed).toBe(1);
54
+ expect(content).toBe(`url = "http://x";`);
55
+ });
56
+ });
57
+
58
+ describe("isAiCommentOnlyChange", () => {
59
+ it("allows inserting an AI_COMMENT marker line", () => {
60
+ const before = "const x = 1;\nconst y = 2;";
61
+ const after = "const x = 1;\n// AI_COMMENT: off-by-one?\nconst y = 2;";
62
+ expect(isAiCommentOnlyChange(before, after)).toBe(true);
63
+ });
64
+
65
+ it("allows removing an AI_COMMENT marker line", () => {
66
+ const before = "const x = 1;\n// AI_COMMENT: note\nconst y = 2;";
67
+ const after = "const x = 1;\nconst y = 2;";
68
+ expect(isAiCommentOnlyChange(before, after)).toBe(true);
69
+ });
70
+
71
+ it("allows a trailing AI_COMMENT marker", () => {
72
+ expect(isAiCommentOnlyChange("const x = 1;", "const x = 1; // AI_COMMENT: hm")).toBe(true);
73
+ });
74
+
75
+ it("rejects an actual code change", () => {
76
+ expect(isAiCommentOnlyChange("const x = 1;", "const x = 2;")).toBe(false);
77
+ });
78
+
79
+ it("rejects a fix disguised alongside a marker", () => {
80
+ const before = "const x = 1;";
81
+ const after = "const x = 2; // AI_COMMENT: fixed";
82
+ expect(isAiCommentOnlyChange(before, after)).toBe(false);
83
+ });
84
+
85
+ it("treats an identical write as allowed", () => {
86
+ expect(isAiCommentOnlyChange("same", "same")).toBe(true);
87
+ });
88
+ });
89
+
@@ -0,0 +1,73 @@
1
+ export const AI_COMMENT_TOKEN = "AI_COMMENT:";
2
+
3
+ // Remove reviewer→user `AI_COMMENT:` markers from a single file's content.
4
+ // - A line whose comment content is ONLY an AI_COMMENT marker is dropped entirely
5
+ // (covers `// AI_COMMENT: ...`, `#`, `--`, `;`, `<!-- ... -->`, `/* ... */`).
6
+ // - A code line with a trailing `<comment-open> AI_COMMENT: ...` is stripped back
7
+ // to the code, dropping the trailing marker only.
8
+ // Anything else containing the token is left untouched (do not corrupt prose).
9
+ // True when `prefix` ends inside an unclosed string literal (single, double, or
10
+ // backtick quote). Used to avoid stripping a `// AI_COMMENT:` that lives inside a
11
+ // string rather than being a real trailing comment. Escaped quotes are honored.
12
+ function insideStringLiteral(prefix: string): boolean {
13
+ let quote: string | null = null;
14
+ for (let i = 0; i < prefix.length; i++) {
15
+ const ch = prefix[i];
16
+ if (ch === "\\") {
17
+ i++;
18
+ continue;
19
+ }
20
+ if (quote) {
21
+ if (ch === quote) quote = null;
22
+ } else if (ch === '"' || ch === "'" || ch === "`") {
23
+ quote = ch;
24
+ }
25
+ }
26
+ return quote !== null;
27
+ }
28
+
29
+ export function stripAiCommentsFromContent(content: string): { content: string; removed: number } {
30
+ if (!content.includes(AI_COMMENT_TOKEN)) return { content, removed: 0 };
31
+ const lines = content.split("\n");
32
+ const out: string[] = [];
33
+ let removed = 0;
34
+
35
+ const fullLineComment = /^\s*(?:\/\/+|#+|--|;+|\/\*|<!--)\s*AI_COMMENT:.*?(?:\*\/|-->)?\s*$/;
36
+ const trailingComment = /(\s*(?:\/\/+|#+|--|;+|\/\*|<!--)\s*AI_COMMENT:.*?(?:\*\/|-->)?)\s*$/;
37
+
38
+ for (const line of lines) {
39
+ if (!line.includes(AI_COMMENT_TOKEN)) {
40
+ out.push(line);
41
+ continue;
42
+ }
43
+ if (fullLineComment.test(line)) {
44
+ removed += 1;
45
+ continue;
46
+ }
47
+ const match = trailingComment.exec(line);
48
+ // Only strip a trailing marker when the code BEFORE the comment opener has
49
+ // balanced quotes — otherwise the "// AI_COMMENT:" is inside a string literal
50
+ // (e.g. `const s = "// AI_COMMENT: x"`) and stripping it would corrupt source.
51
+ if (match && !insideStringLiteral(line.slice(0, match.index))) {
52
+ const stripped = line.slice(0, match.index);
53
+ if (stripped.trim().length > 0) {
54
+ out.push(stripped);
55
+ removed += 1;
56
+ continue;
57
+ }
58
+ }
59
+ // Token present but not in a strippable trailing-comment position — leave as-is.
60
+ out.push(line);
61
+ }
62
+
63
+ return { content: out.join("\n"), removed };
64
+ }
65
+
66
+ // True when the change from `before` to `after` only inserts and/or removes
67
+ // `AI_COMMENT:` markers — i.e. stripping all such markers from both sides yields
68
+ // identical text. Used to enforce the review-phase read-only exception at the
69
+ // tool-call gate (the agent may touch AI_COMMENT markers and nothing else).
70
+ export function isAiCommentOnlyChange(before: string, after: string): boolean {
71
+ if (before === after) return true;
72
+ return stripAiCommentsFromContent(before).content === stripAiCommentsFromContent(after).content;
73
+ }
@@ -309,6 +309,29 @@ describe("transitionToNextPhase", () => {
309
309
  expect(cleanupSpy).toHaveBeenCalledTimes(1);
310
310
  });
311
311
 
312
+ it("does not re-run afterImplement when it already ran for this implement phase (#1)", async () => {
313
+ const pi = makePi();
314
+ const orchestrator = new Orchestrator(pi as any);
315
+ const taskDir = makeTempDir();
316
+ orchestrator.cwd = taskDir;
317
+ orchestrator.config = makeConfig() as any;
318
+ orchestrator.active = makeTransitionTask(taskDir, "implement");
319
+ orchestrator.active.state.repos = [{ path: taskDir, isRoot: true }];
320
+ orchestrator.active.state.afterImplementRan = true;
321
+ orchestrator.active.modifiedFiles = new Set([join(taskDir, "src", "root.ts")]);
322
+ const ctx = makeTransitionCtx();
323
+ vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
324
+ const runSpy = vi.spyOn(commandsModule, "runAfterImplement").mockReturnValue([{ ok: true, command: "npm test", output: "ok" }]);
325
+ vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
326
+ orchestrator.active = null;
327
+ });
328
+
329
+ const result = await transitionToNextPhase(orchestrator, ctx);
330
+
331
+ expect(result.ok).toBe(true);
332
+ expect(runSpy).not.toHaveBeenCalled();
333
+ });
334
+
312
335
  it("clears reviewCycle and review bookkeeping on the done transition (Issue 4)", async () => {
313
336
  const pi = makePi();
314
337
  const orchestrator = new Orchestrator(pi as any);
@@ -509,6 +532,30 @@ describe("transitionToNextPhase", () => {
509
532
  expect(cleanupSpy).toHaveBeenCalledTimes(1);
510
533
  });
511
534
 
535
+ it("completing a task (next === done) requests a discard transition", async () => {
536
+ const pi = makePi();
537
+ const orchestrator = new Orchestrator(pi as any);
538
+ const taskDir = makeTempDir();
539
+ orchestrator.cwd = taskDir;
540
+ orchestrator.config = makeConfig() as any;
541
+ orchestrator.active = makeTransitionTask(taskDir, "quick");
542
+ const ctx = makeTransitionCtx();
543
+ vi.spyOn(machineModule, "validateExitCriteria").mockReturnValue({ ok: true });
544
+ vi.spyOn(orchestrator, "cleanupActive").mockImplementation(async () => {
545
+ orchestrator.active = null;
546
+ });
547
+ vi.spyOn(orchestrator, "abortAllSubagents").mockImplementation(() => undefined);
548
+ const reqSpy = vi.spyOn(orchestrator.transitionController, "requestTransition").mockReturnValue(Promise.resolve());
549
+
550
+ const result = await transitionToNextPhase(orchestrator, ctx);
551
+
552
+ expect(result).toEqual({ ok: true });
553
+ const doneCall = reqSpy.mock.calls.find(([req]: any[]) => req.kind === "done");
554
+ expect(doneCall).toBeDefined();
555
+ expect((doneCall![0] as any).discard).toBe(true);
556
+ expect((doneCall![0] as any).summary).toContain("DISCARD");
557
+ });
558
+
512
559
  it("skips afterImplement when phase is not implement", async () => {
513
560
  const pi = makePi();
514
561
  const orchestrator = new Orchestrator(pi as any);
@@ -13,6 +13,41 @@ function isEnabled(value: { enabled?: boolean }): boolean {
13
13
  return value.enabled !== false;
14
14
  }
15
15
 
16
+ // Runs the configured afterImplement commands per repo (root + extra repos when
17
+ // enabled), grouping by the repo each modified file belongs to. Returns an error
18
+ // string when any command fails (same wording the transition surfaces), or null
19
+ // on success. Shared by the guided/autonomous implement→done transition AND the
20
+ // autonomous terminal handoff, which opens the guided menu instead of advancing.
21
+ export function runAfterImplementForActive(orchestrator: Orchestrator): string | null {
22
+ if (!orchestrator.active) return null;
23
+ const repos = orchestrator.active.state.repos ?? [];
24
+ const grouped = groupFilesByRepo(repos, [...orchestrator.active.modifiedFiles]);
25
+ const afterResults: Array<{ ok: boolean; command: string; output: string }> = [];
26
+ for (const [repoPath] of grouped) {
27
+ if (!repoPath) continue;
28
+ const repo = repos.find((r) => r.path === repoPath);
29
+ if (!repo) continue;
30
+ if (repo.isRoot) {
31
+ afterResults.push(...runAfterImplement(
32
+ orchestrator.config.commands.afterImplement,
33
+ orchestrator.config.performance.commands.afterImplement,
34
+ orchestrator.cwd,
35
+ ));
36
+ continue;
37
+ }
38
+ if (!orchestrator.config.general.loadExtraRepoConfigs) continue;
39
+ const extraCommands = loadRepoAfterImplementCommands(repoPath);
40
+ if (!extraCommands || Object.keys(extraCommands).length === 0) continue;
41
+ afterResults.push(...runAfterImplement(extraCommands, orchestrator.config.performance.commands.afterImplement, repoPath));
42
+ }
43
+ const failures = afterResults.filter((r) => !r.ok);
44
+ if (failures.length > 0) {
45
+ const failureText = failures.map((f) => `${f.command}: ${f.output}`).join("\n");
46
+ return `afterImplement commands failed:\n${failureText}\n\nFix these issues before advancing.`;
47
+ }
48
+ return null;
49
+ }
50
+
16
51
  export async function transitionToNextPhase(
17
52
  orchestrator: Orchestrator,
18
53
  ctx: any,
@@ -39,36 +74,16 @@ export async function transitionToNextPhase(
39
74
  const next = nextPhase(orchestrator.active.type, currentPhase);
40
75
  if (!next) return { ok: false, error: "No next phase available." };
41
76
 
42
- if (currentPhase === "implement") {
43
- const repos = orchestrator.active.state.repos ?? [];
44
- const grouped = groupFilesByRepo(repos, [...orchestrator.active.modifiedFiles]);
45
- const afterResults: Array<{ ok: boolean; command: string; output: string }> = [];
46
- for (const [repoPath] of grouped) {
47
- if (!repoPath) continue;
48
- const repo = repos.find((r) => r.path === repoPath);
49
- if (!repo) continue;
50
- if (repo.isRoot) {
51
- afterResults.push(...runAfterImplement(
52
- orchestrator.config.commands.afterImplement,
53
- orchestrator.config.performance.commands.afterImplement,
54
- orchestrator.cwd,
55
- ));
56
- continue;
57
- }
58
- if (!orchestrator.config.general.loadExtraRepoConfigs) continue;
59
- const extraCommands = loadRepoAfterImplementCommands(repoPath);
60
- if (!extraCommands || Object.keys(extraCommands).length === 0) continue;
61
- afterResults.push(...runAfterImplement(extraCommands, orchestrator.config.performance.commands.afterImplement, repoPath));
62
- }
63
- const failures = afterResults.filter((r) => !r.ok);
64
- if (failures.length > 0) {
65
- const failureText = failures.map((f) => `${f.command}: ${f.output}`).join("\n");
66
- return { ok: false, error: `afterImplement commands failed:\n${failureText}\n\nFix these issues before advancing.` };
67
- }
77
+ if (currentPhase === "implement" && !orchestrator.active.state.afterImplementRan) {
78
+ const afterError = runAfterImplementForActive(orchestrator);
79
+ if (afterError) return { ok: false, error: afterError };
80
+ orchestrator.active.state.afterImplementRan = true;
68
81
  }
69
82
 
70
83
  orchestrator.active.state.phase = next;
71
84
  orchestrator.active.state.reviewCycle = null;
85
+ orchestrator.active.state.plannotatorCursor = undefined;
86
+ orchestrator.active.state.afterImplementRan = false;
72
87
  orchestrator.active.state.reviewPass = 0;
73
88
  orchestrator.active.state.reviewApprovedClean = false;
74
89
  orchestrator.active.reviewPass = 0;
@@ -101,7 +116,8 @@ export async function transitionToNextPhase(
101
116
  // Route the task-done compaction through the controller as a "done" target.
102
117
  void orchestrator.transitionController.requestTransition({
103
118
  kind: "done",
104
- summary: `Task "${name}" (${type}) completed.`,
119
+ discard: true,
120
+ summary: `Task "${name}" (${type}) is finished — DISCARD its entire conversation. Do NOT carry forward, reference, or act on any of this task's messages, phase, plan, or aborted turns; the next task starts from a clean slate.`,
105
121
  });
106
122
  ctx.ui.notify("Task completed!", "info");
107
123
  return { ok: true };
@@ -122,10 +122,16 @@ describe("validateConfig", () => {
122
122
  );
123
123
  });
124
124
 
125
+ it("throws for invalid injectAgentsMd value", () => {
126
+ expect(() => validateConfig({ general: { injectAgentsMd: "yes" } })).toThrow(
127
+ "config.general.injectAgentsMd",
128
+ );
129
+ });
130
+
125
131
  it("accepts valid partial config", () => {
126
132
  expect(() =>
127
133
  validateConfig({
128
- general: { autoCommit: false },
134
+ general: { autoCommit: false, injectAgentsMd: false },
129
135
  commands: {
130
136
  afterEdit: { fmt: { run: "npm run fmt", globs: ["**/*.ts"] } },
131
137
  afterImplement: { test: { run: "npm test" } },
@@ -192,6 +198,39 @@ describe("loadConfig", () => {
192
198
  expect(config.performance.commands.afterEdit).toBe(1234);
193
199
  expect(config.performance.commands.afterImplement).toBe(300000);
194
200
  expect(config.general.autoCommit).toBe(false);
201
+ expect(config.general.injectAgentsMd).toBe(true);
202
+ });
203
+
204
+ it("defaults injectAgentsMd to true and honors an explicit override", () => {
205
+ const cwd = makeTempDir();
206
+ const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
207
+ expect(defaults.general.injectAgentsMd).toBe(true);
208
+
209
+ const cwd2 = makeTempDir();
210
+ const ppDir = join(cwd2, ".pp");
211
+ mkdirSync(ppDir, { recursive: true });
212
+ writeFileSync(join(ppDir, "config.json"), JSON.stringify({ general: { injectAgentsMd: false } }), "utf-8");
213
+ const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
214
+ expect(overridden.general.injectAgentsMd).toBe(false);
215
+ });
216
+
217
+ it("defaults mainTurnStale to 10m and normalizes an override to ms", () => {
218
+ const cwd = makeTempDir();
219
+ const defaults = loadConfig(cwd, "/nonexistent/global/config.json");
220
+ expect(defaults.performance.internals.mainTurnStale).toBe(600000);
221
+
222
+ const cwd2 = makeTempDir();
223
+ const ppDir = join(cwd2, ".pp");
224
+ mkdirSync(ppDir, { recursive: true });
225
+ writeFileSync(join(ppDir, "config.json"), JSON.stringify({ performance: { internals: { mainTurnStale: "90s" } } }), "utf-8");
226
+ const overridden = loadConfig(cwd2, "/nonexistent/global/config.json");
227
+ expect(overridden.performance.internals.mainTurnStale).toBe(90000);
228
+ });
229
+
230
+ it("rejects an invalid mainTurnStale duration", () => {
231
+ expect(() => validateConfig({ performance: { internals: { mainTurnStale: "soon" } } })).toThrow(
232
+ "config.performance.internals.mainTurnStale",
233
+ );
195
234
  });
196
235
 
197
236
  it("creates default config when config.json does not exist", () => {