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.
- package/README.md +22 -11
- package/agents/ref-analyst.md +18 -0
- package/index.ts +12 -14
- package/package.json +11 -2
- package/references/pi-planning-workflow.md +26 -5
- package/references/state-and-config.md +21 -6
- package/scripts/validate.ts +1 -0
- package/skills/plan-with-refs/SKILL.md +3 -3
- package/src/code-graph/commands.ts +73 -27
- package/src/code-graph/prompts.ts +1 -1
- package/src/config-command.ts +35 -0
- package/src/exec.ts +237 -5
- package/src/guard.ts +14 -1
- package/src/refine-prompts.ts +59 -0
- package/src/refine-ui-state.ts +1 -1
- package/src/refine-ui.ts +1 -1
- package/src/state.ts +17 -1
- package/src/subagent.ts +1 -0
- package/src/termination-prompt.ts +22 -0
- package/tests/analyze-refs.test.ts +265 -0
- package/tests/ask-choice.test.ts +1 -0
- package/tests/code-graph-apply-action.test.ts +173 -0
- package/tests/config-command.test.ts +8 -0
- package/tests/exec-lifecycle.test.ts +137 -0
- package/tests/exec.test.ts +15 -2
- package/tests/goal-wait.test.ts +269 -0
- package/tests/guard.test.ts +27 -1
- package/tests/plans.test.ts +10 -0
- package/tests/refine-prompts.test.ts +35 -1
- package/tests/refine-ui.test.ts +34 -0
- package/tests/state.test.ts +32 -0
- package/tests/subagent.test.ts +22 -0
- package/tools/analyze-refs.ts +263 -0
- package/tools/ask-choice.ts +2 -1
- package/tools/code-graph.ts +25 -2
- package/tools/execute-plan.ts +18 -0
- package/tools/graph-aware-file-tools.ts +2 -2
- package/tools/plans.ts +14 -2
package/src/config-command.ts
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
|
package/src/refine-prompts.ts
CHANGED
|
@@ -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
|
|
package/src/refine-ui-state.ts
CHANGED
|
@@ -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
|
+
}
|