pi-workflow-engine 1.1.0 → 1.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.
Files changed (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -85
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +5 -2
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +8 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +4 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +18 -4
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +34 -7
  8. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +9 -3
  9. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +51 -8
  10. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +14 -0
  11. package/.pi/extensions/pi-workflow-engine/src/codemode-slots.ts +73 -0
  12. package/.pi/extensions/pi-workflow-engine/src/config-file.ts +52 -0
  13. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +127 -58
  14. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +112 -31
  15. package/.pi/extensions/pi-workflow-engine/src/engine.ts +75 -18
  16. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +5 -0
  17. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +7 -1
  18. package/.pi/extensions/pi-workflow-engine/src/journal.ts +22 -4
  19. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +9 -47
  20. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +1 -1
  21. package/.pi/extensions/pi-workflow-engine/src/progress.ts +32 -4
  22. package/.pi/extensions/pi-workflow-engine/src/prompt-sections.ts +15 -0
  23. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +4 -4
  24. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +1 -1
  25. package/.pi/extensions/pi-workflow-engine/src/types.ts +54 -9
  26. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +1 -0
  27. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +9 -3
  28. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +69 -18
  29. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +17 -6
  30. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +8 -3
  31. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +16 -8
  32. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +82 -23
  33. package/.pi/extensions/pi-workflow-engine/src/workflow-catalog.ts +79 -0
  34. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +6 -2
  35. package/.pi/extensions/pi-workflow-engine/src/workflow-input.ts +13 -0
  36. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +21 -4
  37. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +26 -6
  38. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +2 -0
  39. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +69 -13
  40. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +23 -4
  41. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +27 -1
  42. package/.pi/extensions/pi-workflow-engine/src/workflow-run-seal.ts +83 -0
  43. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +30 -4
  44. package/.pi/extensions/pi-workflow-engine/src/workflow-script-file.ts +50 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-settings.ts +147 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-tool-request.ts +61 -0
  47. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +59 -22
  48. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +7 -4
  49. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +7 -4
  50. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +7 -4
  51. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +12 -4
  52. package/README.md +11 -3
  53. package/USAGE.md +124 -23
  54. package/package.json +13 -4
  55. package/skills/workflow-authoring/SKILL.md +162 -0
@@ -1,4 +1,4 @@
1
- import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
1
+ import { AdvisoryBatchVerdictSchema, AdvisoryCandidatesSchema, AdvisoryVerdictSchema, formatAdvisoryLocation, type AdvisoryCandidate, type AdvisoryFinding, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
2
2
  import { AdvisorySynthesisSchema, SYNTHESIS_ID_INSTRUCTIONS, withAdvisoryCoverage, collectAdvisoryStage, dedupeCandidates, identifyCandidates, mergeCandidateInto, repoPathCandidates, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
3
3
  import { isFatalWorkflowError } from "./cancellation.ts";
4
4
  import { WorkflowClassifierUnavailableError } from "./classify.ts";
@@ -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
  }
@@ -52,15 +54,29 @@ export function publishVerifiedKeptProgress(
52
54
  }
53
55
 
54
56
  export interface LensVerificationPipelineOptions {
55
- api: Pick<WorkflowApi, "agent" | "classify" | "parallel" | "phase" | "progress" | "log">;
57
+ api: Pick<WorkflowApi, "agent" | "agents" | "classify" | "parallel" | "phase" | "progress" | "log">;
56
58
  lenses: readonly AdvisoryLens[];
57
59
  perLens: number;
58
60
  finderPhase?: "Find" | "Hypothesize";
61
+ finderProfile?: AgentOptions["profile"];
59
62
  finderPrompt(lens: AdvisoryLens): string;
60
- verifierPrompt(candidate: IdentifiedAdvisoryCandidate): string;
63
+ /** One verifier's prompt. It gets several candidates only when the agent limit leaves fewer verifiers than candidates. */
64
+ verifierPrompt(candidates: readonly IdentifiedAdvisoryCandidate[]): string;
61
65
  boundCandidate?(candidate: IdentifiedAdvisoryCandidate, lens: AdvisoryLens): IdentifiedAdvisoryCandidate | undefined;
62
66
  }
63
67
 
68
+ /** The candidate section of a verifier prompt: one record, or a batch judged in one pass. */
69
+ export function verifierCandidates(
70
+ candidates: readonly IdentifiedAdvisoryCandidate[],
71
+ heading: { one: string; many: string },
72
+ fields: (candidate: IdentifiedAdvisoryCandidate) => string,
73
+ ): string {
74
+ if (candidates.length === 1) return `## ${heading.one}\n${fields(candidates[0]!)}`;
75
+ return `## ${heading.many} (${candidates.length}: the run's agent limit leaves one verifier for several)\n` +
76
+ candidates.map((candidate) => `### candidateId ${candidate.candidateId}\n${fields(candidate)}`).join("\n\n") +
77
+ "\n\nJudge each on its own evidence and return one verdict per candidateId.";
78
+ }
79
+
64
80
  export interface AdvisoryBackfillDefaults {
65
81
  impact: string;
66
82
  recommendation?: string;
@@ -70,19 +86,27 @@ export interface AdvisoryBackfillDefaults {
70
86
  export async function runLensVerificationPipeline(
71
87
  options: LensVerificationPipelineOptions,
72
88
  ): Promise<LensVerificationPipelineResult> {
73
- const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
89
+ const { api, lenses, perLens, finderPhase = "Find", finderProfile = "small", finderPrompt, verifierPrompt, boundCandidate } = options;
74
90
  const coverage: AdvisoryStageCoverage[] = [];
75
91
  let rawCandidates = 0;
92
+ let overCap = 0;
76
93
  api.phase(finderPhase);
77
94
  const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
78
95
  const result = await api.agent(finderPrompt(lens), {
79
96
  phase: finderPhase, label: `${finderPhase.toLowerCase()}:${lens.label}`,
80
97
  tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
81
- profile: "small", schema: AdvisoryCandidatesSchema,
98
+ profile: finderProfile, schema: AdvisoryCandidatesSchema,
82
99
  });
100
+ rawCandidates += result.candidates.length;
101
+ api.progress({ type: "counter_delta", key: "candidates", label: "candidates", delta: result.candidates.length });
102
+ // A finder can return more than the cap its prompt states; keep the first perLens and report the rest.
103
+ const cut = Math.max(0, result.candidates.length - perLens);
104
+ if (cut > 0) {
105
+ overCap += cut;
106
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: cut });
107
+ api.log(`Dropped ${cut} ${lens.label} candidate(s) over the per-lens cap of ${perLens}`);
108
+ }
83
109
  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
110
  const candidates = boundCandidate ? raw.flatMap((candidate) => {
87
111
  const bounded = boundCandidate(candidate, lens);
88
112
  return bounded ? [bounded] : [];
@@ -94,27 +118,62 @@ export async function runLensVerificationPipeline(
94
118
  return candidates;
95
119
  } })), coverage);
96
120
  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)`);
121
+ const duplicateOrOutOfScope = rawCandidates - overCap - candidates.length;
122
+ if (duplicateOrOutOfScope > 0) {
123
+ api.progress({ type: "counter_delta", key: "dropped", label: "dropped", delta: duplicateOrOutOfScope });
124
+ api.log(`Dropped ${duplicateOrOutOfScope} duplicate or out-of-scope candidate(s)`);
101
125
  }
126
+ const dropped = overCap + duplicateOrOutOfScope;
102
127
  api.phase("Verify");
103
- const verified = await collectAdvisoryStage(api, "Verify", candidates.map((candidate) => ({
104
- id: candidate.candidateId, candidate, run: async (): Promise<AdvisoryVerified> => {
105
- const location = primaryLocation(candidate);
106
- const judged = await api.agent(`${verifierPrompt(candidate)}\nCandidate record: ${JSON.stringify(candidate)}`, {
107
- phase: "Verify", label: `verify:${location.file.split("/").pop() ?? location.file}`,
108
- tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
109
- profile: "small", schema: AdvisoryVerdictSchema,
110
- });
111
- recordVerdictProgress(api.progress, candidate, judged);
112
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence };
113
- },
114
- })), coverage);
128
+ // Keep one admission for the synthesis that follows. A host-set agent limit can leave fewer verifiers than
129
+ // candidates; then each verifier judges a batch, so no candidate goes unverified and synthesis still runs.
130
+ const batches = verificationBatches(candidates, Math.max(1, api.agents.remaining() - 1));
131
+ if (batches.length < candidates.length) api.log(`Verifying ${candidates.length} candidates in ${batches.length} batch(es) to fit the agent limit`);
132
+ // Each candidate keeps its own coverage branch, so a failed batch still lists every candidate it held.
133
+ const verified = await collectAdvisoryStage(api, "Verify", batches.flatMap((batch) => {
134
+ let judged: Promise<AdvisoryVerified[]> | undefined;
135
+ return batch.map((candidate, index) => ({
136
+ id: candidate.candidateId, candidate,
137
+ run: async () => (await (judged ??= verifyBatch(api, batch, verifierPrompt)))[index]!,
138
+ }));
139
+ }), coverage);
115
140
  return { verified, rawCandidates, dropped, coverage };
116
141
  }
117
142
 
143
+ /** Split candidates into at most `count` batches, keeping candidates in one file together where it fits. */
144
+ function verificationBatches(candidates: readonly IdentifiedAdvisoryCandidate[], count: number): IdentifiedAdvisoryCandidate[][] {
145
+ if (candidates.length <= count) return candidates.map((candidate) => [candidate]);
146
+ const byFile = [...candidates].sort((a, b) => primaryLocation(a).file.localeCompare(primaryLocation(b).file));
147
+ const size = Math.ceil(byFile.length / count);
148
+ return Array.from({ length: Math.ceil(byFile.length / size) }, (_, index) => byFile.slice(index * size, (index + 1) * size));
149
+ }
150
+
151
+ async function verifyBatch(
152
+ api: Pick<WorkflowApi, "agent" | "progress">,
153
+ batch: readonly IdentifiedAdvisoryCandidate[],
154
+ verifierPrompt: LensVerificationPipelineOptions["verifierPrompt"],
155
+ ): Promise<AdvisoryVerified[]> {
156
+ const location = primaryLocation(batch[0]!);
157
+ const options = {
158
+ phase: "Verify", label: `verify:${location.file.split("/").pop() ?? location.file}${batch.length > 1 ? ` +${batch.length - 1}` : ""}`,
159
+ tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
160
+ } as const;
161
+ if (batch.length === 1) {
162
+ const candidate = batch[0]!;
163
+ const judged = await api.agent(`${verifierPrompt(batch)}\nCandidate record: ${JSON.stringify(candidate)}`, { ...options, profile: "small", schema: AdvisoryVerdictSchema });
164
+ recordVerdictProgress(api.progress, candidate, judged);
165
+ return [{ ...candidate, verdict: judged.verdict, evidence: judged.evidence }];
166
+ }
167
+ // One agent does several verifiers' work, so it gets the medium route.
168
+ const judged = await api.agent(`${verifierPrompt(batch)}\nCandidate records: ${JSON.stringify(batch)}`, { ...options, profile: "medium", schema: AdvisoryBatchVerdictSchema });
169
+ const verdicts = new Map(judged.verdicts.map((verdict) => [verdict.candidateId, verdict]));
170
+ return batch.map((candidate) => {
171
+ const verdict = verdicts.get(candidate.candidateId) ?? { verdict: "NOT_SUBSTANTIATED" as const, evidence: ["The batch verifier returned no verdict for this candidate."] };
172
+ recordVerdictProgress(api.progress, candidate, verdict);
173
+ return { ...candidate, verdict: verdict.verdict, evidence: verdict.evidence };
174
+ });
175
+ }
176
+
118
177
  /** TypeSafe Jev routes in preference order. Jev is the classifier the duplicate merge was measured with. */
119
178
  export const DUPLICATE_CANDIDATE_CLASSIFIERS = [
120
179
  "typesafe/jev-latest",
@@ -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)}`,