pi-plans 0.1.1 → 0.2.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/src/plan.ts CHANGED
@@ -35,6 +35,149 @@ export function scanDoneMarkers(text: string): string[] {
35
35
  return [...text.matchAll(/\[DONE:(VC-\d+)\]/g)].map((match) => match[1]);
36
36
  }
37
37
 
38
+ export interface CurrentIMarker {
39
+ id: string;
40
+ }
41
+
42
+ /** Extract current implementation-item anchors from an assistant message. */
43
+ export function scanCurrentIMarkers(text: string): CurrentIMarker[] {
44
+ return [...text.matchAll(/\[(I-\d+):current\]/g)].map((match) => ({ id: match[1] }));
45
+ }
46
+
47
+ export const scanCurrentMarkers = scanCurrentIMarkers;
48
+
49
+ /** Resolve the last known current-I marker, ignoring unknown ids. */
50
+ export function resolveCurrentI(
51
+ implItems: ImplItem[],
52
+ markers: CurrentIMarker[] | string[],
53
+ fallback?: string,
54
+ ): string | undefined {
55
+ const known = new Set(implItems.map((item) => item.id));
56
+ let current: string | undefined;
57
+ for (const marker of markers) {
58
+ const id = typeof marker === "string" ? marker : marker.id;
59
+ if (known.has(id)) current = id;
60
+ }
61
+ return current ?? fallback;
62
+ }
63
+
64
+ /** Deterministic frontier used by snapshots written before currentI existed. */
65
+ export function inferCurrentI(
66
+ implItems: ImplItem[] | undefined,
67
+ items: CheckItem[],
68
+ implStatus: Record<string, ImplMarkerState> | undefined,
69
+ ): string | undefined {
70
+ if (!implItems?.length) return undefined;
71
+ const statuses = resolveImplStatuses(implItems, items, implStatus);
72
+ return implItems.find((item) => statuses[item.id] !== "vc-passed")?.id ?? implItems.at(-1)?.id;
73
+ }
74
+
75
+ export type ImplMarkerState = "implemented" | "validating";
76
+
77
+ export interface ImplMarker {
78
+ id: string;
79
+ state: ImplMarkerState;
80
+ }
81
+
82
+ /** Extract every [I-xxx:implemented|validating] marker from an assistant message. */
83
+ export function scanImplMarkers(text: string): ImplMarker[] {
84
+ return [...text.matchAll(/\[(I-\d+):(implemented|validating)\]/g)].map((match) => ({
85
+ id: match[1],
86
+ state: match[2] as ImplMarkerState,
87
+ }));
88
+ }
89
+
90
+ export interface ImplItem {
91
+ id: string;
92
+ text: string;
93
+ }
94
+
95
+ /**
96
+ * Parse the `## Implementation Items` section of a PLAN_vN.md. Strict grammar:
97
+ * top-level `- `I-001`: text` single lines only; multi-line bodies and nested
98
+ * sub-bullets are ignored (text stops at end of the first line).
99
+ */
100
+ export function parseImplItems(planText: string): ImplItem[] {
101
+ const lines = planText.split("\n");
102
+ const headerIndex = lines.findIndex((line) => /^##\s+Implementation Items\s*$/.test(line.trim()));
103
+ if (headerIndex < 0) return [];
104
+ const items: ImplItem[] = [];
105
+ const seen = new Set<string>();
106
+ for (let i = headerIndex + 1; i < lines.length; i++) {
107
+ const line = lines[i];
108
+ if (/^##\s/.test(line.trim())) break; // next section ends the items
109
+ const match = line.match(/^\s*-\s+`(I-\d+)`\s*:\s+(.*)$/);
110
+ if (!match) continue;
111
+ const id = match[1];
112
+ if (seen.has(id)) continue;
113
+ seen.add(id);
114
+ items.push({ id, text: match[2].trim() });
115
+ }
116
+ return items;
117
+ }
118
+
119
+ /**
120
+ * One-sentence description: cut at the first sentence boundary (。/.) or
121
+ * semicolon (;/;), then cap at 80 characters with an ellipsis.
122
+ */
123
+ export function shortImplDescription(text: string): string {
124
+ const clipped = text.split(/[。;;]/)[0] ?? text;
125
+ const sentence = clipped.split(/(?<=[.])\s/)[0] ?? clipped;
126
+ const trimmed = sentence.trim();
127
+ if (trimmed.length <= 80) return trimmed;
128
+ return `${trimmed.slice(0, 79)}…`;
129
+ }
130
+
131
+ /**
132
+ * Extract the covered I-ids from a VC checklist line's coverage clause
133
+ * ("`VC-001` covers `I-002` and `I-003`; pass condition: ..." → ["I-002",
134
+ * "I-003"]). Only references before the first ";" count.
135
+ */
136
+ export function extractCoverage(vcText: string): string[] {
137
+ const clause = vcText.split(";")[0] ?? "";
138
+ return [...clause.matchAll(/\bI-\d+\b/g)].map((match) => match[0]);
139
+ }
140
+
141
+ export type ImplDisplayState = "pending" | "implementing" | "implemented" | "validating" | "vc-passed";
142
+
143
+ /**
144
+ * Resolve the display state for every I-item. Precedence: vc-passed (all
145
+ * covering VCs done, final) > explicit marker (validating / implemented) >
146
+ * derivation (some covering VC done → validating; first I with no covering
147
+ * VC done → implementing; rest → pending).
148
+ */
149
+ export function resolveImplStatuses(
150
+ implItems: ImplItem[],
151
+ items: CheckItem[],
152
+ implStatus: Record<string, ImplMarkerState> | undefined,
153
+ ): Record<string, ImplDisplayState> {
154
+ const result: Record<string, ImplDisplayState> = {};
155
+ let frontierAssigned = false;
156
+ for (const impl of implItems) {
157
+ const coverage = items.filter((item) => extractCoverage(item.text).includes(impl.id));
158
+ if (coverage.length > 0 && coverage.every((item) => item.done)) {
159
+ result[impl.id] = "vc-passed";
160
+ continue;
161
+ }
162
+ const marker = implStatus?.[impl.id];
163
+ if (marker === "validating" || marker === "implemented") {
164
+ result[impl.id] = marker;
165
+ continue;
166
+ }
167
+ if (coverage.some((item) => item.done)) {
168
+ result[impl.id] = "validating";
169
+ continue;
170
+ }
171
+ if (!frontierAssigned) {
172
+ result[impl.id] = "implementing";
173
+ frontierAssigned = true;
174
+ continue;
175
+ }
176
+ result[impl.id] = "pending";
177
+ }
178
+ return result;
179
+ }
180
+
38
181
  export interface PlanVersionFile {
39
182
  path: string;
40
183
  version: number;
@@ -0,0 +1,82 @@
1
+ import type { ExtensionAPI, ExtensionContext, InputEvent } from "@earendil-works/pi-coding-agent";
2
+
3
+ export const QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE = "pi-plans-query-interview";
4
+ export const QUERY_INTERVIEW_MESSAGE =
5
+ "Before implementing a new user request, if any requirement, constraint, expected behavior, or acceptance criterion is unclear, interview the user with focused clarification questions and confirm the answers before making changes. If the request is clear, proceed directly with the implementation.";
6
+
7
+ interface QueryHookState {
8
+ pendingQueries: number;
9
+ }
10
+
11
+ type QueryHookSession = { __piPlansQueryHook?: QueryHookState };
12
+
13
+ function sessionCarrier(ctx: ExtensionContext): QueryHookSession | undefined {
14
+ return ctx.sessionManager as unknown as QueryHookSession | undefined;
15
+ }
16
+
17
+ function ensureState(ctx: ExtensionContext): QueryHookState | undefined {
18
+ const session = sessionCarrier(ctx);
19
+ if (!session) return undefined;
20
+ return (session.__piPlansQueryHook ??= { pendingQueries: 0 });
21
+ }
22
+
23
+ export function isOrdinaryExternalQuery(
24
+ input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
25
+ ): boolean {
26
+ const text = input.text.trim();
27
+ return (
28
+ (input.source === "interactive" || input.source === "rpc")
29
+ && input.streamingBehavior === undefined
30
+ && text.length > 0
31
+ && !text.startsWith("/")
32
+ );
33
+ }
34
+
35
+ export function recordOrdinaryQuery(
36
+ ctx: ExtensionContext,
37
+ input: Pick<InputEvent, "text" | "source" | "streamingBehavior">,
38
+ ): boolean {
39
+ if (!isOrdinaryExternalQuery(input)) return false;
40
+ const state = ensureState(ctx);
41
+ if (!state) return false;
42
+ state.pendingQueries += 1;
43
+ return true;
44
+ }
45
+
46
+ export function consumeOrdinaryQuery(ctx: ExtensionContext): boolean {
47
+ const session = sessionCarrier(ctx);
48
+ const state = session?.__piPlansQueryHook;
49
+ if (!state || state.pendingQueries <= 0) return false;
50
+ state.pendingQueries -= 1;
51
+ if (state.pendingQueries === 0) delete session.__piPlansQueryHook;
52
+ return true;
53
+ }
54
+
55
+ export function resetOrdinaryQueryState(ctx: ExtensionContext): void {
56
+ const session = sessionCarrier(ctx);
57
+ if (session) delete session.__piPlansQueryHook;
58
+ }
59
+
60
+ export function registerQueryInterviewHooks(
61
+ pi: ExtensionAPI,
62
+ suppressForWorkflow: (ctx: ExtensionContext) => boolean,
63
+ ): void {
64
+ pi.on("input", async (event, ctx) => {
65
+ recordOrdinaryQuery(ctx, event);
66
+ });
67
+
68
+ pi.on("before_agent_start", async (_event, ctx) => {
69
+ if (!consumeOrdinaryQuery(ctx) || suppressForWorkflow(ctx)) return;
70
+ return {
71
+ message: {
72
+ customType: QUERY_INTERVIEW_MESSAGE_CUSTOM_TYPE,
73
+ content: QUERY_INTERVIEW_MESSAGE,
74
+ display: false,
75
+ },
76
+ };
77
+ });
78
+
79
+ pi.on("session_start", async (_event, ctx) => {
80
+ resetOrdinaryQueryState(ctx);
81
+ });
82
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Lightweight, dependency-free replacements for the pi-tui text utilities used
3
+ * by the refine overlay. Keeping these local lets the overlay render correctly
4
+ * in contexts where the pi-tui module is not on the resolver path (notably the
5
+ * standalone `node:test` runs in this repository, since `@earendil-works/pi-tui`
6
+ * is a peer of `@earendil-works/pi-coding-agent` rather than a direct
7
+ * dependency of pi-plans).
8
+ */
9
+
10
+ const ELLIPSIS = "…";
11
+
12
+ export function visibleWidth(text: string): number {
13
+ if (!text) return 0;
14
+ let width = 0;
15
+ for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text)) {
16
+ const cp = segment.segment.codePointAt(0) ?? 0;
17
+ if (cp < 0x20) continue;
18
+ if (cp === 0x09) { width += 3; continue; }
19
+ if (cp >= 0x1100 && cp <= 0x115f) { width += 2; continue; }
20
+ if (cp >= 0x2e80 && cp <= 0x9fff) { width += 2; continue; }
21
+ if (cp >= 0xac00 && cp <= 0xd7a3) { width += 2; continue; }
22
+ if (cp >= 0xff00 && cp <= 0xff60) { width += 2; continue; }
23
+ if (cp >= 0x1f300 && cp <= 0x1faff) { width += 2; continue; }
24
+ width += 1;
25
+ }
26
+ return width;
27
+ }
28
+
29
+ export function truncateToWidth(text: string, maxWidth: number, ellipsis = ELLIPSIS): string {
30
+ const measured = visibleWidth(text);
31
+ if (measured <= maxWidth) return text;
32
+ const ellipsisWidth = ellipsis ? visibleWidth(ellipsis) : 0;
33
+ const target = Math.max(0, maxWidth - ellipsisWidth);
34
+ let result = "";
35
+ 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;
41
+ }
42
+ return ellipsis ? `${result}${ellipsis}` : result;
43
+ }
44
+
45
+ export function wrapTextWithAnsi(text: string, maxWidth: number): string[] {
46
+ if (maxWidth <= 0) return [text];
47
+ const normalized = text.replace(/\r\n/g, "\n");
48
+ const words = normalized.split(/(\s+)/);
49
+ const lines: string[] = [];
50
+ let current = "";
51
+ let currentWidth = 0;
52
+ for (const word of words) {
53
+ if (word === "") continue;
54
+ const wordWidth = visibleWidth(word);
55
+ if (wordWidth > maxWidth) {
56
+ // Hard split a single overlong word.
57
+ if (current) { lines.push(current); current = ""; currentWidth = 0; }
58
+ let buffer = "";
59
+ 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;
69
+ }
70
+ }
71
+ if (buffer) { lines.push(buffer); buffer = ""; bufferWidth = 0; }
72
+ continue;
73
+ }
74
+ if (currentWidth + wordWidth > maxWidth) {
75
+ lines.push(current.trimEnd());
76
+ current = word.trimStart();
77
+ currentWidth = visibleWidth(current);
78
+ } else {
79
+ current += word;
80
+ currentWidth += wordWidth;
81
+ }
82
+ }
83
+ if (current.trim().length > 0) lines.push(current.trimEnd());
84
+ return lines;
85
+ }
86
+
87
+ export function matchesEscape(data: string): boolean {
88
+ return data === "\x1b" || data === "\x1b\x1b" || /^(\x1b\[\??\d*[A-Za-z])|(\x1bO[A-Za-z])$/.test(data);
89
+ }
@@ -0,0 +1,78 @@
1
+ import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
2
+
3
+ export type RefineOverlayRole = "reviewer" | "criticizer";
4
+ export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
5
+
6
+ export interface RefineLaneState {
7
+ id: string;
8
+ label: string;
9
+ status: RefineLaneStatus;
10
+ phase: string;
11
+ 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;
17
+ }
18
+
19
+ export function statusLabel(status: RefineLaneStatus): string {
20
+ switch (status) {
21
+ case "queued":
22
+ return "queued";
23
+ case "running":
24
+ return "running";
25
+ case "complete":
26
+ return "done";
27
+ case "failed":
28
+ return "failed";
29
+ case "cancelled":
30
+ return "cancelled";
31
+ }
32
+ }
33
+
34
+ export function applyRefineProgress(lane: RefineLaneState, event: SubagentProgressEvent): void {
35
+ if (lane.status === "complete" || lane.status === "failed" || lane.status === "cancelled") return;
36
+
37
+ switch (event.type) {
38
+ case "process":
39
+ lane.phase = event.phase === "started" ? "starting" : "exiting";
40
+ return;
41
+ case "turn":
42
+ lane.status = "running";
43
+ lane.phase = event.phase === "start" ? "thinking" : "waiting";
44
+ 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);
54
+ return;
55
+ case "stderr":
56
+ lane.phase = "diagnostic";
57
+ if (event.text) lane.detail = shorten(event.text, 180);
58
+ return;
59
+ }
60
+ }
61
+
62
+ export function applyRefineResult(lane: RefineLaneState, result: SubagentResult): void {
63
+ if (result.ok) {
64
+ lane.status = "complete";
65
+ lane.phase = "complete";
66
+ lane.detail = shorten(result.output, 180);
67
+ return;
68
+ }
69
+ if (result.cancelled) {
70
+ lane.status = "cancelled";
71
+ lane.phase = "cancelled";
72
+ lane.detail = shorten(result.errorMessage ?? "Subagent was aborted", 180);
73
+ return;
74
+ }
75
+ lane.status = "failed";
76
+ lane.phase = result.timedOut ? "timed out" : "failed";
77
+ lane.detail = shorten((result.errorMessage ?? result.stderr) || "Subagent failed", 180);
78
+ }