pi-workflow-engine 1.1.0 → 1.2.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.
Files changed (52) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -78
  2. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  3. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  4. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  5. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  6. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  7. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  8. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  9. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  10. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  11. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +114 -31
  12. package/.pi/extensions/pi-workflow-engine/src/engine.ts +74 -18
  13. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  14. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  15. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  16. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  17. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  18. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  19. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  20. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  21. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  22. package/.pi/extensions/pi-workflow-engine/src/types.ts +44 -9
  23. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  24. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  25. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  26. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  29. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  31. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  44. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +1 -0
  45. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +1 -0
  46. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +1 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +1 -0
  48. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  49. package/README.md +11 -3
  50. package/USAGE.md +121 -22
  51. package/package.json +13 -4
  52. package/skills/workflow-authoring/SKILL.md +160 -0
@@ -3,7 +3,7 @@ import { truncateToWidth, visibleWidth, type Component } from "@earendil-works/p
3
3
  import type { AgentRowSnapshot, PhaseSnapshot, WorkflowProgressSnapshot } from "../progress-types.ts";
4
4
  import { formatCount } from "../text.ts";
5
5
  import { compactUsage, dot, fit, GLYPH, joinParts, keyHints, progressBar, spread, type ThemeColor } from "./kit.ts";
6
- import { countAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
6
+ import { countAgents, finishedAgents, formatDuration, formatElapsed, type WorkflowStatusCounts } from "./workflow-format.ts";
7
7
 
8
8
  /** pi caps string widgets at this many rows; the component keeps to the same budget. */
9
9
  const MAX_WIDGET_LINES = 10;
@@ -64,7 +64,7 @@ export class WidthAwareWidget implements Component {
64
64
  /** `◆ background · code-review 56b57550 · Find 6/10 · 12s`: one line for a run the user sent to the background. */
65
65
  export function renderBackgroundWorkflowLine(snapshot: WorkflowProgressSnapshot, width: number, theme: Theme): string[] {
66
66
  const counts = countAgents(snapshot.phases);
67
- const progress = counts.total > 0 ? `${snapshot.currentPhase} ${counts.done + counts.failed}/${counts.total}` : snapshot.currentPhase;
67
+ const progress = counts.total > 0 ? `${snapshot.currentPhase} ${finishedAgents(counts)}/${counts.total}` : snapshot.currentPhase;
68
68
  const line = joinParts([
69
69
  `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("muted", "background")}`,
70
70
  `${theme.bold(snapshot.title)} ${theme.fg("dim", snapshot.runId.slice(0, 8))}`,
@@ -92,7 +92,7 @@ function classifyPhases(snapshot: WorkflowProgressSnapshot): ClassifiedPhases {
92
92
 
93
93
  function headerLine(snapshot: WorkflowProgressSnapshot, counts: WorkflowStatusCounts, width: number, now: number, theme: Theme): string {
94
94
  const name = `${theme.fg("accent", GLYPH.workflow)} ${theme.bold(snapshot.title)}`;
95
- const finished = counts.done + counts.failed;
95
+ const finished = finishedAgents(counts);
96
96
  const stats = joinParts([
97
97
  counts.total > 0 ? theme.fg("text", `${finished}/${counts.total}`) : undefined,
98
98
  theme.fg("muted", formatElapsed(now - snapshot.startedAt)),
@@ -156,8 +156,8 @@ function phaseBody(
156
156
 
157
157
  /**
158
158
  * Spread the row budget over active phases by importance — a reserved share for failures so they always
159
- * show, then running, remaining failures, done, queued — while each phase lists its chosen rows as
160
- * running, failed, done, queued.
159
+ * show, then running, remaining failures, skipped, done, queued — while each phase lists its chosen rows
160
+ * in that same status order.
161
161
  */
162
162
  function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<PhaseSnapshot, AgentRowPlan> {
163
163
  const byStatus = (status: AgentRowSnapshot["status"]) =>
@@ -177,10 +177,11 @@ function planAgentRows(active: readonly PhaseSnapshot[], budget: number): Map<Ph
177
177
  take(failed, failed.length > 0 ? Math.max(1, Math.floor(budget / 3)) : 0);
178
178
  take(byStatus("running"));
179
179
  take(failed);
180
+ take(byStatus("skipped"));
180
181
  take(byStatus("done"));
181
182
  take(byStatus("queued"));
182
183
 
183
- const rank = { running: 0, failed: 1, done: 2, queued: 3 } as const;
184
+ const rank = { running: 0, failed: 1, skipped: 2, done: 3, queued: 4 } as const;
184
185
  const plans = new Map<PhaseSnapshot, AgentRowPlan>();
185
186
  for (const phase of active) {
186
187
  const shown = phase.agents.filter((agent) => chosen.has(agent)).sort((a, b) => rank[a.status] - rank[b.status]);
@@ -196,7 +197,7 @@ function agentLabelWidth(plans: ReadonlyMap<PhaseSnapshot, AgentRowPlan>, width:
196
197
 
197
198
  function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Theme): string {
198
199
  const counts = countAgents([phase]);
199
- const glyph = counts.failed === 0
200
+ const glyph = counts.failed === 0 && counts.skipped === 0
200
201
  ? theme.fg("success", GLYPH.done)
201
202
  : counts.failed === counts.total ? theme.fg("error", GLYPH.failed) : theme.fg("warning", GLYPH.warning);
202
203
  const span = phaseSpan(phase.agents);
@@ -204,17 +205,19 @@ function finishedPhaseLine(phase: PhaseSnapshot, titleWidth: number, theme: Them
204
205
  counts.total > 0 ? theme.fg("muted", plural(counts.total, "agent")) : undefined,
205
206
  span === undefined ? undefined : theme.fg("muted", shortDuration(span)),
206
207
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
208
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
207
209
  ], theme);
208
210
  return ` ${glyph} ${theme.fg("muted", fit(phase.title, titleWidth))} ${detail}`.trimEnd();
209
211
  }
210
212
 
211
213
  function foldedPhasesLine(phases: readonly PhaseSnapshot[], theme: Theme): string {
212
214
  const counts = countAgents(phases);
213
- const glyph = counts.failed > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
215
+ const glyph = counts.failed > 0 || counts.skipped > 0 ? theme.fg("warning", GLYPH.warning) : theme.fg("success", GLYPH.done);
214
216
  const titles = phases.map((phase) => phase.title).join(" · ");
215
217
  const detail = joinParts([
216
218
  theme.fg("muted", plural(counts.total, "agent")),
217
219
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
220
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
218
221
  ], theme);
219
222
  return ` ${glyph} ${theme.fg("muted", titles)} ${detail}`;
220
223
  }
@@ -226,6 +229,7 @@ function activePhaseLine(plan: AgentRowPlan, titleWidth: number, theme: Theme):
226
229
  counts.queued > 0 ? theme.fg("muted", `${counts.queued} queued`) : undefined,
227
230
  counts.done > 0 ? theme.fg("muted", `${counts.done} done`) : undefined,
228
231
  counts.failed > 0 ? theme.fg("error", `${counts.failed} failed`) : undefined,
232
+ counts.skipped > 0 ? theme.fg("warning", `${counts.skipped} skipped`) : undefined,
229
233
  plan.hidden > 0 ? theme.fg("dim", `+${plan.hidden} more`) : undefined,
230
234
  ], theme);
231
235
  return ` ${theme.fg("accent", GLYPH.running)} ${theme.bold(fit(plan.phase.title, titleWidth))} ${detail}`.trimEnd();
@@ -246,6 +250,8 @@ function agentGlyph(agent: AgentRowSnapshot, theme: Theme): string {
246
250
  return theme.fg("error", GLYPH.failed);
247
251
  case "done":
248
252
  return theme.fg("success", GLYPH.done);
253
+ case "skipped":
254
+ return theme.fg("warning", GLYPH.skipped);
249
255
  case "queued":
250
256
  return theme.fg("dim", GLYPH.queued);
251
257
  }
@@ -264,6 +270,8 @@ function agentDetail(agent: AgentRowSnapshot, now: number, theme: Theme): string
264
270
  ], theme);
265
271
  case "failed":
266
272
  return theme.fg("error", oneLine(agent.error ?? "failed"));
273
+ case "skipped":
274
+ return theme.fg("warning", "skipped");
267
275
  case "queued":
268
276
  return theme.fg("dim", "queued");
269
277
  }
@@ -20,7 +20,9 @@ export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
20
20
 
21
21
  export interface LensVerificationPipelineResult {
22
22
  verified: AdvisoryVerified[];
23
+ /** Every candidate the finders returned, including those over the per-lens cap. */
23
24
  rawCandidates: number;
25
+ /** Candidates never verified: over the per-lens cap, duplicates, or out of scope. */
24
26
  dropped: number;
25
27
  coverage: AdvisoryStageCoverage[];
26
28
  }
@@ -73,6 +75,7 @@ export async function runLensVerificationPipeline(
73
75
  const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
74
76
  const coverage: AdvisoryStageCoverage[] = [];
75
77
  let rawCandidates = 0;
78
+ let overCap = 0;
76
79
  api.phase(finderPhase);
77
80
  const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
78
81
  const result = await api.agent(finderPrompt(lens), {
@@ -80,9 +83,16 @@ export async function runLensVerificationPipeline(
80
83
  tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
81
84
  profile: "small", schema: AdvisoryCandidatesSchema,
82
85
  });
86
+ rawCandidates += result.candidates.length;
87
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: result.candidates.length });
88
+ // A finder can return more than the cap its prompt states; keep the first perLens and report the rest.
89
+ const cut = Math.max(0, result.candidates.length - perLens);
90
+ if (cut > 0) {
91
+ overCap += cut;
92
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: cut });
93
+ api.log(`Dropped ${cut} ${lens.label} candidate(s) over the per-lens cap of ${perLens}`);
94
+ }
83
95
  const raw = identifyCandidates(result.candidates.slice(0, perLens), lens.label);
84
- rawCandidates += raw.length;
85
- api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: raw.length });
86
96
  const candidates = boundCandidate ? raw.flatMap((candidate) => {
87
97
  const bounded = boundCandidate(candidate, lens);
88
98
  return bounded ? [bounded] : [];
@@ -94,11 +104,12 @@ export async function runLensVerificationPipeline(
94
104
  return candidates;
95
105
  } })), coverage);
