pi-better-goal 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.2.2",
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
  }
package/src/index.ts CHANGED
@@ -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();
@@ -248,6 +267,15 @@ export default function (pi: ExtensionAPI): void {
248
267
  refreshGoalWidget?.(wasVisible);
249
268
  };
250
269
 
270
+ const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
271
+ const goal = getGoal(ctx);
272
+ if (!isPokeable(goal)) {
273
+ return;
274
+ }
275
+ setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
276
+ notifyGoal(ctx, "Goal paused (interrupted).");
277
+ };
278
+
251
279
  const queueGoalContinuation = (goal: GoalSnapshot): void => {
252
280
  if (!isPokeable(goal) || continuationQueuedFor === goal.goalId) {
253
281
  return;
@@ -258,7 +286,7 @@ export default function (pi: ExtensionAPI): void {
258
286
  pi.sendMessage(
259
287
  {
260
288
  customType: EXTENSION_NAME,
261
- content: continuationPrompt(goal),
289
+ content: continuationPrompt(goal, currentCtx ? getWorkflow(currentCtx) : null),
262
290
  display: false,
263
291
  details: { kind: "continuation", goalId: goal.goalId },
264
292
  },
@@ -324,8 +352,8 @@ export default function (pi: ExtensionAPI): void {
324
352
  continuationQueuedFor = goal.goalId;
325
353
  const content = kind === "background-drained"
326
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" +
327
- continuationPrompt(goal)
328
- : continuationPrompt(goal);
355
+ continuationPrompt(goal, getWorkflow(ctx))
356
+ : continuationPrompt(goal, getWorkflow(ctx));
329
357
  pi.sendMessage(
330
358
  {
331
359
  customType: EXTENSION_NAME,
@@ -345,6 +373,9 @@ export default function (pi: ExtensionAPI): void {
345
373
  ctx: ExtensionContext,
346
374
  source: "command",
347
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
+ }
348
379
  const objectiveError = validateObjective(objective);
349
380
  if (objectiveError) {
350
381
  throw new Error(objectiveError);
@@ -557,6 +588,10 @@ export default function (pi: ExtensionAPI): void {
557
588
  notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
558
589
  return;
559
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
+ }
560
595
  const goal = goalWithStatus(current, "active");
561
596
  setGoal(goal, ctx, "command");
562
597
  queueGoalContinuation(goal);
@@ -576,6 +611,7 @@ export default function (pi: ExtensionAPI): void {
576
611
  return;
577
612
  }
578
613
  setGoal(goalWithStatus(current, "complete"), ctx, "command");
614
+ if (getWorkflow(ctx)) recordWorkflow(null);
579
615
  notifyGoal(ctx, "Goal marked complete.");
580
616
  return;
581
617
  }
@@ -653,6 +689,7 @@ export default function (pi: ExtensionAPI): void {
653
689
  }
654
690
  const goal = goalWithStatus(current, "complete");
655
691
  setGoal(goal, ctx, "tool");
692
+ if (getWorkflow(ctx)) recordWorkflow(null);
656
693
  return {
657
694
  content: [{ type: "text", text: "Goal marked complete." }],
658
695
  details: { ok: true, goal },
@@ -669,6 +706,32 @@ export default function (pi: ExtensionAPI): void {
669
706
  },
670
707
  });
671
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
+
672
735
  pi.registerTool({
673
736
  name: "get_background_activity",
674
737
  label: "Get Background Activity",
@@ -688,6 +751,11 @@ export default function (pi: ExtensionAPI): void {
688
751
  pi.on("session_start", async (_event, ctx) => {
689
752
  currentCtx = ctx;
690
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
+ }
691
759
  pi.events.emit(EVENT_READY, { version: EXTENSION_VERSION });
692
760
  installGoalWidget(ctx);
693
761
  latestSnapshot = await publishSnapshot(ctx);
@@ -698,6 +766,19 @@ export default function (pi: ExtensionAPI): void {
698
766
  if (event.source === "extension") {
699
767
  return;
700
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
+ }
701
782
  const goal = getGoal(ctx);
702
783
  if (goal?.status === "active") {
703
784
  resetContinuationState(goal);
@@ -707,11 +788,32 @@ export default function (pi: ExtensionAPI): void {
707
788
  pi.on("before_agent_start", async (event, ctx) => {
708
789
  currentCtx = ctx;
709
790
  const goal = currentGoalSnapshot(ctx);
791
+ const owner = getWorkflow(ctx);
710
792
  const snapshot = await publishSnapshot(ctx);
711
- if (!isPokeable(goal)) {
793
+ if (!isPokeable(goal) && !owner) {
712
794
  return;
713
795
  }
714
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;
715
817
  const backgroundInstruction = snapshot.backgroundRunning
716
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.`
717
819
  : "";
@@ -728,6 +830,14 @@ export default function (pi: ExtensionAPI): void {
728
830
  clearIdleContinuation();
729
831
  continuationQueuedFor = null;
730
832
  lastAgentEvidence = null;
833
+ const turnSignal = ctx.signal;
834
+ if (turnSignal) {
835
+ if (turnSignal.aborted) {
836
+ pauseGoalOnInterrupt(ctx);
837
+ } else {
838
+ turnSignal.addEventListener("abort", () => pauseGoalOnInterrupt(ctx), { once: true });
839
+ }
840
+ }
731
841
  await publishSnapshot(ctx);
732
842
  });
733
843
 
@@ -738,13 +848,7 @@ export default function (pi: ExtensionAPI): void {
738
848
  // aborted (escape / ctrl+c while streaming), pause the active goal so it does
739
849
  // not auto-continue after the user stopped the agent.
740
850
  if (wasTurnAborted(event.messages)) {
741
- const goal = getGoal(ctx);
742
- if (isPokeable(goal)) {
743
- setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
744
- if (ctx.hasUI) {
745
- ctx.ui.notify("Goal paused (interrupted).", "info");
746
- }
747
- }
851
+ pauseGoalOnInterrupt(ctx);
748
852
  }
749
853
  });
750
854
 
@@ -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
+ }