pi-workflow-engine 0.13.0 → 1.0.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 (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +1 -1
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +8 -8
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +7 -1
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +4 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +25 -80
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +0 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +75 -139
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +7 -9
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -117
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +8 -13
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +6 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -129
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +56 -19
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +48 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +64 -93
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +25 -67
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +19 -53
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +21 -49
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +31 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -24
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -1,7 +1,8 @@
1
- import { AdvisoryCandidatesSchema, AdvisoryVerdictSchema, type AdvisoryCandidate, type IdentifiedAdvisoryCandidate, type AdvisoryLocation, type AdvisoryReport, type AdvisoryVerdict } from "./advisory-schema.ts";
1
+ import { 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, uniqueLocations, type AdvisoryStageCoverage, type AdvisorySynthesis } from "./advisory-evidence.ts";
3
3
  import type { AgentOptions, WorkflowApi, WorkflowProgressEvent, WorkflowRunStats } from "./types.ts";
4
4
 
5
+
5
6
  export interface AdvisoryLens {
6
7
  label: string;
7
8
  category: string;
@@ -11,7 +12,6 @@ export interface AdvisoryLens {
11
12
  export type AdvisoryVerified = IdentifiedAdvisoryCandidate & {
12
13
  verdict: AdvisoryVerdict["verdict"];
13
14
  evidence: string[];
14
- confidence?: AdvisoryVerdict["confidence"];
15
15
  challenge?: import("./advisory-challenge.ts").ChallengeRecord;
16
16
  };
17
17
 
@@ -19,7 +19,6 @@ export interface LensVerificationPipelineResult {
19
19
  verified: AdvisoryVerified[];
20
20
  rawCandidates: number;
21
21
  dropped: number;
22
- refuted: number;
23
22
  coverage: AdvisoryStageCoverage[];
24
23
  }
25
24
 
@@ -71,7 +70,6 @@ export async function runLensVerificationPipeline(
71
70
  const { api, lenses, perLens, finderPhase = "Find", finderPrompt, verifierPrompt, boundCandidate } = options;
72
71
  const coverage: AdvisoryStageCoverage[] = [];
73
72
  let rawCandidates = 0;
74
- let refuted = 0;
75
73
  api.phase(finderPhase);
76
74
  const found = await collectAdvisoryStage(api, "Find", lenses.map((lens) => ({ id: lens.label, run: async () => {
77
75
  const result = await api.agent(finderPrompt(lens), {
@@ -107,11 +105,59 @@ export async function runLensVerificationPipeline(
107
105
  tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS,
108
106
  profile: "small", schema: AdvisoryVerdictSchema,
109
107
  });
110
- recordVerdictProgress(api.progress, candidate, judged, () => { refuted += 1; });
111
- return { ...candidate, verdict: judged.verdict, evidence: judged.evidence, confidence: judged.confidence };
108
+ recordVerdictProgress(api.progress, candidate, judged);
109
+ return { ...candidate, verdict: judged.verdict, evidence: judged.evidence };
112
110
  },
113
111
  })), coverage);
114
- return { verified, rawCandidates, dropped, refuted, coverage };
112
+ return { verified, rawCandidates, dropped, coverage };
113
+ }
114
+
115
+ /** Stats for a lens review that stopped before discovery. */
116
+ export const EMPTY_LENS_REVIEW_STATS: Readonly<WorkflowRunStats> = Object.freeze({ files: 0, candidates: 0, verified: 0, kept: 0, dropped: 0, refuted: 0 });
117
+
118
+ export interface LensReviewConclusion {
119
+ /** Scoped file count reported in stats. */
120
+ files: number;
121
+ rank(finding: AdvisoryVerified): number;
122
+ /** One ranked finding's synthesis-prompt block, headed by the IDs synthesis selects; defaults to {@link formatRankedFinding}. */
123
+ formatFinding?(finding: AdvisoryVerified, index: number): string;
124
+ /** Report used when no finding survives verification and challenge. */
125
+ empty: { summary: string; nextSteps: string[] };
126
+ synthesisPrompt(block: string, ranked: readonly AdvisoryVerified[], refuted: readonly AdvisoryVerified[]): string;
127
+ }
128
+
129
+ export function formatRankedFinding(finding: AdvisoryVerified, index: number, recommendationLabel = "Recommendation"): string {
130
+ return `### [${index}] IDs: ${finding.sourceCandidateIds.join(", ")} ${formatLocation(finding)} (${finding.verdict}, ${finding.category})\n` +
131
+ `${finding.summary}\nImpact: ${finding.impact}\nEvidence: ${formatEvidence(finding.evidence)}\n${recommendationLabel}: ${finding.recommendation ?? "(none supplied)"}`;
132
+ }
133
+
134
+ /** Rank and synthesize the findings that survived verification and challenge into the final report. */
135
+ export async function concludeLensReview(
136
+ api: Pick<WorkflowApi, "agent" | "parallel" | "phase" | "progress" | "log">,
137
+ pipeline: LensVerificationPipelineResult,
138
+ verified: AdvisoryVerified[],
139
+ conclusion: LensReviewConclusion,
140
+ ) {
141
+ const { coverage } = pipeline;
142
+ const surviving = verified.filter((finding) => finding.verdict !== "REFUTED");
143
+ const stats: WorkflowRunStats = {
144
+ files: conclusion.files,
145
+ candidates: pipeline.rawCandidates,
146
+ verified: verified.length,
147
+ kept: surviving.length,
148
+ dropped: pipeline.dropped,
149
+ refuted: verified.length - surviving.length,
150
+ };
151
+ publishVerifiedKeptProgress(api, verified.length, surviving.length);
152
+ if (surviving.length === 0) {
153
+ return finishAdvisoryReport(emptyAdvisoryReport(conclusion.empty.summary, conclusion.empty.nextSteps, stats), coverage, verified);
154
+ }
155
+
156
+ const ranked = [...surviving].sort((a, b) => conclusion.rank(a) - conclusion.rank(b));
157
+ const block = ranked.map((finding, index) => (conclusion.formatFinding ?? formatRankedFinding)(finding, index)).join("\n\n");
158
+ const refuted = verified.filter((finding) => finding.verdict === "REFUTED");
159
+ const resolved = await synthesizeAdvisoryReport(api, conclusion.synthesisPrompt(block, ranked, refuted), ranked, coverage);
160
+ return finishAdvisoryReport({ ...resolved, stats: { ...stats, kept: resolved.findings.length } }, coverage, verified);
115
161
  }
116
162
 
117
163
  export async function synthesizeAdvisoryReport(
@@ -123,7 +169,7 @@ export async function synthesizeAdvisoryReport(
123
169
  api.phase("Synthesize");
124
170
  const [report] = await collectAdvisoryStage(api, "Synthesize", [{ id: "synthesize", run: () => api.agent(
125
171
  SYNTHESIS_ID_INSTRUCTIONS + prompt,
126
- { phase: "Synthesize", label: "synthesize", tools: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
172
+ { phase: "Synthesize", label: "synthesize", tools: [], skills: [], profile: "medium", resume: "read-only", schema: AdvisorySynthesisSchema },
127
173
  ) }], coverage);
128
174
  return resolveAdvisorySynthesis(report, ranked, {
129
175
  impact: "Impact not restated by verification.",
@@ -142,27 +188,20 @@ export function primaryLocation(candidate: Pick<AdvisoryCandidate, "locations">)
142
188
  }
143
189
 
144
190
  export function formatLocation(candidate: Pick<AdvisoryCandidate, "locations">): string {
145
- const location = primaryLocation(candidate);
146
- const line = location.line != null ? `:${location.line}` : "";
147
- const symbol = location.symbol ? ` (${location.symbol})` : "";
148
- return `${location.file}${line}${symbol}`;
191
+ return formatAdvisoryLocation(primaryLocation(candidate));
149
192
  }
150
193
 
151
194
  export function formatEvidence(evidence: readonly string[]): string {
152
195
  return evidence.join("; ");
153
196
  }
154
197
 
155
- export function normalizePath(path: string): string {
156
- return path.replace(/^\.\//, "").replace(/^[ab]\//, "");
157
- }
158
-
159
198
  const VERDICT_PRESENTATION = {
160
199
  CONFIRMED: { lane: "Confirmed", status: "success", confidence: "high" },
161
200
  PLAUSIBLE: { lane: "Plausible", status: "warning", confidence: "medium" },
162
201
  NOT_SUBSTANTIATED: { lane: "Unresolved", status: "warning", confidence: "medium" },
163
202
  REFUTED: { lane: "Refuted", status: "error", confidence: "low" },
164
203
  } satisfies Record<AdvisoryVerdict["verdict"], {
165
- lane: string; status: "success" | "warning" | "error"; confidence: NonNullable<AdvisoryVerdict["confidence"]>;
204
+ lane: string; status: "success" | "warning" | "error"; confidence: AdvisoryFinding["confidence"];
166
205
  }>;
167
206
 
168
207
  export function verdictConfidence(verdict: AdvisoryVerdict["verdict"]): "high" | "medium" | "low" {
@@ -173,11 +212,9 @@ export function recordVerdictProgress(
173
212
  progress: (event: WorkflowProgressEvent) => void,
174
213
  candidate: Pick<AdvisoryCandidate, "locations" | "summary">,
175
214
  verdict: Pick<AdvisoryVerdict, "verdict" | "evidence">,
176
- onRefuted?: () => void,
177
215
  ): void {
178
216
  progress({ type: "counter_delta", key: `verdict.${verdict.verdict.toLowerCase()}`, label: verdict.verdict, delta: 1 });
179
217
  if (verdict.verdict === "REFUTED") {
180
- onRefuted?.();
181
218
  progress({ type: "counter_delta", key: "refuted", label: "refuted", delta: 1 });
182
219
  }
183
220
  progress({
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { isRecord } from "./guards.ts";
2
3
  import type { ResolvedWorkflowRunOptions } from "./options.ts";
3
4
  import type { PerfAggregate, PerfSink, PerfSnapshot } from "./perf.ts";
4
5
  import type { WorkflowProgressSnapshot } from "./progress-types.ts";
@@ -11,14 +12,14 @@ import type {
11
12
  import type { WorkflowUsageSnapshot } from "./usage.ts";
12
13
 
13
14
  export interface WorkflowPerfDetails {
14
- readonly enabled: boolean;
15
- readonly startedAt: number;
16
15
  readonly aggregates: readonly PerfAggregate[];
17
16
  }
18
17
 
19
18
  export interface WorkflowResultEnvelope {
20
19
  readonly name: string;
21
20
  readonly result: unknown;
21
+ /** Optional so envelopes persisted before it existed still render. */
22
+ readonly startedAt?: number;
22
23
  readonly completedAt: number;
23
24
  readonly usage?: WorkflowUsageSnapshot;
24
25
  readonly perf?: WorkflowPerfDetails;
@@ -26,6 +27,12 @@ export interface WorkflowResultEnvelope {
26
27
  readonly resumedFromRunId?: string;
27
28
  }
28
29
 
30
+ /** A workflow result's own summary: the result itself when it is a string, else its string `summary` field. */
31
+ export function workflowResultSummary(result: unknown): string | undefined {
32
+ if (typeof result === "string") return result;
33
+ return isRecord(result) && typeof result.summary === "string" ? result.summary : undefined;
34
+ }
35
+
29
36
  export type ResolvedWorkflowRunner = (
30
37
  ctx: ExtensionContext,
31
38
  mod: LoadedWorkflow,
@@ -46,13 +53,8 @@ export interface WorkflowExecutionInput {
46
53
  readonly onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
47
54
  }
48
55
 
49
- export interface WorkflowExecution {
50
- readonly metadata?: WorkflowRunMetadata;
51
- readonly envelope: WorkflowResultEnvelope;
52
- }
53
-
54
56
  /** Shared command/tool execution path. The engine remains lazy-loaded by the caller. */
55
- export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<WorkflowExecution> {
57
+ export async function executeWorkflowInvocation(input: WorkflowExecutionInput): Promise<WorkflowResultEnvelope> {
56
58
  let perfSnapshot: PerfSnapshot | undefined;
57
59
  let usageSnapshot: WorkflowUsageSnapshot | undefined;
58
60
  let runMetadata: WorkflowRunMetadata | undefined;
@@ -86,27 +88,20 @@ export async function executeWorkflowInvocation(input: WorkflowExecutionInput):
86
88
  );
87
89
  },
88
90
  };
91
+ const startedAt = Date.now();
89
92
  const result = await input.runResolvedWorkflow(input.ctx, input.mod, input.args, runOptions);
90
- const perf = compactPerfSnapshot(perfSnapshot);
91
93
  return {
92
- metadata: runMetadata,
93
- envelope: {
94
- name: input.name,
95
- result,
96
- completedAt: Date.now(),
97
- usage: usageSnapshot,
98
- perf,
99
- runId: runMetadata?.runId,
100
- resumedFromRunId: runMetadata?.resumedFromRunId,
101
- },
94
+ name: input.name,
95
+ result,
96
+ startedAt,
97
+ completedAt: Date.now(),
98
+ usage: usageSnapshot,
99
+ perf: perfSnapshot?.enabled ? { aggregates: perfSnapshot.aggregates } : undefined,
100
+ runId: runMetadata?.runId,
101
+ resumedFromRunId: runMetadata?.resumedFromRunId,
102
102
  };
103
103
  }
104
104
 
105
- function compactPerfSnapshot(snapshot: PerfSnapshot | undefined): WorkflowPerfDetails | undefined {
106
- if (!snapshot?.enabled) return undefined;
107
- return { enabled: true, startedAt: snapshot.startedAt, aggregates: snapshot.aggregates };
108
- }
109
-
110
105
  async function notifyLifecycleObservers(...observers: ReadonlyArray<() => void | Promise<void>>): Promise<void> {
111
106
  const failures: unknown[] = [];
112
107
  for (const observer of observers) {
@@ -5,35 +5,33 @@ import type {
5
5
  WorkflowSourceIdentity,
6
6
  } from "./types.ts";
7
7
  import type { WorktreeBaseline } from "./worktree.ts";
8
+ import { isRecord } from "./guards.ts";
8
9
 
9
- type WorkflowMetaCandidate = {
10
- readonly name?: unknown;
11
- readonly description?: unknown;
12
- readonly phases?: unknown;
13
- };
10
+ /** Validate and normalize workflow metadata without executing workflow code. */
11
+ export function parseWorkflowMeta(meta: unknown): { meta: WorkflowMeta } | { reason: string } {
12
+ if (!isRecord(meta)) return { reason: "meta export must be an object" };
13
+ if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
14
14
 
15
- type WorkflowModuleCandidate = {
16
- readonly meta?: unknown;
17
- readonly default?: unknown;
18
- };
15
+ const description = meta.description;
16
+ if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
19
17
 
20
- /** Validate and normalize workflow metadata without executing workflow code. */
21
- export function parseWorkflowMeta(value: unknown): { meta: WorkflowMeta } | { reason: string } {
22
- if (!isRecord(value)) return { reason: "meta export must be an object" };
23
- return parseWorkflowMetaObject(value);
18
+ const phases = meta.phases;
19
+ if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
20
+
21
+ const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
22
+ if (phases !== undefined) workflowMeta.phases = phases;
23
+ return { meta: workflowMeta };
24
24
  }
25
25
 
26
26
  export function parseWorkflowModule(value: unknown): { module: WorkflowModule } | { reason: string } {
27
27
  if (!isRecord(value)) return { reason: "module export is not an object" };
28
- const candidate = value as WorkflowModuleCandidate;
29
- const meta = candidate.meta;
30
- if (!isRecord(meta)) return { reason: "missing meta export" };
28
+ if (value.meta === undefined) return { reason: "missing meta export" };
31
29
 
32
- const parsedMeta = parseWorkflowMetaObject(meta);
30
+ const parsedMeta = parseWorkflowMeta(value.meta);
33
31
  if ("reason" in parsedMeta) return parsedMeta;
34
- if (!isWorkflowRun(candidate.default)) return { reason: "default export must be a function" };
32
+ if (!isWorkflowRun(value.default)) return { reason: "default export must be a function" };
35
33
 
36
- return { module: { meta: parsedMeta.meta, default: candidate.default } };
34
+ return { module: { meta: parsedMeta.meta, default: value.default } };
37
35
  }
38
36
 
39
37
  /** Attach engine-owned source provenance after an authored module has been validated. */
@@ -48,20 +46,6 @@ export function loadWorkflow(
48
46
  : { ...loaded, isolatedWorktreeBaseline };
49
47
  }
50
48
 
51
- function parseWorkflowMetaObject(meta: WorkflowMetaCandidate): { meta: WorkflowMeta } | { reason: string } {
52
- if (typeof meta.name !== "string") return { reason: "meta.name must be a string" };
53
-
54
- const description = meta.description;
55
- if (description !== undefined && typeof description !== "string") return { reason: "meta.description must be a string when provided" };
56
-
57
- const phases = meta.phases;
58
- if (phases !== undefined && !isWorkflowPhases(phases)) return { reason: "meta.phases must be an array of { title: string }" };
59
-
60
- const workflowMeta: WorkflowMeta = { name: meta.name, description: description ?? "" };
61
- if (phases !== undefined) workflowMeta.phases = phases;
62
- return { meta: workflowMeta };
63
- }
64
-
65
49
  function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
66
50
  return typeof value === "function";
67
51
  }
@@ -69,7 +53,3 @@ function isWorkflowRun(value: unknown): value is WorkflowModule["default"] {
69
53
  function isWorkflowPhases(value: unknown): value is Array<{ title: string }> {
70
54
  return Array.isArray(value) && value.every((phase) => isRecord(phase) && typeof phase.title === "string");
71
55
  }
72
-
73
- function isRecord(value: unknown): value is Record<string, unknown> {
74
- return typeof value === "object" && value !== null;
75
- }
@@ -0,0 +1,79 @@
1
+ import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
2
+ import { isRecord } from "./guards.ts";
3
+ import { toReviewIssues } from "./review/review-issues.ts";
4
+ import { formatPerfSummary } from "./perf.ts";
5
+ import { prettyJson } from "./text.ts";
6
+ import { formatWorkflowUsageLine } from "./usage.ts";
7
+ import { workflowResultSummary, type WorkflowResultEnvelope } from "./workflow-execution.ts";
8
+
9
+ /** Cap on the result JSON copied into the host agent's context; the run record keeps the full value. */
10
+ const MAX_CONTEXT_RESULT_JSON_CHARS = 20_000;
11
+
12
+ /** The host model only sees this text, never the envelope `details`, so it carries every result field. */
13
+ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope): string {
14
+ const details = formatWorkflowDetailLines(envelope);
15
+ return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
16
+ }
17
+
18
+ /** Run id, usage and perf lines appended for the host model. */
19
+ 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
+ return [
24
+ run,
25
+ formatWorkflowUsageLine(envelope.usage),
26
+ envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
27
+ ].filter((line): line is string => line !== undefined);
28
+ }
29
+
30
+ function formatResultForContext(result: unknown): string {
31
+ if (typeof result === "string") return result;
32
+ if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
33
+ const summary = workflowResultSummary(result);
34
+ if (summary !== undefined && isRecord(result) && Object.keys(result).length === 1) return summary;
35
+ const json = formatResultJson(result);
36
+ if (json === undefined) return summary ?? "Workflow finished.";
37
+ return summary === undefined ? json : `${summary}\n\n${json}`;
38
+ }
39
+
40
+ function formatAdvisoryReportForContext(report: AdvisoryReport): string {
41
+ const lines = [report.summary];
42
+ const issues = toReviewIssues(report);
43
+ if (issues.length > 0) {
44
+ lines.push("", "Findings:");
45
+ for (const { id, finding } of issues) {
46
+ lines.push(
47
+ `\n### ${id}: ${finding.summary}`,
48
+ `- Severity: ${finding.severity}`,
49
+ `- Confidence: ${finding.confidence}`,
50
+ `- Category: ${finding.category}`,
51
+ `- Location: ${formatFindingLocations(finding)}`,
52
+ `- Impact: ${finding.impact}`,
53
+ `- Evidence: ${finding.evidence.length > 0 ? finding.evidence.join("; ") : "(none cited)"}`,
54
+ `- Recommendation: ${finding.recommendation}`,
55
+ );
56
+ }
57
+ }
58
+ if (report.gaps && report.gaps.length > 0) {
59
+ lines.push("", "Coverage gaps:", ...report.gaps.map((gap) => `- ${gap}`));
60
+ }
61
+ if (report.nextSteps.length > 0) {
62
+ lines.push("", "Next steps:", ...report.nextSteps.map((step) => `- ${step}`));
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+
67
+ function formatResultJson(result: unknown): string | undefined {
68
+ if (result === null || result === undefined) return undefined;
69
+ const json = prettyJson(result);
70
+ if (json === undefined) return undefined;
71
+ if (json.length <= MAX_CONTEXT_RESULT_JSON_CHARS) return `Result:\n\`\`\`json\n${json}\n\`\`\``;
72
+ return `Result (first ${MAX_CONTEXT_RESULT_JSON_CHARS} of ${json.length} characters; truncated, the run record holds the full value):\n\`\`\`json\n${json.slice(0, MAX_CONTEXT_RESULT_JSON_CHARS)}\n\`\`\``;
73
+ }
74
+
75
+ /** The review anchor that the findings viewer and PR comments cite comes first, then every other cited location. */
76
+ function formatFindingLocations(finding: AdvisoryFinding): string {
77
+ const locations = finding.reviewAnchor ? [finding.reviewAnchor, ...finding.locations] : finding.locations;
78
+ return [...new Set(locations.map(formatAdvisoryLocation))].join(", ");
79
+ }
@@ -1,3 +1,5 @@
1
+ import { isFiniteNumber, isRecord } from "./guards.ts";
2
+ import { truncateText } from "./text.ts";
1
3
  import type { WorkflowBackgroundOrigin } from "./types.ts";
2
4
  import type { WorkflowRunRecord } from "./workflow-run-record.ts";
3
5
 
@@ -19,7 +21,7 @@ export function createPersistedWorkflowBackground(
19
21
  if (!origin) return undefined;
20
22
  return {
21
23
  origin: {
22
- sessionId: boundedText(origin.sessionId),
24
+ sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
23
25
  requestedAt: origin.requestedAt,
24
26
  },
25
27
  delivery: { state: "pending" },
@@ -38,7 +40,7 @@ export function updateWorkflowRunDelivery(
38
40
  background: {
39
41
  origin: record.background.origin,
40
42
  delivery: delivery.state === "unavailable"
41
- ? { ...delivery, message: boundedText(delivery.message) }
43
+ ? { ...delivery, message: truncateText(delivery.message, MAX_BACKGROUND_TEXT) }
42
44
  : delivery,
43
45
  },
44
46
  };
@@ -58,15 +60,3 @@ export function isPersistedWorkflowBackground(value: unknown): value is Persiste
58
60
  return false;
59
61
  }
60
62
  }
61
-
62
- function boundedText(value: string): string {
63
- return value.length <= MAX_BACKGROUND_TEXT ? value : `${value.slice(0, MAX_BACKGROUND_TEXT - 1)}…`;
64
- }
65
-
66
- function isFiniteNumber(value: unknown): value is number {
67
- return typeof value === "number" && Number.isFinite(value);
68
- }
69
-
70
- function isRecord(value: unknown): value is Record<string, unknown> {
71
- return typeof value === "object" && value !== null && !Array.isArray(value);
72
- }