96
106
  const candidates = await mergeEquivalentCandidates(api, dedupeCandidates(found.flat()));
97
- const dropped = rawCandidates - candidates.length;
98
- if (dropped > 0) {
99
- api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: dropped });
100
- api.log(`Dropped ${dropped} duplicate or out-of-scope candidate(s)`);
107
+ const duplicateOrOutOfScope = rawCandidates - overCap - candidates.length;
108
+ if (duplicateOrOutOfScope > 0) {
109
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: duplicateOrOutOfScope });
110
+ api.log(`Dropped ${duplicateOrOutOfScope} duplicate or out-of-scope candidate(s)`);
101
111
  }
112
+ const dropped = overCap + duplicateOrOutOfScope;
102
113
  api.phase("Verify");
103
114
  const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
104
115
  id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
@@ -0,0 +1,79 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sessionKey } from "./session-identity.ts";
3
+ import type { WorkflowMeta } from "./types.ts";
4
+ import { unknownErrorMessage } from "./unknown-error.ts";
5
+ import { DEFAULT_WORKFLOW_SIZE_GUIDELINE, workflowSizeGuidelineLine, type WorkflowSizeGuideline } from "./workflow-settings.ts";
6
+
7
+ /** System prompt section pi wraps as `<workflows>`: the registered workflows the host may run by name. */
8
+ export const WORKFLOWS_SECTION = "workflows";
9
+
10
+ /**
11
+ * One line per workflow, sorted by name, then the size guideline, so the section text depends only on
12
+ * what is registered and configured.
13
+ */
14
+ export function formatWorkflowCatalog(workflows: Iterable<WorkflowMeta>, sizeLine?: string): string | undefined {
15
+ const lines = [...workflows]
16
+ // Code-unit order rather than localeCompare, whose result depends on the host locale.
17
+ .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))
18
+ .map((meta) => {
19
+ const description = meta.description.trim();
20
+ const whenToUse = meta.whenToUse?.trim();
21
+ return `- ${meta.name}${description ? `: ${description}` : ""}${whenToUse ? ` ${whenToUse}` : ""}`;
22
+ });
23
+ const blocks: string[] = [];
24
+ if (lines.length > 0) {
25
+ blocks.push(["Registered workflows the workflow tool runs by `name`, passing the target or instructions as `args` (only once the user opted into workflow orchestration):", ...lines].join("\n"));
26
+ }
27
+ if (sizeLine) blocks.push(sizeLine);
28
+ return blocks.length > 0 ? blocks.join("\n\n") : undefined;
29
+ }
30
+
31
+ export interface WorkflowCatalogSources {
32
+ readonly discover: (ctx: ExtensionContext) => Promise<Iterable<WorkflowMeta>>;
33
+ readonly size: (ctx: ExtensionContext) => WorkflowSizeGuideline;
34
+ readonly log?: (message: string) => void;
35
+ }
36
+
37
+ /**
38
+ * The catalog section for each session, built once from discovery and settings and then reused, so every
39
+ * run in the session sends identical text and keeps the provider's prompt cache. A new session start or a
40
+ * size change rebuilds it.
41
+ */
42
+ export class WorkflowCatalogPrompt {
43
+ private readonly sessions = new Map<string, Promise<string | undefined>>();
44
+ private readonly log: (message: string) => void;
45
+
46
+ constructor(private readonly sources: WorkflowCatalogSources) {
47
+ this.log = sources.log ?? ((message) => process.stderr.write(`${message}\n`));
48
+ }
49
+
50
+ section(ctx: ExtensionContext): Promise<string | undefined> {
51
+ const key = sessionKey(ctx);
52
+ const cached = this.sessions.get(key);
53
+ if (cached) return cached;
54
+ const built = this.build(ctx);
55
+ this.sessions.set(key, built);
56
+ return built;
57
+ }
58
+
59
+ forget(ctx: Pick<ExtensionContext, "sessionManager">): void {
60
+ this.sessions.delete(sessionKey(ctx));
61
+ }
62
+
63
+ // The catalog only advertises workflows and a guideline; neither failure may block the prompt.
64
+ private async build(ctx: ExtensionContext): Promise<string | undefined> {
65
+ let size = DEFAULT_WORKFLOW_SIZE_GUIDELINE;
66
+ try {
67
+ size = this.sources.size(ctx);
68
+ } catch (error) {
69
+ this.log(`[workflow] workflow size guideline unavailable, using ${size}: ${unknownErrorMessage(error)}`);
70
+ }
71
+ let workflows: Iterable<WorkflowMeta> = [];
72
+ try {
73
+ workflows = await this.sources.discover(ctx);
74
+ } catch (error) {
75
+ this.log(`[workflow] workflow catalog unavailable: ${unknownErrorMessage(error)}`);
76
+ }
77
+ return formatWorkflowCatalog(workflows, workflowSizeGuidelineLine(size));
78
+ }
79
+ }
@@ -25,6 +25,8 @@ export interface WorkflowResultEnvelope {
25
25
  readonly perf?: WorkflowPerfDetails;
26
26
  readonly runId?: string;
27
27
  readonly resumedFromRunId?: string;
28
+ /** Whether an inline run's script was persisted beside its journal. */
29
+ readonly scriptPersisted?: boolean;
28
30
  }
