pi-better-harness 0.24.0 → 0.25.0

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,7 +6,7 @@
6
6
 
7
7
  ## What It Does
8
8
 
9
- - Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
9
+ - Gives models one `update_plan` interface for checklist updates, workflow binding, and workflow transitions, plus `get_plan` for inspection.
10
10
  - Supports dependency edges (`id` and `dependsOn`) so independent ready steps can run concurrently.
11
11
  - Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
12
12
  - Persists plan state and display preferences on the active Pi session branch.
@@ -38,7 +38,18 @@ For a DAG, assign stable ids to prerequisite steps and list those ids in depende
38
38
 
39
39
  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 generic checklist updates until workflow ownership is released. `/plan` never opens a stale generic checklist during workflow ownership.
40
40
 
41
- For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), call `sync_workflow_plan` once with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`. This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`; the binding survives Pi session resume, and ownership release restores the prior generic checklist. After that, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
41
+ For any workflow using the shared task-plan contract (including `resolve-issues` and `rush-issues`), bind its plan through `update_plan` with the absolute `.resolve-issues/rush/<run-id>/task-plan.json` path and its persisted `planRevision`:
42
+
43
+ ```json
44
+ { "workflow": {
45
+ "path": "/absolute/project/.resolve-issues/rush/run-1/task-plan.json",
46
+ "revision": 12
47
+ } }
48
+ ```
49
+
50
+ This binds the run to the session and shows its fleet stages and units in the widget, `/plan`, and `get_plan`. It does not rewrite the plan, increment its revision, or append a profiling event. The binding survives Pi session resume, and ownership release restores the prior generic checklist. Repeat this form to reload an externally saved checkpoint at its exact revision. `sync_workflow_plan` has been removed; migrate its old `{path, revision}` arguments into `update_plan`'s `workflow` object.
51
+
52
+ After binding, record every transition with `update_plan` and a `workflow` object instead of editing `task-plan.json` or the profiling log by hand:
42
53
 
43
54
  ```json
44
55
  { "workflow": {
@@ -52,6 +63,8 @@ For any workflow using the shared task-plan contract (including `resolve-issues`
52
63
  } }
53
64
  ```
54
65
 
66
+ You may include `path` and `revision` alongside `event` and `changes` in the first call to bind and save a transition together. A rejected transition does not activate a new binding. Every successful binding or transition requests a TUI refresh; there is no separate synchronization tool to call afterward.
67
+
55
68
  Each issue row shows where it is between code and delivery: `●` while it is being built, `◐ implemented` once `stage` is `done` with a `headSha`, `◑ review passed` while `reviewedHead` equals `headSha`, and `✓` only at `status: succeeded`, when the change has landed on the target branch by a merged PR or a direct push. Only `✓` rows count as complete.
56
69
 
57
70
  `changes` addresses units, components, and fleet stages by id (a change without an id sets run-level fields); all changes in one call are one transition. For a scope change, a change with `target` `unit` or `component` and an `add` row appends a new row; existing rows cannot be removed or renamed. The tool checks ids, dependencies (including cycles), worker slots, and status/stage values against the shared issue-resolution contract (a fleet status of `n/a` is saved as `not-applicable`), refuses a stale `revision`, appends an optional `decision`, then saves the plan atomically with `planRevision + 1` and a new `updatedAt`, and appends one profiling event with the same revision to the log the run already uses (`profiling/run.jsonl` or `profiling.jsonl`). A rejected update changes nothing. The event is appended before the plan is renamed into place, so a crash between the two can leave the log one revision ahead; the next update notices, reuses that revision, and marks its event with `logAheadRevision`.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -53,8 +53,9 @@ const WorkflowRowChangeSchema = Type.Object({
53
53
  });
54
54
 
