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 +13 -0
- package/package.json +4 -1
- package/src/index.ts +117 -13
- package/src/workflow.ts +63 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/workflow.ts
ADDED
|
@@ -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
|
+
}
|