@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
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
86
|
-
"deeper, and
|
|
87
|
-
"",
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
"- A
|
|
93
|
-
"- A
|
|
94
|
-
"",
|
|
95
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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", () => {
|