pi-better-goal 0.2.3 → 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.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
  }
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();
@@ -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
+ }