29
31
 
30
32
  /** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
@@ -36,7 +38,7 @@ export function workflowResultSummary(result: unknown): string | undefined {
36
38
  export type ResolvedWorkflowRunner = (
37
39
  ctx: ExtensionContext,
38
40
  mod: LoadedWorkflow,
39
- args: string,
41
+ args: unknown,
40
42
  options: ResolvedWorkflowRunOptions,
41
43
  ) => Promise<unknown>;
42
44
 
@@ -44,7 +46,8 @@ export interface WorkflowExecutionInput {
44
46
  readonly ctx: ExtensionContext;
45
47
  readonly name: string;
46
48
  readonly mod: LoadedWorkflow;
47
- readonly args: string;
49
+ /** A slash command's argument string, or a tool call's string or JSON value. */
50
+ readonly args: unknown;
48
51
  readonly options: ResolvedWorkflowRunOptions;
49
52
  readonly perfRecorder?: PerfSink;
50
53
  readonly runResolvedWorkflow: ResolvedWorkflowRunner;
@@ -99,6 +102,7 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
99
102
  perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
100
103
  runId: runMetadata?.runId,
101
104
  resumedFromRunId: runMetadata?.resumedFromRunId,
105
+ scriptPersisted: runMetadata?.scriptPath !== undefined,
102
106
  };
