pi-plans 0.3.0 → 0.3.1

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/exec.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
37
37
  import { graphBlockForExecutor } from "./code-graph/prompts.ts";
38
38
  import { resolveGraphMode } from "./code-graph/mode.ts";
39
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
39
40
  import {
40
41
  extractCoverage,
41
42
  latestPlanVersion,
@@ -58,8 +59,21 @@ export interface ExecState {
58
59
  implItems?: ImplItem[];
59
60
  implStatus?: Record<string, ImplMarkerState>;
60
61
  currentI?: string;
62
+ goalWait?: GoalWaitState;
61
63
  }
62
64
 
65
+ export interface GoalWaitState {
66
+ noProgressRounds: number;
67
+ waitRounds: number;
68
+ /** Marker/progress snapshot of the last goal-wait round; null = baseline not set. */
69
+ lastMarkers: string | null;
70
+ paused: boolean;
71
+ pausedReason?: string;
72
+ }
73
+
74
+ const GOAL_WAIT_MAX_NO_PROGRESS = 3;
75
+ const GOAL_WAIT_MAX_WAITING = 6;
76
+
63
77
  let execution: ExecState | null = null;
64
78
 
65
79
  // Execution-loop persistence is deferred until the agent settles so turn_end
@@ -198,7 +212,14 @@ function formatToks(tokens: number): string {
198
212
 
199
213
  export function formatExecutionStatusLine(execution: ExecState): string {
200
214
  const progress = computeExecutionProgress(execution);
201
- return `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
215
+ let line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
216
+ const goalWait = execution.goalWait;
217
+ if (goalWait?.paused) {
218
+ line += ` · ⏸ goal-wait paused (${goalWait.pausedReason ?? "paused"})`;
219
+ } else if (goalWait && (goalWait.noProgressRounds > 0 || goalWait.waitRounds > 0)) {
220
+ line += ` · 🔁 goal-wait · 无进展 ${goalWait.noProgressRounds}/3 · 等待 ${goalWait.waitRounds}/6`;
221
+ }
222
+ return line;
202
223
  }
203
224
 
204
225
  export function updateStatusWidget(ctx: ExtensionContext): void {
@@ -250,6 +271,7 @@ function persist(pi: ExtensionAPI): void {
250
271
  implItems: execution.implItems,
251
272
  implStatus: execution.implStatus,
252
273
  currentI: execution.currentI,
274
+ goalWait: execution.goalWait,
253
275
  });
254
276
  }
255
277
 
@@ -260,7 +282,10 @@ export async function startExecution(
260
282
  items: CheckItem[],
261
283
  implItems?: ImplItem[],
262
284
  ): Promise<void> {
263
- execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
285
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {}, goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false } };
286
+ // Seed the marker baseline so the first quiet round is counted against a
287
+ // real snapshot instead of counting unconditionally (F-006).
288
+ if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
264
289
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
265
290
  resetExecutionCompactionState(ctx);
266
291
  persist(pi);
@@ -336,6 +361,8 @@ export function registerExecutionTurnHandlers(
336
361
  }
337
362
  if (getExecution() && isExecutionComplete()) {
338
363
  await completeExecution(pi, ctx);
364
+ } else if (getExecution()) {
365
+ maybeGoalWaitFollowUp(pi, ctx, text);
339
366
  }
340
367
  await onTurnEnd?.(ctx);
341
368
  });
@@ -465,6 +492,7 @@ export async function handleExecutionCompact(pi: ExtensionAPI, ctx: ExtensionCon
465
492
  if (!event.willRetry && stats) {
466
493
  ctx.ui.notify(formatVccCompactionStats(stats), "info");
467
494
  if (followUpPrompt) {
495
+ compactionFollowUpSentThisTurn = true;
468
496
  await pi.sendUserMessage?.(followUpPrompt);
469
497
  } else if ((event.reason === "threshold" || event.reason === "overflow") && shouldScheduleAutoContinue(continueAfterThresholdCompact, runtimePiVersion(ctx))) {
470
498
  state.resumeGuard = true;
@@ -881,6 +909,109 @@ export function isExecutionComplete(): boolean {
881
909
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
882
910
  }
883
911
 
912
+ let compactionFollowUpSentThisTurn = false;
913
+
914
+ /** Reset per-turn continuation flags at the start of a new agent turn. */
915
+ export function resetGoalWaitTurnFlags(): void {
916
+ compactionFollowUpSentThisTurn = false;
917
+ }
918
+
919
+ function goalWaitSnapshot(): string {
920
+ if (!execution) return "";
921
+ return JSON.stringify({
922
+ done: execution.items
923
+ .filter((item) => item.done)
924
+ .map((item) => item.id)
925
+ .sort()
926
+ .join("|"),
927
+ implStatus: execution.implStatus ?? {},
928
+ currentI: execution.currentI ?? null,
929
+ });
930
+ }
931
+
932
+ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
933
+ const ex = getExecution();
934
+ if (!ex?.goalWait) return;
935
+ ex.goalWait.paused = true;
936
+ ex.goalWait.pausedReason = reason;
937
+ persist(pi);
938
+ ctx.ui.notify?.(
939
+ `pi-plans: goal-wait paused (${reason}). Send any message or run /plans-execute to resume.`,
940
+ "warning",
941
+ );
942
+ updateStatusWidget(ctx);
943
+ }
944
+
945
+ /**
946
+ * Goal-wait continuation: a turn that ends with unpassed VCs gets one light
947
+ * followUp so the worker keeps going (working, or polling an external event
948
+ * per the taught backoff rules). Skipped when the compaction machinery owns
949
+ * continuation for this turn, and paused entirely by the no-progress guard.
950
+ * Note: the tri-flag check here deliberately runs BEFORE index.ts's
951
+ * handleExecutionTurnCompaction consumes resumeGuard — checking after the
952
+ * one-shot consumption would never observe it.
953
+ */
954
+ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext, assistantText: string): void {
955
+ const ex = getExecution();
956
+ if (!ex) return;
957
+ ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
958
+ const goalWait = ex.goalWait;
959
+ if (goalWait.paused) {
960
+ updateStatusWidget(ctx);
961
+ return;
962
+ }
963
+ const compaction = executionCompactionState(ctx);
964
+ if (compaction && (compaction.inFlight || compaction.pendingFollowUpPrompt != null || compaction.resumeGuard)) {
965
+ updateStatusWidget(ctx);
966
+ return;
967
+ }
968
+ if (compactionFollowUpSentThisTurn) {
969
+ compactionFollowUpSentThisTurn = false; // the compaction path already queued a continuation
970
+ updateStatusWidget(ctx);
971
+ return;
972
+ }
973
+ const snapshot = goalWaitSnapshot();
974
+ const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
975
+ goalWait.lastMarkers = snapshot;
976
+ if (!changed) {
977
+ if (/waiting for/i.test(assistantText)) {
978
+ goalWait.waitRounds += 1;
979
+ } else {
980
+ goalWait.noProgressRounds += 1;
981
+ }
982
+ } else {
983
+ goalWait.noProgressRounds = 0;
984
+ goalWait.waitRounds = 0;
985
+ }
986
+ if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
987
+ pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
988
+ return;
989
+ }
990
+ if (goalWait.waitRounds >= GOAL_WAIT_MAX_WAITING) {
991
+ pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
992
+ return;
993
+ }
994
+ const remaining = ex.items.filter((item) => !item.done);
995
+ const remainingIds = remaining.map((item) => `\`${item.id}\``).join(", ");
996
+ pi.sendUserMessage?.(
997
+ `Goal wait: ${remaining.length}/${ex.items.length} verifier items still open (${remainingIds}). Continue the plan — if blocked on an external event, keep waiting per the backoff rules; otherwise resolve the remaining items.`,
998
+ { deliverAs: "followUp" },
999
+ );
1000
+ updateStatusWidget(ctx);
1001
+ }
1002
+
1003
+ /** Any external input re-kicks a paused goal-wait (clears pause and counters). */
1004
+ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): void {
1005
+ const ex = getExecution();
1006
+ if (!ex?.goalWait?.paused) return;
1007
+ ex.goalWait.paused = false;
1008
+ ex.goalWait.pausedReason = undefined;
1009
+ ex.goalWait.noProgressRounds = 0;
1010
+ ex.goalWait.waitRounds = 0;
1011
+ persist(pi);
1012
+ updateStatusWidget(ctx);
1013
+ }
1014
+
884
1015
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
885
1016
  if (!execution) return;
