pi-plans 0.2.0 → 0.3.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 (65) hide show
  1. package/README.md +74 -21
  2. package/index.ts +115 -9
  3. package/package.json +7 -1
  4. package/references/pi-planning-workflow.md +18 -3
  5. package/references/state-and-config.md +34 -2
  6. package/scripts/validate.ts +4 -0
  7. package/src/code-graph/commands.ts +437 -0
  8. package/src/code-graph/discovery.ts +118 -0
  9. package/src/code-graph/git.ts +108 -0
  10. package/src/code-graph/identity.ts +59 -0
  11. package/src/code-graph/indexer.ts +281 -0
  12. package/src/code-graph/materialize.ts +166 -0
  13. package/src/code-graph/mode.ts +28 -0
  14. package/src/code-graph/mutations.ts +160 -0
  15. package/src/code-graph/parser.ts +51 -0
  16. package/src/code-graph/parsers/javascript.ts +35 -0
  17. package/src/code-graph/parsers/python.ts +160 -0
  18. package/src/code-graph/parsers/tree-sitter.ts +316 -0
  19. package/src/code-graph/paths.ts +85 -0
  20. package/src/code-graph/prompts.ts +18 -0
  21. package/src/code-graph/resolver.ts +69 -0
  22. package/src/code-graph/runtime.ts +158 -0
  23. package/src/code-graph/schema.ts +135 -0
  24. package/src/code-graph/screening.ts +82 -0
  25. package/src/code-graph/store.ts +278 -0
  26. package/src/code-graph/summary.ts +435 -0
  27. package/src/code-graph/types.ts +163 -0
  28. package/src/compaction.ts +1125 -371
  29. package/src/config-command.ts +326 -0
  30. package/src/exec.ts +356 -686
  31. package/src/refine-prompts.ts +50 -0
  32. package/src/refine-ui-helpers.ts +71 -18
  33. package/src/refine-ui-state.ts +87 -21
  34. package/src/refine-ui.ts +210 -102
  35. package/src/state.ts +19 -6
  36. package/src/subagent.ts +163 -61
  37. package/tests/ask-choice.test.ts +263 -0
  38. package/tests/autocomplete.test.ts +6 -1
  39. package/tests/code-graph-apply.test.ts +185 -0
  40. package/tests/code-graph-commands.test.ts +211 -0
  41. package/tests/code-graph-db.test.ts +166 -0
  42. package/tests/code-graph-discovery.test.ts +38 -0
  43. package/tests/code-graph-git.test.ts +94 -0
  44. package/tests/code-graph-index.test.ts +175 -0
  45. package/tests/code-graph-loop.e2e.test.ts +159 -0
  46. package/tests/code-graph-mutations.test.ts +117 -0
  47. package/tests/code-graph-parser.test.ts +85 -0
  48. package/tests/code-graph-rollback.test.ts +100 -0
  49. package/tests/code-graph-summary-batching.test.ts +518 -0
  50. package/tests/code-graph-summary.test.ts +148 -0
  51. package/tests/compaction.test.ts +371 -57
  52. package/tests/config-command.test.ts +255 -0
  53. package/tests/exec.test.ts +665 -241
  54. package/tests/fixtures/code-graph/sample.js +36 -0
  55. package/tests/fixtures/code-graph/sample.py +20 -0
  56. package/tests/fixtures/code-graph/sample.ts +15 -0
  57. package/tests/graph-aware-file-tools.test.ts +411 -0
  58. package/tests/refine-prompts.test.ts +67 -2
  59. package/tests/refine-ui.test.ts +337 -72
  60. package/tests/subagent.test.ts +26 -20
  61. package/tools/ask-choice.ts +158 -11
  62. package/tools/code-graph.ts +254 -0
  63. package/tools/graph-aware-file-tools.ts +392 -0
  64. package/tools/plans.ts +84 -1
  65. package/tools/refine.ts +61 -15