103
107
  }
104
108
 
@@ -0,0 +1,13 @@
1
+ // A workflow's input is the string a slash command passes, or any JSON value from a tool call or
2
+ // `api.workflow()`. Workflows read it verbatim as `api.input` and as text as `api.args`.
3
+
4
+ /** `api.args`: a string verbatim, any other JSON value as JSON text, and "" when nothing was passed. */
5
+ export function workflowArgsText(input: unknown): string {
6
+ if (typeof input === "string") return input;
7
+ return JSON.stringify(input) ?? "";
8
+ }
9
+
10
+ /** Whether a run had arguments. Run records never keep them, so such a run cannot be relaunched. */
11
+ export function workflowInputPresent(input: unknown): boolean {
12
+ return input !== undefined && input !== "";
13
+ }
@@ -2,10 +2,14 @@ import type {
2
2
  LoadedWorkflow,
3
3
  WorkflowMeta,
4
4
  WorkflowModule,
5
+ WorkflowPhaseMeta,
5
6
  WorkflowSourceIdentity,
6
7
  } from "./types.ts";
7
8
  import type { WorktreeBaseline } from "./worktree.ts";
8
9
  import { isRecord } from "./guards.ts";
10
+ import { isWorkflowModelProfileName } from "./model-profiles.ts";
11
+
12
+ const PHASES_SHAPE = 'meta.phases must be an array of { title: string, profile?: "small" | "medium" | "big" }';
9
13
 