886
1017
  resetExecutionCompactionState(ctx);
@@ -925,9 +1056,10 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
925
1056
 
926
1057
  /** Instructions appended to the post-execution completion message in
927
1058
  * interactive sessions, telling the agent to enter the goal-running
928
- * implementation-review loop. */
1059
+ * implementation-review loop. Termination options are single-sourced from
1060
+ * src/termination-prompt.ts (shared with the ask_choice trailing branch). */
929
1061
  export const AMELIORATION_PROMPT_TEXT = `---
930
- Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) for the termination condition of the implementation-review loop: until no high-severity finding (hard cap 5 rounds, recommended) / 1 round / 2 rounds / 3 rounds. Then keep running the loop without asking whether to continue: each round calls refine (role: "reviewer", target: "implementation"), accepts findings on evidence, applies fixes, re-runs relevant tests, and repeats until the chosen termination condition or the 5-round cap.`;
1062
+ Goal-running continuation: immediately ask the user now via ask_choice (autoComplete: false, in the session language) the termination question: "${TERMINATION_QUESTION}" Options (recommended first): ${renderTerminationOptions()}. Then keep running the implementation-review loop without asking whether to continue; the goal-wait option keeps the loop running until no unpassed VCs remain.`;
931
1063
 
932
1064
  /** Injection text for before_agent_start while executing. */
933
1065
  export function executionContextMessage(ctx: ExtensionContext): string | null {
@@ -1015,6 +1147,9 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1015
1147
  implItems: snapshot.implItems ?? [],
1016
1148
  implStatus: { ...(snapshot.implStatus ?? {}) },
1017
1149
  currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
1150
+ goalWait: snapshot.goalWait
1151
+ ? { ...snapshot.goalWait }
1152
+ : { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false },
1018
1153
  };
1019
1154
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
1020
1155
  const entry = entries[i];
@@ -1034,6 +1169,16 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1034
1169
  }
1035
1170
  }
1036
1171
  if (execution) {
1172
+ // D-010: replay may have advanced progress past the persisted baseline.
1173
+ // Recompute the goal-wait markers; new progress resets the guard counters.
1174
+ if (execution.goalWait) {
1175
+ const markerSnapshot = goalWaitSnapshot();
1176
+ if (markerSnapshot !== execution.goalWait.lastMarkers) {
1177
+ execution.goalWait.lastMarkers = markerSnapshot;
1178
+ execution.goalWait.noProgressRounds = 0;
1179
+ execution.goalWait.waitRounds = 0;
1180
+ }
1181
+ }
1037
1182
  persist(pi); // refresh snapshot so the next resume has less to rescan
1038
1183
  if (isExecutionComplete()) {
1039
1184
  // Completed during the rescan: restore the planning model on the way out.
package/src/guard.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import * as os from "node:os";
9
9
  import * as path from "node:path";
10
- import { getRun, readActive, resolveStateRootOrNull } from "./state.ts";
10
+ import { getRun, loadConfig, readActive, resolveStateRootOrNull } from "./state.ts";
11
11
 
12
12
  const GUARDED_TOOLS = new Set(["write", "edit"]);
13
13
  const GUARDED_STATUSES = new Set(["planning", "accepted"]);
@@ -31,6 +31,19 @@ export function planningWriteBlockReason(input: GuardInput): string | null {
31
31
  const allowedRoots = [stateRoot, active.artifact_dir, path.join(os.homedir(), ".cache", "pi-plans")].filter(
32
32
  (root): root is string => root !== null,
33
33
  );
34
+ // A configured refs root (plan-with-refs downloads) is writable while planning.
35
+ if (stateRoot !== null) {
36
+ try {
37
+ const config = loadConfig(stateRoot);
38
+ if (config.refs_root) {
39
+ allowedRoots.push(
40
+ path.isAbsolute(config.refs_root) ? config.refs_root : path.resolve(input.workdir, config.refs_root),
41
+ );
42
+ }
43
+ } catch {
44
+ /* config read failed: no extra root */
45
+ }
46
+ }
34
47
  const allowed = allowedRoots.some((root) => target === root || target.startsWith(`${root}${path.sep}`));
35
48
  if (allowed) return null;
36
49
 
@@ -103,6 +103,65 @@ ${opts.planText}
103
103
  ---8<--- END PLAN CONTENT ---8<---`;
