pi-better-goal 0.4.0 → 0.4.1

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
@@ -13,7 +13,8 @@ Use `pi-better-goal` when a Pi session should keep an explicit objective visible
13
13
  ## Core Features
14
14
 
15
15
  - `/goal` runtime for starting, pausing, resuming, completing, and clearing the current objective.
16
- - `escape` pauses the active goal; paused goals are never poked.
16
+ - `escape` pauses the active goal and your next message resumes it; `/goal pause` stays paused until `/goal resume`. Paused goals are never poked.
17
+ - Background work that finishes while an `ask_user_question` is pending is handed to the agent right after the answer.
17
18
  - A compact goal widget that does not replace Pi's footer.
18
19
  - Background activity tracking for subagents and other registered providers.
19
20
  - A progress-aware follow-up loop that holds after repeated identical outcomes.
@@ -30,6 +31,15 @@ metadata:
30
31
 
31
32
  Invoke the skill with Pi's `/skill:name` command, or supervise it with `/goal /skill:name task`. The goal extension resolves the command against Pi's registry, persists its source, and expands the skill on kickoff and continuation (including after session resume). A bound command that disappears or changes source pauses the goal. `/goal /template task` also re-expands prompt templates on continuation; `/goal /extension-command task` dispatches the extension command once, then continues with ordinary goal supervision to avoid repeating side effects. Plain-language goals work as before. This command binding requires Pi 0.84.4 or later. Legacy slash-shaped goals without a binding pause on resume rather than running without the skill. `/workflow` shows a coordinator skill 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
 
34
+ A skill that is only an alias of a coordinator declares the target instead of a role:
35
+
36
+ ```yaml
37
+ metadata:
38
+ workflow-alias-of: rush-issues
39
+ ```
40
+
41
+ Invoking the alias (`/skill:resolve-issues`, or `/goal /skill:resolve-issues task`) records the target coordinator, with the target's registered path, as the workflow owner, so its task plan, continuation instructions, and plan tools behave exactly as if the target had been invoked. The target must be a registered `workflow-role: coordinator` skill; an unregistered target, a non-coordinator target, or a chain of aliases is refused with a notice.
42
+
33
43
  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. The former `pi-better-plan-workflow: coordinator` metadata remains supported for installed skills.
34
44
 
35
45
  ## Install
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-better-goal",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension for goal tracking with background-aware continuation.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/goal-state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { GoalCommandBinding } from "./command-binding.js";
3
3
 
4
- import { EXTENSION_NAME, type GoalSnapshot, type GoalStatus } from "./types.js";
4
+ import { EXTENSION_NAME, type GoalPauseReason, type GoalSnapshot, type GoalStatus } from "./types.js";
5
5
 
6
6
  const LEGACY_GOAL_ENTRY_TYPE = "pi-codex-goal";
7
7
  const MAX_OBJECTIVE_CHARS = 8000;
