@geminixiang/pi-simplify 0.0.5 → 0.0.7

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.
@@ -0,0 +1,146 @@
1
+ ---
2
+ name: release
3
+ description: Prepare and publish a new pi-simplify release. Use when bumping the version, committing and pushing it, and creating a GitHub release in the style of earlier pi-simplify releases.
4
+ ---
5
+
6
+ # pi-simplify Release
7
+
8
+ Repo defaults:
9
+
10
+ - branch: `main`
11
+ - remote: `origin`
12
+ - repo: `geminixiang/pi-simplify`
13
+ - npm package: `@geminixiang/pi-simplify`
14
+ - use `package.json` version as the git tag and GitHub release title
15
+ - versions with `-alpha.`, `-beta.`, or `-rc.` are prereleases by default
16
+
17
+ ## Version rules
18
+
19
+ Examples:
20
+
21
+ - stable: `0.2.1`, `0.3.0`, `1.0.0`
22
+ - prerelease: `0.2.0-beta.8`, `0.3.0-rc.1`, `1.0.0-alpha.2`
23
+
24
+ Guidance:
25
+
26
+ - `patch` = bugfix / small maintenance
27
+ - `minor` = backward-compatible features
28
+ - `major` = breaking changes
29
+ - prerelease numbers increase within the same line, e.g. `beta.8 -> beta.9`
30
+ - promote prerelease to stable by dropping the suffix, e.g. `0.2.0-beta.8 -> 0.2.0`
31
+
32
+ ## Flow
33
+
34
+ ### 1. Check state
35
+
36
+ ```bash
37
+ git status --short
38
+ git branch --show-current
39
+ git remote -v
40
+ git tag --list | tail -20
41
+ ```
42
+
43
+ Read `package.json` and `package-lock.json`. If unrelated files are modified, ask before committing.
44
+
45
+ ### 2. Sync version files
46
+
47
+ Preferred:
48
+
49
+ ```bash
50
+ npm version <version> --no-git-tag-version
51
+ ```
52
+
53
+ Use this to sync `package.json` and `package-lock.json` without creating an automatic commit or tag. If the user already edited `package.json`, just verify `package-lock.json` matches.
54
+
55
+ ### 3. Update CHANGELOG
56
+
57
+ `CHANGELOG.md` follows Keep a Changelog with `### Added / Changed / Fixed / Removed / Security / Performance / Tests` subsections. The newest release sits at the top under an `## [Unreleased]` placeholder.
58
+
59
+ Gather user-visible changes since the previous tag:
60
+
61
+ ```bash
62
+ git log --pretty=format:'%h %s' <previous-tag>..HEAD | grep -v "^[a-f0-9]* chore: bump version"
63
+ ```
64
+
65
+ Then in `CHANGELOG.md`:
66
+
67
+ - Keep `## [Unreleased]` at the top as an empty placeholder.
68
+ - Insert a new section `## [<version>] - <YYYY-MM-DD>` (use today's date for stable, omit the date for prereleases to match the existing style).
69
+ - Group entries by subsection; one bullet per user-visible change, imperative voice, no commit hashes.
70
+ - Skip pure internal refactors that don't change behavior unless they affect contributors (then list under `### Changed`).
71
+
72
+ If you generated draft release notes in step 5 first, copy the same wording into CHANGELOG — they should match.
73
+
74
+ ### 4. Commit and push
75
+
76
+ Stage version files and CHANGELOG together — the bump and the changelog entry belong in the same commit.
77
+
78
+ ```bash
79
+ git add package.json package-lock.json CHANGELOG.md
80
+ git commit -m "chore: bump version to <version>"
81
+ git push origin main
82
+ ```
83
+
84
+ ### 5. Draft release notes
85
+
86
+ Use the previous GitHub release as the style reference.
87
+
88
+ ```bash
89
+ gh release list --repo geminixiang/pi-simplify --limit 10
90
+ gh release view <previous-tag> --repo geminixiang/pi-simplify --json tagName,name,body,url,publishedAt
91
+ git log --pretty=format:'%h %s' <previous-tag>..HEAD
92
+ git diff --stat <previous-tag>..HEAD
93
+ ```
94
+
95
+ Write concise notes focused on user-visible changes, usually with:
96
+
97
+ - `## What's changed`
98
+ - `### Highlights`
99
+ - `### Notable changes`
100
+ - `### Docs and maintenance`
101
+ - `### Verification`
102
+
103
+ Write notes to `/tmp/pi-simplify-release-<version>.md`. Keep them consistent with the CHANGELOG entry from step 3.
104
+
105
+ ### 6. Create or update release
106
+
107
+ Prerelease:
108
+
109
+ ```bash
110
+ gh release create <version> \
111
+ --repo geminixiang/pi-simplify \
112
+ --target main \
113
+ --title <version> \
114
+ --notes-file /tmp/pi-simplify-release-<version>.md \
115
+ --prerelease
116
+ ```
117
+
118
+ Stable:
119
+
120
+ ```bash
121
+ gh release create <version> \
122
+ --repo geminixiang/pi-simplify \
123
+ --target main \
124
+ --title <version> \
125
+ --notes-file /tmp/pi-simplify-release-<version>.md
126
+ ```
127
+
128
+ If it already exists, use `gh release edit <version> ...` and keep prerelease/stable intent consistent.
129
+
130
+ ## Report back
131
+
132
+ Return:
133
+
134
+ - released version
135
+ - stable or prerelease
136
+ - version-bump commit hash
137
+ - push status
138
+ - release URL
139
+
140
+ ## Guardrails
141
+
142
+ - Always use `geminixiang/pi-simplify`.
143
+ - Infer stable vs prerelease from the version, or ask.
144
+ - Do not include raw commit hashes in release notes unless requested.
145
+ - If hooks fail during commit, fix or report before retrying.
146
+ - Never publish a release without a corresponding CHANGELOG entry — the version bump commit must include the new CHANGELOG section.
package/CHANGELOG.md ADDED
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.0.7] - 2026-05-25
10
+
11
+ ### Changed
12
+
13
+ - Use pi's native select list for findings selection.
14
+ - Wrap long finding recommendations in the selection menu.
15
+ - Split the simplify extension into prompt, selector, workflow, and shared type modules.
16
+ - Update dependencies to the current `@earendil-works` pi packages.
17
+
18
+ ### Removed
19
+
20
+ - Remove the `/simplify-quick` command and its documentation.
21
+
22
+ ### Added
23
+
24
+ - Add a project release workflow skill for future releases.
package/README.md CHANGED
@@ -36,21 +36,6 @@ Analyzes all git changes and presents cleanup candidates:
36
36
  - **Confirm** (yellow) - delete after user confirms
37
37
  - **Review** (orange) - user should review first
38
38
 
39
- ### Quick Simplify
40
-
41
- ```
42
- /simplify-quick
43
- ```
44
-
45
- Only removes the obviously safe stuff:
46
-
47
- - `console.log` / `console.warn` / `console.error`
48
- - `debugger` statements
49
- - Unused imports
50
- - Empty catch blocks
51
-
52
- No confirmation needed - just does it.
53
-
54
39
  ### With Focus