104
104
  }
105
105
 
106
+ export interface RefAnalystTaskInput {
107
+ refId: string;
108
+ localPath: string;
109
+ title?: string;
110
+ url?: string;
111
+ kind?: string;
112
+ context?: string;
113
+ languageTag?: string | null;
114
+ }
115
+
116
+ const REF_ANALYST_SECTIONS = [
117
+ "Overview",
118
+ "Key Mechanisms And Design Tradeoffs",
119
+ "Adoptable Ideas For The Target Repo",
120
+ "Pitfalls And Anti-Patterns",
121
+ "Evidence Citations",
122
+ "Coverage",
123
+ "Evidence Gaps",
124
+ ] as const;
125
+
126
+ export function refAnalystSections(): readonly string[] {
127
+ return REF_ANALYST_SECTIONS;
128
+ }
129
+
130
+ /** Task brief for the plan-with-refs per-reference analysis subagent. */
131
+ export function buildRefAnalystTask(opts: RefAnalystTaskInput): string {
132
+ const titleLine = opts.title ? `\nTitle: ${opts.title}` : "";
133
+ const urlLine = opts.url ? `\nURL: ${opts.url}` : "";
134
+ const kindLine = opts.kind ? `\nKind: ${opts.kind}` : "";
135
+ const contextLine = opts.context ? `\n\nTarget repo context: ${opts.context}` : "";
136
+ const languageLine = opts.languageTag
137
+ ? `\n\nWrite all prose in the language with BCP47 tag "${opts.languageTag}". Keep file paths, identifiers, and code snippets verbatim.`
138
+ : "";
139
+ const template = REF_ANALYST_SECTIONS.map((section) => `## ${section}`).join("\n\n(empty)\n\n");
140
+ return `Goal: deep-read this downloaded reference and extract what the target repository should adopt from it.
141
+
142
+ Reference id: ${opts.refId}${titleLine}${urlLine}${kindLine}
143
+ Local path (your working directory): ${opts.localPath}
144
+
145
+ Authority boundary: read-only analysis only. Do not edit, write, delete, commit, push, or spawn subagents. Stay inside the reference directory.
146
+
147
+ Evidence: inspect the reference with read, grep, find, and ls before judging it. Cite files as <relative-path>:<line> for every claim; quote only what you verified.${contextLine}${languageLine}
148
+
149
+ Success criteria: a structured analysis the main agent can paste into REF_ANALYSIS.md and turn into adoption questions.
150
+
151
+ Output: Markdown with exactly these seven top-level sections, in this order:
152
+
153
+ ${template}
154
+
155
+ Section contracts:
156
+ - Overview: what the reference is, its scope, maturity, and license (when discoverable).
157
+ - Key Mechanisms And Design Tradeoffs: the mechanisms that make it work and the tradeoffs they embody.
158
+ - Adoptable Ideas For The Target Repo: concrete, portable ideas ranked by expected value; name the target-repo surface each would touch.
159
+ - Pitfalls And Anti-Patterns: what to avoid when borrowing; failure modes the reference itself documents or exhibits.
160
+ - Evidence Citations: the file:line references backing the claims above.
161
+ - Coverage: which parts of the reference you actually read versus skipped.
162
+ - Evidence Gaps: what you could not determine from the reference alone.`;
163
+ }
164
+
106
165
  export function buildImplementationCriticizerTask(opts: RefinePromptInput): string {
107
166
  return `${buildImplementationSharedHeader("criticizer", opts)}
108
167
 
@@ -1,6 +1,6 @@
1
1
  import type { SubagentProgressEvent, SubagentResult } from "./subagent.ts";
2
2
 
3
- export type RefineOverlayRole = "reviewer" | "criticizer";
3
+ export type RefineOverlayRole = "reviewer" | "criticizer" | "refs";
4
4
  export type RefineLaneStatus = "queued" | "running" | "complete" | "failed" | "cancelled";
5
5
  export type RefineTranscriptEntryType = "assistant-text" | "thinking" | "tool-call" | "tool-result" | "diagnostic";
6
6
 
package/src/refine-ui.ts CHANGED
@@ -203,7 +203,7 @@ function summaryFor(role: RefineOverlayRole, lanes: RefineLaneState[], modelLabe
203
203
  const complete = lanes.filter((lane) => lane.status === "complete").length;
204
204
  const terminal = lanes.filter((lane) => ["complete", "failed", "cancelled"].includes(lane.status)).length;
205
205
  const running = lanes.filter((lane) => lane.status === "running").length;
206
- const title = role === "reviewer" ? "Reviewer" : "Criticizer";
206
+ const title = role === "reviewer" ? "Reviewer" : role === "refs" ? "Refs" : "Criticizer";
207
207
  const visibleTitle = modelLabel ? `${title} (${modelLabel})` : title;
208
208
  const state = terminal === lanes.length ? "done" : running > 0 ? `${running} running` : "queued";
209
209
  return `${visibleTitle} · ${complete}/${lanes.length} done · ${state}`;
package/src/state.ts CHANGED
@@ -43,6 +43,10 @@ export interface PlansConfig {
43
43
  artifact_root: string;
44
44
  artifact_root_source: SettingSource;
45
45
  artifact_root_updated_at: string | null;
46
+ /** null = never asked; plan-with-refs must ask once (three options) before downloading. */
47
+ refs_root: string | null;
48
+ refs_root_source: SettingSource;
49
+ refs_root_updated_at: string | null;
46
50
  /** null = never asked; the plans tool surfaces a hint so the agent asks once. */
47
51
  graph_enabled: boolean | null;
48
52
  graph_enabled_updated_at: string | null;
@@ -83,6 +87,9 @@ export const DEFAULT_CONFIG: PlansConfig = {
83
87
  artifact_root: DEFAULT_ARTIFACT_ROOT,
84
88
  artifact_root_source: "unset",
85
89
  artifact_root_updated_at: null,
90
+ refs_root: null,
91
+ refs_root_source: "unset",
92
+ refs_root_updated_at: null,
86
93
  graph_enabled: null,
87
94
  graph_enabled_updated_at: null,
88
95
  };
@@ -138,7 +145,7 @@ export interface RefEntry {
138
145
  }
139
146
 
140
147
  export interface SubagentEntry {
141
- role: "reviewer" | "criticizer";
148
+ role: "reviewer" | "criticizer" | "ref-analyst";
142
149
  name: string;
143
150
  model?: string | null;
144
151
  session_dir?: string;
@@ -330,6 +337,15 @@ export function setArtifactRoot(workdir: string, artifactRoot: string, source: "
330
337
  return { config, stateRoot, notices };
331
338
  }
332
339
 
340
+ export function setRefsRoot(workdir: string, refsRoot: string, source: "user" | "auto"): EnsureResult {
341
+ const { config, stateRoot, notices } = ensureState(workdir);
342
+ config.refs_root = refsRoot;
343
+ config.refs_root_source = source;
344
+ config.refs_root_updated_at = utcNow();
345
+ atomicWriteJson(path.join(stateRoot, "config.json"), config);
346
+ return { config, stateRoot, notices };
347
+ }
348
+
333
349
  export function setGraphEnabled(workdir: string, enabled: boolean): EnsureResult {
334
350
  const { config, stateRoot, notices } = ensureState(workdir);
335
351
  config.graph_enabled = enabled;
package/src/subagent.ts CHANGED
@@ -306,6 +306,7 @@ export async function runPiSubagent(options: SubagentOptions): Promise<SubagentR
306
306
  cwd: options.cwd,
307
307
  shell: false,
308
308
  stdio: ["ignore", "pipe", "pipe"],
309
+ env: { ...process.env, PI_PLANS_REFINER: "1" },
309
310
  });
310
311
  let buffer = "";
311
312
  let closed = false;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Single source of truth for the post-execution implementation-review
3
+ * termination question. Consumed by BOTH the goal-running continuation prompt
4
+ * (src/exec.ts AMELIORATION_PROMPT_TEXT) and the ask_choice trailing branch
5
+ * (tools/ask-choice.ts). Pure constants only — no runtime imports, no
6
+ * execution-loop coupling.
7
+ */
8
+
9
+ export const TERMINATION_QUESTION = "How should the implementation-review loop terminate?";
10
+
11
+ export const TERMINATION_OPTIONS = [
12
+ "goal wait: continue until no unpassed VCs remain (auto-continue each round)",
13
+ "until no high-severity finding (hard cap 5 rounds)",
14
+ "1 round",
15
+ "2 rounds",
16
+ "3 rounds",
17
+ ] as const;
18
+
19
+ /** "1. <option> 2. <option> …" — recommended (goal wait) first. */
20
+ export function renderTerminationOptions(): string {
21
+ return TERMINATION_OPTIONS.map((option, index) => `${index + 1}. ${option}`).join(" ");
22
+ }