@@ -17,6 +17,7 @@ interface GoalLike {
17
17
  objective: string;
18
18
  command?: unknown;
19
19
  status: GoalStatus;
20
+ pauseReason?: unknown;
20
21
  tokenBudget?: number | null;
21
22
  usage?: { tokensUsed?: number; activeSeconds?: number };
22
23
  createdAt?: number;
@@ -64,6 +65,10 @@ function isGoalStatus(value: unknown): value is GoalStatus {
64
65
  return value === "active" || value === "paused" || value === "budgetLimited" || value === "complete";
65
66
  }
66
67
 
68
+ function isPauseReason(value: unknown): value is GoalPauseReason {
69
+ return value === "interrupt";
70
+ }
71
+
67
72
  function isGoalLike(value: unknown): value is GoalLike {
68
73
  if (!value || typeof value !== "object") {
69
74
  return false;
@@ -119,6 +124,7 @@ function normalizeGoal(goal: GoalLike): GoalSnapshot {
119
124
  objective: goal.objective,
120
125
  ...(isCommandBinding(goal.command) ? { command: goal.command } : {}),
121
126
  status: goal.status,
127
+ ...(goal.status === "paused" && isPauseReason(goal.pauseReason) ? { pauseReason: goal.pauseReason } : {}),
122
128
  tokenBudget: typeof goal.tokenBudget === "number" ? goal.tokenBudget : null,
123
129
  usage: {
124
130
  tokensUsed: typeof goal.usage?.tokensUsed === "number" ? goal.usage.tokensUsed : 0,
@@ -209,7 +215,10 @@ export function goalWithStatus(
209
215
  goal: GoalSnapshot,
210
216
  status: GoalStatus,
211
217
  now = unixSeconds(),
218
+ pauseReason?: GoalPauseReason,
212
219
  ): GoalSnapshot {
220
+ const { pauseReason: _previousReason, ...rest } = goal;
221
+ goal = rest;
213
222
  const accruedActiveSeconds =
214
223
  goal.status === "active" && goal.activeStartedAt !== null
215
224
  ? Math.max(0, now - goal.activeStartedAt)
@@ -233,6 +242,7 @@ export function goalWithStatus(
233
242
  return {
234
243
  ...goal,
235
244
  status,
245
+ ...(status === "paused" && pauseReason ? { pauseReason } : {}),
236
246
  usage,
237
247
  updatedAt: now,
238
248
  activeStartedAt: null,
package/src/index.ts CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  EXTENSION_VERSION,
46
46
  type ActivitySnapshot,
47
47
  type BackgroundActivityProvider,
48
+ type BackgroundWorkItem,
48
49
  type GoalSnapshot,
49
50
  } from "./types.js";
50
51
 
@@ -63,6 +64,14 @@ const MAX_NO_PROGRESS_RETRIES = parseRetryLimit(
63
64
  DEFAULT_MAX_NO_PROGRESS_RETRIES,
64
65
  );
65
66
 
67
+ /**
68
+ * Tools that block the foreground turn until the user answers. Background
69
+ * completions that arrive meanwhile queue behind the answer (Pi only drains
70
+ * steering after the whole tool batch, and callback batches are follow-ups
71
+ * that wait for the entire run), so the goal harvests them explicitly.
72
+ */
73
+ const BLOCKING_QUESTION_TOOLS: ReadonlySet<string> = new Set(["ask_user_question"]);
74
+
66
75
  const GOAL_ACTIONS: readonly AutocompleteItem[] = [
67
76
  { value: "pause", label: "pause", description: "Pause the active goal" },
68
77
  { value: "resume", label: "resume", description: "Resume the paused goal" },
@@ -112,6 +121,46 @@ function wasTurnAborted(messages: readonly unknown[]): boolean {
112
121
  return false;
113
122
  }
114
123
 
124
+ function activeItemsByKey(snapshot: ActivitySnapshot): Map<string, BackgroundWorkItem> {
125
+ const items = new Map<string, BackgroundWorkItem>();
126
+ for (const provider of snapshot.providers) {
127
+ for (const item of provider.items) {
128
+ if (item.active) items.set(`${provider.providerId}:${item.id}`, item);
129
+ }
130
+ }
131
+ return items;
132
+ }
133
+
134
+ /** Items that were active when a question started and are no longer active now. */
135
+ export function finishedSinceQuestion(
136
+ activeAtStart: ReadonlyMap<string, BackgroundWorkItem>,
137
+ snapshot: ActivitySnapshot,
138
+ ): BackgroundWorkItem[] {
139
+ const stillActive = activeItemsByKey(snapshot);
140
+ const current = new Map<string, BackgroundWorkItem>();
141
+ for (const provider of snapshot.providers) {
142
+ for (const item of provider.items) current.set(`${provider.providerId}:${item.id}`, item);
143
+ }
144
+ const finished: BackgroundWorkItem[] = [];
145
+ for (const [key, started] of activeAtStart) {
146
+ if (stillActive.has(key)) continue;
147
+ finished.push(current.get(key) ?? { ...started, active: false, status: "unknown" });
148
+ }
149
+ return finished;
150
+ }
151
+
152
+ function questionHarvestPrompt(finished: readonly BackgroundWorkItem[]): string {
153
+ const rows = finished.map((item) => {
154
+ const label = item.label ? `${item.label} (${item.id})` : item.id;
155
+ return `- ${label}: ${item.status}`;
156
+ });
157
+ return [
158
+ `${finished.length} background item${finished.length === 1 ? "" : "s"} finished while you were waiting on the user's answer:`,
159
+ ...rows,
160
+ "Inspect and integrate these results now (for example with subagent_result or bg_task_status) before acting on the answer; their completion notices may still arrive later as a batch.",
161
+ ].join("\n");
162
+ }
163
+
115
164
  function continuationPrompt(goal: GoalSnapshot, owner: ReturnType<typeof currentWorkflowOwner> = null): string {
116
165
  return [
117
166
  "Continue working toward the active thread goal.",
@@ -188,12 +237,15 @@ export default function (pi: ExtensionAPI): void {
188
237
  let idleContinuationSignature = "";
189
238
  let refreshGoalWidget: ((force?: boolean) => void) | undefined;
190
239
  let lastAgentEvidence: ContinuationEvidence | null = null;
240
+ /** Background items active when each pending blocking question started, keyed by tool call id. */
241
+ const pendingQuestions = new Map<string, Map<string, BackgroundWorkItem>>();
191
242
 
192
243
  const getGoal = (ctx: ExtensionContext): GoalSnapshot | null => currentGoalSnapshot(ctx);
193
244
  const getWorkflow = (ctx: ExtensionContext) => currentWorkflowOwner(ctx.sessionManager.getBranch());
245
+ const registeredSkillPath = (name: string): string | undefined =>
246
+ pi.getCommands?.().find((item) => item.name === `skill:${name}` && item.source === "skill")?.sourceInfo.path;
194
247
  const workflowAvailable = (owner: NonNullable<ReturnType<typeof currentWorkflowOwner>>): boolean => {
195
- const command = pi.getCommands?.().find((item) => item.name === `skill:${owner.name}` && item.source === "skill");
196
- if (command?.sourceInfo.path !== owner.path) return false;
248
+ if (registeredSkillPath(owner.name) !== owner.path) return false;
197
249
  try {
198
250
  return workflowOwnerFromSkill(owner.name, owner.path)?.planOwner === owner.planOwner;
199
251
  } catch {
@@ -268,13 +320,30 @@ export default function (pi: ExtensionAPI): void {
268
320
  refreshGoalWidget?.(wasVisible);
269
321
  };
270
322
 
323
+ /**
324
+ * An interrupt (escape, or anything else that aborts the running turn) is a
325
+ * soft pause: it stops autonomous continuation now, and the user's next
326
+ * conversational message resumes the goal once that exchange settles. Only
327
+ * `/goal pause` (or an unavailable command/workflow) is a sticky pause.
328
+ */
271
329
  const pauseGoalOnInterrupt = (ctx: ExtensionContext): void => {
272
330
  const goal = getGoal(ctx);
273
331
  if (!isPokeable(goal)) {
274
332
  return;
275
333
  }
276
- setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
277
- notifyGoal(ctx, "Goal paused (interrupted).");
334
+ setGoal(goalWithStatus(goal, "paused", undefined, "interrupt"), ctx, "runtime");
335
+ notifyGoal(ctx, "Goal paused (interrupted). Send a message to resume it after that exchange, or use /goal pause to keep it paused.");
336
+ };
337
+
338
+ /** Why a paused goal cannot become active again, or null when it can. */
339
+ const resumeBlocker = (goal: GoalSnapshot): string | null => {
340
+ if (!goal.command && skillCommandName(goal.objective)) {
341
+ return "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.";
342
+ }
343
+ if (goal.command && !commandAvailable(pi, goal.command)) {
344
+ return `Cannot resume: /${goal.command.name} is no longer registered at its original source.`;
345
+ }
346
+ return null;
278
347
  };
279
348
 
280
349
  const boundCommandReady = (goal: GoalSnapshot, ctx: ExtensionContext): boolean => {
@@ -392,7 +461,7 @@ export default function (pi: ExtensionAPI): void {
392
461
  }
393
462
  const goal = createGoalSnapshot(objective.trim(), tokenBudget, undefined, command ?? undefined);
394
463
  const owner = command?.source === "skill"
395
- ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path)
464
+ ? workflowOwnerFromSkill(command.name.slice("skill:".length), command.path, registeredSkillPath)
396
465
  : null;
397
466
  if (getWorkflow(ctx)) recordWorkflow(null);
398
467
  setGoal(goal, ctx, source);
@@ -441,7 +510,13 @@ export default function (pi: ExtensionAPI): void {
441
510
  foregroundRunning,
442
511
  backgroundRunning: latestSnapshot?.backgroundRunning ?? false,
443
512
  });
444
- const status = snapshot.backgroundRunning
513
+ let waitingOnAnswer = 0;
514
+ for (const activeAtStart of pendingQuestions.values()) {
515
+ waitingOnAnswer += finishedSinceQuestion(activeAtStart, snapshot).length;
516
+ }
517
+ const status = waitingOnAnswer > 0
518
+ ? `${waitingOnAnswer} background done; waiting on your answer`
519
+ : snapshot.backgroundRunning
445
520
  ? `bg ${snapshot.activeBackgroundCount}${snapshot.unhealthyBackgroundCount ? `, ${snapshot.unhealthyBackgroundCount} unhealthy` : ""}`
446
521
  : continuation?.blocked
447
522
  ? "waiting: no progress"
@@ -604,12 +679,9 @@ export default function (pi: ExtensionAPI): void {
604
679
  notifyGoal(ctx, "Only paused goals can be resumed.", "warning");
605
680
  return;
606
681
  }
607
- if (!current.command && skillCommandName(current.objective)) {
608
- notifyGoal(ctx, "Invoke the skill directly; this legacy slash-command goal cannot resume as plain text.", "error");
609
- return;
610
- }
611
- if (current.command && !commandAvailable(pi, current.command)) {
612
- notifyGoal(ctx, `Cannot resume: /${current.command.name} is no longer registered at its original source.`, "error");
682
+ const blocker = resumeBlocker(current);
683
+ if (blocker) {
684
+ notifyGoal(ctx, blocker, "error");
613
685
  return;
614
686
  }
615
687
  const goal = goalWithStatus(current, "active");
@@ -794,7 +866,7 @@ export default function (pi: ExtensionAPI): void {
794
866
  const command = pi.getCommands?.().find((item) => item.name === `skill:${skillName}` && item.source === "skill");
795
867
  if (command) {
796
868
  try {
797
- const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path);
869
+ const owner = workflowOwnerFromSkill(skillName, command.sourceInfo.path, registeredSkillPath);
798
870
  if (owner) recordWorkflow(owner);
799
871
  } catch (error) {
800
872
  notifyGoal(ctx, error instanceof Error ? error.message : String(error), "error");
@@ -803,6 +875,22 @@ export default function (pi: ExtensionAPI): void {
803
875
  }
804
876
  }
805
877
  const goal = getGoal(ctx);
878
+ if (goal?.status === "paused" && goal.pauseReason === "interrupt") {
879
+ // Conversational input after an interrupt means the user has taken the
880
+ // wheel, not stopped the goal: reactivate it so continuation resumes once
881
+ // this exchange settles. Pi's built-in commands (/settings, /model, ...)
882
+ // and extension commands never reach this handler.
883
+ const blocker = resumeBlocker(goal);
884
+ if (blocker) {
885
+ notifyGoal(ctx, `Goal stays paused. ${blocker}`, "warning");
886
+ return;
887
+ }
888
+ const resumed = goalWithStatus(goal, "active");
889
+ setGoal(resumed, ctx, "runtime");
890
+ resetContinuationState(resumed);
891
+ notifyGoal(ctx, "Goal resumed; it continues after this exchange.");
892
+ return;
893
+ }
806
894
  if (goal?.status === "active") {
807
895
  resetContinuationState(goal);
808
896
  }
@@ -817,6 +905,10 @@ export default function (pi: ExtensionAPI): void {
817
905
  return;
818
906
  }
819
907
 
908
+ const questionInstruction = snapshot.backgroundRunning
909
+ ? " A blocking user question (ask_user_question) holds this whole turn until the user answers, and background completions wait behind it. Harvest finished background results before asking, and ask only when the answer is needed to proceed."
910
+ : "";
911
+
820
912
  if (owner) {
821
913
  if (!workflowAvailable(owner)) {
822
914
  if (isPokeable(goal)) setGoal(goalWithStatus(goal, "paused"), ctx, "runtime");
@@ -832,7 +924,8 @@ export default function (pi: ExtensionAPI): void {
832
924
  }
833
925
  return {
834
926
  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}` +
835
- (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : ""),
927
+ (isPokeable(goal) ? `\n\nActive objective: ${goal.objective}. Complete it only after the workflow completion audit.` : "") +
928
+ (questionInstruction ? `\n\n${questionInstruction.trim()}` : ""),
836
929
  };
837
930
  }
838
931
 
@@ -843,7 +936,7 @@ export default function (pi: ExtensionAPI): void {
843
936
  return {
844
937
  systemPrompt:
845
938
  `${event.systemPrompt}\n\n` +
846
- `Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}`,
939
+ `Pi Better Goal active objective: ${goal.objective}. Keep working through clear low-risk next steps, and mark complete only after an evidence-backed completion audit.${backgroundInstruction}${questionInstruction}`,
847
940
  };
848
941
  });
849
942
 
@@ -875,6 +968,32 @@ export default function (pi: ExtensionAPI): void {
875
968
  }
876
969
  });
877
970
 
971
+ pi.on("tool_execution_start", async (event, ctx) => {
972
+ if (!BLOCKING_QUESTION_TOOLS.has(event.toolName)) return;
973
+ currentCtx = ctx;
974
+ const snapshot = await publishSnapshot(ctx);
975
+ const active = activeItemsByKey(snapshot);
976
+ if (active.size > 0) pendingQuestions.set(event.toolCallId, active);
977
+ });
978
+
979
+ pi.on("tool_execution_end", async (event, ctx) => {
980
+ const activeAtStart = pendingQuestions.get(event.toolCallId);
981
+ if (!activeAtStart) return;
982
+ pendingQuestions.delete(event.toolCallId);
983
+ currentCtx = ctx;
984
+ const snapshot = await publishSnapshot(ctx);
985
+ const finished = finishedSinceQuestion(activeAtStart, snapshot);
986
+ if (finished.length === 0) return;
987
+ // Steering is drained right after this tool batch, so the model sees the
988
+ // finished work together with the user's answer instead of after the run.
989
+ pi.sendMessage({
990
+ customType: EXTENSION_NAME,
991
+ content: questionHarvestPrompt(finished),
992
+ display: false,
993
+ details: { kind: "question-harvest", toolCallId: event.toolCallId, finished: finished.map((item) => ({ id: item.id, status: item.status })) },
994
+ }, { deliverAs: "steer", triggerTurn: true });
995
+ });
996
+
878
997
  pi.on("agent_settled", async (_event, ctx) => {
879
998
  currentCtx = ctx;
880
999
  foregroundRunning = false;
@@ -917,6 +1036,7 @@ export default function (pi: ExtensionAPI): void {
917
1036
  pi.on("session_shutdown", async (_event, ctx) => {
918
1037
  stopPolling();
919
1038
  foregroundRunning = false;
1039
+ pendingQuestions.clear();
920
1040
  clearIdleContinuation();
921
1041
  currentCtx = undefined;
922
1042
  latestSnapshot = null;
package/src/types.ts CHANGED
@@ -65,6 +65,14 @@ export interface ActivitySnapshot {
65
65
 
66
66
  export type GoalStatus = "active" | "paused" | "budgetLimited" | "complete";
67
67
 
68
+ /**
69
+ * Why a paused goal is paused. `interrupt` marks a soft pause from escape (or
70
+ * any other abort of the running turn): the user's next conversational message
71
+ * resumes it. A paused goal without a reason (`/goal pause`, an unavailable
72
+ * command or workflow) stays paused until `/goal resume`.
73
+ */
74
+ export type GoalPauseReason = "interrupt";
75
+
68
76
  export interface GoalUsage {
69
77
  tokensUsed: number;
70
78
  activeSeconds: number;
@@ -75,6 +83,8 @@ export interface GoalSnapshot {
75
83
  objective: string;
76
84
  command?: GoalCommandBinding;
77
85
  status: GoalStatus;
86
+ /** Present only while `status` is "paused". */
87
+ pauseReason?: GoalPauseReason;
78
88
  tokenBudget: number | null;
79
89
  usage: GoalUsage;
80
90
  createdAt: number;
package/src/workflow.ts CHANGED
@@ -16,7 +16,12 @@ interface SessionEntryLike {
16
16
  data?: unknown;
17
17
  }
18
18
 
19
- export function workflowOwnerFromSkill(name: string, path: string): WorkflowOwner | null {
19
+ const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
20
+
21
+ /** Resolves a registered skill's name to its SKILL.md path, or undefined when it is not registered. */
22
+ export type SkillPathResolver = (name: string) => string | undefined;
23
+
24
+ function skillMetadata(path: string): Record<string, unknown> | null {
20
25
  const source = readFileSync(path, "utf8");
21
26
  const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(source);
22
27
  if (!match) return null;
@@ -24,9 +29,34 @@ export function workflowOwnerFromSkill(name: string, path: string): WorkflowOwne
24
29
  if (!frontmatter || typeof frontmatter !== "object") return null;
25
30
  const metadata = (frontmatter as { metadata?: unknown }).metadata;
26
31
  if (!metadata || typeof metadata !== "object") return null;
27
- const fields = metadata as Record<string, unknown>;
32
+ return metadata as Record<string, unknown>;
33
+ }
34
+
35
+ /**
36
+ * Reads a skill's workflow declaration. A coordinator declares `workflow-role: coordinator`.
37
+ * An alias declares `workflow-alias-of: <skill>` and resolves, through the registered skill
38
+ * commands, to that coordinator: the recorded owner is the target (its name and path), so
39
+ * invoking the alias is indistinguishable from invoking the coordinator. Aliases do not chain.
40
+ */
41
+ export function workflowOwnerFromSkill(name: string, path: string, resolveSkillPath?: SkillPathResolver): WorkflowOwner | null {
42
+ const fields = skillMetadata(path);
43
+ if (!fields) return null;
28
44
  const role = fields["workflow-role"];
29
45
  const legacyRole = fields["pi-better-plan-workflow"];
46
+ const aliasOf = fields["workflow-alias-of"];
47
+ if (aliasOf !== undefined) {
48
+ if (typeof aliasOf !== "string" || !SKILL_NAME.test(aliasOf) || aliasOf === name ||
49
+ role !== undefined || legacyRole !== undefined) {
50
+ throw new Error(`Invalid workflow metadata in ${path}. Expected workflow-alias-of: <coordinator skill name> without workflow-role.`);
51
+ }
52
+ const targetPath = resolveSkillPath?.(aliasOf);
53
+ if (!targetPath) throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is not a registered skill.`);
54
+ const target = workflowOwnerFromSkill(aliasOf, targetPath, () => {
55
+ throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is itself an alias; aliases do not chain.`);
56
+ });
57
+ if (!target) throw new Error(`Workflow alias ${name} targets ${aliasOf}, which is not a workflow coordinator.`);
58
+ return target;
59
+ }
30
60
  if (role === undefined && legacyRole === undefined) {
31
61
  if (fields["pi-better-workflow-role"] !== undefined || fields["pi-better-plan-owner"] !== undefined) {
32
62
  throw new Error(`Outdated workflow metadata in ${path}. Use workflow-role: coordinator.`);