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 +13 -0
- package/package.json +4 -1
- package/src/index.ts +99 -6
- 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();
|
|
@@ -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
|
: "";
|
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
|
+
}
|