pi-plans 0.3.0 → 0.3.2

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.
@@ -71,6 +71,11 @@ function parseArtifactRoot(input: string): string | null {
71
71
  return value ? value : null;
72
72
  }
73
73
 
74
+ function parseRefsRoot(input: string): string | null {
75
+ const value = input.trim();
76
+ return value ? value : null;
77
+ }
78
+
74
79
  function parseModelSelector(input: string): string | null {
75
80
  const value = input.trim();
76
81
  if (!value) return null;
@@ -144,6 +149,24 @@ async function promptArtifactRoot(ctx: ConfigCommandContext, current: string): P
144
149
  return promptMenu(ctx, "Artifact root?", options);
145
150
  }
146
151
 
152
+ async function promptRefsRoot(ctx: ConfigCommandContext, current: string | null): Promise<ChoiceResult<string>> {
153
+ const options: Array<MenuOption<string>> = [];
154
+ if (current) {
155
+ options.push({ label: `Keep current (${current})`, value: current });
156
+ }
157
+ for (const root of [".git/pi-plans/refs", "./refs", "~/.cache/pi-plans/refs"]) {
158
+ if (root === current) continue;
159
+ options.push({ label: root, value: root });
160
+ }
161
+ options.push({
162
+ label: "Other...",
163
+ parse: parseRefsRoot,
164
+ prompt: "Refs root:",
165
+ errorMessage: "Refs root cannot be empty.",
166
+ });
167
+ return promptMenu(ctx, "Refs root (plan-with-refs downloads)?", options);
168
+ }
169
+
147
170
  async function promptGraphEnabled(ctx: ConfigCommandContext, current: boolean | null): Promise<ChoiceResult<boolean>> {
148
171
  const options: Array<MenuOption<boolean>> = [];
149
172
  if (current === true) {
@@ -219,6 +242,7 @@ function summarizeConfig(config: PlansConfig): string[] {
219
242
  "pi-plans config updated.",
220
243
  `Language: ${config.language.tag ?? "(unset)"}`,
221
244
  `Artifact root: ${config.artifact_root}`,
245
+ `Refs root: ${config.refs_root ?? "(unset)"}`,
222
246
  `Code graph: ${config.graph_enabled === true ? "enabled" : config.graph_enabled === false ? "disabled" : "unset"}`,
223
247
  `Reviewer: ${config.reviewer.mode} / ${config.reviewer.model_selector ?? "inherit"}`,
224
248
  `Criticizer: ${config.criticizer.mode} / ${config.criticizer.model_selector ?? "inherit"}`,
@@ -251,6 +275,14 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
251
275
  return;
252
276
  }
253
277
 
278
+ const refsRoot = await promptRefsRoot(ctx, current.refs_root);
279
+ if (refsRoot.cancelled) {
280
+ if (refsRoot.reason === "user") {
281
+ ctx.ui.notify("Configuration wizard cancelled. No changes were written.", "warning");
282
+ }
283
+ return;
284
+ }
285
+
254
286
  const graphEnabled = await promptGraphEnabled(ctx, current.graph_enabled);
255
287
  if (graphEnabled.cancelled) {
256
288
  if (graphEnabled.reason === "user") {
@@ -297,6 +329,9 @@ export async function configPiPlansCommand(_args: string, ctx: ConfigCommandCont
297
329
  config.artifact_root = artifactRoot.value;
298
330
  config.artifact_root_source = "user";
299
331
  config.artifact_root_updated_at = now;
332
+ config.refs_root = refsRoot.value;
333
+ config.refs_root_source = "user";
334
+ config.refs_root_updated_at = now;
300
335
  config.graph_enabled = graphEnabled.value;
301
336
  config.graph_enabled_updated_at = now;
302
337
  config.reviewer = {
package/src/exec.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as fs from "node:fs";
11
+ import { randomUUID } from "node:crypto";
11
12
  import type {
12
13
  CompactionResult,
13
14
  ExtensionAPI,
@@ -36,6 +37,7 @@ import {
36
37
  import { getRun, readActive, resolveStateRootOrNull, setRunStatus, utcNow } from "./state.ts";
37
38
  import { graphBlockForExecutor } from "./code-graph/prompts.ts";
38
39
  import { resolveGraphMode } from "./code-graph/mode.ts";
40
+ import { TERMINATION_QUESTION, TERMINATION_OPTIONS, renderTerminationOptions } from "./termination-prompt.ts";
39
41
  import {
40
42
  extractCoverage,
41
43
  latestPlanVersion,
@@ -58,10 +60,49 @@ export interface ExecState {
58
60
  implItems?: ImplItem[];
59
61
  implStatus?: Record<string, ImplMarkerState>;
60
62
  currentI?: string;
63
+ goalWait?: GoalWaitState;
61
64
  }
62
65
 
66
+ export interface GoalWaitState {
67
+ noProgressRounds: number;
68
+ waitRounds: number;
69
+ /** Marker/progress snapshot of the last goal-wait round; null = baseline not set. */
70
+ lastMarkers: string | null;
71
+ paused: boolean;
72
+ pausedReason?: string;
73
+ }
74
+
75
+ const GOAL_WAIT_MAX_NO_PROGRESS = 3;
76
+ const GOAL_WAIT_MAX_WAITING = 6;
77
+
63
78
  let execution: ExecState | null = null;
64
79
 
80
+ export const GOAL_WAIT_CUSTOM_TYPE = "pi-plans-goal-wait";
81
+
82
+ interface GoalWaitRuntime {
83
+ owner: ExecState;
84
+ session: ExtensionContext["sessionManager"];
85
+ handled: boolean;
86
+ stopReason?: string;
87
+ text: string;
88
+ wakeId?: string;
89
+ }
90
+
91
+ // Dispatch identity belongs to a live session, never to a persisted checklist.
92
+ let goalWaitRuntime: GoalWaitRuntime | null = null;
93
+
94
+ function resetGoalWaitRuntime(ctx: ExtensionContext): void {
95
+ goalWaitRuntime = execution
96
+ ? { owner: execution, session: ctx.sessionManager, handled: false, text: "" }
97
+ : null;
98
+ }
99
+
100
+ function currentGoalWaitRuntime(ctx: ExtensionContext): GoalWaitRuntime | null {
101
+ return goalWaitRuntime?.owner === execution && goalWaitRuntime.session === ctx.sessionManager
102
+ ? goalWaitRuntime
103
+ : null;
104
+ }
105
+
65
106
  // Execution-loop persistence is deferred until the agent settles so turn_end
66
107
  // never causes session writes during a streaming run.
67
108
  let pendingExecutionFlush = false;
@@ -198,7 +239,14 @@ function formatToks(tokens: number): string {
198
239
 
199
240
  export function formatExecutionStatusLine(execution: ExecState): string {
200
241
  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`;
242
+ let line = `⌛ plans ${progress.done}/${progress.total}: spent ${formatElapsed(execution.startedAt)} · ${formatToks(execution.usage.inToks)} in-toks · ${formatToks(execution.usage.outToks)} out-toks`;
243
+ const goalWait = execution.goalWait;
244
+ if (goalWait?.paused) {
245
+ line += ` · ⏸ goal-wait paused (${goalWait.pausedReason ?? "paused"})`;
246
+ } else if (goalWait && (goalWait.noProgressRounds > 0 || goalWait.waitRounds > 0)) {
247
+ line += ` · 🔁 goal-wait · 无进展 ${goalWait.noProgressRounds}/3 · 等待 ${goalWait.waitRounds}/6`;
248
+ }
249
+ return line;
202
250
  }
203
251
 
204
252
  export function updateStatusWidget(ctx: ExtensionContext): void {
@@ -250,6 +298,7 @@ function persist(pi: ExtensionAPI): void {
250
298
  implItems: execution.implItems,
251
299
  implStatus: execution.implStatus,
252
300
  currentI: execution.currentI,
301
+ goalWait: execution.goalWait,
253
302
  });
254
303
  }
255
304
 
@@ -260,7 +309,11 @@ export async function startExecution(
260
309
  items: CheckItem[],
261
310
  implItems?: ImplItem[],
262
311
  ): Promise<void> {
263
- execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {} };
312
+ execution = { planPath, items, startedAt: utcNow(), usage: { inToks: 0, outToks: 0 }, implItems: implItems ?? [], implStatus: {}, goalWait: { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false } };
313
+ // Seed the marker baseline so the first quiet round is counted against a
314
+ // real snapshot instead of counting unconditionally (F-006).
315
+ if (execution.goalWait) execution.goalWait.lastMarkers = goalWaitSnapshot();
316
+ resetGoalWaitRuntime(ctx);
264
317
  pendingExecutionFlush = false; // fresh run: no inherited flush debt
265
318
  resetExecutionCompactionState(ctx);
266
319
  persist(pi);
@@ -306,6 +359,30 @@ export function registerExecutionTurnHandlers(
306
359
  // The turn_end projection does not carry usage; message_end delivers the
307
360
  // full assistant message, so cache it here and consume it per turn.
308
361
  let lastAssistantUsage: { input: number; output: number } | null = null;
362
+ pi.on("agent_start", async (_event, ctx) => {
363
+ const runtime = currentGoalWaitRuntime(ctx);
364
+ if (!runtime) return;
365
+ runtime.handled = false;
366
+ runtime.stopReason = undefined;
367
+ runtime.text = "";
368
+ });
369
+ pi.on("before_agent_start", async (_event, ctx) => {
370
+ const runtime = currentGoalWaitRuntime(ctx);
371
+ if (runtime) runtime.wakeId = undefined;
372
+ });
373
+ pi.on("input", async (event, ctx) => {
374
+ if (event.source === "interactive" || event.source === "rpc") resumeGoalWaitIfPaused(pi, ctx);
375
+ });
376
+ pi.on("agent_settled", async (_event, ctx) => {
377
+ drainExecutionFlush(pi, ctx);
378
+ maybeGoalWaitFollowUp(pi, ctx);
379
+ });
380
+ pi.on("session_shutdown", async (_event, ctx) => {
381
+ drainExecutionFlush(pi, ctx);
382
+ execution = null;
383
+ goalWaitRuntime = null;
384
+ lastAssistantUsage = null;
385
+ });
309
386
  pi.on("message_end", async (event) => {
310
387
  const message = event.message as { role?: string; usage?: { input?: number; output?: number } };
311
388
  if (message?.role === "assistant" && message.usage) {
@@ -314,7 +391,7 @@ export function registerExecutionTurnHandlers(
314
391
  });
315
392
 
316
393
  pi.on("turn_end", async (event, ctx) => {
317
- const message = event.message as { role?: string; content?: Array<{ type: string; text?: string }> };
394
+ const message = event.message as { role?: string; stopReason?: string; content?: Array<{ type: string; text?: string }> };
318
395
  if (!message || message.role !== "assistant") {
319
396
  updateStatusWidget(ctx);
320
397
  return;
@@ -323,6 +400,11 @@ export function registerExecutionTurnHandlers(
323
400
  .filter((part) => part.type === "text")
324
401
  .map((part) => part.text ?? "")
325
402
  .join("\n");
403
+ const runtime = currentGoalWaitRuntime(ctx);
404
+ if (runtime) {
405
+ runtime.stopReason = message.stopReason;
406
+ runtime.text = text;
407
+ }
326
408
  const changedIds = applyDoneMarkers(text);
327
409
  const changedImpls = applyImplMarkers(text);
328
410
  const changedCurrentI = applyCurrentIMarker(text);
@@ -815,6 +897,7 @@ export async function stopExecution(pi: ExtensionAPI, ctx: ExtensionContext, rea
815
897
  pendingExecutionFlush = false;
816
898
  persist(pi);
817
899
  execution = null;
900
+ goalWaitRuntime = null;
818
901
  pi.appendEntry("pi-plans-exec-cleared", { reason });
819
902
  pi.sendMessage(
820
903
  {
@@ -881,6 +964,138 @@ export function isExecutionComplete(): boolean {
881
964
  return execution !== null && execution.items.length > 0 && execution.items.every((item) => item.done);
882
965
  }
883
966
 
967
+ function goalWaitSnapshot(): string {
968
+ if (!execution) return "";
969
+ return JSON.stringify({
970
+ done: execution.items
971
+ .filter((item) => item.done)
972
+ .map((item) => item.id)
973
+ .sort()
974
+ .join("|"),
975
+ implStatus: execution.implStatus ?? {},
976
+ currentI: execution.currentI ?? null,
977
+ });
978
+ }
979
+
980
+ function pauseGoalWait(pi: ExtensionAPI, ctx: ExtensionContext, reason: string): void {
981
+ const ex = getExecution();
982
+ if (!ex?.goalWait) return;
983
+ ex.goalWait.paused = true;
984
+ ex.goalWait.pausedReason = reason;
985
+ persist(pi);
986
+ ctx.ui.notify?.(
987
+ `pi-plans: goal-wait paused (${reason}). Send any message or run /plans-execute to resume.`,
988
+ "warning",
989
+ );
990
+ updateStatusWidget(ctx);
991
+ }
992
+
993
+ function canWakeExecution(ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
994
+ const compaction = executionCompactionState(ctx);
995
+ return currentGoalWaitRuntime(ctx) === runtime
996
+ && (ctx.mode === "tui" || ctx.mode === "rpc")
997
+ && !isExecutionComplete()
998
+ && !runtime.owner.goalWait?.paused
999
+ && ctx.isIdle()
1000
+ && !ctx.hasPendingMessages()
1001
+ && !ctx.signal?.aborted
1002
+ && !compactionInFlight(ctx, "execution")
1003
+ && !compaction?.inFlight
1004
+ && !compaction?.resumeGuard
1005
+ && compaction?.pendingFollowUpPrompt == null;
1006
+ }
1007
+
1008
+ function sendGoalWaitWake(pi: ExtensionAPI, ctx: ExtensionContext, runtime: GoalWaitRuntime): boolean {
1009
+ if (!canWakeExecution(ctx, runtime)) return false;
1010
+ try {
1011
+ // Custom messages bypass before_agent_start, so carry fresh execution rules.
1012
+ const content = executionContextMessage(ctx);
1013
+ if (!content) return false;
1014
+ runtime.wakeId = randomUUID();
1015
+ pi.sendMessage({
1016
+ customType: GOAL_WAIT_CUSTOM_TYPE,
1017
+ content,
1018
+ display: false,
1019
+ details: { wakeId: runtime.wakeId },
1020
+ }, { triggerTurn: true });
1021
+ return true;
1022
+ } catch (error) {
1023
+ runtime.wakeId = undefined;
1024
+ pauseGoalWait(pi, ctx, `continuation failed: ${String(error)}`);
1025
+ return false;
1026
+ }
1027
+ }
1028
+
1029
+ /** Only a fully settled agent run can need an extra wake, never a tool turn. */
1030
+ function maybeGoalWaitFollowUp(pi: ExtensionAPI, ctx: ExtensionContext): void {
1031
+ const runtime = currentGoalWaitRuntime(ctx);
1032
+ if (!runtime || runtime.handled || !ctx.isIdle()) return;
1033
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") return;
1034
+ if (runtime.stopReason === "error" || runtime.stopReason === "aborted" || ctx.signal?.aborted) {
1035
+ runtime.handled = true;
1036
+ pauseGoalWait(pi, ctx, runtime.stopReason === "error" ? "agent failed" : "agent interrupted");
1037
+ return;
1038
+ }
1039
+ if (runtime.stopReason !== "stop" || !canWakeExecution(ctx, runtime)) return;
1040
+ runtime.handled = true;
1041
+ const ex = runtime.owner;
1042
+ ex.goalWait ??= { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false };
1043
+ const goalWait = ex.goalWait;
1044
+ const snapshot = goalWaitSnapshot();
1045
+ const changed = goalWait.lastMarkers !== null && snapshot !== goalWait.lastMarkers;
1046
+ goalWait.lastMarkers = snapshot;
1047
+ if (changed) {
1048
+ goalWait.noProgressRounds = 0;
1049
+ goalWait.waitRounds = 0;
1050
+ } else if (/waiting for/i.test(runtime.text)) {
1051
+ goalWait.waitRounds += 1;
1052
+ } else {
1053
+ goalWait.noProgressRounds += 1;
1054
+ }
1055
+ if (goalWait.noProgressRounds >= GOAL_WAIT_MAX_NO_PROGRESS) {
1056
+ pauseGoalWait(pi, ctx, `no progress in ${goalWait.noProgressRounds} rounds`);
1057
+ return;
1058
+ }
1059
+ if (goalWait.waitRounds >= GOAL_WAIT_MAX_WAITING) {
1060
+ pauseGoalWait(pi, ctx, `waiting without progress for ${goalWait.waitRounds} rounds`);
1061
+ return;
1062
+ }
1063
+ persist(pi);
1064
+ updateStatusWidget(ctx);
1065
+ // No await between the live gate and dispatch: another input cannot interleave.
1066
+ sendGoalWaitWake(pi, ctx, runtime);
1067
+ }
1068
+
1069
+ export function filterGoalWaitMessages<T extends { customType?: string; details?: unknown }>(messages: T[]): T[] {
1070
+ return messages.filter((message) => message.customType !== GOAL_WAIT_CUSTOM_TYPE
1071
+ || (goalWaitRuntime?.owner === execution && goalWaitRuntime?.wakeId !== undefined
1072
+ && (message.details as { wakeId?: unknown } | undefined)?.wakeId === goalWaitRuntime.wakeId));
1073
+ }
1074
+
1075
+ /** Called only for genuine user input or an explicit same-execution resume. */
1076
+ export function resumeGoalWaitIfPaused(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1077
+ const ex = getExecution();
1078
+ if (!ex?.goalWait?.paused || !currentGoalWaitRuntime(ctx)) return false;
1079
+ ex.goalWait.paused = false;
1080
+ ex.goalWait.pausedReason = undefined;
1081
+ ex.goalWait.noProgressRounds = 0;
1082
+ ex.goalWait.waitRounds = 0;
1083
+ ex.goalWait.lastMarkers = goalWaitSnapshot();
1084
+ persist(pi);
1085
+ updateStatusWidget(ctx);
1086
+ return true;
1087
+ }
1088
+
1089
+ export function resumeActiveExecution(pi: ExtensionAPI, ctx: ExtensionContext): boolean {
1090
+ if (!resumeGoalWaitIfPaused(pi, ctx)) return false;
1091
+ const runtime = currentGoalWaitRuntime(ctx)!;
1092
+ if (canWakeExecution(ctx, runtime)) {
1093
+ runtime.handled = true;
1094
+ sendGoalWaitWake(pi, ctx, runtime);
1095
+ }
1096
+ return true;
1097
+ }
1098
+
884
1099
  export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext): Promise<void> {
885
1100
  if (!execution) return;
886
1101
  resetExecutionCompactionState(ctx);
@@ -891,6 +1106,7 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
891
1106
  const summary = execution.items.map((item) => `- ✅ \`${item.id}\` ${item.text.split(";")[0]}`).join("\n");
892
1107
  const planPath = execution.planPath;
893
1108
  execution = null;
1109
+ goalWaitRuntime = null;
894
1110
  pi.appendEntry("pi-plans-exec-cleared", { reason: "complete" });
895
1111
  // Post-execution goal-running continuation: in interactive sessions, attach
896
1112
  // the continuation block and trigger a new turn so the agent immediately
@@ -925,9 +1141,10 @@ export async function completeExecution(pi: ExtensionAPI, ctx: ExtensionContext)
925
1141
 
926
1142
  /** Instructions appended to the post-execution completion message in
927
1143
  * interactive sessions, telling the agent to enter the goal-running
928
- * implementation-review loop. */
1144
+ * implementation-review loop. Termination options are single-sourced from
1145
+ * src/termination-prompt.ts (shared with the ask_choice trailing branch). */
929
1146
  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.`;
1147
+ 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
1148
 
932
1149
  /** Injection text for before_agent_start while executing. */
933
1150
  export function executionContextMessage(ctx: ExtensionContext): string | null {
@@ -979,6 +1196,7 @@ interface SessionEntry {
979
1196
  */
980
1197
  export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext, entries: SessionEntry[]): Promise<void> {
981
1198
  pendingExecutionFlush = false; // no flush debt survives a restart
1199
+ goalWaitRuntime = null;
982
1200
  resetExecutionCompactionState(ctx);
983
1201
  let snapshotIndex = -1;
984
1202
  let snapshot: ExecState | null = null;
@@ -1015,6 +1233,9 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1015
1233
  implItems: snapshot.implItems ?? [],
1016
1234
  implStatus: { ...(snapshot.implStatus ?? {}) },
1017
1235
  currentI: snapshot.currentI ?? inferCurrentI(snapshot.implItems, snapshot.items, snapshot.implStatus),
1236
+ goalWait: snapshot.goalWait
1237
+ ? { ...snapshot.goalWait }
1238
+ : { noProgressRounds: 0, waitRounds: 0, lastMarkers: null, paused: false },
1018
1239
  };
1019
1240
  for (let i = snapshotIndex + 1; i < entries.length; i++) {
1020
1241
  const entry = entries[i];
@@ -1034,6 +1255,17 @@ export async function restoreFromSession(pi: ExtensionAPI, ctx: ExtensionContext
1034
1255
  }
1035
1256
  }
1036
1257
  if (execution) {
1258
+ resetGoalWaitRuntime(ctx);
1259
+ // D-010: replay may have advanced progress past the persisted baseline.
1260
+ // Recompute the goal-wait markers; new progress resets the guard counters.
1261
+ if (execution.goalWait) {
1262
+ const markerSnapshot = goalWaitSnapshot();
1263
+ if (markerSnapshot !== execution.goalWait.lastMarkers) {
1264
+ execution.goalWait.lastMarkers = markerSnapshot;
1265
+ execution.goalWait.noProgressRounds = 0;
1266
+ execution.goalWait.waitRounds = 0;
1267
+ }
1268
+ }
1037
1269
  persist(pi); // refresh snapshot so the next resume has less to rescan
1038
1270
  if (isExecutionComplete()) {
1039
1271
  // 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
+ }