pi-workflow-engine 0.11.0 → 0.13.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 (79) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +13 -5
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  6. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  8. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  10. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +26 -4
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  16. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  17. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  18. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  19. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  20. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +82 -10
  21. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  22. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +13 -6
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  31. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  32. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  33. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  34. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  35. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  36. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  37. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +127 -41
  38. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +24 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +120 -0
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +43 -7
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +2 -1
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  48. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  49. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  50. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  51. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  52. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  53. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  54. package/.pi/extensions/pi-workflow-engine/src/types.ts +52 -15
  55. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  56. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  57. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  58. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -3
  59. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  61. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +151 -178
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  64. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  65. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  66. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  67. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  68. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  70. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  71. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +47 -3
  72. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +65 -162
  73. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +48 -149
  74. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +28 -77
  75. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +28 -77
  76. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  77. package/README.md +72 -141
  78. package/USAGE.md +270 -17
  79. package/package.json +1 -1
@@ -5,6 +5,14 @@ export class WorkflowAbortError extends Error {
5
5
  }
6
6
  }
7
7
 
8
+ /** Abort reason used when a durable run can be resumed after its host session shuts down. */
9
+ export class WorkflowPauseError extends WorkflowAbortError {
10
+ constructor(message = "Workflow paused because its host session shut down") {
11
+ super(message);
12
+ this.name = "WorkflowPauseError";
13
+ }
14
+ }
15
+
8
16
  export function abortReason(signal: AbortSignal | undefined): Error {
9
17
  const reason = signal?.reason;
10
18
  if (safeInstanceOf(reason, Error)) return reason;
@@ -29,6 +37,10 @@ export function isFatalWorkflowError(error: unknown, signal: AbortSignal | undef
29
37
  return false;
30
38
  }
31
39
 
40
+ export function isWorkflowPauseError(error: unknown): error is WorkflowPauseError {
41
+ return safeInstanceOf(error, WorkflowPauseError);
42
+ }
43
+
32
44
  export function linkAbortSignal(parent: AbortSignal | undefined, controller: AbortController): () => void {
33
45
  if (!parent) return () => {};
34
46
  if (parent.aborted) {
@@ -40,6 +52,32 @@ export function linkAbortSignal(parent: AbortSignal | undefined, controller: Abo
40
52
  return () => parent.removeEventListener("abort", onAbort);
41
53
  }
42
54
 
55
+ export async function raceWithAbort<T>(operation: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
56
+ throwIfAborted(signal);
57
+ if (!signal) return await operation();
58
+
59
+ return await new Promise<T>((resolve, reject) => {
60
+ let settled = false;
61
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
62
+ const finish = (action: () => void) => {
63
+ if (settled) return;
64
+ settled = true;
65
+ cleanup();
66
+ action();
67
+ };
68
+ const onAbort = () => finish(() => reject(abortReason(signal)));
69
+ signal.addEventListener("abort", onAbort, { once: true });
70
+ if (signal.aborted) {
71
+ onAbort();
72
+ return;
73
+ }
74
+ void operation().then(
75
+ (value) => finish(() => resolve(value)),
76
+ (error: unknown) => finish(() => reject(error)),
77
+ );
78
+ });
79
+ }
80
+
43
81
  function safeInstanceOf<T>(value: unknown, constructor: abstract new (...args: never[]) => T): value is T {
44
82
  try {
45
83
  return value instanceof constructor;
@@ -0,0 +1,36 @@
1
+ import { fuzzyFilter, type AutocompleteItem } from "@earendil-works/pi-tui";
2
+
3
+ interface ArgumentCompletionCandidate {
4
+ readonly value: string;
5
+ readonly description?: string;
6
+ }
7
+
8
+ interface ArgumentPrefixParts {
9
+ readonly completed: readonly string[];
10
+ readonly current: string;
11
+ readonly base: string;
12
+ }
13
+
14
+ /** Split a slash-command argument prefix while retaining the text Pi will replace. */
15
+ export function splitArgumentPrefix(argumentPrefix: string): ArgumentPrefixParts {
16
+ const match = /(\S*)$/.exec(argumentPrefix);
17
+ const current = match?.[1] ?? "";
18
+ const base = argumentPrefix.slice(0, argumentPrefix.length - current.length);
19
+ const completed = base.trim().split(/\s+/).filter(Boolean);
20
+ return { completed, current, base };
21
+ }
22
+
23
+ /** Complete the current argument and return full replacement values, as Pi expects. */
24
+ export function completeCurrentArgument(
25
+ argumentPrefix: string,
26
+ candidates: readonly ArgumentCompletionCandidate[],
27
+ ): AutocompleteItem[] | null {
28
+ const { current, base } = splitArgumentPrefix(argumentPrefix);
29
+ const matches = fuzzyFilter([...candidates], current, (candidate) => candidate.value)
30
+ .map((candidate) => ({
31
+ value: `${base}${candidate.value}`,
32
+ label: candidate.value,
33
+ ...(candidate.description === undefined ? {} : { description: candidate.description }),
34
+ }));
35
+ return matches.length > 0 ? matches : null;
36
+ }
@@ -14,22 +14,28 @@ export class Semaphore {
14
14
  private active = 0;
15
15
  private readonly waiters: Array<() => void> = [];
16
16
 
17
- constructor(private readonly max: number) {}
17
+ constructor(private readonly max: number) {
18
+ if (!Number.isInteger(max) || max < 1) throw new RangeError("Semaphore capacity must be a positive integer");
19
+ }
18
20
 
19
21
  async run<T>(fn: () => Promise<T>, options: { onQueueWaitMs?: (durationMs: number) => void; signal?: AbortSignal } = {}): Promise<T> {
20
22
  throwIfAborted(options.signal);
21
23
  const queuedAt = performance.now();
22
24
  if (this.active >= this.max) {
23
25
  await this.waitForSlot(options.signal);
26
+ } else {
27
+ this.active++;
24
28
  }
25
- options.onQueueWaitMs?.(performance.now() - queuedAt);
26
- throwIfAborted(options.signal);
27
- this.active++;
28
29
  try {
30
+ options.onQueueWaitMs?.(performance.now() - queuedAt);
31
+ throwIfAborted(options.signal);
29
32
  return await fn();
30
33
  } finally {
31
- this.active--;
32
- this.waiters.shift()?.();
34
+ // Transfer ownership directly. A selected waiter owns this reservation
35
+ // even before its continuation runs, including if it is then cancelled.
36
+ const next = this.waiters.shift();
37
+ if (next) next();
38
+ else this.active--;
33
39
  }
34
40
  }
35
41
 
@@ -78,6 +84,8 @@ export interface ParallelSettledOptions {
78
84
  export interface ParallelSettledError {
79
85
  readonly name?: string;
80
86
  readonly message: string;
87
+ readonly code?: string;
88
+ readonly details?: Readonly<Record<string, string | number | boolean | null>>;
81
89
  }
82
90
 
83
91
  export type ParallelSettledResult<T> =
@@ -229,14 +237,48 @@ async function runParallel<T, Result>(
229
237
  function serializeParallelError(error: unknown): ParallelSettledError {
230
238
  const message = readStringProperty(error, "message") ?? unknownErrorMessage(error);
231
239
  const name = readStringProperty(error, "name");
232
- return name && name.length > 0 ? { name, message } : { message };
240
+ const code = readStringProperty(error, "code");
241
+ const details = readSerializableDetails(error);
242
+ return {
243
+ ...(name && name.length > 0 ? { name } : {}),
244
+ message,
245
+ ...(code && code.length > 0 ? { code } : {}),
246
+ ...(details ? { details } : {}),
247
+ };
233
248
  }
234
249
 
235
- function readStringProperty(value: unknown, property: "message" | "name"): string | undefined {
250
+ function readStringProperty(value: unknown, property: "message" | "name" | "code"): string | undefined {
251
+ const candidate = readProperty(value, property);
252
+ return typeof candidate === "string" ? candidate : undefined;
253
+ }
254
+
255
+ function readSerializableDetails(
256
+ value: unknown,
257
+ ): Readonly<Record<string, string | number | boolean | null>> | undefined {
258
+ const candidate = readProperty(value, "details");
259
+ if (typeof candidate !== "object" || candidate === null) return undefined;
260
+ try {
261
+ const details: Record<string, string | number | boolean | null> = {};
262
+ for (const [key, detail] of Object.entries(candidate)) {
263
+ if (
264
+ typeof detail === "string" ||
265
+ typeof detail === "boolean" ||
266
+ detail === null ||
267
+ (typeof detail === "number" && Number.isFinite(detail))
268
+ ) {
269
+ details[key] = detail;
270
+ }
271
+ }
272
+ return Object.keys(details).length > 0 ? details : undefined;
273
+ } catch {
274
+ return undefined;
275
+ }
276
+ }
277
+
278
+ function readProperty(value: unknown, property: string): unknown {
236
279
  if ((typeof value !== "object" && typeof value !== "function") || value === null) return undefined;
237
280
  try {
238
- const candidate = Reflect.get(value, property);
239
- return typeof candidate === "string" ? candidate : undefined;
281
+ return Reflect.get(value, property);
240
282
  } catch {
241
283
  return undefined;
242
284
  }
@@ -275,6 +317,21 @@ export async function pipeline<Item, A, B, C>(
275
317
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
276
318
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
277
319
  ): Promise<Array<C | null>>;
320
+ export async function pipeline<Item, A, B, C, D>(
321
+ items: readonly Item[],
322
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
323
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
324
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
325
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
326
+ ): Promise<Array<D | null>>;
327
+ export async function pipeline<Item, A, B, C, D, E>(
328
+ items: readonly Item[],
329
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
330
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
331
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
332
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
333
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
334
+ ): Promise<Array<E | null>>;
278
335
  export async function pipeline(
279
336
  items: readonly unknown[],
280
337
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -302,6 +359,21 @@ export function bindPipeline(options: PipelineOptions): Pipeline {
302
359
  stage2: (prev: A, item: Item, index: number) => Promise<B>,
303
360
  stage3: (prev: B, item: Item, index: number) => Promise<C>,
304
361
  ): Promise<Array<C | null>>;
362
+ async function boundPipeline<Item, A, B, C, D>(
363
+ items: readonly Item[],
364
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
365
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
366
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
367
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
368
+ ): Promise<Array<D | null>>;
369
+ async function boundPipeline<Item, A, B, C, D, E>(
370
+ items: readonly Item[],
371
+ stage1: (prev: Item, item: Item, index: number) => Promise<A>,
372
+ stage2: (prev: A, item: Item, index: number) => Promise<B>,
373
+ stage3: (prev: B, item: Item, index: number) => Promise<C>,
374
+ stage4: (prev: C, item: Item, index: number) => Promise<D>,
375
+ stage5: (prev: D, item: Item, index: number) => Promise<E>,
376
+ ): Promise<Array<E | null>>;
305
377
  async function boundPipeline(
306
378
  items: readonly unknown[],
307
379
  ...stages: Array<(prev: unknown, item: unknown, index: number) => Promise<unknown>>
@@ -1,30 +1,21 @@
1
- import { Type, type Static } from "typebox";
2
- import { Value } from "typebox/value";
3
1
  import type { PerfSink } from "./perf.ts";
4
2
  import { runBoundedProcess, type BoundedProcessFailure } from "./process-runner.ts";
5
-
6
- export type GitDiffBaselineTarget =
7
- | { readonly kind: "working-tree" }
8
- | { readonly kind: "index" }
9
- | { readonly kind: "range"; readonly ref: string };
10
-
11
- export const GitReviewDiffTargetSchema = Type.Object({
12
- kind: Type.Literal("git"),
13
- args: Type.Array(Type.String()),
14
- });
15
-
16
- export const PullRequestReviewDiffTargetSchema = Type.Object({
17
- kind: Type.Literal("pull-request"),
18
- number: Type.Integer({ minimum: 1 }),
19
- });
20
-
21
- export const ReviewDiffTargetSchema = Type.Union([GitReviewDiffTargetSchema, PullRequestReviewDiffTargetSchema]);
22
-
23
- export type GitReviewDiffTarget = Static<typeof GitReviewDiffTargetSchema>;
24
- export type PullRequestReviewDiffTarget = Static<typeof PullRequestReviewDiffTargetSchema>;
25
-
26
- /** Serializable semantic identity of the diff being reviewed. */
27
- export type ReviewDiffTarget = Static<typeof ReviewDiffTargetSchema>;
3
+ import { parseAllowedDiffCommand, reviewDiffCommand, type ReviewDiffTarget } from "./review-diff-target.ts";
4
+
5
+ export {
6
+ formatReviewDiffTarget,
7
+ GitReviewDiffTargetSchema,
8
+ isReviewDiffTarget,
9
+ parseAllowedDiffCommand,
10
+ PullRequestReviewDiffTargetSchema,
11
+ reviewDiffCommand,
12
+ ReviewDiffTargetSchema,
13
+ reviewGitDiffBaseline,
14
+ type GitDiffBaselineTarget,
15
+ type GitReviewDiffTarget,
16
+ type PullRequestReviewDiffTarget,
17
+ type ReviewDiffTarget,
18
+ } from "./review-diff-target.ts";
28
19
 
29
20
  interface DiffCaptureResultBase {
30
21
  readonly stdout: string;
@@ -48,78 +39,6 @@ export interface DiffCaptureOptions {
48
39
  readonly killGraceMs?: number;
49
40
  }
50
41
 
51
- const SAFE_REF_OR_PATH = /^[A-Za-z0-9_./:@~+=,\-]+$/;
52
- const SAFE_GH_DIFF_FLAGS = new Set(["--color=never"]);
53
- const SAFE_GIT_DIFF_FLAGS = new Set(["--binary", "--cached", "--staged", "--no-color", "--color=never", "--no-ext-diff"]);
54
-
55
- export function parseAllowedDiffCommand(command: string): ReviewDiffTarget | { error: string } {
56
- const tokens = command.trim().split(/\s+/).filter(Boolean);
57
- if (tokens.length < 2) return { error: "empty or incomplete diff command" };
58
- if (tokens[0] === "git" && tokens[1] === "diff") {
59
- const parsed = parseGitDiff(tokens);
60
- return "error" in parsed ? parsed : parsed.target;
61
- }
62
- if (tokens[0] === "gh" && tokens[1] === "pr" && tokens[2] === "diff" && /^\d+$/.test(tokens[3] ?? "")) {
63
- const number = Number(tokens[3]);
64
- if (!Number.isSafeInteger(number) || number <= 0) return { error: "pull-request number must be a positive safe integer" };
65
- const flags = tokens.slice(4);
66
- if (flags.includes("--patch")) return { error: "gh pr diff --patch is not supported because reviews require the cumulative pull-request diff" };
67
- const unsupported = flags.find((token) => !SAFE_GH_DIFF_FLAGS.has(token));
68
- if (unsupported) return { error: `unsupported gh pr diff option: ${unsupported}` };
69
- return { kind: "pull-request", number };
70
- }
71
- return { error: "diff command is not in the git/gh allowlist" };
72
- }
73
-
74
- function parseGitDiff(
75
- tokens: readonly string[],
76
- ): { readonly target: GitReviewDiffTarget; readonly baseline: GitDiffBaselineTarget } | { readonly error: string } {
77
- const args = tokens.slice(2);
78
- const safeArgs = ["diff", "--no-ext-diff"];
79
- const operands: string[] = [];
80
- let staged = false;
81
- let pathMode = false;
82
-
83
- for (const token of args) {
84
- if (!SAFE_REF_OR_PATH.test(token)) return { error: `unsupported git diff token: ${token}` };
85
- if (token === "--") {
86
- pathMode = true;
87
- safeArgs.push(token);
88
- continue;
89
- }
90
- if (!pathMode && token.startsWith("-")) {
91
- if (!isAllowedGitDiffFlag(token)) return { error: `unsupported git diff option: ${token}` };
92
- staged ||= token === "--cached" || token === "--staged";
93
- if (token !== "--no-ext-diff") safeArgs.push(token);
94
- continue;
95
- }
96
- if (!pathMode) operands.push(token);
97
- safeArgs.push(token);
98
- }
99
-
100
- const baseline = classifyGitDiffBaseline(staged, operands);
101
- return "error" in baseline ? baseline : { target: { kind: "git", args: safeArgs }, baseline };
102
- }
103
-
104
- function classifyGitDiffBaseline(staged: boolean, operands: readonly string[]): GitDiffBaselineTarget | { readonly error: string } {
105
- if (operands.length > 1) {
106
- return { error: "ambiguous git diff operands; use A..B or A...B for revisions, or -- before multiple paths" };
107
- }
108
- if (staged) return { kind: "index" };
109
-
110
- const range = operands.find((operand) => operand.includes(".."));
111
- if (range) {
112
- const separator = range.includes("...") ? "..." : "..";
113
- return { kind: "range", ref: range.slice(range.indexOf(separator) + separator.length) || "HEAD" };
114
- }
115
-
116
- return { kind: "working-tree" };
117
- }
118
-
119
- function isAllowedGitDiffFlag(token: string): boolean {
120
- return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
121
- }
122
-
123
42
  export async function captureDiff(command: string, options: DiffCaptureOptions): Promise<DiffCaptureResult> {
124
43
  const parsed = parseAllowedDiffCommand(command);
125
44
  if ("error" in parsed) {
@@ -157,39 +76,6 @@ export async function captureDiffTarget(target: ReviewDiffTarget, options: DiffC
157
76
  return result;
158
77
  }
159
78
 
160
- export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
161
- return target.kind === "pull-request"
162
- ? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
163
- : { file: "git", args: target.args };
164
- }
165
-
166
- /** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
167
- export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
168
- const command = reviewDiffCommand(target);
169
- return [command.file, ...command.args].join(" ");
170
- }
171
-
172
- /** Derive reconstruction semantics from the canonical git command instead of persisting duplicate state. */
173
- export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBaselineTarget {
174
- const parsed = parseGitDiff(["git", ...target.args]);
175
- if ("error" in parsed) throw new Error(`Invalid persisted git review target: ${parsed.error}`);
176
- return parsed.baseline;
177
- }
178
-
179
- /** Validate persisted targets by round-tripping through the single command allowlist parser. */
180
- export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
181
- if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
182
- const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
183
- return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
184
- }
185
-
186
- function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
187
- if (left.kind !== right.kind) return false;
188
- if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
189
- if (right.kind !== "git" || left.args.length !== right.args.length) return false;
190
- return left.args.every((arg, index) => arg === right.args[index]);
191
- }
192
-
193
79
  function diffCaptureEnv(env: NodeJS.ProcessEnv | undefined): NodeJS.ProcessEnv {
194
80
  return { ...(env ?? process.env), GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" };
195
81
  }
@@ -1,13 +1,14 @@
1
1
  import { readdir } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
3
  import { pathToFileURL } from "node:url";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
  import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
6
6
  import type { PerfSink } from "./perf.ts";
7
7
  import { loadWorkflow, parseWorkflowModule } from "./workflow-module.ts";
8
8
  import { BUILTIN_SOURCE_ROOT, BUILTIN_WORKFLOW_DEFINITIONS, BUILTIN_WORKFLOW_FILES } from "./workflows.ts";
9
9
  import { captureTreeFingerprint } from "./tree-fingerprint.ts";
10
10
  import { FINGERPRINT_EXCLUDED_RELATIVE_PATHS } from "./resume-context.ts";
11
+ import { unknownErrorMessage } from "./unknown-error.ts";
11
12
 
12
13
  export interface DiscoverWorkflowsOptions {
13
14
  readonly refresh?: boolean;
@@ -15,6 +16,9 @@ export interface DiscoverWorkflowsOptions {
15
16
  readonly userWorkflowDir?: string;
16
17
  }
17
18
 
19
+ const DISCOVERY_FINGERPRINT_MAX_BYTES = 32 << 20;
20
+ const DISCOVERY_FINGERPRINT_MAX_FILES = 4096;
21
+
18
22
  const discoveryCache = new Map<string, Map<string, LoadedWorkflow>>();
19
23
  /** Best-effort dynamic load of every `*.ts` workflow in a directory. */
20
24
  async function loadDir(
@@ -46,7 +50,7 @@ async function loadDir(
46
50
  } catch (error) {
47
51
  // Drop-in loading depends on the runtime resolving TS + the bundled typebox.
48
52
  // Failures here are non-fatal: the static registry still works.
49
- console.error(`[workflow-engine] skipped ${name}: ${error instanceof Error ? error.message : String(error)}`);
53
+ console.error(`[workflow-engine] skipped ${name}: ${unknownErrorMessage(error)}`);
50
54
  }
51
55
  }
52
56
  return modules.map(({ path, module }) => loadWorkflow(module, sourceIdentity(path)));
@@ -56,8 +60,8 @@ async function captureDiscoveryFingerprint(root: string) {
56
60
  return await captureTreeFingerprint({
57
61
  root,
58
62
  excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
59
- maxBytes: 32 << 20,
60
- maxFiles: 4096,
63
+ maxBytes: DISCOVERY_FINGERPRINT_MAX_BYTES,
64
+ maxFiles: DISCOVERY_FINGERPRINT_MAX_FILES,
61
65
  });
62
66
  }
63
67
 
@@ -66,7 +70,7 @@ async function captureDiscoveryFingerprint(root: string) {
66
70
  * bundled example is always the verified one even if a same-named file is dropped in.
67
71
  */
68
72
  export async function discoverWorkflows(repoDir: string, options: DiscoverWorkflowsOptions = {}): Promise<Map<string, LoadedWorkflow>> {
69
- const userWorkflowDir = options.userWorkflowDir ?? join(homedir(), ".pi", "agent", "workflows");
73
+ const userWorkflowDir = options.userWorkflowDir ?? join(getAgentDir(), "workflows");
70
74
  const cacheKey = `${repoDir}\0${userWorkflowDir}`;
71
75
  const cached = discoveryCache.get(cacheKey);
72
76
  if (cached && !options.refresh) {