55
55
  const WorkflowUpdateSchema = Type.Object({
56
- event: Type.String({ description: "Short name for this transition in the profiling log, e.g. unit-validated or component-pr-opened." }),
57
- revision: Type.Optional(Type.Integer({ minimum: 0, description: "The planRevision you last saw. The update is refused if the saved plan has a different revision." })),
56
+ path: Type.Optional(Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json. Send with revision to bind or reload a workflow plan; omit event for a read-only bind." })),
57
+ event: Type.Optional(Type.String({ description: "Short name for a saved transition, e.g. unit-validated. Required when sending changes, decision, or profiling; omit for a read-only bind." })),
58
+ revision: Type.Optional(Type.Integer({ minimum: 0, description: "The planRevision you last saw. Required with path. The update is refused if the saved plan has a different revision." })),
58
59
  changes: Type.Optional(Type.Array(WorkflowRowChangeSchema, { maxItems: 50, description: "Every row this transition changes. All of them are saved together as one revision." })),
59
60
  decision: Type.Optional(Type.Object({
60
61
  id: Type.String({ description: "New, unique decision id." }),
@@ -66,7 +67,7 @@ const WorkflowUpdateSchema = Type.Object({
66
67
  additionalProperties: true,
67
68
  description: "Extra fields for this transition's profiling event, e.g. outcome, wallMs, waitMs, headSha.",
68
69
  })),
69
- }, { description: "Workflow task-plan transition. Use only while a workflow owns the plan and it is bound with sync_workflow_plan; send this instead of plan." });
70
+ }, { description: "Bind, reload, or update a workflow-owned task plan. Send this instead of plan while a workflow owns planning." });
70
71
 
71
72
  const UpdatePlanSchema = Type.Object({
72
73
  explanation: Type.Optional(Type.String({ description: "Why the plan or its status changed." })),
@@ -74,6 +75,8 @@ const UpdatePlanSchema = Type.Object({
74
75
  workflow: Type.Optional(WorkflowUpdateSchema),
75
76
  });
76
77
 
78
+ type WorkflowPlanInput = Omit<RushPlanUpdate, "event"> & { path?: string; event?: string };
79
+
77
80
  const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
78
81
  const DELEGATION_MODE_REQUEST = "pi-better-subagents:delegation-mode-request";
79
82
  type DelegationMode = "manual" | "adaptive" | "coordinator";
@@ -148,6 +151,17 @@ export default function planExtension(pi: ExtensionAPI): void {
148
151
  return rushPlan;
149
152
  };
150
153
 
154
+ const displayWorkflowPlan = (plan: RushPlan, binding: WorkflowPlanBinding): void => {
155
+ if (!rushBinding || rushBinding.owner !== binding.owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
156
+ pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
157
+ }
158
+ displayedWorkflowOwner = binding.owner;
159
+ rushBinding = binding;
160
+ rushPlan = plan;
161
+ rushError = null;
162
+ refresh(true);
163
+ };
164
+
151
165
  const cancelCompletedPlanClear = (): void => {
152
166
  if (completedPlanClearTimer) clearTimeout(completedPlanClearTimer);
153
167
  completedPlanClearTimer = undefined;
@@ -282,41 +296,10 @@ export default function planExtension(pi: ExtensionAPI): void {
282
296
  };
283
297
 
284
298
 
285
- pi.registerTool({
286
- name: "sync_workflow_plan",
287
- label: "Sync Workflow Plan",
288
- description: "Bind and display an active workflow's compatible persisted task plan at its exact checkpoint revision. Record later transitions with update_plan's workflow field.",
289
- parameters: Type.Object({
290
- path: Type.String({ description: "Absolute path to .resolve-issues/rush/<run-id>/task-plan.json" }),
291
- revision: Type.Integer({ minimum: 0, description: "Persisted planRevision to display" }),
292
- }),
293
- async execute(_id, params, _signal, _onUpdate, ctx) {
294
- const owner = workflowPlanOwner(ctx);
295
- if (!owner) throw new Error("Only an active workflow can sync its plan.");
296
- const plan = readRushPlan(params.path, ctx.cwd);
297
- if (plan.planRevision !== params.revision) {
298
- rushPlan = null;
299
- rushError = `revision mismatch: expected ${params.revision}, found ${plan.planRevision}`;
300
- refresh(true);
301
- throw new Error(`Rush plan ${rushError}.`);
302
- }
303
- const binding: WorkflowPlanBinding = { owner, path: params.path, runId: plan.runId };
304
- if (!rushBinding || rushBinding.owner !== owner || rushBinding.path !== binding.path || rushBinding.runId !== binding.runId) {
305
- pi.appendEntry(WORKFLOW_PLAN_ENTRY, { version: 1, kind: "set", ...binding });
306
- }
307
- displayedWorkflowOwner = owner;
308
- rushBinding = binding;
309
- rushPlan = plan;
310
- rushError = null;
311
- refresh(true);
312
- return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { runId: plan.runId, revision: plan.planRevision } };
313
- },
314
- });
315
-
316
299
  pi.registerTool({
317
300
  name: "update_plan",
318
301
  label: "Update Plan",
319
- description: "Create or atomically replace the current structured execution plan and its step statuses. While a workflow owns a bound plan, send workflow instead of plan to save one task-plan transition.",
302
+ description: "Universal plan interface: create or replace a generic checklist, bind or reload a workflow plan with workflow.path and revision, or save a workflow transition with event and changes. Every successful call refreshes the displayed plan.",
320
303
  promptSnippet: "Create and update a persistent structured execution plan",
321
304
  promptGuidelines: [
322
305
  "Use update_plan for work with three or more meaningful steps unless a skill owns planning; update it immediately when a step completes, becomes blocked, or scope changes.",
@@ -325,26 +308,47 @@ export default function planExtension(pi: ExtensionAPI): void {
325
308
  "For generic plans, follow the active delegation mode. Manual forbids proactive delegation even in plan mode; adaptive favors substantial independent work; coordinator delegates nontrivial role-owned tasks after agents_catalog discovery.",
326
309
  "Use a generic plan as a milestone ledger only when no workflow owns planning. Otherwise follow the workflow's task plan and foreground role.",
327
310
  "For generic plans, use separate steps for distinct deliverables, not one step per worker process. Before completing verification or the plan, inspect and integrate every relevant delegated result or failure.",
328
- "While a workflow owns the plan and it is bound with sync_workflow_plan, record every transition with update_plan's workflow field instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together.",
311
+ "While a workflow owns planning, bind its persisted task plan with update_plan({workflow:{path,revision}}). Then record every transition with update_plan's workflow event and changes instead of editing task-plan.json or its profiling log; it saves the rows, the next planRevision, and the matching profiling event together. Path and revision can accompany the first transition to bind and update in one call.",
329
312
  ],
330
313
  parameters: UpdatePlanSchema,
331
314
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
332
315
  const owner = workflowPlanOwner(ctx);
333
- const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: RushPlanUpdate };
316
+ const input = params as { explanation?: string; plan?: PlanStepInput[]; workflow?: WorkflowPlanInput };
334
317
  if (input.workflow !== undefined) {
335
318
  if (!owner) throw new Error("No workflow owns the task plan; send plan instead of workflow.");
336
319
  if (input.plan !== undefined) throw new Error("Send either plan or workflow, not both.");
337
- if (!rushBinding || rushBinding.owner !== owner) throw new Error(`No ${owner} plan is bound; call sync_workflow_plan with the task-plan.json path first.`);
338
- const result = applyRushPlanUpdate(rushBinding.path, ctx.cwd, rushBinding.runId, input.workflow);
339
- rushPlan = result.plan;
340
- rushError = null;
341
- refresh(true);
320
+ const { path, ...transition } = input.workflow;
321
+ if (transition.event === undefined && (transition.changes !== undefined || transition.decision !== undefined || transition.profiling !== undefined)) {
322
+ throw new Error("workflow.event is required when sending changes, decision, or profiling.");
323
+ }
324
+ let binding = rushBinding?.owner === owner ? rushBinding : null;
325
+ if (path !== undefined) {
326
+ if (transition.revision === undefined) throw new Error("workflow.revision is required with workflow.path.");
327
+ const plan = readRushPlan(path, ctx.cwd);
328
+ if (plan.planRevision !== transition.revision) {
329
+ if (transition.event === undefined) {
330
+ rushPlan = null;
331
+ rushError = `revision mismatch: expected ${transition.revision}, found ${plan.planRevision}`;
332
+ refresh(true);
333
+ }
334
+ throw new Error(`Rush plan revision mismatch: expected ${transition.revision}, found ${plan.planRevision}.`);
335
+ }
336
+ binding = { owner, path, runId: plan.runId };
337
+ if (transition.event === undefined) {
338
+ displayWorkflowPlan(plan, binding);
339
+ return { content: [{ type: "text", text: `Showing ${owner} rev ${plan.planRevision}: ${plan.issues.length} units.` }], details: { ok: true, runId: plan.runId, revision: plan.planRevision } };
340
+ }
341
+ }
342
+ if (!binding) throw new Error(`No ${owner} plan is bound; send workflow.path and workflow.revision to update_plan first.`);
343
+ if (transition.event === undefined) throw new Error("workflow.path is required to bind or reload a plan; otherwise send workflow.event and changes.");
344
+ const result = applyRushPlanUpdate(binding.path, ctx.cwd, binding.runId, { ...transition, event: transition.event });
345
+ displayWorkflowPlan(result.plan, binding);
342
346
  const rows = result.changed.map(({ target, id, added }) =>
343
347
  `${added ? "+" : ""}${id === null ? "run" : target === "unit" ? `#${id}` : id}`);
344
348
  const logNote = result.logAheadRevision === undefined ? ""
345
349
  : ` The profiling log already held rev ${result.logAheadRevision} (an earlier write stopped after logging); this event supersedes it.`;
346
350
  return {
347
- content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${input.workflow.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
351
+ content: [{ type: "text", text: `Saved ${owner} rev ${result.revision} (${transition.event.trim()})${rows.length ? `: ${rows.join(", ")}` : ""}${input.workflow.decision ? `${rows.length ? ";" : ":"} decision ${input.workflow.decision.id}` : ""}. Profiling: ${result.profilingPath}.${logNote}` }],
348
352
  details: {
349
353
  ok: true, runId: result.plan.runId, revision: result.revision, changed: result.changed, profilingPath: result.profilingPath,
350
354
  ...(result.logAheadRevision === undefined ? {} : { logAheadRevision: result.logAheadRevision }),
@@ -352,7 +356,7 @@ export default function planExtension(pi: ExtensionAPI): void {
352
356
  };
353
357
  }
354
358
  if (owner) {
355
- throw new Error(`${owner} owns the task plan. Send workflow (bound with sync_workflow_plan) instead of plan.`);
359
+ throw new Error(`${owner} owns the task plan. Send workflow instead of plan; include path and revision to bind its persisted task plan.`);
356
360
  }
357
361
  if (input.plan === undefined) throw new Error("plan is required: send the full list of steps.");
358
362
  const plan = replacePlan(currentPlan, input.plan, input.explanation);
@@ -366,7 +370,7 @@ export default function planExtension(pi: ExtensionAPI): void {
366
370
  renderCall(args, theme) {
367
371
  return new Text(
368
372
  theme.fg("toolTitle", theme.bold("update_plan ")) + theme.fg("muted", args.workflow
369
- ? `workflow ${args.workflow.event ?? ""}`.trimEnd()
373
+ ? `workflow ${args.workflow.event ?? "bind"}`
370
374
  : `${args.plan?.length ?? 0} steps`),
371
375
  0,
372
376
  0,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.24.0",
3
+ "version": "0.25.0",
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",
@@ -55,7 +55,7 @@
55
55
  "dependencies": {
56
56
  "pi-better-background-tasks": "0.7.0",
57
57
  "pi-better-goal": "0.8.1",
58
- "pi-better-plan": "0.6.0",
58
+ "pi-better-plan": "0.7.0",
59
59
  "pi-better-sandbox": "0.11.0",
60
60
  "pi-better-ssh": "0.1.2",
61
61
  "pi-better-subagents": "0.15.0",