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.
@@ -6,6 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import {
9
+ AMELIORATION_PROMPT_TEXT,
9
10
  applyDoneMarkers,
10
11
  applyImplMarkers,
11
12
  applyCurrentIMarker,
@@ -19,6 +20,7 @@ import {
19
20
  filterExecutionResumeMessages,
20
21
  filterPlanningResumeMessages,
21
22
  getExecution,
23
+ resumeGoalWaitIfPaused,
22
24
  handleExecutionBeforeCompact,
23
25
  handleExecutionCompact,
24
26
  handleExecutionTurnCompaction,
@@ -35,6 +37,7 @@ import {
35
37
  refreshPlanningCompactionCooldown,
36
38
  requestPlanningCompaction,
37
39
  restoreFromSession,
40
+ resetGoalWaitTurnFlags,
38
41
  shouldTriggerPlanningCompaction,
39
42
  startExecution,
40
43
  recordExecutionTurn,
@@ -59,6 +62,7 @@ interface Recorded {
59
62
  current: { provider: string; id: string } | null;
60
63
  thinking: string | null;
61
64
  userMessages: string[];
65
+ userMessageOptions: Array<Record<string, unknown> | null>;
62
66
  compacts?: { customInstructions?: string }[];
63
67
  }
64
68
 
@@ -83,6 +87,7 @@ function makeHarness(workdir: string): Harness {
83
87
  current: { provider: "p", id: "m" },
84
88
  thinking: "high",
85
89
  userMessages: [],
90
+ userMessageOptions: [],
86
91
  };
87
92
  let contextPercent: number | null = 0;
88
93
  const registryModels = [
@@ -113,8 +118,9 @@ function makeHarness(workdir: string): Harness {
113
118
  sendMessage: (message: { customType: string; content: string }, options?: { triggerTurn?: boolean }) => {
114
119
  recorded.messages.push({ ...message, options });
115
120
  },
116
- sendUserMessage: async (content: string) => {
121
+ sendUserMessage: async (content: string, options?: Record<string, unknown>) => {
117
122
  recorded.userMessages.push(content);
123
+ recorded.userMessageOptions.push(options ?? null);
118
124
  },
119
125
  setModel: async (model: { provider: string; id: string }) => {
120
126
  recorded.models.push({ provider: model.provider, id: model.id });
@@ -269,7 +275,8 @@ describe("execution loop", () => {
269
275
  const completeMessage = recorded.messages.find((message) => message.customType === "pi-plans-complete");
270
276
  assert.ok(completeMessage);
271
277
  assert.match(completeMessage.content, /Goal-running continuation/);
272
- assert.match(completeMessage.content, /termination condition of the implementation-review loop/);
278
+ assert.match(completeMessage.content, /How should the implementation-review loop terminate\?/);
279
+ assert.match(completeMessage.content, /goal wait: continue until no unpassed VCs remain/);
273
280
  assert.doesNotMatch(completeMessage.content, /Run a post-execution amelioration round/);
274
281
  assert.equal(completeMessage.options?.triggerTurn, true);
275
282
  const ameliorateEntry = recorded.entries.find((entry) => entry.customType === "pi-plans-ameliorate");
@@ -1451,3 +1458,139 @@ function makePreparation(reason: "manual" | "threshold" | "overflow", previousSu
1451
1458
  settings: { enabled: true, reserveTokens: 16384, keepRecentTokens: 20000 },
1452
1459
  };
1453
1460
  }
1461
+
1462
+ describe("execution goal-wait continuation", () => {
1463
+ // Fresh per-turn continuation flags, mirroring the before_agent_start reset.
1464
+ const setup = (workdir: string) => {
1465
+ resetGoalWaitTurnFlags();
1466
+ const harness = makeHarness(workdir);
1467
+ registerExecutionTurnHandlers(harness.pi);
1468
+ return harness;
1469
+ };
1470
+
1471
+ it("sends a goal-wait followUp when a turn ends with unpassed VCs", async () => {
1472
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1473
+ const { pi, ctx, recorded, emit } = setup(workdir);
1474
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1475
+ recorded.userMessages.length = 0;
1476
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1477
+ assert.equal(recorded.userMessages.length, 1);
1478
+ assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1479
+ assert.match(recorded.userMessages[0], /\`VC-001\`/);
1480
+ assert.equal(recorded.userMessageOptions.at(-1)?.deliverAs, "followUp");
1481
+ assert.match(recorded.status ?? "", /goal-wait · 无进展 1\/3 · 等待 0\/6/);
1482
+ });
1483
+
1484
+ it("sends the goal-wait followUp in headless sessions too", async () => {
1485
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1486
+ const { pi, ctx, recorded, emit } = setup(workdir);
1487
+ (ctx as any).hasUI = false;
1488
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1489
+ recorded.userMessages.length = 0;
1490
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1491
+ assert.equal(recorded.userMessages.length, 1);
1492
+ assert.match(recorded.userMessages[0], /Goal wait: 1\/1 verifier items still open/);
1493
+ });
1494
+
1495
+ it("does not goal-wait when every VC is done (completion path)", async () => {
1496
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1497
+ const { pi, ctx, recorded, emit } = setup(workdir);
1498
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1499
+ recorded.userMessages.length = 0;
1500
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "done [DONE:VC-001]" }] } });
1501
+ assert.equal(recorded.userMessages.length, 0);
1502
+ assert.ok(recorded.messages.some((message) => message.customType === "pi-plans-complete"));
1503
+ });
1504
+
1505
+ it("skips goal-wait while any compaction continuation flag is active", async () => {
1506
+ const variants = [
1507
+ { inFlight: true, resumeGuard: false, pendingFollowUpPrompt: null },
1508
+ { inFlight: false, resumeGuard: true, pendingFollowUpPrompt: null },
1509
+ { inFlight: false, resumeGuard: false, pendingFollowUpPrompt: "compaction follow-up" },
1510
+ ];
1511
+ for (const flags of variants) {
1512
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1513
+ const { pi, ctx, recorded, emit } = setup(workdir);
1514
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1515
+ (ctx.sessionManager as any).__executionCompaction = { ...flags, cooldownActive: false };
1516
+ recorded.userMessages.length = 0;
1517
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1518
+ assert.equal(recorded.userMessages.length, 0, `flags ${JSON.stringify(flags)} must skip goal-wait`);
1519
+ }
1520
+ });
1521
+
1522
+ it("pauses after 3 no-progress rounds and resumes on kick", async () => {
1523
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1524
+ const { pi, ctx, recorded, emit } = setup(workdir);
1525
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1526
+ recorded.userMessages.length = 0;
1527
+ for (let round = 0; round < 3; round++) {
1528
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "still working" }] } });
1529
+ }
1530
+ assert.equal(getExecution()?.goalWait?.paused, true);
1531
+ assert.equal(recorded.userMessages.length, 2, "third quiet round must not queue another followUp");
1532
+ assert.ok(recorded.notifies.some((entry) => /goal-wait paused/.test(entry.message)));
1533
+ assert.match(recorded.status ?? "", /⏸ goal-wait paused/);
1534
+
1535
+ resumeGoalWaitIfPaused(pi, ctx);
1536
+ assert.equal(getExecution()?.goalWait?.paused, false);
1537
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1538
+ assert.match(recorded.userMessages.at(-1) ?? "", /Goal wait/);
1539
+ });
1540
+
1541
+ it("waiting rounds are exempt until the sixth quiet waiting round", async () => {
1542
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1543
+ const { pi, ctx, recorded, emit } = setup(workdir);
1544
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001"));
1545
+ for (let round = 1; round <= 5; round++) {
1546
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: `waiting for CI (${round})` }] } });
1547
+ assert.equal(getExecution()?.goalWait?.paused, false, `round ${round} must not pause`);
1548
+ }
1549
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "waiting for CI (6)" }] } });
1550
+ assert.equal(getExecution()?.goalWait?.paused, true);
1551
+ assert.ok(recorded.notifies.some((entry) => /waiting without progress for 6 rounds/.test(entry.message)));
1552
+ });
1553
+
1554
+ it("progress resets both guard counters", async () => {
1555
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1556
+ const { pi, ctx, emit } = makeHarness(workdir);
1557
+ await startExecution(pi, ctx, path.join(workdir, "PLAN_v1.md"), items("VC-001", "VC-002"));
1558
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1559
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "working" }] } });
1560
+ await emit("turn_end", { message: { role: "assistant", content: [{ type: "text", text: "progress [DONE:VC-001]" }] } });
1561
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1562
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1563
+ });
1564
+
1565
+ it("keeps goal-wait counters across restore", async () => {
1566
+ const workdir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-goal-wait-"));
1567
+ const { pi, ctx } = setup(workdir);
1568
+ const planPath = path.join(workdir, "PLAN_v1.md");
1569
+ fs.writeFileSync(planPath, "# plan");
1570
+ const snapshot = {
1571
+ planPath,
1572
+ items: items("VC-001"),
1573
+ startedAt: "2026-08-25T00:00:00Z",
1574
+ usage: { inToks: 0, outToks: 0 },
1575
+ implItems: [],
1576
+ implStatus: {},
1577
+ goalWait: { noProgressRounds: 2, waitRounds: 1, lastMarkers: null, paused: false },
1578
+ };
1579
+ const entries = [
1580
+ { type: "custom", customType: "pi-plans-exec", data: snapshot },
1581
+ { type: "message", message: { role: "assistant", content: [{ type: "text", text: "no new progress this turn" }] } },
1582
+ ];
1583
+ await restoreFromSession(pi, ctx, entries as any);
1584
+ // Replay advanced the marker snapshot past the persisted baseline → counters reset (D-010).
1585
+ assert.equal(getExecution()?.goalWait?.noProgressRounds, 0);
1586
+ assert.equal(getExecution()?.goalWait?.waitRounds, 0);
1587
+ });
1588
+ });
1589
+
1590
+ describe("amelioration termination prompt", () => {
1591
+ it("recommends goal-wait first and keeps the round options", () => {
1592
+ assert.match(AMELIORATION_PROMPT_TEXT, /goal wait: continue until no unpassed VCs remain/);
1593
+ assert.match(AMELIORATION_PROMPT_TEXT, /until no high-severity finding \(hard cap 5 rounds\)/);
1594
+ assert.match(AMELIORATION_PROMPT_TEXT, /How should the implementation-review loop terminate\?/);
1595
+ });
1596
+ });
@@ -6,7 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { after, before, describe, it } from "node:test";
8
8
  import { planningWriteBlockReason } from "../src/guard.ts";
9
- import { initState, setRunStatus, startRun } from "../src/state.ts";
9
+ import { initState, setRefsRoot, setRunStatus, startRun } from "../src/state.ts";
10
10
 
11
11
  let tmpRoot: string;
12
12
 
@@ -61,6 +61,32 @@ describe("planning write guard", () => {
61
61
  assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }), null);
62
62
  });
63
63
 
64
+ it("allows writes under a configured refs_root and stays strict without one", () => {
65
+ const workdir = path.join(tmpRoot, "refs-root-guard");
66
+ fs.mkdirSync(workdir);
67
+ initState(workdir);
68
+ startRun(workdir, { topic: "refs guard", skill: "plan-with-refs", requestText: "x" });
69
+
70
+ // Without a configured refs_root, ./refs/ writes stay blocked.
71
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "./refs/paper.md" }));
72
+
73
+ setRefsRoot(workdir, "./refs", "user");
74
+ assert.equal(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "./refs/paper.md" }), null);
75
+ assert.equal(
76
+ planningWriteBlockReason({ workdir, toolName: "edit", rawPath: path.join(workdir, "refs", "notes.md") }),
77
+ null,
78
+ );
79
+ // The refs root does not open up the whole worktree.
80
+ assert.ok(planningWriteBlockReason({ workdir, toolName: "write", rawPath: "src/main.ts" }));
81
+
82
+ // The .git/pi-plans/refs (hyphenated) recommendation is covered by an absolute entry too.
83
+ setRefsRoot(workdir, ".git/pi-plans/refs", "user");
84
+ assert.equal(
85
+ planningWriteBlockReason({ workdir, toolName: "write", rawPath: ".git/pi-plans/refs/repo-a/" }),
86
+ null,
87
+ );
88
+ });
89
+
64
90
  it("is inactive without an active run", () => {
65
91
  const workdir = path.join(tmpRoot, "no-run");
66
92
  fs.mkdirSync(workdir);
@@ -34,4 +34,14 @@ describe("plans tool source", () => {
34
34
  assert.doesNotMatch(source, /case "set-execution-model"/);
35
35
  assert.match(source, /params\.artifactRootSource/);
36
36
  });
37
+
38
+ it("declares refsRootSource and wires the set-refs-root action plus ref-analyst role", () => {
39
+ const source = readPlansSource();
40
+ assert.match(source, /refsRoot:\s*Type\.Optional/);
41
+ assert.match(source, /refsRootSource:\s*Type\.Optional/);
42
+ assert.match(source, /import \{[\s\S]*setRefsRoot,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
43
+ assert.match(source, /case "set-refs-root"/);
44
+ assert.match(source, /params\.refsRootSource/);
45
+ assert.match(source, /"reviewer", "criticizer", "ref-analyst"/);
46
+ });
37
47
  });
@@ -2,7 +2,7 @@ import * as assert from "node:assert/strict";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { describe, it } from "node:test";
5
- import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildReviewerTask, reviewerLanes } from "../src/refine-prompts.ts";
5
+ import { buildCriticizerTask, buildImplementationCriticizerTask, buildImplementationReviewerTask, buildRefAnalystTask, buildReviewerTask, refAnalystSections, reviewerLanes } from "../src/refine-prompts.ts";
6
6
 
7
7
  describe("reviewerLanes", () => {
8
8
  it("uses stable lane ids for the big-plan fanout", () => {
@@ -34,6 +34,40 @@ describe("buildReviewerTask", () => {
34
34
  });
35
35
  });
36
36
 
37
+ describe("buildRefAnalystTask", () => {
38
+ it("carries the seven-section contract, ref metadata, and language instruction", () => {
39
+ const text = buildRefAnalystTask({
40
+ refId: "ref-1",
41
+ localPath: "/cache/refs/some-repo",
42
+ title: "Some Repo",
43
+ url: "https://github.com/x/some-repo",
44
+ kind: "project",
45
+ context: "pi-plans extension",
46
+ languageTag: "zh-Hans",
47
+ });
48
+
49
+ assert.match(text, /Reference id: ref-1/);
50
+ assert.match(text, /Title: Some Repo/);
51
+ assert.match(text, /URL: https:\/\/github\.com\/x\/some-repo/);
52
+ assert.match(text, /Local path \(your working directory\): \/cache\/refs\/some-repo/);
53
+ assert.match(text, /Authority boundary: read-only analysis only\./);
54
+ assert.match(text, /Target repo context: pi-plans extension/);
55
+ assert.match(text, /BCP47 tag "zh-Hans"/);
56
+ for (const section of refAnalystSections()) {
57
+ assert.ok(text.includes(`## ${section}`), `missing section: ${section}`);
58
+ }
59
+ assert.equal(refAnalystSections().length, 7);
60
+ });
61
+
62
+ it("omits optional lines when metadata is absent", () => {
63
+ const text = buildRefAnalystTask({ refId: "ref-2", localPath: "/tmp/r" });
64
+ assert.doesNotMatch(text, /Title:/);
65
+ assert.doesNotMatch(text, /URL:/);
66
+ assert.doesNotMatch(text, /Kind:/);
67
+ assert.doesNotMatch(text, /BCP47 tag/);
68
+ });
69
+ });
70
+
37
71
  describe("buildCriticizerTask", () => {
38
72
  it("asks for short adversarial questions only", () => {
39
73
  const text = buildCriticizerTask({
@@ -127,6 +127,40 @@ describe("refine overlay viewport", () => {
127
127
  assert.ok(lines.some((line) => line.includes("output")));
128
128
  });
129
129
 
130
+ it("renders the Refs title for the refs role and keeps legacy titles distinct", () => {
131
+ const refs = new RefineOverlayComponent(
132
+ fakeTheme,
133
+ "refs",
134
+ [readyLane("ref-1", "ref-1", "analysis output")],
135
+ () => {},
136
+ undefined,
137
+ "zai/glm-5.3-flash:high",
138
+ );
139
+ const refLines = refs.render(120);
140
+ assert.ok(refLines.some((line) => line.includes("Refs (zai/glm-5.3-flash:high)")));
141
+ assert.ok(refLines.every((line) => !line.includes("Criticizer") && !line.includes("Reviewer")));
142
+
143
+ const reviewer = new RefineOverlayComponent(
144
+ fakeTheme,
145
+ "reviewer",
146
+ [readyLane("lane-1", "general", "ok")],
147
+ () => {},
148
+ undefined,
149
+ "m1",
150
+ );
151
+ assert.ok(reviewer.render(120).some((line) => line.includes("Reviewer (m1)")));
152
+
153
+ const criticizer = new RefineOverlayComponent(
154
+ fakeTheme,
155
+ "criticizer",
156
+ [readyLane("lane-1", "criticizer", "ok")],
157
+ () => {},
158
+ undefined,
159
+ "m2",
160
+ );
161
+ assert.ok(criticizer.render(120).some((line) => line.includes("Criticizer (m2)")));
162
+ });
163
+
130
164
  it("renders model-aware titles and footer hints", () => {
131
165
  const lanes = [
132
166
  readyLane("lane-1", "correctness", "correctness output"),
@@ -13,6 +13,7 @@ import {
13
13
  recordDecision,
14
14
  setLanguage,
15
15
  setArtifactRoot,
16
+ setRefsRoot,
16
17
  setRole,
17
18
  setRunStatus,
18
19
  showConfig,
@@ -72,6 +73,37 @@ describe("init", () => {
72
73
  assert.equal(config.artifact_root, "./docs/pi-plans");
73
74
  assert.equal(config.artifact_root_source, "unset");
74
75
  assert.equal(config.artifact_root_updated_at, null);
76
+ assert.equal(config.refs_root, null);
77
+ assert.equal(config.refs_root_source, "unset");
78
+ assert.equal(config.refs_root_updated_at, null);
79
+ });
80
+
81
+ it("normalizes old configs missing the refs_root trio", () => {
82
+ const workdir = mkWorkdir("refs-root-normalize");
83
+ initState(workdir);
84
+ const configPath = path.join(commonDir(workdir), "pi_plans", "config.json");
85
+ const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
86
+ delete config.refs_root;
87
+ delete config.refs_root_source;
88
+ delete config.refs_root_updated_at;
89
+ fs.writeFileSync(configPath, `${JSON.stringify(config, null, "\t")}\n`, "utf8");
90
+ const updated = initState(workdir);
91
+ assert.equal(updated.config.refs_root, null);
92
+ assert.equal(updated.config.refs_root_source, "unset");
93
+ assert.equal(updated.config.refs_root_updated_at, null);
94
+ assert.equal(readConfig(workdir).refs_root, null);
95
+ });
96
+
97
+ it("setRefsRoot persists the trio", () => {
98
+ const workdir = mkWorkdir("refs-root-set");
99
+ initState(workdir);
100
+ setRefsRoot(workdir, ".git/pi-plans/refs", "user");
101
+ assert.equal(readConfig(workdir).refs_root, ".git/pi-plans/refs");
102
+ assert.equal(readConfig(workdir).refs_root_source, "user");
103
+ assert.ok(typeof readConfig(workdir).refs_root_updated_at === "string");
104
+ const shown = showConfig(workdir);
105
+ assert.equal(shown.refs_root, ".git/pi-plans/refs");
106
+ assert.equal(shown.refs_root_source, "user");
75
107
  });
76
108
 
77
109
  it("migrates legacy artifact roots to ./docs/pi-plans", () => {
@@ -60,6 +60,28 @@ describe("subagent runner lifecycle", () => {
60
60
  assert.equal(result.turns, 1);
61
61
  });
62
62
 
63
+ it("marks refiner children with PI_PLANS_REFINER=1", async () => {
64
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-plans-fake-pi-env-"));
65
+ const script = path.join(dir, "fake-pi-env.mjs");
66
+ fs.writeFileSync(
67
+ script,
68
+ [
69
+ 'const emit = (event) => process.stdout.write(JSON.stringify(event) + "\\n");',
70
+ 'emit({ type: "message_end", message: { role: "assistant", model: "fake/model", content: [{ type: "text", text: "marker=" + String(process.env.PI_PLANS_REFINER) }] } });',
71
+ ].join("\n"),
72
+ );
73
+ const previousScript = process.argv[1];
74
+ process.argv[1] = script;
75
+ try {
76
+ const result = await runPiSubagent({ systemPrompt: "p", task: "env", cwd: process.cwd(), timeoutMs: 2000 });
77
+ assert.equal(result.ok, true);
78
+ assert.equal(result.output, "marker=1");
79
+ } finally {
80
+ process.argv[1] = previousScript;
81
+ fs.rmSync(dir, { recursive: true, force: true });
82
+ }
83
+ });
84
+
63
85
  it("returns a cancelled result after aborting the child", async () => {
64
86
  const abort = new AbortController();
65
87
  const promise = withFakePi("slow", { signal: abort.signal, timeoutMs: 2000 });