55
40
 
56
41
  ```
package/index.ts CHANGED
@@ -1,610 +1,16 @@
1
1
  /**
2
- * pi-simplify: Code Cleanup Extension for pi
2
+ * pi-simplify: Code Review & Cleanup Extension for pi
3
3
  *
4
- * Removes leftover code after feature implementation:
5
- * - Dead code (unused exports, orphaned files)
6
- * - Debug remnants (console.log, debugger, temp flags)
7
- * - Commented-out code
8
- * - Over-engineering ("might use later" abstractions)
9
- * - Duplicate logic
4
+ * Reviews changed code for reuse, quality, and efficiency:
5
+ * - Reuse: replace newly-written code with existing utilities
6
+ * - Quality: dead code, debug remnants, commented-out code, over-engineering, duplicate logic
7
+ * - Efficiency: redundant work, missed concurrency, hot-path bloat, memory leaks
10
8
  */
11
9
 
12
- import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
13
- import { matchesKey, Key, truncateToWidth, type SelectItem } from "@mariozechner/pi-tui";
10
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
14
11
 
15
- type SimplifyResult = {
16
- file: string;
17
- lines: string;
18
- reason: string;
19
- risk: "safe" | "confirm" | "review";
20
- };
21
-
22
- const SIMPLIFY_PROMPT = `# Simplify: Clean Up Leftover Code
23
-
24
- You are a code cleanup assistant. Your job is to identify and remove unnecessary code left over after feature implementation.
25
-
26
- ## What to Find
27
-
28
- ### 1. Dead Code (Safe to Delete)
29
- - **Unused exports**: Functions/classes defined but never imported elsewhere
30
- - **Orphan files**: Files created but never referenced
31
- - **Zombie variables**: Variables assigned but never used
32
- - **Empty blocks**: try/catch/if blocks with no logic
33
-
34
- ### 2. Debug Remnants (Safe to Delete)
35
- - console.log, console.warn, console.error statements
36
- - debugger statements
37
- - Temporary feature flags (e.g., \`ENABLE_DEBUG\`, \`DEBUG_MODE\`)
38
- - Temporary todo comments that are now implemented
39
-
40
- ### 3. Commented-out Code (Review Before Deleting)
41
- - Old logic left in comments
42
- - Disabled features (commented out rather than deleted)
43
- - Copy-pasted templates never customized
44
-
45
- ### 4. Over-engineering (Confirm Before Deleting)
46
- - Abstractions created "for future use" but never used
47
- - Helper functions with single call sites (could be inlined)
48
- - Layers of indirection that add no value
49
-
50
- ### 5. Duplicate Logic (Confirm Before Deleting)
51
- - Repeated if-else blocks doing the same thing
52
- - Copy-paste code with minor variations
53
- - Duplicate utility functions
54
-
55
- ## How to Analyze
56
-
57
- 1. Run \`git diff\` to see what changed
58
- 2. For each change, determine if it's:
59
- - **Essential**: Required for the feature to work
60
- - **Residual**: Left over from development/debugging
61
- - **Legacy**: Old code not touched by this change
62
-
63
- 3. For each residual item:
64
- - Identify exact file and line(s)
65
- - Assess risk level
66
- - Provide clear reason for removal
67
-
68
- ## Risk Levels
69
-
70
- - **safe**: Definitely can be deleted (unused, debug code)
71
- - **confirm**: Delete after user confirms (over-engineered, duplicates)
72
- - **review**: User should review before action (commented code, ambiguous)
73
-
74
- ## Rules
75
-
76
- 1. When in doubt, mark as "confirm" or "review" - don't delete without consent
77
- 2. For inline candidates, show the alternative
78
- 3. Don't flag necessary code just because it's simple
79
- 4. Respect existing abstraction boundaries
80
- 5. Be especially careful with:
81
- - Error handling code
82
- - Security-related logic
83
- - Code that looks "unused" but is called via reflection/eval
84
- - Database migration files
85
-
86
- ## Output Format (REQUIRED)
87
-
88
- You may write prose analysis first, but you MUST end your response with a single fenced JSON block containing ALL candidates. This JSON is parsed programmatically — it must be valid JSON and use this exact shape:
89
-
90
- \`\`\`json
91
- {
92
- "candidates": [
93
- {
94
- "risk": "safe",
95
- "file": "path/to/file.ext",
96
- "lines": "12-15",
97
- "reason": "Why this should be removed",
98
- "action": "delete"
99
- }
100
- ]
101
- }
102
- \`\`\`
103
-
104
- Field notes:
105
- - \`risk\` — one of: "safe", "confirm", "review"
106
- - \`file\` — repository-relative path, no backticks, no markdown
107
- - \`lines\` — line number or range ("42" or "42-57"); empty string if unknown
108
- - \`reason\` — single plain-text sentence
109
- - \`action\` — one of: "delete", "inline", "confirm" (appended to reason)
110
-
111
- If there are no candidates, output \`{"candidates": []}\`. Do NOT output anything after the closing \`\`\` fence.
112
- `;
12
+ import { registerSimplifyWorkflow } from "./workflow.js";
113
13
 
114
14
  export default function simplifyExtension(pi: ExtensionAPI) {
115
- // Accumulate assistant text across the whole turn (multiple messages when
116
- // the AI makes tool calls). Overwriting would drop the JSON block if it
117
- // appears in an earlier message and a later one adds a short confirmation.
118
- let assistantTextBuffer = "";
119
- let resolvePendingTurnStart: (() => void) | null = null;
120
-
121
- pi.on("turn_start", () => {
122
- if (!resolvePendingTurnStart) return;
123
- const resolve = resolvePendingTurnStart;
124
- resolvePendingTurnStart = null;
125
- resolve();
126
- });
127
-
128
- pi.on("message_end", (event) => {
129
- const msg = event.message;
130
- if (msg.role !== "assistant" || !Array.isArray(msg.content)) return;
131
- const text = (msg.content as { type: string; text?: string }[])
132
- .filter((b) => b.type === "text" && typeof b.text === "string")
133
- .map((b) => b.text as string)
134
- .join("\n");
135
- if (text) assistantTextBuffer += (assistantTextBuffer ? "\n" : "") + text;
136
- });
137
-
138
- const RISK_VALUES = new Set(["safe", "confirm", "review"]);
139
-
140
- function normalizeRisk(value: unknown): "safe" | "confirm" | "review" {
141
- const v = String(value ?? "")
142
- .toLowerCase()
143
- .trim();
144
- return RISK_VALUES.has(v) ? (v as "safe" | "confirm" | "review") : "review";
145
- }
146
-
147
- function stripMarkdown(text: string): string {
148
- return text.replace(/[`*_]/g, "").trim();
149
- }
150
-
151
- /**
152
- * Try to parse a JSON candidates block. The prompt requires this format,
153
- * but we tolerate the AI wrapping it in ```json ... ``` or ``` ... ```,
154
- * and also handle the case where it embeds the JSON inline without fences.
155
- * Returns null only when no valid JSON with a `candidates` array is found —
156
- * a valid empty list returns [] (which means "the AI found nothing").
157
- */
158
- function parseCandidatesJSON(response: string): SimplifyResult[] | null {
159
- const fenceRe = /```(?:json)?\s*\n([\s\S]*?)\n```/gi;
160
- const blocks: string[] = [];
161
- let m: RegExpExecArray | null;
162
- while ((m = fenceRe.exec(response)) !== null) blocks.push(m[1]);
163
-
164
- const bareMatch = response.match(/\{[\s\S]*"candidates"[\s\S]*\}/);
165
- if (bareMatch) blocks.push(bareMatch[0]);
166
-
167
- // Walk newest-first: the contract puts the JSON at the end of the response.
168
- for (let i = blocks.length - 1; i >= 0; i--) {
169
- try {
170
- const parsed = JSON.parse(blocks[i]);
171
- const list = parsed?.candidates;
172
- if (!Array.isArray(list)) continue;
173
- const out: SimplifyResult[] = [];
174
- for (const item of list) {
175
- if (!item || typeof item !== "object") continue;
176
- const file = stripMarkdown(String(item.file ?? item.location ?? ""));
177
- // Validate path is within repo (no path traversal)
178
- if (!file || file.includes("..") || file.startsWith("/")) continue;
179
- const reason = stripMarkdown(String(item.reason ?? item.description ?? ""));
180
- const action = item.action ? ` [${stripMarkdown(String(item.action))}]` : "";
181
- out.push({
182
- file,
183
- lines: stripMarkdown(String(item.lines ?? "")),
184
- reason: reason ? reason + action : action.trim() || "(no reason provided)",
185
- risk: normalizeRisk(item.risk),
186
- });
187
- }
188
- // A valid candidates array (even empty) is a successful parse
189
- return out;
190
- } catch {
191
- // try next block
192
- }
193
- }
194
- return null;
195
- }
196
-
197
- /**
198
- * Show selection dialog for candidates with proper multi-select support
199
- */
200
- async function showCandidateSelector(
201
- ctx: ExtensionCommandContext,
202
- candidates: SimplifyResult[],
203
- ): Promise<SimplifyResult[]> {
204
- if (candidates.length === 0) {
205
- return [];
206
- }
207
-
208
- // Categorize by risk level for display
209
- const safeCandidates = candidates.filter((c) => c.risk === "safe");
210
- const confirmCandidates = candidates.filter((c) => c.risk === "confirm");
211
- const reviewCandidates = candidates.filter((c) => c.risk === "review");
212
-
213
- const RISK_CONFIG = {
214
- safe: { label: "Safe to delete", description: "Will be deleted", selected: true },
215
- confirm: { label: "Needs confirmation", description: "Select to delete", selected: false },
216
- review: { label: "Needs review", description: "Review before deleting", selected: false },
217
- } as const;
218
-
219
- const sections = [
220
- { key: "safe" as const, items: safeCandidates },
221
- { key: "confirm" as const, items: confirmCandidates },
222
- { key: "review" as const, items: reviewCandidates },
223
- ]
224
- .map(({ key, items }) => ({ key, items, config: RISK_CONFIG[key], total: items.length }))
225
- .filter((s) => s.total > 0);
226
-
227
- const displayItems: SelectItem[] = [];
228
- const selectableItems: { index: number; candidate: SimplifyResult }[] = [];
229
- let globalIndex = 0;
230
-
231
- for (const section of sections) {
232
- displayItems.push({
233
- value: `__section__${section.key}__`,
234
- label: `── ${section.config.label} (${section.total}) ──`,
235
- description: "",
236
- });
237
- globalIndex++;
238
- for (const c of section.items) {
239
- displayItems.push({
240
- value: JSON.stringify(c),
241
- label: `${c.file} - ${c.reason}`,
242
- description: section.config.description,
243
- });
244
- selectableItems.push({ index: globalIndex++, candidate: c });
245
- }
246
- }
247
-
248
- // Pre-select all safe items
249
- const selectedSet = new Set<number>(
250
- safeCandidates.length > 0 ? Array.from({ length: safeCandidates.length }, (_, i) => i) : [],
251
- );
252
-
253
- const maxVisible = 12;
254
- let scrollOffset = 0;
255
- let cursorPos = 0;
256
-
257
- const result = await ctx.ui.custom<SimplifyResult[]>((tui, theme, _keybindings, done) => {
258
- // Render the multi-select list manually
259
- const renderList = (width: number): string[] => {
260
- const lines: string[] = [];
261
-
262
- // Calculate which items are visible
263
- const visibleItems: { selectableIdx: number | null; item: SelectItem }[] = [];
264
- for (let i = 0; i < displayItems.length; i++) {
265
- const item = displayItems[i];
266
- if (item.value.startsWith("__section__")) {
267
- visibleItems.push({ selectableIdx: null, item });
268
- } else {
269
- const selectableIdx = selectableItems.findIndex((si) => si.index === i);
270
- if (selectableIdx >= 0) {
271
- visibleItems.push({ selectableIdx, item: displayItems[i] });
272
- }
273
- }
274
- }
275
-
276
- // Show visible range with scroll
277
- const startIdx = Math.max(0, scrollOffset);
278
- const endIdx = Math.min(visibleItems.length, startIdx + maxVisible);
279
-
280
- for (let vIdx = startIdx; vIdx < endIdx; vIdx++) {
281
- const { selectableIdx, item } = visibleItems[vIdx];
282
-
283
- if (item.value.startsWith("__section__")) {
284
- // Section header
285
- lines.push(theme.fg("accent", item.label));
286
- } else {
287
- // Selectable item
288
- const isCursor = selectableIdx === cursorPos;
289
- const isSelected = selectableIdx !== null && selectedSet.has(selectableIdx);
290
- const prefix = isCursor ? theme.fg("accent", "> ") : " ";
291
- const checkbox = isSelected ? theme.fg("success", "[x]") : theme.fg("dim", "[ ]");
292
- const truncatedLabel = truncateToWidth(item.label, width - 6);
293
- const line = `${prefix}${checkbox} ${truncatedLabel}`;
294
- lines.push(isCursor ? theme.fg("accent", line) : line);
295
- }
296
- }
297
-
298
- // Scroll indicator
299
- if (visibleItems.length > maxVisible) {
300
- const scrollInfo = ` (${startIdx + 1}-${endIdx}/${visibleItems.length})`;
301
- lines.push(theme.fg("dim", scrollInfo));
302
- }
303
-
304
- return lines;
305
- };
306
-
307
- return {
308
- render(width: number) {
309
- const lines: string[] = [];
310
- lines.push(theme.bold("Simplify: Select items to remove"));
311
- lines.push(
312
- theme.fg(
313
- "muted",
314
- `Found ${candidates.length} candidates (${safeCandidates.length} safe, ${confirmCandidates.length} confirm, ${reviewCandidates.length} review)`,
315
- ),
316
- );
317
- lines.push("");
318
- lines.push(...renderList(width));
319
- lines.push("");
320
- lines.push(
321
- theme.fg(
322
- "muted",
323
- `Selected: ${selectedSet.size} • ↑↓ navigate • space toggle • a select all • enter confirm • esc cancel`,
324
- ),
325
- );
326
- return lines;
327
- },
328
- invalidate() {},
329
- handleInput(data: string) {
330
- if (matchesKey(data, Key.enter)) {
331
- const results = Array.from(selectedSet).map((i) => selectableItems[i].candidate);
332
- done(results);
333
- return;
334
- }
335
- if (matchesKey(data, Key.escape)) {
336
- done([]);
337
- return;
338
- }
339
- if (matchesKey(data, Key.space)) {
340
- // Toggle current selection
341
- if (selectedSet.has(cursorPos)) {
342
- selectedSet.delete(cursorPos);
343
- } else {
344
- selectedSet.add(cursorPos);
345
- }
346
- tui.requestRender();
347
- return;
348
- }
349
- if (matchesKey(data, "a")) {
350
- // Select/deselect all
351
- if (selectedSet.size === selectableItems.length) {
352
- selectedSet.clear();
353
- } else {
354
- for (let i = 0; i < selectableItems.length; i++) {
355
- selectedSet.add(i);
356
- }
357
- }
358
- tui.requestRender();
359
- return;
360
- }
361
-
362
- // Navigation
363
- if (matchesKey(data, Key.down)) {
364
- cursorPos = Math.min(selectableItems.length - 1, cursorPos + 1);
365
- // Ensure cursor is within visible window
366
- const displayIdx = selectableItems[cursorPos].index;
367
- if (displayIdx >= scrollOffset + maxVisible) {
368
- scrollOffset = displayIdx - maxVisible + 1;
369
- }
370
- tui.requestRender();
371
- return;
372
- }
373
- if (matchesKey(data, Key.up)) {
374
- cursorPos = Math.max(0, cursorPos - 1);
375
- // Ensure cursor is within visible window
376
- const displayIdx = selectableItems[cursorPos].index;
377
- if (displayIdx < scrollOffset) {
378
- // Scroll up to show the item, and include section header if it's just above
379
- scrollOffset = Math.max(0, displayIdx - 1);
380
- }
381
- tui.requestRender();
382
- return;
383
- }
384
- if (matchesKey(data, Key.home)) {
385
- cursorPos = 0;
386
- scrollOffset = 0;
387
- tui.requestRender();
388
- return;
389
- }
390
- if (matchesKey(data, Key.end)) {
391
- cursorPos = selectableItems.length - 1;
392
- const lastDisplayIdx = selectableItems[cursorPos].index;
393
- scrollOffset = Math.max(0, lastDisplayIdx - maxVisible + 1);
394
- tui.requestRender();
395
- return;
396
- }
397
- },
398
- };
399
- });
400
-
401
- return result ?? [];
402
- }
403
-
404
- /**
405
- * Execute the cleanup by sending deletion commands
406
- */
407
- async function executeCleanup(
408
- ctx: ExtensionCommandContext,
409
- selected: SimplifyResult[],
410
- ): Promise<void> {
411
- if (selected.length === 0) {
412
- ctx.ui.notify("No items selected for cleanup", "info");
413
- return;
414
- }
415
-
416
- const safeItems = selected.filter((c) => c.risk === "safe");
417
- const confirmItems = selected.filter((c) => c.risk === "confirm");
418
-
419
- // Build cleanup prompt with verification step
420
- const cleanupPrompt = `# Cleanup Instructions
421
-
422
- Delete the following code:
423
-
424
- ${selected.map((c) => `- ${c.file}: ${c.reason}`).join("\n")}
425
-
426
- For each item:
427
- 1. Read the file to find the exact location
428
- 2. Remove only the specified code (not surrounding code unless instructed)
429
- 3. If the removal affects other code, stop and report the issue
430
- 4. After all deletions, verify the code still works by running any existing tests
431
-
432
- IMPORTANT: After completing deletions:
433
- - Run \`npm test\` or equivalent test command
434
- - If tests fail, report which tests failed and whether it's related to the cleanup
435
- - If there are no tests, at least verify the files parse correctly (e.g., \`node --check\`)
436
-
437
- Report:
438
- - What was deleted
439
- - Test results (pass/fail)
440
- - Any issues encountered
441
- - Files that may need further attention`;
442
-
443
- ctx.ui.notify(
444
- `Starting cleanup of ${selected.length} items (${safeItems.length} safe, ${confirmItems.length} confirmed)`,
445
- "info",
446
- );
447
-
448
- pi.sendUserMessage(cleanupPrompt);
449
- }
450
-
451
- /**
452
- * Main command handler
453
- */
454
- pi.registerCommand("simplify", {
455
- description: "Clean up leftover code (dead code, debug remnants, over-engineering)",
456
- handler: async (args: string, ctx: ExtensionCommandContext) => {
457
- if (!ctx.hasUI) {
458
- ctx.ui.notify("Simplify requires interactive mode", "error");
459
- return;
460
- }
461
-
462
- if (!ctx.isIdle()) {
463
- ctx.ui.notify(
464
- "Cannot run simplify while agent is busy. Wait for current task to complete.",
465
- "warning",
466
- );
467
- return;
468
- }
469
-
470
- // Check if in git repo
471
- const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
472
- if (code !== 0) {
473
- ctx.ui.notify("Not a git repository", "error");
474
- return;
475
- }
476
-
477
- // Check if there are changes
478
- const { stdout: gitStatus } = await pi.exec("git", ["status", "--porcelain"]);
479
- if (!gitStatus.trim()) {
480
- ctx.ui.notify("No git changes found. Simplify reviews uncommitted changes.", "info");
481
- return;
482
- }
483
-
484
- ctx.ui.notify("Analyzing code for cleanup candidates...", "info");
485
-
486
- // Build prompt with optional focus
487
- let fullPrompt = SIMPLIFY_PROMPT;
488
- if (args.trim()) {
489
- fullPrompt += `\n\n## Additional Focus\n\n${args.trim()}`;
490
- }
491
-
492
- // Reset capture buffer, then send prompt and wait.
493
- // message_end events append to assistantTextBuffer during the turn.
494
- //
495
- // Wait for the queued analysis turn to actually begin before waiting for
496
- // idle; otherwise waitForIdle() can return immediately while the agent is
497
- // still idle and the queued prompt has not started yet.
498
- assistantTextBuffer = "";
499
- const turnStarted = new Promise<void>((r) => {
500
- resolvePendingTurnStart = r;
501
- });
502
-
503
- pi.sendUserMessage(fullPrompt);
504
-
505
- const started = await Promise.race<boolean>([
506
- turnStarted.then(() => true),
507
- new Promise<boolean>((r) => setTimeout(() => r(false), 10000)),
508
- ]);
509
- if (!started) {
510
- resolvePendingTurnStart = null;
511
- ctx.ui.notify("Analysis did not start within 10 seconds. Please try again.", "warning");
512
- return;
513
- }
514
-
515
- await ctx.waitForIdle();
516
- const analysisText = assistantTextBuffer;
517
-
518
- if (!analysisText) {
519
- ctx.ui.notify("Could not read analysis — no response captured.", "error");
520
- return;
521
- }
522
-
523
- const candidates = parseCandidatesJSON(analysisText);
524
-
525
- if (candidates === null) {
526
- ctx.ui.notify(
527
- "Could not parse cleanup candidates — the model did not return the expected JSON block.",
528
- "warning",
529
- );
530
- return;
531
- }
532
-
533
- if (candidates.length === 0) {
534
- ctx.ui.notify("No cleanup candidates found!", "info");
535
- return;
536
- }
537
-
538
- ctx.ui.notify(`Found ${candidates.length} candidates. Select items to remove...`, "info");
539
-
540
- // Show selector
541
- const selected = await showCandidateSelector(ctx, candidates);
542
-
543
- if (!selected || selected.length === 0) {
544
- ctx.ui.notify("Cleanup cancelled", "info");
545
- return;
546
- }
547
-
548
- // Execute cleanup
549
- await executeCleanup(ctx, selected);
550
- },
551
- });
552
-
553
- /**
554
- * Quick simplify - auto-clean safe items only
555
- */
556
- pi.registerCommand("simplify-quick", {
557
- description: "Quick cleanup of obviously safe items (debug code, unused exports)",
558
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
559
- if (!ctx.hasUI) {
560
- ctx.ui.notify("Simplify requires interactive mode", "error");
561
- return;
562
- }
563
-
564
- if (!ctx.isIdle()) {
565
- ctx.ui.notify("Agent is busy. Try again after current task.", "warning");
566
- return;
567
- }
568
-
569
- const quickPrompt = `# Quick Cleanup
570
-
571
- Find and delete ONLY the most obviously safe cleanup items:
572
-
573
- 1. \`console.log\`, \`console.warn\`, \`console.error\` statements
574
- 2. \`debugger\` statements
575
- 3. Unused \`import\` statements (imported but never used)
576
- 4. Empty \`catch\` blocks (just \`catch (e) {}\`)
577
- 5. Unused \`const\`/\`let\`/\`var\` declarations
578
-
579
- DO NOT delete:
580
- - Commented-out code
581
- - Over-engineered abstractions
582
- - Duplicate logic
583
- - Anything that might be needed
584
-
585
- For each item found:
586
- 1. Confirm it's truly unused by searching for references
587
- 2. Delete only the specific lines
588
- 3. Report what was deleted
589
-
590
- Return a summary of what was deleted.`;
591
-
592
- ctx.ui.notify("Running quick cleanup...", "info");
593
- pi.sendUserMessage(quickPrompt, { deliverAs: "followUp" });
594
- },
595
- });
596
-
597
- /**
598
- * Status indicator on startup
599
- */
600
- /**pi.on("session_start", async (_event, ctx) => {
601
- const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
602
- if (code === 0) {
603
- ctx.ui.setStatus(
604
- "simplify",
605
- `${ctx.ui.theme.fg("accent", "simplify")} ${ctx.ui.theme.fg("muted", "ready (try /simplify)")}`,
606
- );
607
- }
608
- });
609
- */
15
+ registerSimplifyWorkflow(pi);
610
16
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@geminixiang/pi-simplify",
3
- "version": "0.0.5",
4
- "description": "Simplify: Clean up leftover code after feature implementation",
3
+ "version": "0.0.7",
4
+ "description": "Simplify: Review changed code for reuse, quality, and efficiency",
5
5
  "keywords": [
6
6
  "pi-package"
7
7
  ],
@@ -19,7 +19,9 @@
19
19
  "fmt:check": "oxfmt --check"
20
20
  },
21
21
  "dependencies": {
22
- "@mariozechner/pi-coding-agent": "^0.65.2"
22
+ "@earendil-works/pi-coding-agent": ">=0.74.0",
23
+ "@earendil-works/pi-tui": ">=0.74.0",
24
+ "typebox": "^1.1.38"
23
25
  },
24
26
  "devDependencies": {
25
27
  "oxfmt": "^0.45.0",
package/prompt.ts ADDED
@@ -0,0 +1,93 @@
1
+ export const SIMPLIFY_PROMPT = `# Simplify: Review Changed Code for Reuse, Quality, and Efficiency
2
+
3
+ You are a code review assistant. Review the changed code along three axes — **Reuse**, **Quality**, and **Efficiency** — and surface concrete fixes.
4
+
5
+ Match the user's language for candidate reasons and summaries; if the user's request is in Chinese, write the candidate \`reason\` fields in Chinese.
6
+
7
+ ## What to Find
8
+
9
+ ### A. Reuse (Confirm Before Replacing)
10
+
11
+ Before flagging, **search the codebase** (utility directories, shared modules, files adjacent to the change) for existing helpers. Quote the existing symbol you'd use.
12
+
13
+ - **Duplicates an existing function**: a newly written helper does what an existing one already does
14
+ - **Inline logic that has a utility**: hand-rolled string manipulation, manual path handling, custom env checks, ad-hoc type guards
15
+ - **Reinvented framework primitive**: re-implementing something the language/stdlib/framework already provides
16
+
17
+ ### B. Quality
18
+
19
+ #### B1. Dead Code (Safe to Delete)
20
+ - Unused exports, orphan files, zombie variables, empty try/catch/if blocks
21
+
22
+ #### B2. Debug Remnants (Safe to Delete)
23
+ - \`console.log\` / \`console.warn\` / \`console.error\`, \`debugger\`, temporary feature flags, stale TODO comments
24
+
25
+ #### B3. Commented-out Code (Review)
26
+ - Old logic left in comments, disabled features, uncustomized templates
27
+
28
+ #### B4. Over-engineering (Confirm)
29
+ - Abstractions created "for future use" but unused, single-call-site helpers that should be inlined, useless indirection
30
+
31
+ #### B5. Hacky Patterns (Confirm)
32
+ - **Redundant state**: state that duplicates other state, cached values that could be derived, observers that could be direct calls
33
+ - **Parameter sprawl**: piling new parameters onto a function instead of restructuring
34
+ - **Copy-paste with variation**: near-duplicate blocks that should share an abstraction
35
+ - **Leaky abstractions**: exposing internals or breaking existing boundaries
36
+ - **Stringly-typed code**: raw strings where an existing constant/enum/union exists
37
+ - **Unnecessary wrapper elements**: JSX/DOM wrappers that add no layout value
38
+ - **Nested conditionals**: ternary chains or if/else nested 3+ levels deep — flatten with early returns or a lookup table
39
+ - **Useless comments**: comments restating WHAT the code does, narrating the change, or referencing the task/caller — keep only non-obvious WHY
40
+
41
+ ### C. Efficiency (Confirm)
42
+
43
+ - **Unnecessary work**: redundant computations, repeated file reads, duplicate API calls, N+1 patterns
44
+ - **Missed concurrency**: independent operations run sequentially when they could run in parallel
45
+ - **Hot-path bloat**: blocking work added to startup or per-request/per-render hot paths
46
+ - **Recurring no-op updates**: state writes inside loops/intervals/handlers that fire unconditionally — add a change-detection guard
47
+ - **Unnecessary existence checks**: pre-checking file/resource existence before operating (TOCTOU) — operate directly and handle the error
48
+ - **Memory**: unbounded data structures, missing cleanup, event listener leaks
49
+ - **Overly broad operations**: reading whole files when a slice would do, loading all items when filtering for one
50
+
51
+ ## How to Analyze
52
+
53
+ 1. Run \`git diff\` to see what changed.
54
+ 2. For each change, classify as **Essential** / **Residual** / **Legacy** (don't flag legacy code unless the diff touches it).
55
+ 3. For each finding:
56
+ - Identify exact file and line(s)
57
+ - Assign **category** (reuse / quality / efficiency) and **risk**
58
+ - State the concrete fix (which existing utility to call, which lines to delete, how to parallelize, etc.)
59
+
60
+ ## Risk Levels
61
+
62
+ - **safe**: Definitely apply (dead code, debug remnants)
63
+ - **confirm**: Apply after user confirms (reuse swaps, over-engineering, hacky patterns, efficiency fixes)
64
+ - **review**: User should look first (commented-out code, ambiguous cases)
65
+
66
+ ## Rules
67
+
68
+ 1. When in doubt, mark as "confirm" or "review" — don't change without consent.
69
+ 2. For reuse findings, name the existing symbol/file you'd swap to.
70
+ 3. For efficiency findings, briefly justify the win (e.g., "N+1 → single query", "sequential awaits → Promise.all").
71
+ 4. Don't flag necessary code just because it's simple.
72
+ 5. Respect existing abstraction boundaries.
73
+ 6. Be especially careful with:
74
+ - Error handling code
75
+ - Security-related logic
76
+ - Code that looks "unused" but is called via reflection/eval
77
+ - Database migration files
78
+
79
+ ## Output Format (REQUIRED)
80
+
81
+ When analysis is complete, call the \`simplify_candidates\` tool exactly once as your final action. Put ALL candidates in that tool call.
82
+
83
+ Field notes:
84
+ - \`category\` — one of: "reuse", "quality", "efficiency"
85
+ - \`risk\` — one of: "safe", "confirm", "review"
86
+ - \`file\` — repository-relative path, no backticks, no markdown
87
+ - \`lines\` — line number or range ("42" or "42-57"); empty string if unknown
88
+ - \`reason\` — single plain-text sentence stating the concrete fix
89
+ - \`action\` — one of: "delete", "inline", "refactor", "parallelize"
90
+
91
+ If there are no candidates, call \`simplify_candidates\` with an empty \`candidates\` array.
92
+ Do NOT write a prose-only final answer; the extension relies on the tool result.
93
+ `;
package/selector.ts ADDED
@@ -0,0 +1,176 @@
1
+ import { getSelectListTheme, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ matchesKey,
4
+ Key,
5
+ SelectList,
6
+ wrapTextWithAnsi,
7
+ type SelectItem,
8
+ } from "@earendil-works/pi-tui";
9
+
10
+ import type { Action, SimplifyResult } from "./types.js";
11
+
12
+ const RISK_CONFIG = {
13
+ safe: { label: "Safe to apply" },
14
+ confirm: { label: "Needs confirmation" },
15
+ review: { label: "Needs review" },
16
+ } as const;
17
+
18
+ const ACTION_LABEL: Record<Action, string> = {
19
+ delete: "Delete code",
20
+ inline: "Inline code",
21
+ refactor: "Refactor code",
22
+ parallelize: "Run work in parallel",
23
+ };
24
+
25
+ export async function showCandidateSelector(
26
+ ctx: ExtensionCommandContext,
27
+ candidates: SimplifyResult[],
28
+ ): Promise<SimplifyResult[]> {
29
+ if (candidates.length === 0) return [];
30
+
31
+ const safeCandidates = candidates.filter((c) => c.risk === "safe");
32
+ const confirmCandidates = candidates.filter((c) => c.risk === "confirm");
33
+ const reviewCandidates = candidates.filter((c) => c.risk === "review");
34
+
35
+ const sections = [
36
+ { key: "safe" as const, items: safeCandidates },
37
+ { key: "confirm" as const, items: confirmCandidates },
38
+ { key: "review" as const, items: reviewCandidates },
39
+ ]
40
+ .map(({ key, items }) => ({ key, items, config: RISK_CONFIG[key], total: items.length }))
41
+ .filter((s) => s.total > 0);
42
+
43
+ const selectableItems = sections.flatMap((section) =>
44
+ section.items.map((candidate) => ({ section, candidate })),
45
+ );
46
+
47
+ const listItems: SelectItem[] = selectableItems.map(({ section, candidate }, index) => ({
48
+ value: String(index),
49
+ label: "",
50
+ description: `${section.config.label} · ${ACTION_LABEL[candidate.action]}`,
51
+ }));
52
+
53
+ const selectedSet = new Set<number>();
54
+ selectableItems.forEach(({ candidate }, index) => {
55
+ if (candidate.risk === "safe") selectedSet.add(index);
56
+ });
57
+
58
+ const maxVisible = 8;
59
+ let cursorPos = 0;
60
+
61
+ const getSelectListStartIndex = (selectedIndex: number) =>
62
+ Math.max(
63
+ 0,
64
+ Math.min(selectedIndex - Math.floor(maxVisible / 2), selectableItems.length - maxVisible),
65
+ );
66
+
67
+ const result = await ctx.ui.custom<SimplifyResult[]>((tui, theme, _keybindings, done) => {
68
+ const updateListLabel = (index: number) => {
69
+ const item = listItems[index];
70
+ const { candidate } = selectableItems[index];
71
+ const checkbox = selectedSet.has(index) ? "[x]" : "[ ]";
72
+ const location = candidate.lines ? `${candidate.file}:${candidate.lines}` : candidate.file;
73
+ item.label = `${checkbox} ${location}`;
74
+ };
75
+ const updateAllListLabels = () => listItems.forEach((_item, index) => updateListLabel(index));
76
+
77
+ updateAllListLabels();
78
+
79
+ const selectList = new SelectList(listItems, maxVisible, getSelectListTheme());
80
+ selectList.onSelectionChange = (item) => {
81
+ cursorPos = Number(item.value);
82
+ };
83
+ selectList.onCancel = () => done([]);
84
+
85
+ return {
86
+ render(width: number) {
87
+ selectList.setSelectedIndex(cursorPos);
88
+
89
+ const lines: string[] = [];
90
+ lines.push(theme.bold("Simplify: Select findings to apply"));
91
+ lines.push(
92
+ theme.fg(
93
+ "muted",
94
+ `Found ${candidates.length} candidates (${safeCandidates.length} safe, ${confirmCandidates.length} confirm, ${reviewCandidates.length} review)`,
95
+ ),
96
+ );
97
+ lines.push("");
98
+
99
+ const listLines = selectList.render(width);
100
+ const selectedLineIndex = cursorPos - getSelectListStartIndex(cursorPos);
101
+ const current = selectableItems[cursorPos]?.candidate;
102
+ for (const [lineIndex, line] of listLines.entries()) {
103
+ lines.push(line);
104
+ if (lineIndex !== selectedLineIndex || !current) continue;
105
+
106
+ const detailIndent = " ";
107
+ const detailPrefix = `${detailIndent}│ `;
108
+ const location = current.lines ? `${current.file}:${current.lines}` : current.file;
109
+ lines.push(theme.fg("borderMuted", `${detailIndent}┌─ recommendation`));
110
+ lines.push(
111
+ ...wrapTextWithAnsi(current.reason, Math.max(1, width - detailPrefix.length)).map(
112
+ (wrappedLine) => theme.fg("muted", `${detailPrefix}${wrappedLine}`),
113
+ ),
114
+ );
115
+ lines.push(theme.fg("muted", `${detailPrefix}`));
116
+ lines.push(theme.fg("muted", `${detailPrefix}File: ${location}`));
117
+ lines.push(
118
+ theme.fg("muted", `${detailPrefix}Review: ${RISK_CONFIG[current.risk].label}`),
119
+ );
120
+ lines.push(theme.fg("muted", `${detailPrefix}Kind: ${current.category}`));
121
+ lines.push(theme.fg("muted", `${detailPrefix}Fix: ${ACTION_LABEL[current.action]}`));
122
+ lines.push(theme.fg("borderMuted", `${detailIndent}└─ space selects this fix`));
123
+ }
124
+
125
+ lines.push("");
126
+ lines.push(
127
+ theme.fg(
128
+ "muted",
129
+ `Selected: ${selectedSet.size} • ↑↓ navigate • space toggle • a select all • enter confirm • esc cancel`,
130
+ ),
131
+ );
132
+ return lines;
133
+ },
134
+ invalidate() {
135
+ selectList.invalidate();
136
+ },
137
+ handleInput(data: string) {
138
+ if (matchesKey(data, Key.enter)) {
139
+ const results = Array.from(selectedSet).map((i) => selectableItems[i].candidate);
140
+ done(results);
141
+ return;
142
+ }
143
+ if (matchesKey(data, Key.escape)) {
144
+ done([]);
145
+ return;
146
+ }
147
+ if (matchesKey(data, Key.space)) {
148
+ if (selectedSet.has(cursorPos)) {
149
+ selectedSet.delete(cursorPos);
150
+ } else {
151
+ selectedSet.add(cursorPos);
152
+ }
153
+ updateListLabel(cursorPos);
154
+ tui.requestRender();
155
+ return;
156
+ }
157
+ if (matchesKey(data, "a")) {
158
+ if (selectedSet.size === selectableItems.length) {
159
+ selectedSet.clear();
160
+ } else {
161
+ for (let i = 0; i < selectableItems.length; i++) selectedSet.add(i);
162
+ }
163
+ updateAllListLabels();
164
+ tui.requestRender();
165
+ return;
166
+ }
167
+
168
+ const previousCursorPos = cursorPos;
169
+ selectList.handleInput(data);
170
+ if (cursorPos !== previousCursorPos) tui.requestRender();
171
+ },
172
+ };
173
+ });
174
+
175
+ return result ?? [];
176
+ }
package/types.ts ADDED
@@ -0,0 +1,12 @@
1
+ export type Category = "reuse" | "quality" | "efficiency";
2
+ export type Risk = "safe" | "confirm" | "review";
3
+ export type Action = "delete" | "inline" | "refactor" | "parallelize";
4
+
5
+ export type SimplifyResult = {
6
+ category: Category;
7
+ file: string;
8
+ lines: string;
9
+ reason: string;
10
+ risk: Risk;
11
+ action: Action;
12
+ };
package/workflow.ts ADDED
@@ -0,0 +1,257 @@
1
+ import {
2
+ defineTool,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+
8
+ import { SIMPLIFY_PROMPT } from "./prompt.js";
9
+ import { showCandidateSelector } from "./selector.js";
10
+ import type { SimplifyResult } from "./types.js";
11
+
12
+ const categorySchema = Type.Union([
13
+ Type.Literal("reuse"),
14
+ Type.Literal("quality"),
15
+ Type.Literal("efficiency"),
16
+ ]);
17
+ const riskSchema = Type.Union([
18
+ Type.Literal("safe"),
19
+ Type.Literal("confirm"),
20
+ Type.Literal("review"),
21
+ ]);
22
+ const actionSchema = Type.Union([
23
+ Type.Literal("delete"),
24
+ Type.Literal("inline"),
25
+ Type.Literal("refactor"),
26
+ Type.Literal("parallelize"),
27
+ ]);
28
+
29
+ function registerSimplifyCandidatesTool(
30
+ pi: ExtensionAPI,
31
+ state: {
32
+ getResolver: () => ((candidates: SimplifyResult[]) => void) | null;
33
+ clearResolver: () => void;
34
+ setLatestCandidates: (candidates: SimplifyResult[]) => void;
35
+ },
36
+ ) {
37
+ const parameters = Type.Object({
38
+ candidates: Type.Array(
39
+ Type.Object({
40
+ category: categorySchema,
41
+ risk: riskSchema,
42
+ file: Type.String({
43
+ description: "Repository-relative path",
44
+ minLength: 1,
45
+ pattern: "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$)).+$",
46
+ }),
47
+ lines: Type.String({ description: "Line number or range, or empty string if unknown" }),
48
+ reason: Type.String({ description: "Concrete fix in one sentence" }),
49
+ action: actionSchema,
50
+ }),
51
+ ),
52
+ });
53
+
54
+ pi.registerTool(
55
+ defineTool({
56
+ name: "simplify_candidates",
57
+ label: "Simplify Candidates",
58
+ description:
59
+ "Return the complete candidate list for /simplify. Use as the final action after analyzing changed code.",
60
+ promptSnippet: "Return structured cleanup candidates for /simplify as a terminating result",
61
+ promptGuidelines: [
62
+ "Use simplify_candidates exactly once as the final action when the /simplify command asks for cleanup candidates.",
63
+ ],
64
+ parameters,
65
+ async execute(_toolCallId, params) {
66
+ const candidates: SimplifyResult[] = params.candidates;
67
+ const resolve = state.getResolver();
68
+ if (!resolve) {
69
+ return {
70
+ content: [
71
+ {
72
+ type: "text" as const,
73
+ text: "Ignored simplify candidates because no /simplify analysis is pending.",
74
+ },
75
+ ],
76
+ details: {
77
+ ignored: true,
78
+ candidates: [],
79
+ byRisk: { safe: 0, confirm: 0, review: 0 },
80
+ },
81
+ terminate: true,
82
+ };
83
+ }
84
+
85
+ state.setLatestCandidates(candidates);
86
+ state.clearResolver();
87
+ resolve(candidates);
88
+
89
+ const byRisk = {
90
+ safe: candidates.filter((c) => c.risk === "safe").length,
91
+ confirm: candidates.filter((c) => c.risk === "confirm").length,
92
+ review: candidates.filter((c) => c.risk === "review").length,
93
+ };
94
+
95
+ return {
96
+ content: [
97
+ {
98
+ type: "text" as const,
99
+ text: `Captured ${candidates.length} simplify candidates (${byRisk.safe} safe, ${byRisk.confirm} confirm, ${byRisk.review} review).`,
100
+ },
101
+ ],
102
+ details: { ignored: false, candidates, byRisk },
103
+ terminate: true,
104
+ };
105
+ },
106
+ }),
107
+ );
108
+ }
109
+
110
+ async function applyFindings(
111
+ ctx: ExtensionCommandContext,
112
+ selected: SimplifyResult[],
113
+ pi: ExtensionAPI,
114
+ ) {
115
+ if (selected.length === 0) {
116
+ ctx.ui.notify("No findings selected to apply", "info");
117
+ return;
118
+ }
119
+
120
+ const safeItems = selected.filter((c) => c.risk === "safe");
121
+ const confirmItems = selected.filter((c) => c.risk === "confirm");
122
+
123
+ const cleanupPrompt = `# Apply Review Findings
124
+
125
+ Apply the following findings. Each item includes a bracketed action (e.g. \`[delete]\`, \`[refactor]\`, \`[parallelize]\`, \`[inline]\`) — follow that action, not a blanket delete.
126
+
127
+ ${selected.map((c) => `- ${c.file} (${c.lines || "?"}): ${c.reason} [${c.action}]`).join("\n")}
128
+
129
+ For each item:
130
+ 1. Read the file to find the exact location
131
+ 2. Apply only the specified change (not surrounding code unless instructed)
132
+ 3. For refactor/inline/parallelize: preserve behavior; if the change has visible side effects, stop and report
133
+ 4. If the change affects other code, stop and report the issue
134
+
135
+ After applying all changes:
136
+ - Run \`npm test\` or equivalent; if there are no tests, verify the files parse/type-check
137
+ - Report what changed, test results, and any items skipped with reasons`;
138
+
139
+ ctx.ui.notify(
140
+ `Applying ${selected.length} findings (${safeItems.length} safe, ${confirmItems.length} confirmed)`,
141
+ "info",
142
+ );
143
+
144
+ pi.sendUserMessage(cleanupPrompt);
145
+ }
146
+
147
+ export function registerSimplifyWorkflow(pi: ExtensionAPI) {
148
+ let resolvePendingTurnStart: (() => void) | null = null;
149
+ let resolvePendingCandidatesTool: ((candidates: SimplifyResult[]) => void) | null = null;
150
+ let latestToolCandidates: SimplifyResult[] | null = null;
151
+
152
+ pi.on("turn_start", () => {
153
+ if (!resolvePendingTurnStart) return;
154
+ const resolve = resolvePendingTurnStart;
155
+ resolvePendingTurnStart = null;
156
+ resolve();
157
+ });
158
+
159
+ registerSimplifyCandidatesTool(pi, {
160
+ getResolver: () => resolvePendingCandidatesTool,
161
+ clearResolver: () => {
162
+ resolvePendingCandidatesTool = null;
163
+ },
164
+ setLatestCandidates: (candidates) => {
165
+ latestToolCandidates = candidates;
166
+ },
167
+ });
168
+
169
+ pi.registerCommand("simplify", {
170
+ description: "Review changed code for reuse, quality, and efficiency, then apply fixes",
171
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
172
+ if (!ctx.hasUI) {
173
+ ctx.ui.notify("Simplify requires interactive mode", "error");
174
+ return;
175
+ }
176
+
177
+ if (!ctx.isIdle()) {
178
+ ctx.ui.notify(
179
+ "Cannot run simplify while agent is busy. Wait for current task to complete.",
180
+ "warning",
181
+ );
182
+ return;
183
+ }
184
+
185
+ const { code } = await pi.exec("git", ["rev-parse", "--git-dir"]);
186
+ if (code !== 0) {
187
+ ctx.ui.notify("Not a git repository", "error");
188
+ return;
189
+ }
190
+
191
+ const { stdout: gitStatus } = await pi.exec("git", ["status", "--porcelain"]);
192
+ if (!gitStatus.trim()) {
193
+ ctx.ui.notify("No git changes found. Simplify reviews uncommitted changes.", "info");
194
+ return;
195
+ }
196
+
197
+ ctx.ui.notify("Analyzing code for review findings...", "info");
198
+
199
+ let fullPrompt = SIMPLIFY_PROMPT;
200
+ if (args.trim()) fullPrompt += `\n\n## Additional Focus\n\n${args.trim()}`;
201
+
202
+ latestToolCandidates = null;
203
+ const turnStarted = new Promise<void>((r) => {
204
+ resolvePendingTurnStart = r;
205
+ });
206
+ const toolResult = new Promise<SimplifyResult[]>((r) => {
207
+ resolvePendingCandidatesTool = r;
208
+ });
209
+
210
+ pi.sendUserMessage(fullPrompt);
211
+
212
+ const started = await Promise.race<boolean>([
213
+ turnStarted.then(() => true),
214
+ new Promise<boolean>((r) => setTimeout(() => r(false), 10000)),
215
+ ]);
216
+ if (!started) {
217
+ resolvePendingTurnStart = null;
218
+ resolvePendingCandidatesTool = null;
219
+ ctx.ui.notify("Analysis did not start within 10 seconds. Please try again.", "warning");
220
+ return;
221
+ }
222
+
223
+ await ctx.waitForIdle();
224
+
225
+ const candidates =
226
+ latestToolCandidates ??
227
+ (await Promise.race<SimplifyResult[] | null>([
228
+ toolResult,
229
+ new Promise<null>((r) => setTimeout(() => r(null), 100)),
230
+ ]));
231
+ resolvePendingCandidatesTool = null;
232
+
233
+ if (candidates === null) {
234
+ ctx.ui.notify(
235
+ "Could not read review findings — the model did not call simplify_candidates.",
236
+ "warning",
237
+ );
238
+ return;
239
+ }
240
+
241
+ if (candidates.length === 0) {
242
+ ctx.ui.notify("No review findings found!", "info");
243
+ return;
244
+ }
245
+
246
+ ctx.ui.notify(`Found ${candidates.length} findings. Select findings to apply...`, "info");
247
+
248
+ const selected = await showCandidateSelector(ctx, candidates);
249
+ if (!selected || selected.length === 0) {
250
+ ctx.ui.notify("Apply findings cancelled", "info");
251
+ return;
252
+ }
253
+
254
+ await applyFindings(ctx, selected, pi);
255
+ },
256
+ });
257
+ }