@@ -30,6 +30,23 @@ Authority boundary: read-only analysis only. Do not edit, write, delete, commit,
30
30
  Evidence: inspect the repository with read, grep, find, and ls before judging the plan.${lensLine}${focusLine}${contextLine}`;
31
31
  }
32
32
 
33
+ /** Shared header for the post-execution implementation review: the
34
+ * accepted plan is the contract, the IMPLEMENTATION in the worktree is
35
+ * under review. Findings must anchor to the plan's goals/acceptance
36
+ * criteria and explicitly assess delivery maturity. */
37
+ function buildImplementationSharedHeader(role: "reviewer" | "criticizer", opts: RefinePromptInput): string {
38
+ const lensLine = role === "reviewer" && opts.lens ? `\nReview lens: ${opts.lens}.` : "";
39
+ const focusLine = opts.focus ? `\n\nSpecific concerns from the main agent: ${opts.focus}` : "";
40
+ const contextLine = opts.context ? `\n\nContext: ${opts.context}` : "";
41
+ return `Goal: ${role === "reviewer" ? "review the implemented result in the worktree against the plan" : "stress-test the implemented result's assumptions"}.
42
+
43
+ Target: ${opts.planPath} (the accepted plan; the IMPLEMENTATION in the worktree is under review)
44
+
45
+ Authority boundary: read-only analysis only. Do not edit, write, delete, commit, push, or spawn subagents.
46
+
47
+ Evidence: inspect the repository with read, grep, find, and ls before judging the implementation. Judge the implementation against the plan's goals, verifier checklist, and acceptance criteria. Explicitly assess delivery maturity: did the executor ship a minimal MVP only, or refine for long-term growth (no stopgaps, long-term architectural decisions, missing tests, technical debt, production readiness)? Calibrate severity accordingly. Out-of-scope improvement ideas are low severity by default and must not be forced into findings.${lensLine}${focusLine}${contextLine}`;
48
+ }
49
+
33
50
  export function reviewerLanes(count: number): ReviewerLane[] {
34
51
  if (count === 3) return [...REVIEWER_LENSES];
35
52
  if (count === 2) return [...REVIEWER_LENSES.slice(0, 2)];
@@ -68,3 +85,36 @@ Plan file: ${opts.planPath}
68
85
  ${opts.planText}
69
86
  ---8<--- END PLAN CONTENT ---8<---`;
70
87
  }
88
+
89
+ export function buildImplementationReviewerTask(opts: RefinePromptInput): string {
90
+ return `${buildImplementationSharedHeader("reviewer", opts)}
91
+
92
+ Success criteria: return evidence-backed findings or explicitly say the implementation holds up.
93
+
94
+ Output: Markdown, highest severity first. For each finding use this shape:
95
+ - \`F-###\` — severity: high | medium | low; affected plan IDs (e.g. R-001, I-003) or files; evidence: repo path/command that proves it; impact; recommended fix; suggested disposition (accept | reject | needs-discussion).
96
+
97
+ Surface at most five high-priority findings; list lower-severity findings after them. If the implementation holds up, say so explicitly and list what you checked.
98
+
99
+ Plan file: ${opts.planPath}
100
+
101
+ ---8<--- PLAN CONTENT ---8<---
102
+ ${opts.planText}
103
+ ---8<--- END PLAN CONTENT ---8<---`;
104
+ }
105
+
106
+ export function buildImplementationCriticizerTask(opts: RefinePromptInput): string {
107
+ return `${buildImplementationSharedHeader("criticizer", opts)}
108
+
109
+ Success criteria: return concrete, answerable questions only; never rewrite the plan or the implementation.
110
+
111
+ Output: Markdown in exactly this shape:
112
+ 1. A summary of your core criticism in at most three sentences, highlighting the single most important point.
113
+ 2. Then at most five adaptive questions, numbered, each with one line of why it matters. Questions must be answerable by a user with repo access — never rhetorical. Stop earlier if the implementation genuinely holds.
114
+
115
+ Plan file: ${opts.planPath}
116
+
117
+ ---8<--- PLAN CONTENT ---8<---
118
+ ${opts.planText}
119
+ ---8<--- END PLAN CONTENT ---8<---`;
120
+ }
@@ -9,8 +9,32 @@
9
9
 
10
10
  const ELLIPSIS = "…";
