pi-better-harness 0.3.13 → 0.3.14

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.
@@ -23,7 +23,9 @@ Independent foreground and delegated milestones may both be `in_progress`. Use s
23
23
 
24
24
  For a DAG, assign stable ids to prerequisite steps and list those ids in dependent steps' `dependsOn`. Dependencies must exist in the same plan; cycles and starting or completing a step before its prerequisites are complete are rejected. `get_plan` reports pending steps whose prerequisites are complete as ready. Plans without edges keep their existing behavior.
25
25
 
26
- When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses competing updates until workflow ownership is released. Rush-style workflows can use their own dependency graph without copying it into this plan.
26
+ When an explicitly invoked skill declares `workflow-role: coordinator` in its metadata, that skill's task plan takes precedence. The generic checklist stays persisted but is hidden, and `update_plan` refuses competing updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
27
+
28
+ For `rush-issues`, call `sync_workflow_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision` after each checkpoint. This binds the active run to the session and displays its fleet stages and actual units in the widget, `/plan`, and `get_plan`. The view reads the file without writing to it; revision mismatches are rejected. The run binding survives Pi session resume, and ownership release restores the prior generic checklist. Other workflow-owned skills retain their own planning and have no generic plan view.
27
29
 
28
30
  ## Install
29
31
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -13,6 +13,10 @@ import {
13
13
  replacePlan,
14
14
  } from "./plan-state.js";
15
15
  import { createFullPlanComponent, renderCompactPlan } from "./plan-render.js";
16
+ import {
17
+ createRushPlanComponent, readRushPlan, renderRushPlan, workflowBinding,
18
+ WORKFLOW_PLAN_ENTRY, type RushPlan, type WorkflowPlanBinding,
19
+ } from "./workflow-plan.js";
16
20
  import {
17
21
  EXTENSION_NAME,
18
22
  type PlanDisplayMode,
@@ -59,16 +63,37 @@ export default function planExtension(pi: ExtensionAPI): void {
59
63
  let currentPlan: PlanSnapshot | null = null;
60
64
  let displayMode: PlanDisplayMode = "auto";
61
65
  let displayedWorkflowOwner: string | null = null;
66
+ let rushBinding: WorkflowPlanBinding | null = null;
67
+ let rushPlan: RushPlan | null = null;
68
+ let rushError: string | null = null;
62
69
  let refreshWidget: ((force?: boolean) => void) | undefined;
63
70
  let completedPlanClearTimer: ReturnType<typeof setTimeout> | undefined;
64
71
  const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
65
72
  displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
66
73
  ? owner.name : null;
74
+ rushBinding = null;
75
+ rushPlan = null;
76
+ rushError = null;
67
77
  refreshWidget?.(true);
68
78
  });
69
79
 
70
80
  const refresh = (force = false): void => refreshWidget?.(force);
71
81
 