10
14
  /** Validate and normalize workflow metadata without executing workflow code. */
11
15
  export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
@@ -15,10 +19,14 @@ export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { rea
15
19
  const description = meta.description;
16
20
  if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
17
21
 
18
- const phases = meta.phases;
19
- if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
22
+ const whenToUse = meta.whenToUse;
23
+ if (whenToUse !== undefined && typeof whenToUse !== "string") return { reason: "meta.whenToUse must be a string when provided" };
24
+
25
+ const phases = meta.phases === undefined ? undefined : parseWorkflowPhases(meta.phases);
26
+ if (phases === null) return { reason: PHASES_SHAPE };
20
27
 
21
28
  const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
29
+ if (whenToUse !== undefined) workflowMeta.whenToUse = whenToUse;
22
30
  if (phases !== undefined) workflowMeta.phases = phases;
23
31
  return { meta: workflowMeta };
24
32
  }
@@ -50,6 +58,15 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
50
58
  return typeof value === "function";
51
59
  }
52
60
 
53
- function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
54
- return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
61
+ /** The declared phases keeping only known fields, or null when any entry is malformed. */
62
+ function parseWorkflowPhases(value: unknown): WorkflowPhaseMeta[] | null {
63
+ if (!Array.isArray(value)) return null;
64
+ const phases: WorkflowPhaseMeta[] = [];
65
+ for (const phase of value) {
66
+ if (!isRecord(phase) || typeof phase.title !== "string") return null;
67
+ const { profile } = phase;
68
+ if (profile !== undefined && (typeof profile !== "string" || !isWorkflowModelProfileName(profile))) return null;
69
+ phases.push({ title: phase.title, ...(profile === undefined ? {} : { profile }) });
70
+ }
71
+ return phases;
55
72
  }
@@ -1,5 +1,8 @@
1
+ import { join } from "node:path";
1
2
  import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
2
3
  import { isRecord } from "./guards.ts";
4
+ import { WORKFLOW_RUNS_DIR } from "./journal.ts";
5
+ import { workflowRunScriptFile } from "./workflow-run-record.ts";
3
6
  import { toReviewIssues } from "./review/review-issues.ts";
4
7
  import { formatPerfSummary } from "./perf.ts";
5
8
  import { prettyJson } from "./text.ts";
@@ -15,19 +18,36 @@ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope)
15
18
  return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
16
19
  }
17
20
 
18
- /** Run id, usage and perf lines appended for the host model. */
21
+ /** Run id, run files, usage and perf lines appended for the host model. */
19
22
  function formatWorkflowDetailLines(envelope: WorkflowResultEnvelope): string[] {
20
- const run = envelope.runId
21
- ? envelope.resumedFromRunId ? `Run: ${envelope.runId} (resumed from ${envelope.resumedFromRunId})` : `Run: ${envelope.runId}`
22
- : undefined;
23
23
  return [
24
- run,
24
+ ...(envelope.runId
25
+ ? [formatWorkflowRunLine({ runId: envelope.runId, resumedFromRunId: envelope.resumedFromRunId }), ...formatWorkflowRunFileLines(envelope.runId, envelope.scriptPersisted === true)]
26
+ : []),
25
27
  formatWorkflowUsageLine(envelope.usage),
26
28
  envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
27
29
  ].filter((line): line is string => line !== undefined);
28
30
  }
29
31
 
