pi-better-harness 0.3.8 → 0.3.10

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-background-tasks",
3
- "version": "0.2.12",
3
+ "version": "0.2.13",
4
4
  "description": "Pi extension for durable background shell tasks, watchers, logs, and status inspection.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -918,7 +918,7 @@ function createOverlayComponent(
918
918
  width,
919
919
  deps.truncate,
920
920
  fg,
921
- { minRows: detailRows, bottomFooter: false, logTailRows },
921
+ { minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows },
922
922
  );
923
923
  } else {
924
924
  transcriptDetail = null;
@@ -1003,21 +1003,33 @@ function buildTranscriptDetailLines(
1003
1003
  width: number,
1004
1004
  truncate: (s: string, width: number) => string,
1005
1005
  fg: (color: string, value: string) => string,
1006
- options: { minRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
1006
+ options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
1007
1007
  ): string[] {
1008
1008
  const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
1009
1009
  const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
1010
- const shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
1010
+ let shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
1011
1011
  const lines: string[] = [
1012
1012
  fg("accent", rule(detail.title, width)),
1013
1013
  dim(` ← main · ${actions}`, fg),
1014
1014
  "",
1015
1015
  ` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
1016
1016
  ];
1017
- if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
1018
- for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
1019
- lines.push("", dim(section(`transcript · latest ${tailRows} rows`, width), fg));
1020
- if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1017
+ const optionalMetadata = [
1018
+ ...(detail.subtitle ? [` summary ${detail.subtitle}`] : []),
1019
+ ...detail.metadata.map((item) => ` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`),
1020
+ ];
1021
+ const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
1022
+ if (detail.transcriptDiagnostic) transcriptHeader.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1023
+ if (options.maxRows !== undefined) {
1024
+ const fixedRows = lines.length + transcriptHeader.length + 1;
1025
+ const transcriptBudget = Math.max(1, options.maxRows - fixedRows);
1026
+ shownTranscriptLines = shownTranscriptLines.slice(-transcriptBudget);
1027
+ const metadataBudget = Math.max(0, options.maxRows - fixedRows - shownTranscriptLines.length);
1028
+ lines.push(...optionalMetadata.slice(0, metadataBudget));
1029
+ } else {
1030
+ lines.push(...optionalMetadata);
1031
+ }
1032
+ lines.push(...transcriptHeader);
1021
1033
  lines.push(...shownTranscriptLines);
1022
1034
  lines.push("");
1023
1035
  if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
@@ -19,6 +19,19 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
19
19
  - A progress-aware follow-up loop that holds after repeated identical outcomes.
20
20
  - An observable-progress stall state for active goals.
21
21
 
22
+ ## Skill-Owned Workflows
23
+
24
+ Skills that own execution and their own task plan can opt in through `SKILL.md` frontmatter:
25
+
26
+ ```yaml
27
+ metadata:
28
+ pi-better-plan-workflow: coordinator
29
+ ```
30
+
31
+ Invoke the skill with Pi's `/skill:name` command. The goal extension checks Pi's skill-command provenance, persists the owner on the session branch, and restores its instructions on resumed turns. It does not infer ownership from ordinary text or skill names. A goal such as `/goal /skill:name task` is rejected; invoke `/skill:name task` directly and use `/goal` only for a plain-language objective. Legacy slash-shaped goals pause on resume rather than running without the skill. `/workflow` shows the owner; call `release_workflow` after the workflow's completion audit (or use `/workflow clear` to release it manually). Completing an active goal also releases ownership.
32
+
33
+ When `pi-better-plan` is installed, it defers its prompt, checklist, and `update_plan` tool to a skill-owned task plan. Skills without this metadata retain normal goal and plan behavior. The skill itself owns its planning format and worker policy; the harness does not enumerate skills or impose a shared workflow schema.
34
+
22
35
  ## Install
23
36
 
24
37
  ```sh
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.2.3",
3
+ "version": "0.3.0",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -67,5 +67,8 @@
67
67
  },
68
68
  "engines": {
69
69
  "node": ">=22.0.0"
70
+ },
71
+ "dependencies": {
72
+ "yaml": "^2.9.1"
70
73
  }
71
74
  }
@@ -1,5 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import { readFileSync } from "node:fs";
3
4
  import { Type } from "typebox";
4
5
 
5
6
  import {
@@ -33,6 +34,7 @@ import {
33
34
  } from "./goal-clock.js";
34
35
  import { createRenderScheduler } from "./shared-render-scheduler.js";
35
36
  import { collectSubagentActivity } from "./subagents.js";
37
+ import { currentWorkflowOwner, skillCommandName, workflowEntry, workflowOwnerFromSkill, WORKFLOW_ENTRY_TYPE } from "./workflow.js";
36
38
  import {
37
39
  EVENT_ACTIVITY,
38
40
  EVENT_READY,
@@ -109,13 +111,15 @@ function wasTurnAborted(messages: readonly unknown[]): boolean {
109
111
  return false;
110
112
  }
111
113
 
112
- function continuationPrompt(goal: GoalSnapshot): string {
114
+ function continuationPrompt(goal: GoalSnapshot, owner: ReturnType<typeof currentWorkflowOwner> = null): string {
113
115
  return [
114
116
  "Continue working toward the active thread goal.",
115
117
  "",
116
118
  `Goal: ${goal.objective}`,
117
119
  "",
118
- "Keep working through clear low-risk next steps. Do not stop at a plan. Mark the goal complete only after an evidence-backed completion audit proves no required work remains.",
120
+ owner
121
+ ? `Continue the ${owner.name} workflow as its coordinator. Its own task plan is authoritative; do not implement product code in the parent. Mark the goal complete only after the workflow's completion audit.`
122
+ : "Keep working through clear low-risk next steps. Do not stop at a plan. Mark the goal complete only after an evidence-backed completion audit proves no required work remains.",
119
123
  ].join("\n");
120
124
  }
121
125
 
@@ -185,6 +189,20 @@ export default function (pi: ExtensionAPI): void {
185
189
  let lastAgentEvidence: ContinuationEvidence | null = null;
186
190
 
187
191
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
192
+ const getWorkflow = (ctx: ExtensionContext) => currentWorkflowOwner(ctx.sessionManager.getBranch());
193
+ const workflowAvailable = (owner: NonNullable<ReturnType<typeof currentWorkflowOwner>>): boolean => {
194
+ const command = pi.getCommands?.().find((item) => item.name === `skill:${owner.name}` && item.source === "skill");
195
+ if (command?.sourceInfo.path !== owner.path) return false;
196
+ try {
197
+ return workflowOwnerFromSkill(owner.name, owner.path)?.planOwner === owner.planOwner;
198
+ } catch {
199
+ return false;
200
+ }
201
+ };
202
+ const recordWorkflow = (owner: ReturnType<typeof currentWorkflowOwner>): void => {
203
+ pi.appendEntry(WORKFLOW_ENTRY_TYPE, workflowEntry(owner));
204
+ pi.events.emit("pi-better-workflow:changed", owner);
205
+ };
188
206
 
189
207
  /** Only active goals receive autonomous pokes; paused, complete, and budget-limited goals never do. */
190
208
  const isPokeable = (goal: GoalSnapshot | null): goal is GoalSnapshot => goal?.status === "active";
@@ -241,6 +259,7 @@ export default function (pi: ExtensionAPI): void {
241
259
  const current = getGoal(ctx);
242
260
  const wasVisible = current !== null && isGoalClockVisible(current);
243
261
  pi.appendEntry(EXTENSION_NAME, goalClearEntry(current?.goalId ?? null, source));
262
+ if (getWorkflow(ctx)) recordWorkflow(null);
244
263
  continuationQueuedFor = null;
245
264
  backgroundDrainTracker = null;
246
265
  clearIdleContinuation();
@@ -267,7 +286,7 @@ export default function (pi: ExtensionAPI): void {
267
286
  pi.sendMessage(
268
287
  {
269
288
  customType: EXTENSION_NAME,
270
- content: continuationPrompt(goal),
289
+ content: continuationPrompt(goal, currentCtx ? getWorkflow(currentCtx) : null),
271
290
  display: false,
272
291
  details: { kind: "continuation", goalId: goal.goalId },
273
292
  },
@@ -333,8 +352,8 @@ export default function (pi: ExtensionAPI): void {
333
352
  continuationQueuedFor = goal.goalId;
334
353
  const content = kind === "background-drained"
335
354
  ? "Background activity for the active goal is no longer running. Inspect any subagent callbacks or final results, then continue the completion audit before marking the goal complete.\n\n" +
336
- continuationPrompt(goal)
337
- : continuationPrompt(goal);
355
+ continuationPrompt(goal, getWorkflow(ctx))
356
+ : continuationPrompt(goal, getWorkflow(ctx));
338
357
  pi.sendMessage(
339
358
  {
340
359
  customType: EXTENSION_NAME,
@@ -354,6 +373,9 @@ export default function (pi: ExtensionAPI): void {
354
373
  ctx: ExtensionContext,
355
374
  source: "command",
356
375
  ): GoalSnapshot => {
376
+ if (skillCommandName(objective)) {
377
+ throw new Error("A skill command cannot be a goal objective. Invoke /skill:name directly so Pi loads the skill, then set a plain-language goal if needed.");
378
+ }
357
379
  const objectiveError = validateObjective(objective);
358
380
  if (objectiveError) {
359
381
  throw new Error(objectiveError);
@@ -566,6 +588,10 @@ export default function (pi: ExtensionAPI): void {
566
588
  notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
567
589
  return;
568
590
  }
591
+ if (skillCommandName(current.objective)) {
592
+ notifyGoal(ctx, "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.", "error");
593
+ return;
594
+ }
569
595
  const goal = goalWithStatus(current, "active");
570
596
  setGoal(goal, ctx, "command");
571
597
  queueGoalContinuation(goal);
@@ -585,6 +611,7 @@ export default function (pi: ExtensionAPI): void {
585
611
  return;
586
612
  }
587
613
  setGoal(goalWithStatus(current, "complete"), ctx, "command");
614
+ if (getWorkflow(ctx)) recordWorkflow(null);
588
615
  notifyGoal(ctx, "Goal marked complete.");
589
616
  return;
590
617
  }
@@ -662,6 +689,7 @@ export default function (pi: ExtensionAPI): void {
662
689
  }
663
690
  const goal = goalWithStatus(current, "complete");
664
691
  setGoal(goal, ctx, "tool");
692
+ if (getWorkflow(ctx)) recordWorkflow(null);
665
693
  return {
666
694
  content: [{ type: "text", text: "Goal marked complete." }],
667
695
  details: { ok: true, goal },
@@ -678,6 +706,32 @@ export default function (pi: ExtensionAPI): void {
678
706
  },
679
707
  });
680
708
 
709
+ pi.registerCommand("workflow", {
710
+ description: "Inspect or clear the active skill-owned workflow",
711
+ handler: async (args, ctx) => {
712
+ if (args.trim() === "clear") {
713
+ recordWorkflow(null);
714
+ notifyGoal(ctx, "Workflow ownership cleared.");
715
+ return;
716
+ }
717
+ const owner = getWorkflow(ctx);
718
+ notifyGoal(ctx, owner ? `Workflow: ${owner.name} (${owner.path}); plan owned by workflow.` : "No workflow owns this session.");
719
+ },
720
+ });
721
+
722
+ pi.registerTool({
723
+ name: "release_workflow",
724
+ label: "Release Workflow",
725
+ description: "Release the active skill-owned workflow after its final handoff and completion audit.",
726
+ parameters: Type.Object({}),
727
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
728
+ const owner = getWorkflow(ctx);
729
+ if (!owner) return { content: [{ type: "text", text: "No workflow owns this session." }], details: { released: false } };
730
+ recordWorkflow(null);
731
+ return { content: [{ type: "text", text: `Released ${owner.name} workflow ownership.` }], details: { released: true, owner: owner.name } };
732
+ },
733
+ });
734
+
681
735
  pi.registerTool({
682
736
  name: "get_background_activity",
683
737
  label: "Get Background Activity",
@@ -697,6 +751,11 @@ export default function (pi: ExtensionAPI): void {
697
751
  pi.on("session_start", async (_event, ctx) => {
698
752
  currentCtx = ctx;
699
753
  foregroundRunning = !ctx.isIdle();
754
+ const restoredGoal = getGoal(ctx);
755
+ if (restoredGoal?.status === "active" && skillCommandName(restoredGoal.objective)) {
756
+ setGoal(goalWithStatus(restoredGoal, "paused"), ctx, "runtime");
757
+ notifyGoal(ctx, "Goal paused: invoke its skill directly before resuming.", "error");
758
+ }
700
759
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
701
760
  installGoalWidget(ctx);
702
761
  latestSnapshot = await publishSnapshot(ctx);
@@ -707,6 +766,19 @@ export default function (pi: ExtensionAPI): void {
707
766
  if (event.source === "extension") {
708
767
  return;
709
768
  }
769
+ const skillName = skillCommandName(event.text ?? "");
770
+ if (skillName) {
771
+ const command = pi.getCommands?.().find((item) => item.name === `skill:${skillName}` && item.source === "skill");
772
+ if (command) {
773
+ try {
774
+ const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path);
775
+ if (owner) recordWorkflow(owner);
776
+ } catch (error) {
777
+ notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
778
+ return { action: "handled" as const };
779
+ }
780
+ }
781
+ }
710
782
  const goal = getGoal(ctx);
711
783
  if (goal?.status === "active") {
712
784
  resetContinuationState(goal);
@@ -716,11 +788,32 @@ export default function (pi: ExtensionAPI): void {
716
788
  pi.on("before_agent_start", async (event, ctx) => {
717
789
  currentCtx = ctx;
718
790
  const goal = currentGoalSnapshot(ctx);
791
+ const owner = getWorkflow(ctx);
719
792
  const snapshot = await publishSnapshot(ctx);
720
- if (!isPokeable(goal)) {
793
+ if (!isPokeable(goal) && !owner) {
721
794
  return;
722
795
  }
723
796
 
797
+ if (owner) {
798
+ if (!workflowAvailable(owner)) {
799
+ if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
800
+ recordWorkflow(null);
801
+ return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is no longer a registered, valid skill. Stop work and ask the user to reinvoke the skill.` };
802
+ }
803
+ let instructions: string;
804
+ try {
805
+ instructions = readFileSync(owner.path, "utf8");
806
+ } catch {
807
+ if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
808
+ return { systemPrompt: `${event.systemPrompt}\n\nWorkflow ${owner.name} is unavailable. Stop work and ask the user to restore or reinvoke the skill.` };
809
+ }
810
+ return {
811
+ systemPrompt: `${event.systemPrompt}\n\nActive workflow: ${owner.name} (${owner.path}). Its task plan owns planning and the parent is a coordinator, not a product-code implementer. Follow the workflow instructions below, including on resumed turns:\n\n${instructions}` +
812
+ (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : ""),
813
+ };
814
+ }
815
+
816
+ if (!isPokeable(goal)) return;
724
817
  const backgroundInstruction = snapshot.backgroundRunning
725
818
  ? ` The goal still has delegated background work running (${summarizeActiveBackground(snapshot)}). Foreground idleness alone is not goal completion; keep any structured plan current and do not mark verification, the plan, or the goal complete until every relevant delegated task reaches a terminal state and its result or failure has been inspected and integrated.`
726
819
  : "";
@@ -0,0 +1,63 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { parse } from "yaml";
3
+
4
+ export const WORKFLOW_ENTRY_TYPE = "pi-better-workflow";
5
+
6
+ export interface WorkflowOwner {
7
+ name: string;
8
+ path: string;
9
+ role: "coordinator";
10
+ planOwner: "workflow";
11
+ }
12
+
13
+ interface SessionEntryLike {
14
+ type: string;
15
+ customType?: string;
16
+ data?: unknown;
17
+ }
18
+
19
+ export function workflowOwnerFromSkill(name: string, path: string): WorkflowOwner | null {
20
+ const source = readFileSync(path, "utf8");
21
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
22
+ if (!match) return null;
23
+ const frontmatter: unknown = parse(match[1]!);
24
+ if (!frontmatter || typeof frontmatter !== "object") return null;
25
+ const metadata = (frontmatter as { metadata?: unknown }).metadata;
26
+ if (!metadata || typeof metadata !== "object") return null;
27
+ const fields = metadata as Record<string, unknown>;
28
+ if (fields["pi-better-plan-workflow"] === undefined) {
29
+ if (fields["pi-better-workflow-role"] !== undefined || fields["pi-better-plan-owner"] !== undefined) {
30
+ throw new Error(`Outdated workflow metadata in ${path}. Use pi-better-plan-workflow: coordinator.`);
31
+ }
32
+ return null;
33
+ }
34
+ if (fields["pi-better-plan-workflow"] !== "coordinator") {
35
+ throw new Error(`Invalid workflow metadata in ${path}. Expected pi-better-plan-workflow: coordinator.`);
36
+ }
37
+ return { name, path, role: "coordinator", planOwner: "workflow" };
38
+ }
39
+
40
+ export function workflowEntry(owner: WorkflowOwner | null) {
41
+ return owner === null
42
+ ? { version: 1, kind: "clear" as const }
43
+ : { version: 1, kind: "set" as const, owner };
44
+ }
45
+
46
+ export function currentWorkflowOwner(entries: Iterable<SessionEntryLike>): WorkflowOwner | null {
47
+ let owner: WorkflowOwner | null = null;
48
+ for (const entry of entries) {
49
+ if (entry.type !== "custom" || entry.customType !== WORKFLOW_ENTRY_TYPE) continue;
50
+ const data = entry.data as { version?: unknown; kind?: unknown; owner?: Partial<WorkflowOwner> } | null;
51
+ if (data?.version !== 1) continue;
52
+ if (data.kind === "clear") owner = null;
53
+ else if (data.kind === "set" && typeof data.owner?.name === "string" &&
54
+ typeof data.owner.path === "string" && data.owner.role === "coordinator" && data.owner.planOwner === "workflow") {
55
+ owner = data.owner as WorkflowOwner;
56
+ }
57
+ }
58
+ return owner;
59
+ }
60
+
61
+ export function skillCommandName(text: string): string | null {
62
+ return /^\/skill:([a-z0-9]+(?:-[a-z0-9]+)*)(?:\s|$)/.exec(text.trim())?.[1] ?? null;
63
+ }
@@ -5,6 +5,7 @@
5
5
  ## What It Does
6
6
 
7
7
  - Gives models `update_plan` and `get_plan` tools for atomic, explicit progress updates.
8
+ - Supports dependency edges (`id` and `dependsOn`) so independent ready steps can run concurrently.
8
9
  - Shows the complete checklist of completed, active, pending, and blocked steps above the editor.
9
10
  - Persists plan state and display preferences on the active Pi session branch.
10
11
  - Keeps a completed plan visible for 30 seconds, then clears it automatically.
@@ -16,7 +17,13 @@ Plan progress is checklist progress, not an estimate of effort. The extension ne
16
17
 
17
18
  Use the plan as the foreground coordinator's milestone ledger. Delegate independent, sufficiently substantial work early with subagents, and use background tasks for long-running processes or repeated checks. Keep doing unblocked foreground work after launch; do not poll workers.
18
19
 
19
- Concurrent workers belong under one `in_progress` coordinator step rather than one active plan step per worker. Worker tools and the background-work navigator own individual run status. Complete verification and the plan only after every relevant delegated task is terminal and its result or failure has been inspected and integrated.
20
+ Before the first implementation milestone, check for an independent task that can run alongside foreground work. Launch a bounded subagent task when available; otherwise state the specific dependency or shared-worktree constraint that rules delegation out. The plan records milestones, not worker scheduling.
21
+
22
+ Independent foreground and delegated milestones may both be `in_progress`. Use steps for distinct deliverables, not individual worker processes; worker tools and the background-work navigator own run status. Complete verification and the plan only after every relevant delegated task is terminal and its result or failure has been inspected and integrated.
23
+
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
+
26
+ When an explicitly invoked skill declares `pi-better-plan-workflow: 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.
20
27
 
21
28
  ## Install
22
29
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-plan",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Structured execution plans with persistent progress for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -28,8 +28,10 @@ interface GetPlanDetails {
28
28
  }
29
29
 
30
30
  const PlanStepSchema = Type.Object({
31
+ id: Type.Optional(Type.String({ description: "Stable step id used by dependsOn (letters, digits, underscore, hyphen; max 64 characters)." })),
31
32
  step: Type.String({ description: "A concise, verifiable execution step." }),
32
33
  status: StringEnum(["pending", "in_progress", "completed", "blocked"] as const),
34
+ dependsOn: Type.Optional(Type.Array(Type.String(), { description: "Ids of prerequisite steps in this plan. All must be completed before this step starts." })),
33
35
  });
34
36
 
35
37
  const UpdatePlanSchema = Type.Object({
@@ -39,11 +41,31 @@ const UpdatePlanSchema = Type.Object({
39
41
 
40
42
  const LEGACY_PLAN_NAV_STATUS_KEY = "pi-better-plan-nav";
41
43
 
44
+ function workflowPlanOwner(ctx: ExtensionContext): string | null {
45
+ let owner: string | null = null;
46
+ for (const entry of ctx.sessionManager.getBranch()) {
47
+ if (entry.type !== "custom" || entry.customType !== "pi-better-workflow") continue;
48
+ const data = entry.data as { version?: unknown; kind?: unknown; owner?: { name?: unknown; planOwner?: unknown } } | null;
49
+ if (data?.version !== 1) continue;
50
+ if (data.kind === "clear") owner = null;
51
+ else if (data.kind === "set" && data.owner?.planOwner === "workflow" && typeof data.owner.name === "string") {
52
+ owner = data.owner.name;
53
+ }
54
+ }
55
+ return owner;
56
+ }
57
+
42
58
  export default function planExtension(pi: ExtensionAPI): void {
43
59
  let currentPlan: PlanSnapshot | null = null;
44
60
  let displayMode: PlanDisplayMode = "auto";
61
+ let displayedWorkflowOwner: string | null = null;
45
62
  let refreshWidget: ((force?: boolean) => void) | undefined;
46
63
  let completedPlanClearTimer: ReturnType<typeof setTimeout> | undefined;
64
+ const unsubscribeWorkflow = pi.events.on("pi-better-workflow:changed", (owner) => {
65
+ displayedWorkflowOwner = owner && typeof owner === "object" && "name" in owner && typeof owner.name === "string"
66
+ ? owner.name : null;
67
+ refreshWidget?.(true);
68
+ });
47
69
 
48
70
  const refresh = (force = false): void => refreshWidget?.(force);
49
71
 
@@ -79,6 +101,7 @@ export default function planExtension(pi: ExtensionAPI): void {
79
101
  cancelCompletedPlanClear();
80
102
  const state = reconstructPlanState(ctx.sessionManager.getBranch());
81
103
  currentPlan = state.plan;
104
+ displayedWorkflowOwner = workflowPlanOwner(ctx);
82
105
  displayMode = state.displayMode;
83
106
  if (ctx.hasUI) ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
84
107
  refresh(true);
@@ -107,7 +130,7 @@ export default function planExtension(pi: ExtensionAPI): void {
107
130
  refreshWidget = localRefresh;
108
131
  return {
109
132
  render(width: number): string[] {
110
- if (!currentPlan || displayMode === "hidden") return [];
133
+ if (!currentPlan || displayMode === "hidden" || displayedWorkflowOwner) return [];
111
134
  return renderCompactPlan(currentPlan, width, theme as never);
112
135
  },
113
136
  invalidate() {},
@@ -150,13 +173,17 @@ export default function planExtension(pi: ExtensionAPI): void {
150
173
  description: "Create or atomically replace the current structured execution plan and its step statuses.",
151
174
  promptSnippet: "Create and update a persistent structured execution plan",
152
175
  promptGuidelines: [
153
- "Use update_plan for work with three or more meaningful steps, and update it immediately when a step completes, becomes blocked, or scope changes.",
154
- "Keep at most one update_plan step in_progress and do not mark a step completed until its required verification succeeds.",
155
- "Use the plan as the foreground coordinator's milestone ledger. Delegate independent, sufficiently substantial work early when subagent or background-task tools are available, and continue unblocked foreground work instead of waiting or polling.",
156
- "Represent concurrent delegated work under one in_progress coordinator step. Before completing verification or the plan, ensure every relevant delegated task is terminal, then inspect and integrate its result or failure.",
176
+ "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.",
177
+ "For generic plans only, mark independent foreground and delegated milestones in_progress concurrently when work is actually underway. Do not mark a delegated step completed until its result or failure has been inspected and integrated.",
178
+ "For generic plans with prerequisites, give steps stable ids and dependsOn edges. Start only ready steps whose prerequisites are completed; independent ready steps may run in parallel, including in subagents.",
179
+ "For generic plans only, before the first implementation milestone identify independent substantial work. Launch a bounded subagent task while you continue another, or state the concrete reason delegation is unsuitable.",
180
+ "Use a generic plan as the foreground coordinator's milestone ledger only when no workflow owns planning. Otherwise follow the workflow's task plan and foreground role.",
181
+ "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.",
157
182
  ],
158
183
  parameters: UpdatePlanSchema,
159
- async execute(_toolCallId, params) {
184
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
185
+ const owner = workflowPlanOwner(ctx);
186
+ if (owner) throw new Error(`${owner} owns the task plan. Update its workflow plan instead of update_plan.`);
160
187
  const input = params as { explanation?: string; plan: PlanStepInput[] };
161
188
  const plan = replacePlan(currentPlan, input.plan, input.explanation);
162
189
  persistPlan(plan);
@@ -185,7 +212,12 @@ export default function planExtension(pi: ExtensionAPI): void {
185
212
  description: "Inspect the current structured execution plan and derived checklist progress.",
186
213
  promptSnippet: "Inspect the current structured execution plan and progress",
187
214
  parameters: Type.Object({}),
188
- async execute() {
215
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
216
+ 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
+ };
189
221
  if (!currentPlan) {
190
222
  return {
191
223
  content: [{ type: "text", text: "No plan is set." }],
@@ -235,7 +267,8 @@ export default function planExtension(pi: ExtensionAPI): void {
235
267
  pi.on("session_tree", async (_event, ctx) => {
236
268
  restore(ctx);
237
269
  });
238
- pi.on("before_agent_start", async (event) => {
270
+ pi.on("before_agent_start", async (event, ctx) => {
271
+ if (workflowPlanOwner(ctx)) return;
239
272
  if (!currentPlan) return;
240
273
  return {
241
274
  systemPrompt: `${event.systemPrompt}\n\n${planPrompt(currentPlan)}`,
@@ -243,6 +276,7 @@ export default function planExtension(pi: ExtensionAPI): void {
243
276
  });
244
277
  pi.on("session_shutdown", async (_event, ctx) => {
245
278
  cancelCompletedPlanClear();
279
+ if (typeof unsubscribeWorkflow === "function") unsubscribeWorkflow();
246
280
  refreshWidget = undefined;
247
281
  try {
248
282
  ctx.ui.setStatus(LEGACY_PLAN_NAV_STATUS_KEY, undefined);
@@ -256,24 +290,37 @@ function formatProgress(plan: PlanSnapshot): string {
256
290
  const current = progress.activeIndex === null ? null : plan.steps[progress.activeIndex];
257
291
  const status = [`Plan updated: ${progress.completed}/${progress.total} steps completed`];
258
292
  if (progress.blocked > 0) status.push(`${progress.blocked} blocked`);
259
- if (current) status.push(`Current step: ${current.step}`);
293
+ if (progress.inProgress > 1) status.push(`${progress.inProgress} steps in progress`);
294
+ else if (current) status.push(`Current step: ${current.step}`);
295
+ if (plan.steps.some((item) => item.dependsOn?.length)) status.push(`${progress.readyIndices.length} ready`);
260
296
  return status.join(". ") + ".";
261
297
  }
262
298
 
299
+ function stepLine(item: PlanSnapshot["steps"][number], index: number): string {
300
+ return `${index + 1}. [${item.status}] ${item.step}${item.dependsOn?.length ? ` (after: ${item.dependsOn.join(", ")})` : ""}`;
301
+ }
302
+
263
303
  function formatPlan(plan: PlanSnapshot): string {
264
304
  const progress = planProgress(plan);
265
305
  return [
266
306
  `Plan: ${progress.completed}/${progress.total} steps completed${progress.blocked ? `, ${progress.blocked} blocked` : ""}`,
267
- ...plan.steps.map((item, index) => `${index + 1}. [${item.status}] ${item.step}`),
307
+ ...plan.steps.map(stepLine),
308
+ ...(plan.steps.some((item) => item.dependsOn?.length)
309
+ ? [`Ready: ${progress.readyIndices.map((index) => plan.steps[index]!.step).join(", ") || "none"}`]
310
+ : []),
268
311
  ].join("\n");
269
312
  }
270
313
 
271
314
  function planPrompt(plan: PlanSnapshot): string {
272
315
  return [
273
316
  "Current structured execution plan:",
274
- ...plan.steps.map((item, index) => `${index + 1}. [${item.status}] ${item.step}`),
317
+ ...plan.steps.map(stepLine),
318
+ ...(plan.steps.some((item) => item.dependsOn?.length)
319
+ ? [`Ready pending steps: ${planProgress(plan).readyIndices.map((index) => plan.steps[index]!.step).join(", ") || "none"}. Start only steps whose prerequisites are complete.`]
320
+ : []),
275
321
  "Use update_plan immediately when a step completes, becomes blocked, or the scope changes.",
276
- "Treat the plan as the foreground coordinator's milestone ledger: delegate independent, sufficiently substantial work early, keep one coordinator step in_progress across concurrent workers, and continue unblocked foreground work instead of waiting or polling.",
322
+ "Before starting the first implementation milestone, identify independent substantial work. If a subagent is available, launch one bounded task early while you continue another; otherwise briefly state the concrete dependency or shared-worktree constraint that makes delegation unsuitable.",
323
+ "Treat the plan as the foreground coordinator's milestone ledger: delegate independent, sufficiently substantial work early, mark distinct foreground and delegated milestones in_progress concurrently, and continue unblocked foreground work instead of waiting or polling.",
277
324
  "Do not complete verification or the plan until relevant delegated work is terminal, its results or failures have been inspected and integrated, and the outcome is evidence-backed.",
278
325
  ].join("\n");
279
326
  }
@@ -4,6 +4,7 @@ import { EXTENSION_NAME } from "./types.js";
4
4
  const MAX_STEPS = 50;
5
5
  const MAX_STEP_CHARS = 500;
6
6
  const MAX_EXPLANATION_CHARS = 2_000;
7
+ const STEP_ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
7
8
  export const COMPLETED_PLAN_RETENTION_MS = 30_000;
8
9
 
9
10
  interface SessionEntryLike {
@@ -32,8 +33,8 @@ export function validatePlanInput(steps: readonly PlanStepInput[], explanation?:
32
33
  return `Plan explanation cannot exceed ${MAX_EXPLANATION_CHARS} characters.`;
33
34
  }
34
35
 
35
- let inProgress = 0;
36
36
  const normalized = new Set<string>();
37
+ const ids = new Map<string, number>();
37
38
  for (let index = 0; index < steps.length; index += 1) {
38
39
  const item = steps[index]!;
39
40
  const text = item.step.trim();
@@ -44,9 +45,43 @@ export function validatePlanInput(steps: readonly PlanStepInput[], explanation?:
44
45
  const key = text.toLocaleLowerCase();
45
46
  if (normalized.has(key)) return `Plan step ${index + 1} duplicates an earlier step.`;
46
47
  normalized.add(key);
47
- if (item.status === "in_progress") inProgress += 1;
48
+ if (item.id !== undefined) {
49
+ if (!STEP_ID.test(item.id)) return `Plan step ${index + 1} has an invalid id.`;
50
+ if (ids.has(item.id)) return `Plan step ${index + 1} duplicates id ${item.id}.`;
51
+ ids.set(item.id, index);
52
+ }
53
+ }
54
+
55
+ for (let index = 0; index < steps.length; index += 1) {
56
+ const item = steps[index]!;
57
+ const seen = new Set<string>();
58
+ for (const dependency of item.dependsOn ?? []) {
59
+ if (seen.has(dependency)) return `Plan step ${index + 1} repeats dependency ${dependency}.`;
60
+ seen.add(dependency);
61
+ const prerequisite = ids.get(dependency);
62
+ if (prerequisite === undefined) return `Plan step ${index + 1} references unknown dependency ${dependency}. Use explicit ids for dependencies.`;
63
+ if ((item.status === "in_progress" || item.status === "completed") && steps[prerequisite]!.status !== "completed") {
64
+ return `Plan step ${index + 1} cannot be ${item.status} until dependency ${dependency} is completed.`;
65
+ }
66
+ }
67
+ }
68
+ const visited = new Set<string>();
69
+ const visiting = new Set<string>();
70
+ const visit = (id: string): boolean => {
71
+ if (visiting.has(id)) return true;
72
+ if (visited.has(id)) return false;
73
+ visiting.add(id);
74
+ const item = steps[ids.get(id)!]!;
75
+ for (const dependency of item.dependsOn ?? []) {
76
+ if (visit(dependency)) return true;
77
+ }
78
+ visiting.delete(id);
79
+ visited.add(id);
80
+ return false;
81
+ };
82
+ for (const id of ids.keys()) {
83
+ if (visit(id)) return `Plan dependencies contain a cycle involving ${id}.`;
48
84
  }
49
- if (inProgress > 1) return "A plan can have at most one step in progress.";
50
85
  return null;
51
86
  }
52
87
 
@@ -65,11 +100,15 @@ export function replacePlan(
65
100
  const normalizedSteps: PlanStep[] = steps.map((item, index) => {
66
101
  const step = item.step.trim();
67
102
  return {
68
- id: reusableIds.get(step.toLocaleLowerCase()) ?? `step_${revision}_${index + 1}`,
103
+ id: item.id ?? reusableIds.get(step.toLocaleLowerCase()) ?? `step_${revision}_${index + 1}`,
69
104
  step,
70
105
  status: item.status,
106
+ ...(item.dependsOn?.length ? { dependsOn: [...item.dependsOn] } : {}),
71
107
  };
72
108
  });
109
+ if (new Set(normalizedSteps.map((item) => item.id)).size !== normalizedSteps.length) {
110
+ throw new Error("Plan step ids must be unique, including generated ids.");
111
+ }
73
112
  const isComplete = normalizedSteps.every((item) => item.status === "completed");
74
113
  const currentIsComplete = current?.steps.every((item) => item.status === "completed") === true;
75
114
 
@@ -98,14 +137,19 @@ export function planProgress(plan: PlanSnapshot): PlanProgress {
98
137
  const pending = plan.steps.filter((item) => item.status === "pending").length;
99
138
  const blocked = plan.steps.filter((item) => item.status === "blocked").length;
100
139
  const inProgress = plan.steps.filter((item) => item.status === "in_progress").length;
101
- const activeIndex = plan.steps.findIndex((item) => item.status === "in_progress" || item.status === "blocked");
140
+ const activeIndex = plan.steps.findIndex((item) => item.status === "in_progress");
141
+ const blockedIndex = plan.steps.findIndex((item) => item.status === "blocked");
142
+ const completedIds = new Set(plan.steps.filter((item) => item.status === "completed").map((item) => item.id));
143
+ const readyIndices = plan.steps.flatMap((item, index) =>
144
+ item.status === "pending" && (item.dependsOn ?? []).every((id) => completedIds.has(id)) ? [index] : []);
102
145
  return {
103
146
  total: plan.steps.length,
104
147
  completed,
105
148
  pending,
106
149
  blocked,
107
150
  inProgress,
108
- activeIndex: activeIndex >= 0 ? activeIndex : null,
151
+ activeIndex: activeIndex >= 0 ? activeIndex : blockedIndex >= 0 ? blockedIndex : null,
152
+ readyIndices,
109
153
  state:
110
154
  completed === plan.steps.length
111
155
  ? "complete"
@@ -4,8 +4,10 @@ export type PlanStepStatus = "pending" | "in_progress" | "completed" | "blocked"
4
4
  export type PlanDisplayMode = "auto" | "on" | "off" | "hidden";
5
5
 
6
6
  export interface PlanStepInput {
7
+ id?: string;
7
8
  step: string;
8
9
  status: PlanStepStatus;
10
+ dependsOn?: string[];
9
11
  }
10
12
 
11
13
  export interface PlanStep extends PlanStepInput {
@@ -30,6 +32,7 @@ export interface PlanProgress {
30
32
  blocked: number;
31
33
  inProgress: number;
32
34
  activeIndex: number | null;
35
+ readyIndices: number[];
33
36
  state: "draft" | "in_progress" | "blocked" | "complete";
34
37
  }
35
38
 
@@ -128,7 +128,7 @@ const SUBAGENT_TOOLS = [
128
128
  ];
129
129
 
130
130
  const SUBAGENT_ORCHESTRATION_GUIDELINES = [
131
- "When a structured plan is active, keep it as the parent-owned coordinator ledger: delegate bounded independent work, continue unblocked foreground work, and update the plan after integrating each result or failure.",
131
+ "When a structured plan is active, keep it as the parent-owned coordinator ledger: mark distinct delegated and foreground deliverables in_progress concurrently, continue unblocked foreground work, and update the plan after integrating each result or failure.",
132
132
  "Do not treat launching a subagent as completion of the parent milestone; relevant terminal results must be inspected and integrated before verification or completion.",
133
133
  ];
134
134
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-subagents",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
4
4
  "description": "Pi extension for detached, sandboxed subagent runs that keep the foreground session free.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -918,7 +918,7 @@ function createOverlayComponent(
918
918
  width,
919
919
  deps.truncate,
920
920
  fg,
921
- { minRows: detailRows, bottomFooter: false, logTailRows },
921
+ { minRows: detailRows, maxRows: detailRows, bottomFooter: false, logTailRows },
922
922
  );
923
923
  } else {
924
924
  transcriptDetail = null;
@@ -1003,21 +1003,33 @@ function buildTranscriptDetailLines(
1003
1003
  width: number,
1004
1004
  truncate: (s: string, width: number) => string,
1005
1005
  fg: (color: string, value: string) => string,
1006
- options: { minRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
1006
+ options: { minRows?: number; maxRows?: number; bottomFooter?: boolean; logTailRows?: number } = {},
1007
1007
  ): string[] {
1008
1008
  const actions = [...(detail.footerActions ?? ["x close"]), "Esc close"].join(" · ");
1009
1009
  const tailRows = options.logTailRows ?? DEFAULT_LOG_TAIL_ROWS;
1010
- const shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
1010
+ let shownTranscriptLines = transcriptLines.length ? transcriptLines.slice(-tailRows) : [" (no transcript yet)"];
1011
1011
  const lines: string[] = [
1012
1012
  fg("accent", rule(detail.title, width)),
1013
1013
  dim(` ← main · ${actions}`, fg),
1014
1014
  "",
1015
1015
  ` status ${fg(toneColor(detail.statusTone, detail.status), detail.status)}`,
1016
1016
  ];
1017
- if (detail.subtitle) lines.push(` summary ${detail.subtitle}`);
1018
- for (const item of detail.metadata) lines.push(` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`);
1019
- lines.push("", dim(section(`transcript · latest ${tailRows} rows`, width), fg));
1020
- if (detail.transcriptDiagnostic) lines.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1017
+ const optionalMetadata = [
1018
+ ...(detail.subtitle ? [` summary ${detail.subtitle}`] : []),
1019
+ ...detail.metadata.map((item) => ` ${item.label.padEnd(8, " ").slice(0, 8)} ${item.value}`),
1020
+ ];
1021
+ const transcriptHeader = ["", dim(section(`transcript · latest ${tailRows} rows`, width), fg)];
1022
+ if (detail.transcriptDiagnostic) transcriptHeader.push(` ${dim(detail.transcriptDiagnostic, fg)}`);
1023
+ if (options.maxRows !== undefined) {
1024
+ const fixedRows = lines.length + transcriptHeader.length + 1;
1025
+ const transcriptBudget = Math.max(1, options.maxRows - fixedRows);
1026
+ shownTranscriptLines = shownTranscriptLines.slice(-transcriptBudget);
1027
+ const metadataBudget = Math.max(0, options.maxRows - fixedRows - shownTranscriptLines.length);
1028
+ lines.push(...optionalMetadata.slice(0, metadataBudget));
1029
+ } else {
1030
+ lines.push(...optionalMetadata);
1031
+ }
1032
+ lines.push(...transcriptHeader);
1021
1033
  lines.push(...shownTranscriptLines);
1022
1034
  lines.push("");
1023
1035
  if (options.bottomFooter === false) return lines.map((line) => safeTruncate(line, width, truncate));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-harness",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
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",
@@ -50,12 +50,13 @@
50
50
  "test": "node --test test/*.test.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "pi-better-background-tasks": "0.2.12",
54
- "pi-better-goal": "0.2.3",
55
- "pi-better-plan": "0.1.5",
53
+ "pi-better-background-tasks": "0.2.13",
54
+ "pi-better-goal": "0.3.0",
55
+ "pi-better-plan": "0.2.0",
56
56
  "pi-better-sandbox": "0.3.0",
57
57
  "pi-better-ssh": "0.1.1",
58
- "pi-better-subagents": "0.1.27"
58
+ "pi-better-subagents": "0.1.29",
59
+ "yaml": "^2.9.1"
59
60
  },
60
61
  "bundledDependencies": [
61
62
  "pi-better-background-tasks",