82
+ const loadRushPlan = (ctx: ExtensionContext): RushPlan | null => {
83
+ if (!rushBinding || workflowPlanOwner(ctx) !== "rush-issues") return null;
84
+ try {
85
+ const plan = readRushPlan(rushBinding.path, ctx.cwd);
86
+ if (plan.runId !== rushBinding.runId) throw new Error("Rush run identity changed.");
87
+ rushPlan = plan;
88
+ rushError = null;
89
+ } catch (error) {
90
+ rushPlan = null;
91
+ rushError = error instanceof Error ? error.message : String(error);
92
+ }
93
+ refresh(true);
94
+ return rushPlan;
95
+ };
96
+
72
97
  const cancelCompletedPlanClear = (): void => {
73
98
  if (completedPlanClearTimer) clearTimeout(completedPlanClearTimer);
74
99
  completedPlanClearTimer = undefined;
@@ -102,6 +127,10 @@ export default function planExtension(pi: ExtensionAPI): void {
102
127
  const state = reconstructPlanState(ctx.sessionManager.getBranch());
103
128
  currentPlan = state.plan;
104
129
  displayedWorkflowOwner = workflowPlanOwner(ctx);
130
+ rushBinding = displayedWorkflowOwner === "rush-issues" ? workflowBinding(ctx.sessionManager.getBranch()) : null;
131
+ rushPlan = null;
132
+ rushError = null;
133
+ if (rushBinding) loadRushPlan(ctx);
105
134
  displayMode = state.displayMode;
106
135
  if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
107
136
  refresh(true);
@@ -130,7 +159,14 @@ export default function planExtension(pi: ExtensionAPI): void {
130
159
  refreshWidget = localRefresh;
131
160
  return {
132
161
  render(width: number): string[] {
133
- if (!currentPlan || displayMode === "hidden" || displayedWorkflowOwner) return [];
162
+ if (displayMode === "hidden") return [];
163
+ if (displayedWorkflowOwner) {
164
+ if (displayedWorkflowOwner !== "rush-issues") return [];
165
+ return rushPlan ? renderRushPlan(rushPlan, width) : [
166
+ `rush-issues ${rushError ? `plan unavailable: ${rushError}` : "waiting for task plan"}`,
167
+ ].map((line) => line.slice(0, width));
168
+ }
169
+ if (!currentPlan) return [];
134
170
  return renderCompactPlan(currentPlan, width, theme as never);
135
171
  },
136
172
  invalidate() {},
@@ -144,6 +180,26 @@ export default function planExtension(pi: ExtensionAPI): void {
144
180
  };
145
181
 
146
182
  const showFullPlan = async (ctx: ExtensionContext): Promise<void> => {
183
+ const owner = workflowPlanOwner(ctx);
184
+ if (owner) {
185
+ const plan = owner === "rush-issues" ? loadRushPlan(ctx) : null;
186
+ if (!plan) {
187
+ ctx.ui.notify(owner === "rush-issues" ? `Rush plan unavailable: ${rushError ?? "not bound"}` : `${owner} owns the task plan.`, "warning");
188
+ return;
189
+ }
190
+ if (ctx.mode !== "tui") {
191
+ ctx.ui.notify(renderRushPlan(plan, 160, true).join("\n"), "info");
192
+ return;
193
+ }
194
+ await ctx.ui.custom<void>((tui, _theme, _keys, done) => {
195
+ const component = createRushPlanComponent(plan, () => done());
196
+ return { render: (width) => component.render(width), handleInput(data) {
197
+ component.handleInput?.(data);
198
+ tui.requestRender();
199
+ }, invalidate: () => component.invalidate() };
200
+ });
201
+ return;
202
+ }
147
203
  if (!currentPlan) {
148
204
  if (ctx.hasUI) ctx.ui.notify("No plan is set.", "warning");
149
205
  return;
@@ -167,6 +223,35 @@ export default function planExtension(pi: ExtensionAPI): void {
167
223
  };
168
224
 
169
225
 
226
+ pi.registerTool({
227
+ name: "sync_workflow_plan",
228
+ label: "Sync Workflow Plan",
229
+ description: "Display the persisted rush-issues task plan at its exact checkpoint revision. Read-only; Rush retains plan ownership.",
230
+ parameters: Type.Object({
231
+ path: Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json" }),
232
+ revision: Type.Integer({ minimum: 0, description: "Persisted planRevision to display" }),
233
+ }),
234
+ async execute(_id, params, _signal, _onUpdate, ctx) {
235
+ if (workflowPlanOwner(ctx) !== "rush-issues") throw new Error("Only an active rush-issues workflow can sync its plan.");
236
+ const plan = readRushPlan(params.path, ctx.cwd);
237
+ if (plan.planRevision !== params.revision) {
238
+ rushPlan = null;
239
+ rushError = `revision mismatch: expected ${params.revision}, found ${plan.planRevision}`;
240
+ refresh(true);
241
+ throw new Error(`Rush plan ${rushError}.`);
242
+ }
243
+ const binding: WorkflowPlanBinding = { owner: "rush-issues", path: params.path, runId: plan.runId };
244
+ if (!rushBinding || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
245
+ pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
246
+ }
247
+ rushBinding = binding;
248
+ rushPlan = plan;
249
+ rushError = null;
250
+ refresh(true);
251
+ return { content: [{ type: "text", text: `Showing rush-issues rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
252
+ },
253
+ });
254
+
170
255
  pi.registerTool({
171
256
  name: "update_plan",
172
257
  label: "Update Plan",
@@ -214,10 +299,15 @@ export default function planExtension(pi: ExtensionAPI): void {
214
299
  parameters: Type.Object({}),
215
300
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
216
301
  const owner = workflowPlanOwner(ctx);
217
- if (owner) return {
218
- content: [{ type: "text", text: `${owner} owns the task plan; consult its persisted workflow state.` }],
219
- details: { hasPlan: false, plan: null, progress: null, workflowOwner: owner },
220
- };
302
+ if (owner) {
303
+ const workflowPlan = owner === "rush-issues" ? loadRushPlan(ctx) : null;
304
+ return {
305
+ content: [{ type: "text", text: workflowPlan
306
+ ? renderRushPlan(workflowPlan, 160, true).join("\n")
307
+ : `${owner} owns the task plan; ${rushError ?? "consult its persisted workflow state"}.` }],
308
+ details: { hasPlan: !!workflowPlan, plan: workflowPlan, progress: null, workflowOwner: owner },
309
+ };
310
+ }
221
311
  if (!currentPlan) {
222
312
  return {
223
313
  content: [{ type: "text", text: "No plan is set." }],
@@ -238,6 +328,10 @@ export default function planExtension(pi: ExtensionAPI): void {
238
328
  const input = args.trim().toLowerCase();
239
329
  if (!input) return showFullPlan(ctx);
240
330
  if (input === "clear") {
331
+ if (workflowPlanOwner(ctx)) {
332
+ ctx.ui.notify("The workflow owns its plan; /plan clear cannot change it.", "warning");
333
+ return;
334
+ }
241
335
  clearPlan();
242
336
  ctx.ui.notify("Plan cleared.", "info");
243
337
  return;
@@ -0,0 +1,123 @@
1
+ import { readFileSync, realpathSync, statSync } from "node:fs";
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
3
+ import type { Component } from "@earendil-works/pi-tui";
4
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
5
+
6
+ export const WORKFLOW_PLAN_ENTRY = "pi-better-workflow-plan";
7
+
8
+ interface RushUnit {
9
+ id: string;
10
+ title: string;
11
+ stage: string;
12
+ status: string;
13
+ dependsOn: string[];
14
+ worker?: string | number | undefined;
15
+ note?: string | undefined;
16
+ }
17
+
18
+ export interface RushPlan {
19
+ runId: string;
20
+ planRevision: number;
21
+ issues: RushUnit[];
22
+ fleet: Record<string, { status: string }>;
23
+ warehouseCanaryRequired: boolean;
24
+ spec?: { title?: string } | undefined;
25
+ }
26
+
27
+ export interface WorkflowPlanBinding {
28
+ owner: "rush-issues";
29
+ path: string;
30
+ runId: string;
31
+ }
32
+
33
+ export function readRushPlan(path: string, cwd: string): RushPlan {
34
+ if (!isAbsolute(path) || basename(path) !== "task-plan.json") throw new Error("Expected an absolute Rush task-plan.json path.");
35
+ const root = realpathSync(resolve(cwd, ".resolve-issues", "rush"));
36
+ const file = realpathSync(path);
37
+ const dir = relative(root, dirname(file));
38
+ if (!dir || dir.startsWith(".." + sep) || dir === ".." || dir.includes(sep)) {
39
+ throw new Error("Rush plan must be inside this project's .resolve-issues/rush/<run-id> directory.");
40
+ }
41
+ const size = statSync(file).size;
42
+ if (size > 2_000_000) throw new Error("Rush plan exceeds the 2 MB display limit.");
43
+ const data: unknown = JSON.parse(readFileSync(file, "utf8"));
44
+ if (!isRecord(data) || data.runId !== dir || !Number.isSafeInteger(data.planRevision) ||
45
+ (data.planRevision as number) < 0 || !Array.isArray(data.issues) || !isRecord(data.fleet)) {
46
+ throw new Error("Invalid Rush plan identity or revision.");
47
+ }
48
+ const issues = data.issues.map((unit: unknown) => {
49
+ if (!isRecord(unit) || typeof unit.id !== "string" || typeof unit.title !== "string" ||
50
+ typeof unit.stage !== "string" || typeof unit.status !== "string") {
51
+ throw new Error("Invalid Rush plan unit.");
52
+ }
53
+ return {
54
+ id: unit.id, title: unit.title, stage: unit.stage, status: unit.status,
55
+ dependsOn: Array.isArray(unit.dependsOn) ? unit.dependsOn.filter((id): id is string => typeof id === "string") : [],
56
+ worker: typeof unit.worker === "string" || typeof unit.worker === "number" ? unit.worker : undefined,
57
+ note: typeof unit.note === "string" ? unit.note : undefined,
58
+ };
59
+ });
60
+ const fleet: RushPlan["fleet"] = {};
61
+ for (const [stage, value] of Object.entries(data.fleet)) {
62
+ if (!isRecord(value) || typeof value.status !== "string") throw new Error("Invalid Rush fleet stage.");
63
+ fleet[stage] = { status: value.status };
64
+ }
65
+ return {
66
+ runId: data.runId as string,
67
+ planRevision: data.planRevision as number,
68
+ issues,
69
+ fleet,
70
+ warehouseCanaryRequired: data.warehouseCanaryRequired === true,
71
+ spec: isRecord(data.spec) && typeof data.spec.title === "string" ? { title: data.spec.title } : undefined,
72
+ };
73
+ }
74
+
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return value !== null && typeof value === "object" && !Array.isArray(value);
77
+ }
78
+
79
+ export function workflowBinding(entries: Iterable<{ type: string; customType?: string; data?: unknown }>): WorkflowPlanBinding | null {
80
+ let binding: WorkflowPlanBinding | null = null;
81
+ for (const entry of entries) {
82
+ if (entry.type === "custom" && entry.customType === "pi-better-workflow") {
83
+ const data = entry.data;
84
+ if (isRecord(data) && data.version === 1 && (data.kind === "set" || data.kind === "clear")) binding = null;
85
+ }
86
+ if (entry.type !== "custom" || entry.customType !== WORKFLOW_PLAN_ENTRY) continue;
87
+ const data = entry.data;
88
+ if (!isRecord(data) || data.version !== 1) continue;
89
+ if (data.kind === "clear") binding = null;
90
+ else if (data.kind === "set" && data.owner === "rush-issues" &&
91
+ typeof data.path === "string" && typeof data.runId === "string") {
92
+ binding = { owner: "rush-issues", path: data.path, runId: data.runId };
93
+ }
94
+ }
95
+ return binding;
96
+ }
97
+
98
+ export function renderRushPlan(plan: RushPlan, width: number, full = false): string[] {
99
+ const completed = plan.issues.filter((unit) => unit.status === "succeeded").length;
100
+ const lines = [`rush-issues rev ${plan.planRevision} ${completed}/${plan.issues.length} units${plan.spec?.title ? ` ${plan.spec.title}` : ""}`];
101
+ const fleet = ["explore", "combine", "canary", "review", "cicd"]
102
+ .map((stage) => `${stage}: ${stage === "canary" && !plan.warehouseCanaryRequired ? "n/a" : plan.fleet[stage]?.status ?? "pending"}`);
103
+ lines.push(`fleet ${fleet.join(" ")}`);
104
+ for (const unit of plan.issues) {
105
+ const marker = unit.status === "succeeded" ? "✓" : unit.status === "blocked" ? "!" : unit.status === "pending" ? "○" : "●";
106
+ lines.push(`${marker} #${unit.id} ${unit.title} [${unit.stage} · ${unit.status}${unit.worker !== undefined ? ` · worker ${unit.worker}` : ""}]`);
107
+ if (full) {
108
+ if (unit.dependsOn.length) lines.push(` after: ${unit.dependsOn.map((id) => `#${id}`).join(", ")}`);
109
+ if (unit.note) lines.push(` ${unit.note}`);
110
+ }
111
+ }
112
+ return lines.map((line) => truncateToWidth(line, Math.max(1, width)));
113
+ }
114
+
115
+ export function createRushPlanComponent(plan: RushPlan, onClose: () => void): Component {
116
+ return {
117
+ render: (width) => renderRushPlan(plan, width, true),
118
+ handleInput(data) {
119
+ if (matchesKey(data, "escape") || matchesKey(data, "left") || matchesKey(data, "ctrl+c")) onClose();
120
+ },
121
+ invalidate() {},
122
+ };
123
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.3.13",
3
+ "version": "0.3.14",
4
4
  "description": "Pi extension bundle for a write sandbox, subagents, background tasks, SSH, goals, and structured plans.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,7 +52,7 @@
52
52
  "dependencies": {
53
53
  "pi-better-background-tasks": "0.2.14",
54
54
  "pi-better-goal": "0.4.0",
55
- "pi-better-plan": "0.2.1",
55
+ "pi-better-plan": "0.3.0",
56
56
  "pi-better-sandbox": "0.3.0",
57
57
  "pi-better-ssh": "0.1.1",
58
58
  "pi-better-subagents": "0.1.30",