pi-plans 0.1.0 → 0.1.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.
@@ -111,23 +111,47 @@ describe("execution panel helpers", () => {
111
111
  const execution: any = {
112
112
  planPath: "/tmp/PLAN_v1.md",
113
113
  items: [{ id: "VC-001", text: "item", done: false }],
114
+ startedAt: "2026-08-29T00:00:00Z",
115
+ usage: { inToks: 0, outToks: 0 },
114
116
  panel: createExecutionPanelState(),
115
117
  };
116
118
  const factories: unknown[] = [];
119
+ const statuses: Array<string | undefined> = [];
120
+ let renderRequests = 0;
117
121
  const theme = { fg: (_color: string, text: string) => `\u001b[38;5;2m${text}\u001b[39m`, strikethrough: (text: string) => text };
118
- const ctx = { ui: { setWidget: (_key: string, factory: any) => void factories.push(factory) } } as any;
122
+ const ctx = {
123
+ ui: {
124
+ setWidget: (_key: string, factory: any) => void factories.push(factory),
125
+ setStatus: (_key: string, value: string | undefined) => void statuses.push(value),
126
+ theme: { fg: (_color: string, text: string) => text },
127
+ },
128
+ } as any;
119
129
 
130
+ // Collapsed execution renders no widget — the footer owns the ⌛ line.
120
131
  refreshExecutionPanel(ctx, execution);
121
- refreshExecutionPanel(ctx, execution); // second pass must only invalidate
122
- assert.equal(factories.length, 1, "factory re-registered on refresh");
123
-
124
- const widget = (factories[0] as any)({}, theme);
125
- assert.equal(widget.render(80).length, 1, "collapsed render should be a single line");
132
+ refreshExecutionPanel(ctx, execution); // idempotent while collapsed
133
+ assert.equal(factories.length, 0);
134
+ assert.match(String(statuses.at(-1)), /⌛ plans 0\/1/);
126
135
 
136
+ // Expanding registers the detail widget exactly once and clears the
137
+ // footer ⌛ line (the panel renders its own header).
127
138
  toggleExpanded(execution);
128
139
  refreshExecutionPanel(ctx, execution);
129
140
  assert.equal(factories.length, 1);
130
- assert.ok(widget.render(80).length > 1, "invalidate did not pick up expanded state");
141
+ assert.equal(statuses.at(-1), undefined, "expanded must clear the footer ⌛ line");
142
+ const widget = (factories[0] as any)({ requestRender: () => { renderRequests += 1; } }, theme);
143
+ const expandedLines = widget.render(80);
144
+ assert.match(expandedLines[0] ?? "", /⌛ plans 0\/1/, "panel header must be the first line");
145
+ assert.ok(expandedLines.length >= 1, "expanded render should list items");
146
+
147
+ refreshExecutionPanel(ctx, execution); // same host: reuse, no re-register
148
+ assert.equal(factories.length, 1);
149
+ assert.equal(renderRequests, 1, "same-host refresh must request a TUI render");
150
+ execution.items[0]!.done = true;
151
+ refreshExecutionPanel(ctx, execution);
152
+ assert.equal(factories.length, 1);
153
+ assert.equal(renderRequests, 2, "expanded progress refresh must request a TUI render");
154
+ assert.match(widget.render(80)[0] ?? "", /⌛ plans 1\/1/, "refresh must invalidate the live widget cache");
131
155
 
132
156
  // Clearing releases the slot so a future run registers afresh.
133
157
  refreshExecutionPanel(ctx, null);
@@ -166,6 +190,8 @@ describe("execution panel width safety", () => {
166
190
  it("renders every expanded checklist line inside the terminal width", () => {
167
191
  const execution: any = {
168
192
  planPath: "/tmp/PLAN_v1.md",
193
+ startedAt: "2026-08-29T00:00:00Z",
194
+ usage: { inToks: 0, outToks: 0 },
169
195
  items: [
170
196
  {
171
197
  id: "VC-001",
@@ -180,7 +206,17 @@ describe("execution panel width safety", () => {
180
206
  toggleExpanded(execution);
181
207
 
182
208
  let factory: ((tui: unknown, theme: unknown) => any) | undefined;
183
- refreshExecutionPanel({ ui: { setWidget: (_key: string, f: any) => void (factory = f) } } as any, execution);
209
+ const statuses: Array<string | undefined> = [];
210
+ refreshExecutionPanel(
211
+ {
212
+ ui: {
213
+ setWidget: (_key: string, f: any) => void (factory = f),
214
+ setStatus: (_key: string, value: string | undefined) => void statuses.push(value),
215
+ theme: { fg: (_color: string, text: string) => text },
216
+ },
217
+ } as any,
218
+ execution,
219
+ );
184
220
  const widget = factory!({}, styledTheme);
185
221
 
186
222
  let sawEllipsis = false;
@@ -5,7 +5,7 @@ import * as fs from "node:fs";
5
5
  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
- import { latestPlanVersion, nextPlanVersionPath, parseChecklist, scanDoneMarkers } from "../src/plan.ts";
8
+ import { latestPlanVersion, nextPlanVersionPath, parseChecklist, parseImplItems, resolveImplStatuses, scanDoneMarkers, scanImplMarkers, shortImplDescription, extractCoverage } from "../src/plan.ts";
9
9
 
10
10
  const PLAN = `# PLAN_v1 - demo
11
11
 
@@ -103,3 +103,86 @@ describe("nextPlanVersionPath", () => {
103
103
  assert.equal(next.path, path.join(empty, "PLAN_v1.md"));
104
104
  });
105
105
  });
106
+
107
+ describe("implementation items", () => {
108
+ const IMPL_PLAN = `# PLAN_v1 - demo
109
+
110
+ ## Implementation Items
111
+
112
+ - \`I-001\`: Add state helpers in src/exec.ts; evaluate percent and coverage. Details follow:
113
+ - nested sub-bullet ignored
114
+ - \`I-002\`: Wire the turn_end trigger and status bar.
115
+ - not an item line
116
+ - \`I-001\`: duplicate id ignored
117
+
118
+ ## Verifier Checklist
119
+
120
+ - [ ] \`VC-001\` covers \`I-001\` and \`I-002\`; pass condition: tests green; metric: 0 fail.
121
+ - [ ] \`VC-002\` covers \`I-002\`; pass condition: lint clean; metric: zero findings.
122
+ `;
123
+
124
+ it("parses strict top-level single-line items and ignores continuations", () => {
125
+ const items = parseImplItems(IMPL_PLAN);
126
+ assert.equal(items.length, 2);
127
+ assert.equal(items[0]?.id, "I-001");
128
+ // First line only: the multi-line body and nested sub-bullet are ignored.
129
+ assert.match(items[0]!.text, /^Add state helpers/);
130
+ assert.doesNotMatch(items[0]!.text, /nested/);
131
+ assert.equal(items[1]?.id, "I-002");
132
+ });
133
+
134
+ it("returns empty without an Implementation Items section", () => {
135
+ assert.equal(parseImplItems("# no items here\n- `I-001`: orphan\n").length, 0);
136
+ });
137
+
138
+ it("shortens descriptions at sentence or semicolon boundaries and caps length", () => {
139
+ assert.equal(shortImplDescription("Add helpers; evaluate percent. Then more."), "Add helpers");
140
+ assert.equal(shortImplDescription("First sentence. Second one."), "First sentence.");
141
+ const long = "x".repeat(120);
142
+ assert.equal(shortImplDescription(long).length, 80);
143
+ assert.match(shortImplDescription(long), /…$/);
144
+ });
145
+
146
+ it("extracts coverage refs before the first semicolon only", () => {
147
+ assert.deepEqual(extractCoverage("`VC-001` covers `I-001` and `I-002`; pass: `I-003` mentioned late"), ["I-001", "I-002"]);
148
+ assert.deepEqual(extractCoverage("no coverage clause here"), []);
149
+ });
150
+
151
+ it("scans impl markers", () => {
152
+ assert.deepEqual(scanImplMarkers("[I-001:implemented] then [I-002:validating] and [I-003:done]"), [
153
+ { id: "I-001", state: "implemented" },
154
+ { id: "I-002", state: "validating" },
155
+ ]);
156
+ });
157
+
158
+ it("resolves the status matrix with vc-passed > marker > derivation", () => {
159
+ const items = parseChecklist(IMPL_PLAN);
160
+ const implItems = parseImplItems(IMPL_PLAN);
161
+
162
+ // No markers, nothing done: frontier I-001 implementing, I-002 pending.
163
+ assert.deepEqual(resolveImplStatuses(implItems, items, undefined), {
164
+ "I-001": "implementing",
165
+ "I-002": "pending",
166
+ });
167
+
168
+ // Explicit marker wins over derivation.
169
+ assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "implemented" }), {
170
+ "I-001": "implementing",
171
+ "I-002": "implemented",
172
+ });
173
+
174
+ // Some covering VC done without a marker: deriving validating.
175
+ items[1]!.done = true;
176
+ assert.deepEqual(resolveImplStatuses(implItems, items, {}), {
177
+ "I-001": "implementing",
178
+ "I-002": "validating",
179
+ });
180
+
181
+ // All covering VCs done → vc-passed beats the marker.
182
+ items[0]!.done = true;
183
+ assert.deepEqual(resolveImplStatuses(implItems, items, { "I-002": "validating" }), {
184
+ "I-001": "vc-passed",
185
+ "I-002": "vc-passed",
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,36 @@
1
+ /** Tests for the plans tool source wiring. */
2
+
3
+ import * as assert from "node:assert/strict";
4
+ import * as fs from "node:fs";
5
+ import * as path from "node:path";
6
+ import * as url from "node:url";
7
+ import { describe, it } from "node:test";
8
+
9
+ const ROOT = path.dirname(path.dirname(url.fileURLToPath(import.meta.url)));
10
+
11
+ function readPlansSource(): string {
12
+ return fs.readFileSync(path.join(ROOT, "tools", "plans.ts"), "utf8");
13
+ }
14
+
15
+ describe("plans tool source", () => {
16
+ it("declares artifactRootSource in PlansParams", () => {
17
+ const source = readPlansSource();
18
+ const start = source.indexOf("const PlansParams = Type.Object({");
19
+ const end = source.indexOf("});", start);
20
+ assert.ok(start >= 0, "missing PlansParams block start");
21
+ assert.ok(end >= 0, "missing PlansParams block end");
22
+
23
+ const params = source.slice(start, end);
24
+ assert.match(params, /artifactRootSource:\s*Type\.Optional/);
25
+ assert.match(params, /executionModelSelector:\s*Type\.Optional/);
26
+ assert.match(params, /executionModelSource:\s*Type\.Optional/);
27
+ });
28
+
29
+ it("keeps the plans handler wired to artifact root and execution model actions", () => {
30
+ const source = readPlansSource();
31
+ assert.match(source, /import \{[\s\S]*setArtifactRoot,[\s\S]*setExecutionModel,[\s\S]*\} from "\.\.\/src\/state\.ts";/);
32
+ assert.match(source, /case "set-artifact-root"/);
33
+ assert.match(source, /case "set-execution-model"/);
34
+ assert.match(source, /params\.artifactRootSource/);
35
+ });
36
+ });
@@ -13,6 +13,7 @@ import {
13
13
  recordDecision,
14
14
  setLanguage,
15
15
  setArtifactRoot,
16
+ setExecutionModel,
16
17
  setRole,
17
18
  setRunStatus,
18
19
  showConfig,
@@ -68,6 +69,7 @@ describe("init", () => {
68
69
  assert.equal(config.reviewer.mode, "delegated-subagent");
69
70
  assert.equal(config.reviewer.confirmed_at, null);
70
71
  assert.equal(config.criticizer.confirmed_at, null);
72
+ assert.equal(config.execution.model_selector, null);
71
73
  assert.equal(config.artifact_root, "./docs/pi-plans");
72
74
  assert.equal(config.artifact_root_source, "unset");
73
75
  assert.equal(config.artifact_root_updated_at, null);
@@ -194,6 +196,64 @@ describe("runs", () => {
194
196
  assert.equal(getRun(workdir, run.run_id)?.status, "accepted");
195
197
  assert.throws(() => setRunStatus(workdir, run.run_id, "bogus"), StateError);
196
198
  });
199
+
200
+ it("invokes the onStart callback exactly once when startRun succeeds", () => {
201
+ const workdir = mkWorkdir("onstart");
202
+ git(workdir, "init", "-q");
203
+ const calls: string[] = [];
204
+ const { run } = startRun(workdir, {
205
+ topic: "marker demo",
206
+ skill: "plan-normal",
207
+ requestText: "x",
208
+ onStart: (current) => {
209
+ calls.push(current.run_id);
210
+ },
211
+ });
212
+ assert.deepEqual(calls, [run.run_id]);
213
+ });
214
+
215
+ it("does not invoke the onStart callback when startRun throws", () => {
216
+ const workdir = mkWorkdir("onstart-throw");
217
+ git(workdir, "init", "-q");
218
+ const calls: string[] = [];
219
+ assert.throws(() =>
220
+ startRun({
221
+ workdir: path.resolve(workdir),
222
+ artifact_root: "./docs/pi-plans",
223
+ artifact_root_source: "user",
224
+ artifact_root_updated_at: new Date().toISOString(),
225
+ schema: 1,
226
+ language: { tag: null, source: "unset", updated_at: null },
227
+ reviewer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-reviewer", confirmed_at: null },
228
+ criticizer: { mode: "delegated-subagent", model_selector: null, name_prefix: "pi-plans-criticizer", confirmed_at: null },
229
+ execution: { model_selector: null, source: "unset", updated_at: null },
230
+ }, {
231
+ topic: "" as string,
232
+ skill: "plan-normal",
233
+ requestText: "x",
234
+ onStart: () => calls.push("called"),
235
+ } as any),
236
+ );
237
+ assert.equal(calls.length, 0);
238
+ });
239
+ });
240
+
241
+ describe("execution model config", () => {
242
+ it("roundtrips the selector and clears on inherit", () => {
243
+ const workdir = mkWorkdir("exec-model");
244
+ git(workdir, "init", "-q");
245
+ initState(workdir);
246
+ assert.equal(readConfig(workdir).execution.model_selector, null);
247
+
248
+ setExecutionModel(workdir, { modelSelector: "zai/glm-5.3-flash:high", source: "user" });
249
+ assert.equal(readConfig(workdir).execution.model_selector, "zai/glm-5.3-flash:high");
250
+ assert.equal(readConfig(workdir).execution.source, "user");
251
+ assert.equal(showConfig(workdir).execution.model_selector, "zai/glm-5.3-flash:high");
252
+
253
+ setExecutionModel(workdir, { modelSelector: "inherit", source: "user" });
254
+ assert.equal(readConfig(workdir).execution.model_selector, null);
255
+ assert.equal(readConfig(workdir).execution.source, "user");
256
+ });
197
257
  });
198
258
 
199
259
  describe("set-role invariants", () => {
@@ -8,9 +8,26 @@ import { Text } from "@earendil-works/pi-tui";
8
8
  import { Type } from "typebox";
9
9
  import * as fs from "node:fs";
10
10
  import * as path from "node:path";
11
- import { startExecution } from "../src/exec.ts";
12
- import { latestPlanVersion, parseChecklist } from "../src/plan.ts";
13
- import { normalizeWorkdir, readActive } from "../src/state.ts";
11
+ import {
12
+ chooseExecutionModelSelection,
13
+ snapshotCurrentModelSelector,
14
+ startExecution,
15
+ type ExecutionModelState,
16
+ } from "../src/exec.ts";
17
+ import { extractCoverage, latestPlanVersion, parseChecklist, parseImplItems } from "../src/plan.ts";
18
+ import { loadConfig, normalizeWorkdir, readActive, resolveStateRootOrNull, setExecutionModel, type ExecutionConfig } from "../src/state.ts";
19
+
20
+ function loadExecutionModelConfig(workdir: string): ExecutionConfig {
21
+ const root = resolveStateRootOrNull(workdir);
22
+ if (!root) {
23
+ return { model_selector: null, source: "unset", updated_at: null };
24
+ }
25
+ try {
26
+ return loadConfig(root).execution;
27
+ } catch {
28
+ return { model_selector: null, source: "unset", updated_at: null };
29
+ }
30
+ }
14
31
 
15
32
  const ExecutePlanParams = Type.Object({
16
33
  planPath: Type.Optional(
@@ -55,13 +72,15 @@ export async function executeHandoff(
55
72
  return { status: "error", message: `Plan file not found: ${planPath}` };
56
73
  }
57
74
 
58
- const items = parseChecklist(fs.readFileSync(planPath, "utf8"));
75
+ const planText = fs.readFileSync(planPath, "utf8");
76
+ const items = parseChecklist(planText);
59
77
  if (items.length === 0) {
60
78
  return {
61
79
  status: "error",
62
80
  message: `${planPath} has no parsable \`## Verifier Checklist\` with \`- [ ] \`VC-###\` ...\` items. Fix the plan before execution.`,
63
81
  };
64
82
  }
83
+ const implItems = parseImplItems(planText);
65
84
 
66
85
  if (!ctx.hasUI) {
67
86
  return {
@@ -80,12 +99,33 @@ export async function executeHandoff(
80
99
  return { status: "declined", message: "User declined execution. Stay in planning; ask how to proceed.", planPath };
81
100
  }
82
101
 
83
- startExecution(getCurrentApi(), ctx, planPath, items);
102
+ const configuredExecution = loadExecutionModelConfig(workdir);
103
+ let modelState: ExecutionModelState | undefined;
104
+ const planningSelector = snapshotCurrentModelSelector(ctx);
105
+ const selection = await chooseExecutionModelSelection(
106
+ ctx,
107
+ "Execution model",
108
+ configuredExecution,
109
+ async (selector) => {
110
+ await setExecutionModel(workdir, { modelSelector: selector, source: "user" });
111
+ },
112
+ );
113
+ if (!selection) {
114
+ return { status: "error", message: "No usable execution model; execution cancelled." };
115
+ }
116
+ if (configuredExecution.source === "unset" || configuredExecution.model_selector !== null) {
117
+ modelState = { planningSelector, executionSelector: selection.selector };
118
+ }
119
+
120
+ await startExecution(getCurrentApi(), ctx, planPath, items, modelState, implItems);
121
+ const countedImpls = implItems.filter((impl) => items.some((item) => extractCoverage(item.text).includes(impl.id)));
122
+ const modelNote = modelState ? ` Execution model: ${modelState.executionSelector}.` : "";
123
+ const scopeNote = countedImpls.length ? ` Tracking ${countedImpls.length} implementation item(s).` : "";
84
124
  return {
85
125
  status: "executing",
86
126
  planPath,
87
127
  itemCount: items.length,
88
- message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].`,
128
+ message: `Execution approved. ${items.length} verifier item(s) queued; implement in dependency order and mark verified items with [DONE:VC-xxx].${scopeNote}${modelNote}`,
89
129
  };
90
130
  }
91
131
 
package/tools/plans.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  recordSubagent,
16
16
  resolveStateRootOrNull,
17
17
  setArtifactRoot,
18
+ setExecutionModel,
18
19
  setLanguage,
19
20
  setRunStatus,
20
21
  setRole,
@@ -31,6 +32,7 @@ const PlansParams = Type.Object({
31
32
  "show",
32
33
  "set-language",
33
34
  "set-artifact-root",
35
+ "set-execution-model",
34
36
  "set-role",
35
37
  "start-run",
36
38
  "set-status",
@@ -50,6 +52,10 @@ const PlansParams = Type.Object({
50
52
  languageSource: Type.Optional(StringEnum(["user", "auto"] as const)),
51
53
  artifactRoot: Type.Optional(Type.String({ description: "set-artifact-root: planning docs root, e.g. ./docs/pi-plans" })),
52
54
  artifactRootSource: Type.Optional(StringEnum(["user", "auto"] as const)),
55
+ executionModelSelector: Type.Optional(
56
+ Type.String({ description: "set-execution-model: exact provider/model selector, or 'inherit' to clear" }),
57
+ ),
58
+ executionModelSource: Type.Optional(StringEnum(["user", "auto"] as const)),
53
59
  role: Type.Optional(StringEnum(["reviewer", "criticizer"] as const)),
54
60
  mode: Type.Optional(StringEnum(["delegated-subagent", "current-session"] as const)),
55
61
  modelSelector: Type.Optional(
@@ -96,12 +102,25 @@ const PlansParams = Type.Object({
96
102
  ),
97
103
  });
98
104
 
105
+ // Module-level reference so the start-run action can append a session entry
106
+ // (pi-plans-run-start) at the moment the planning run begins. The ExtensionAPI
107
+ // itself is not in scope for `startRun`, mirroring `setCurrentApi` in
108
+ // tools/execute-plan.ts.
109
+ let runStartAppender: ((runId: string, artifactDir: string) => void) | null = null;
110
+
111
+ export function setRunStartAppender(appender: ((runId: string, artifactDir: string) => void) | null): void {
112
+ runStartAppender = appender;
113
+ }
114
+
99
115
  export function registerPlansTool(pi: ExtensionAPI): void {
116
+ setRunStartAppender((runId, artifactDir) => {
117
+ pi.appendEntry("pi-plans-run-start", { runId, artifactDir });
118
+ });
100
119
  pi.registerTool({
101
120
  name: "plans",
102
121
  label: "Plans",
103
122
  description:
104
- "Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus reviewer/criticizer roles, start planning runs, record decisions/refs/subagents, and update run status. State lives in .git/pi_plans/ inside the resolved git common dir. Actions: init, show, set-language, set-artifact-root, set-role, start-run, set-status, record-decision, record-ref, record-subagent.",
123
+ "Manage pi-plans planning state in the target workspace: init/show config, set language and planning docs root plus execution/reviewer/criticizer roles, start planning runs, record decisions/refs/subagents, and update run status. State lives in .git/pi_plans/ inside the resolved git common dir. Actions: init, show, set-language, set-artifact-root, set-execution-model, set-role, start-run, set-status, record-decision, record-ref, record-subagent.",
105
124
  promptSnippet: "Manage pi-plans planning state, runs, and ledgers",
106
125
  parameters: PlansParams,
107
126
 
@@ -137,6 +156,17 @@ export function registerPlansTool(pi: ExtensionAPI): void {
137
156
  result = { config: updated.config, stateRoot: updated.stateRoot, notices: updated.notices };
138
157
  break;
139
158
  }
159
+ case "set-execution-model": {
160
+ if (!params.executionModelSelector || !params.executionModelSource) {
161
+ throw new StateError("set-execution-model requires executionModelSelector and executionModelSource");
162
+ }
163
+ const updated = setExecutionModel(workdir, {
164
+ modelSelector: params.executionModelSelector,
165
+ source: params.executionModelSource,
166
+ });
167
+ result = { config: updated.config, stateRoot: updated.stateRoot, notices: updated.notices };
168
+ break;
169
+ }
140
170
  case "set-role": {
141
171
  if (!params.role) throw new StateError("set-role requires role");
142
172
  const updated = setRole(workdir, {
@@ -157,6 +187,9 @@ export function registerPlansTool(pi: ExtensionAPI): void {
157
187
  topic: params.topic,
158
188
  skill: params.skill,
159
189
  requestText: params.requestText,
190
+ onStart: (run) => {
191
+ runStartAppender?.(run.run_id, run.artifact_dir);
192
+ },
160
193
  });
161
194
  break;
162
195
  }