30
- function formatResultForContext(result: unknown): string {
32
+ /** The run id, naming the run a resume continues. */
33
+ export function formatWorkflowRunLine(run: { readonly runId: string; readonly resumedFromRunId?: string }): string {
34
+ return run.resumedFromRunId ? `Run: ${run.runId} (resumed from ${run.resumedFromRunId})` : `Run: ${run.runId}`;
35
+ }
36
+
37
+ /**
38
+ * Where the host model can read what the run did, relative to the workflow cwd: the persisted script it
39
+ * can edit and rerun with `scriptPath`, and the replay journal of completed agent results.
40
+ */
41
+ export function formatWorkflowRunFileLines(runId: string, scriptPersisted: boolean): string[] {
42
+ return [
43
+ ...(scriptPersisted ? [`Script: ${join(WORKFLOW_RUNS_DIR, workflowRunScriptFile(runId))}`] : []),
44
+ `Journal: ${join(WORKFLOW_RUNS_DIR, `${runId}.jsonl`)}`,
45
+ "If the result is empty or unexpected, read the journal before diagnosing.",
46
+ ];
47
+ }
48
+
49
+ /** A workflow result as the host model reads it: advisory reports as findings, other values as bounded JSON. */
50
+ export function formatResultForContext(result: unknown): string {
31
51
  if (typeof result === "string") return result;
32
52
  if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
33
53
  const summary = workflowResultSummary(result);
@@ -23,6 +23,7 @@ export function createPersistedWorkflowBackground(
23
23
  origin: {
24
24
  sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
25
25
  requestedAt: origin.requestedAt,
26
+ ...(origin.wakeOnComplete ? { wakeOnComplete: true } : {}),
26
27
  },
27
28
  delivery: { state: "pending" },
28
29
  };
@@ -49,6 +50,7 @@ export function updateWorkflowRunDelivery(
49
50
  export function isPersistedWorkflowBackground(value: unknown): value is PersistedWorkflowBackground {
50
51
  if (!isRecord(value) || !isRecord(value.origin) || !isRecord(value.delivery)) return false;
51
52
  if (typeof value.origin.sessionId !== "string" || !isFiniteNumber(value.origin.requestedAt)) return false;
53
+ if (value.origin.wakeOnComplete !== undefined && typeof value.origin.wakeOnComplete !== "boolean") return false;
52
54
  switch (value.delivery.state) {
53
55
  case "pending":
54
56
  return true;
@@ -6,7 +6,7 @@ import type {
6
6
  import type { AutocompleteItem } from "@earendil-works/pi-tui";
7
7
  import { BackgroundWorkflowCoordinator } from "./background-workflows.ts";
8
8
  import { backgroundUnavailableResult, startBackgroundWorkflowTool } from "./background-workflow-tool.ts";
9
- import { validateWorkflowRunId } from "./journal.ts";
9
+ import { validateWorkflowRunId, WORKFLOW_RUNS_DIR } from "./journal.ts";
10
10
  import { resolveWorkflowRunOptions, type ResolvedWorkflowRunOptions } from "./options.ts";
11
11
  import type { LoadedWorkflow } from "./types.ts";
12
12
  import {
@@ -17,15 +17,19 @@ import {
17
17
  formatWorkflowRunSummary,
18
18
  isWorkflowRunLifecycleAction,
19
19
  parseWorkflowRunsCommand,
20
+ isInlineWorkflowRun,
20
21
  retainedWorkflowRunOutcome,
21
22
  supersedingRuns,
22
23
  WORKFLOW_RUN_ACTIONS,
23
24
  WORKFLOW_RUN_HISTORY_LIMIT,
25
+ workflowRunDisplayName,
24
26
  type WorkflowRunActionContext,
25
27
  type WorkflowRunLifecycleAction,
26
28
  } from "./workflow-run-history.ts";
27
- import { stopWorkflowRunRecord, type WorkflowRunRecord } from "./workflow-run-record.ts";
28
- import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
29
+ import { stopWorkflowRunRecord, workflowRunScriptFile, type WorkflowRunRecord } from "./workflow-run-record.ts";
30
+ import { verifyWorkflowRunScriptSeal } from "./workflow-run-seal.ts";
31
+ import { ProjectWorkflowRunStore, readWorkflowRunScript, type WorkflowRunStore } from "./workflow-run-store.ts";
32
+ import { compileInlineWorkflow, inlineScriptFingerprint } from "./inline-workflow.ts";
29
33
  import { unknownErrorMessage } from "./unknown-error.ts";
30
34
  import {
31
35
  WorkflowUsageLimitScheduler,
@@ -39,7 +43,8 @@ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completi
39
43
  type WorkflowRunCompletionContext = Pick<ExtensionContext, "cwd" | "sessionManager">;
40
44
 
41
45
  interface WorkflowRunControllerDependencies {
42
- readonly resolveWorkflow: (name: string) => Promise<LoadedWorkflow | undefined>;
46
+ /** The registered workflow a relaunch runs; `ctx` decides which project's saved workflows are visible. */
47
+ readonly resolveWorkflow: (name: string, ctx: ExtensionContext) => Promise<LoadedWorkflow | undefined>;
43
48
  readonly execute: (
44
49
  ctx: ExtensionContext,
45
50
  name: string,
@@ -160,7 +165,7 @@ export class WorkflowRunController {
160
165
  }
161
166
  const actions = availableWorkflowRunActions(record, runs);
162
167
  const selectedAction = await ctx.ui.select(
163
- `${record.workflow.name} · ${record.runId}`,
168
+ `${workflowRunDisplayName(record)} · ${record.runId}`,
164
169
  [...actions],
165
170
  );
166
171
  const action = actions.find((candidate) => candidate === selectedAction);
@@ -232,14 +237,10 @@ export class WorkflowRunController {
232
237
  ): Promise<string> {
233
238
  const unavailable = backgroundUnavailableResult(ctx.mode);
234
239
  if (unavailable) throw new Error(unavailable.content[0].text);
235
- const workflow = await this.dependencies.resolveWorkflow(record.workflow.name);
236
- if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
237
- if (workflow.source.kind !== "file") {
238
- throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
239
- }
240
+ const { workflow, fingerprint } = await this.relaunchWorkflow(ctx, record);
240
241
  if (
241
242
  action === "resume"
242
- && workflow.source.fingerprint !== record.workflow.sourceFingerprint
243
+ && fingerprint !== record.workflow.sourceFingerprint
243
244
  ) {
244
245
  throw new Error("workflow source changed, so journal replay cannot resume safely");
245
246
  }
@@ -264,6 +265,8 @@ export class WorkflowRunController {
264
265
  ctx,
265
266
  name: workflow.meta.name,
266
267
  options,
268
+ // A relaunch continues the original run, so it delivers the way that run would have.
269
+ wakeOnComplete: record.background?.origin.wakeOnComplete === true,
267
270
  execute: (backgroundCtx, backgroundOptions) =>
268
271
  this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
269
272
  });
@@ -272,6 +275,35 @@ export class WorkflowRunController {
272
275
  return message;
273
276
  }
274
277
 
278
+ /** The module a relaunch runs: an inline run's persisted script, else the registered workflow of the same name. */
279
+ private async relaunchWorkflow(
280
+ ctx: ExtensionContext,
281
+ record: WorkflowRunRecord,
282
+ ): Promise<{ readonly workflow: LoadedWorkflow; readonly fingerprint: string }> {
283
+ if (record.workflow.sourceKind === "fingerprint") {
284
+ if (record.scriptFile === undefined) throw new Error(`inline workflow ${record.workflow.name} did not retain its script`);
285
+ const fingerprint = await sealedFingerprint(record);
286
+ let source: string;
287
+ try {
288
+ source = await readWorkflowRunScript(ctx.cwd, record.runId);
289
+ } catch (error) {
290
+ throw new Error(`inline workflow script ${record.scriptFile} is unavailable: ${unknownErrorMessage(error)}`);
291
+ }
292
+ if (inlineScriptFingerprint(source) !== fingerprint) throw new Error(unsealedRunMessage(record));
293
+ return { workflow: compileInlineWorkflow(source), fingerprint };
294
+ }
295
+ const workflow = await this.dependencies.resolveWorkflow(record.workflow.name, ctx);
296
+ if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
297
+ if (workflow.source.kind !== "file") {
298
+ throw new Error(`registered workflow ${record.workflow.name} no longer has verifiable file provenance`);
299
+ }
300
+ // A repository can also ship a saved script under the name a forged record claims.
301
+ if (workflow.source.selfContained === true && (await sealedFingerprint(record)) !== workflow.source.fingerprint) {
302
+ throw new Error(unsealedRunMessage(record));
303
+ }
304
+ return { workflow, fingerprint: workflow.source.fingerprint };
305
+ }
306
+
275
307
  private async autoResume(ctx: ExtensionContext, runId: string, attempt: number): Promise<void> {
276
308
  const record = await this.loadRecord(ctx.cwd, runId);
277
309
  if (
@@ -326,7 +358,7 @@ export class WorkflowRunController {
326
358
  .filter((record) => availableWorkflowRunActions(record, runs).includes(action))
327
359
  .map((record) => ({
328
360
  value: record.runId,
329
- description: `${record.state} · ${record.workflow.name}`,
361
+ description: `${record.state} · ${workflowRunDisplayName(record)}`,
330
362
  })),
331
363
  );
332
364
  }
@@ -340,12 +372,36 @@ export class WorkflowRunController {
340
372
  { value: "last", description: "Inspect the current or most recent in-session workflow" },
341
373
  ...records.map((record) => ({
342
374
  value: record.runId,
343
- description: `${record.state} · ${record.workflow.name}`,
375
+ description: `${record.state} · ${workflowRunDisplayName(record)}`,
344
376
  })),
345
377
  ]);
346
378
  }
347
379
  }
348
380
 
381
+ /**
382
+ * The fingerprint a script-sourced record's seal covers. A repository can ship a record of its own, so
383
+ * the seal is checked before any script is read; it is this install's, over the script as recorded.
384
+ */
385
+ async function sealedFingerprint(record: WorkflowRunRecord): Promise<string> {
386
+ const fingerprint = record.workflow.sourceFingerprint;
387
+ if (
388
+ fingerprint === undefined
389
+ || record.scriptSeal === undefined
390
+ || !(await verifyWorkflowRunScriptSeal(record.runId, fingerprint, record.scriptSeal))
391
+ ) {
392
+ throw new Error(unsealedRunMessage(record));
393
+ }
394
+ return fingerprint;
395
+ }
396
+
397
+ /** Why a script-sourced record will not run, and how to run the script anyway. */
398
+ function unsealedRunMessage(record: WorkflowRunRecord): string {
399
+ const rerun = isInlineWorkflowRun(record)
400
+ ? `rerun it with the workflow tool's scriptPath (${WORKFLOW_RUNS_DIR}/${workflowRunScriptFile(record.runId)})`
401
+ : `run ${record.workflow.name} again by name`;
402
+ return `${isInlineWorkflowRun(record) ? "inline run" : "run"} ${record.runId} was not recorded by this pi install, or its script changed since; ${rerun}`;
403
+ }
404
+
349
405
  /**
350
406
  * Runs that any stored run resumed, whatever that resume's outcome. A resumed run keeps its paused
351
407
  * record and pause attempt, so automatic resume treats the pause as spent: re-arming it after a
@@ -86,9 +86,28 @@ export function availableWorkflowRunActions(
86
86
  }
87
87
 
88
88
  export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
89
+ return hasRelaunchableWorkflowSource(record) && record.options.argumentsPresent === false;
90
+ }
91
+
92
+ /**
93
+ * A registered workflow resolves again by name; an inline one relaunches from the script persisted beside
94
+ * its record, and only when the record carries a seal. This check is only the seal's presence: relaunch
95
+ * verifies it, and the script's fingerprint, before running anything.
96
+ */
97
+ export function hasRelaunchableWorkflowSource(record: WorkflowRunRecord): boolean {
98
+ if (typeof record.workflow.sourceFingerprint !== "string") return false;
89
99
  return record.workflow.sourceKind === "file"
90
- && typeof record.workflow.sourceFingerprint === "string"
91
- && record.options.argumentsPresent === false;
100
+ || (isInlineWorkflowRun(record) && record.scriptFile !== undefined && record.scriptSeal !== undefined);
101
+ }
102
+
103
+ /** A run of an inline script (a tool `script` or `scriptPath`), not of a registered workflow. */
104
+ export function isInlineWorkflowRun(record: WorkflowRunRecord): boolean {
105
+ return record.workflow.sourceKind === "fingerprint";
106
+ }
107
+
108
+ /** The run's workflow name, marked when it ran an inline script, so such a record cannot pose as a registered workflow's run. */
109
+ export function workflowRunDisplayName(record: WorkflowRunRecord): string {
110
+ return isInlineWorkflowRun(record) ? `${record.workflow.name} (inline)` : record.workflow.name;
92
111
  }
93
112
 
94
113
  export function formatWorkflowRunHistory(
@@ -112,7 +131,7 @@ export function formatWorkflowRunSummary(
112
131
  const usage = formatWorkflowUsageLine(record.usage);
113
132
  const resumedAs = runs.resumedAs.get(record.runId);
114
133
  const actions = availableWorkflowRunActions(record, runs).filter((action) => action !== "inspect");
115
- return `${record.state.toUpperCase()} ${record.workflow.name} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
134
+ return `${record.state.toUpperCase()} ${workflowRunDisplayName(record)} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
116
135
  }
117
136
 
118
137
  export function formatWorkflowRunDetails(
@@ -127,7 +146,7 @@ export function formatWorkflowRunDetails(
127
146
  const usage = formatWorkflowUsageLine(record.usage);
128
147
  const lines = [
129
148
  `Workflow run ${record.runId}`,
130
- `Workflow: ${record.workflow.name}`,
149
+ `Workflow: ${workflowRunDisplayName(record)}`,
131
150
  `State: ${record.state.toUpperCase()}`,
132
151
  `Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
133
152
  `Duration: ${formatWorkflowRunDuration(record, now)}`,