11
11
 
12
- export function visibleWidth(text: string): number {
13
- if (!text) return 0;
12
+ /**
13
+ * ECMA-48 CSI sequence: ESC [ parameter bytes (0x30-0x3F), intermediate bytes
14
+ * (0x20-0x2F), final byte (0x40-0x7E). Matched atomically so styling payloads
15
+ * never leak into width math and are never split mid-sequence.
16
+ */
17
+ const CSI_PATTERN = /\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]/g;
18
+
19
+ interface AnsiPart {
20
+ kind: "csi" | "text";
21
+ value: string;
22
+ }
23
+
24
+ function splitAnsi(text: string): AnsiPart[] {
25
+ const parts: AnsiPart[] = [];
26
+ let last = 0;
27
+ for (const match of text.matchAll(CSI_PATTERN)) {
28
+ const start = match.index ?? 0;
29
+ if (start > last) parts.push({ kind: "text", value: text.slice(last, start) });
30
+ parts.push({ kind: "csi", value: match[0] });
31
+ last = start + match[0].length;
32
+ }
33
+ if (last < text.length) parts.push({ kind: "text", value: text.slice(last) });
34
+ return parts;
35
+ }
36
+
37
+ function graphemeWidth(text: string): number {
14
38
  let width = 0;
15
39
  for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
16
40
  const cp = segment.segment.codePointAt(0) ?? 0;
@@ -26,6 +50,16 @@ export function visibleWidth(text: string): number {
26
50
  return width;
27
51
  }
28
52
 
53
+ export function visibleWidth(text: string): number {
54
+ if (!text) return 0;
55
+ let width = 0;
56
+ for (const part of splitAnsi(text)) {
57
+ if (part.kind === "csi") continue; // escape sequences render at zero width
58
+ width += graphemeWidth(part.value);
59
+ }
60
+ return width;
61
+ }
62
+
29
63
  export function truncateToWidth(text: string, maxWidth: number, ellipsis = ELLIPSIS): string {
30
64
  const measured = visibleWidth(text);
31
65
  if (measured <= maxWidth) return text;
@@ -33,12 +67,25 @@ export function truncateToWidth(text: string, maxWidth: number, ellipsis = ELLIP
33
67
  const target = Math.max(0, maxWidth - ellipsisWidth);
34
68
  let result = "";
35
69
  let used = 0;
36
- for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
37
- const w = visibleWidth(segment.segment);
38
- if (used + w > target) break;
39
- result += segment.segment;
40
- used += w;
70
+ let exhausted = false;
71
+ for (const part of splitAnsi(text)) {
72
+ if (part.kind === "csi") {
73
+ if (!exhausted) result += part.value; // keep styling runs intact; never split them
74
+ continue;
75
+ }
76
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(part.value)) {
77
+ const w = visibleWidth(segment.segment);
78
+ if (used + w > target) {
79
+ exhausted = true;
80
+ break;
81
+ }
82
+ result += segment.segment;
83
+ used += w;
84
+ }
41
85
  }
86
+ // A kept open SGR (its reset lived past the cut) must not bleed into the
87
+ // ellipsis or anything rendered after this string.
88
+ if (exhausted && result.includes("\x1b[")) result += "\x1b[0m";
42
89
  return ellipsis ? `${result}${ellipsis}` : result;
43
90
  }
44
91
 
@@ -53,19 +100,25 @@ export function wrapTextWithAnsi(text: string, maxWidth: number): string[] {
53
100
  if (word === "") continue;
54
101
  const wordWidth = visibleWidth(word);
55
102
  if (wordWidth > maxWidth) {
56
- // Hard split a single overlong word.
103
+ // Hard split a single overlong word (ANSI-aware: CSI runs are zero-width and atomic).
57
104
  if (current) { lines.push(current); current = ""; currentWidth = 0; }
58
105
  let buffer = "";
59
106
  let bufferWidth = 0;
60
- for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(word)) {
61
- const w = visibleWidth(segment.segment);
62
- if (bufferWidth + w > maxWidth) {
63
- lines.push(buffer);
64
- buffer = segment.segment;
65
- bufferWidth = w;
66
- } else {
67
- buffer += segment.segment;
68
- bufferWidth += w;
107
+ for (const part of splitAnsi(word)) {
108
+ if (part.kind === "csi") {
109
+ buffer += part.value;
110
+ continue;
111
+ }
112
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(part.value)) {
113
+ const w = visibleWidth(segment.segment);
114
+ if (bufferWidth + w > maxWidth) {
115
+ lines.push(buffer);
116
+ buffer = segment.segment;
117
+ bufferWidth = w;
118
+ } else {
119
+ buffer += segment.segment;
120
+ bufferWidth += w;
121
+ }
69
122
  }
70
123
  }
71
124
  if (buffer) { lines.push(buffer); buffer = ""; bufferWidth = 0; }
@@ -86,4 +139,4 @@ export function wrapTextWithAnsi(text: string, maxWidth: number): string[] {
86
139
 
87
140
  export function matchesEscape(data: string): boolean {
88
141
  return data === "\x1b" || data === "\x1b\x1b" || /^(\x1b\[\??\d*[A-Za-z])|(\x1bO[A-Za-z])$/.test(data);
89
- }
142
+ }
@@ -2,6 +2,17 @@ import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
2
2
 
3
3
  export type RefineOverlayRole = "reviewer" | "criticizer";
4
4
  export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
5
+ export type RefineTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result" | "diagnostic";
6
+
7
+ export interface RefineTranscriptEntry {
8
+ id: string;
9
+ type: RefineTranscriptEntryType;
10
+ text: string;
11
+ streaming: boolean;
12
+ toolCallId?: string;
13
+ toolName?: string;
14
+ isError?: boolean;
15
+ }
5
16
 
6
17
  export interface RefineLaneState {
7
18
  id: string;
@@ -9,11 +20,11 @@ export interface RefineLaneState {
9
20
  status: RefineLaneStatus;
10
21
  phase: string;
11
22
  detail: string;
12
- }
13
-
14
- function shorten(text: string, maxLength: number): string {
15
- const normalized = text.replace(/\s+/g, " ").trim();
16
- return normalized.length > maxLength ? `${normalized.slice(0, maxLength - 3)}...` : normalized;
23
+ transcript: RefineTranscriptEntry[];
24
+ currentTurnIndex: number;
25
+ scrollOffset: number;
26
+ followTranscript: boolean;
27
+ viewportHeight: number;
17
28
  }
18
29
 
19
30
  export function statusLabel(status: RefineLaneStatus): string {
@@ -31,6 +42,56 @@ export function statusLabel(status: RefineLaneStatus): string {
31
42
  }
32
43
  }
33
44
 
45
+ function transcriptId(lane: RefineLaneState, event: Extract<SubagentProgressEvent, { type: "transcript" }>): string {
46
+ return `${lane.currentTurnIndex}:${event.key}`;
47
+ }
48
+
49
+ function phaseForEntry(event: Extract<SubagentProgressEvent, { type: "transcript" }>): string {
50
+ switch (event.entryType) {
51
+ case "assistant-text":
52
+ return "responding";
53
+ case "thinking":
54
+ return "thinking";
55
+ case "tool-call":
56
+ return event.toolName ? `tool: ${event.toolName}` : "tool call";
57
+ case "tool-result":
58
+ return event.toolName ? `result: ${event.toolName}` : "tool result";
59
+ case "diagnostic":
60
+ return "diagnostic";
61
+ }
62
+ }
63
+
64
+ function applyTranscriptEvent(lane: RefineLaneState, event: Extract<SubagentProgressEvent, { type: "transcript" }>): void {
65
+ const id = transcriptId(lane, event);
66
+ let entry = lane.transcript.find((candidate) => candidate.id === id);
67
+ if (!entry && event.entryType === "tool-call" && event.toolCallId) {
68
+ entry = lane.transcript.find((candidate) => candidate.type === "tool-call" && candidate.toolCallId === event.toolCallId);
69
+ }
70
+ if (!entry) {
71
+ entry = { id, type: event.entryType, text: "", streaming: event.streaming };
72
+ lane.transcript.push(entry);
73
+ }
74
+ if (event.update === "append") entry.text += event.text;
75
+ else entry.text = event.text;
76
+ entry.streaming = event.streaming;
77
+ if (event.toolCallId !== undefined) entry.toolCallId = event.toolCallId;
78
+ if (event.toolName !== undefined) entry.toolName = event.toolName;
79
+ if (event.isError !== undefined) entry.isError = event.isError;
80
+ lane.detail = entry.text;
81
+ lane.status = "running";
82
+ lane.phase = phaseForEntry(event);
83
+ }
84
+
85
+ function applyDiagnostic(lane: RefineLaneState, text: string): void {
86
+ if (!text) return;
87
+ const id = `${lane.currentTurnIndex}:diagnostic`;
88
+ const existing = lane.transcript.find((entry) => entry.id === id);
89
+ if (existing) existing.text += text;
90
+ else lane.transcript.push({ id, type: "diagnostic", text, streaming: true });
91
+ lane.detail = text;
92
+ lane.phase = "diagnostic";
93
+ }
94
+
34
95
  export function applyRefineProgress(lane: RefineLaneState, event: SubagentProgressEvent): void {
35
96
  if (lane.status === "complete" || lane.status === "failed" || lane.status === "cancelled") return;
36
97
 
@@ -39,40 +100,45 @@ export function applyRefineProgress(lane: RefineLaneState, event: SubagentProgre
39
100
  lane.phase = event.phase === "started" ? "starting" : "exiting";
40
101
  return;
41
102
  case "turn":
42
- lane.status = "running";
43
- lane.phase = event.phase === "start" ? "thinking" : "waiting";
103
+ if (event.phase === "start") {
104
+ lane.status = "running";
105
+ lane.phase = "thinking";
106
+ lane.currentTurnIndex = event.turnIndex ?? lane.currentTurnIndex + 1;
107
+ } else {
108
+ lane.phase = "waiting";
109
+ }
44
110
  return;
45
- case "message":
46
- lane.status = "running";
47
- lane.phase = event.role === "assistant" ? "responding" : event.role;
48
- if (event.text) lane.detail = shorten(event.text, 180);
49
- return;
50
- case "tool":
51
- lane.status = "running";
52
- lane.phase = `tool: ${event.toolName}`;
53
- if (event.detail) lane.detail = shorten(event.detail, 180);
111
+ case "transcript":
112
+ applyTranscriptEvent(lane, event);
54
113
  return;
55
114
  case "stderr":
56
- lane.phase = "diagnostic";
57
- if (event.text) lane.detail = shorten(event.text, 180);
115
+ applyDiagnostic(lane, event.text);
58
116
  return;
59
117
  }
60
118
  }
61
119
 
62
120
  export function applyRefineResult(lane: RefineLaneState, result: SubagentResult): void {
121
+ const finalText = result.output || result.errorMessage || result.stderr || "";
122
+ const finalEntry = [...lane.transcript].reverse().find((entry) => entry.type === "assistant-text");
123
+ if (finalText) {
124
+ if (finalEntry) {
125
+ finalEntry.text = finalText;
126
+ finalEntry.streaming = false;
127
+ } else {
128
+ lane.transcript.push({ id: `${lane.currentTurnIndex}:result`, type: "assistant-text", text: finalText, streaming: false });
129
+ }
130
+ }
131
+ lane.detail = finalText;
63
132
  if (result.ok) {
64
133
  lane.status = "complete";
65
134
  lane.phase = "complete";
66
- lane.detail = shorten(result.output, 180);
67
135
  return;
68
136
  }
69
137
  if (result.cancelled) {
70
138
  lane.status = "cancelled";
71
139
  lane.phase = "cancelled";
72
- lane.detail = shorten(result.errorMessage ?? "Subagent was aborted", 180);
73
140
  return;
74
141
  }
75
142
  lane.status = "failed";
76
143
  lane.phase = result.timedOut ? "timed out" : "failed";
77
- lane.detail = shorten((result.errorMessage ?? result.stderr) || "Subagent failed", 180);
78
144
  }