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,13 +1,15 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { throwIfAborted } from "../cancellation.ts";
3
- import { captureDiffTarget, type DiffCaptureFailure } from "../diff-capture.ts";
3
+ import { isGitObjectId, runGit } from "../git.ts";
4
4
  import {
5
+ GIT_DIFF_MACHINE_FORMAT,
6
+ reviewDiffCommand,
5
7
  reviewGitDiffBaseline,
6
8
  type GitReviewDiffTarget,
7
9
  type PullRequestReviewDiffTarget,
8
10
  type ReviewDiffTarget,
9
11
  } from "../review-diff-target.ts";
10
- import { runBoundedProcess } from "../process-runner.ts";
12
+ import type { BoundedProcessResult } from "../process-runner.ts";
11
13
  import { unknownErrorMessage } from "../unknown-error.ts";
12
14
  import type { WorktreeBaseline } from "../worktree.ts";
13
15
  import type { ReviewContext, ReviewSnapshotIdentity } from "./review-report.ts";
@@ -39,7 +41,6 @@ export interface CapturedReviewMaterial {
39
41
  export interface ReviewMaterialCaptureFailure {
40
42
  readonly ok: false;
41
43
  readonly error: string;
42
- readonly failure: DiffCaptureFailure;
43
44
  }
44
45
 
45
46
  export type ReviewMaterialCaptureResult = CapturedReviewMaterial | ReviewMaterialCaptureFailure;
@@ -64,36 +65,34 @@ export async function captureReviewMaterial(
64
65
  if (!before.ok) {
65
66
  throwIfAborted(signal);
66
67
  return latestDiff === undefined
67
- ? { ok: false, error: before.error, failure: before.failure }
68
+ ? { ok: false, error: before.error }
68
69
  : capturedWithoutSnapshot(latestDiff, `review diff could not be recaptured: ${before.error}`);
69
70
  }
70
- latestDiff = before.diff;
71
+ latestDiff = before.stdout;
71
72
 
72
73
  let baseline: WorktreeBaseline;
73
74
  try {
74
75
  baseline = await captureReviewWorktreeBaseline(target, cwd, signal);
75
76
  } catch (error) {
76
77
  throwIfAborted(signal);
77
- return capturedWithoutSnapshot(before.diff, `review baseline could not be captured: ${unknownErrorMessage(error)}`);
78
+ return capturedWithoutSnapshot(before.stdout, `review baseline could not be captured: ${unknownErrorMessage(error)}`);
78
79
  }
79
80
 
80
81
  const after = await captureReviewDiff(target, cwd, signal);
81
82
  if (!after.ok) {
82
83
  throwIfAborted(signal);
83
- return capturedWithoutSnapshot(before.diff, `review diff could not be recaptured: ${after.error}`);
84
+ return capturedWithoutSnapshot(before.stdout, `review diff could not be recaptured: ${after.error}`);
84
85
  }
85
- latestDiff = after.diff;
86
+ latestDiff = after.stdout;
86
87
 
87
- const beforeFingerprint = fingerprintReviewDiff(before.diff);
88
- const afterFingerprint = fingerprintReviewDiff(after.diff);
89
- if (beforeFingerprint === afterFingerprint) {
88
+ if (before.stdout === after.stdout) {
90
89
  return {
91
90
  ok: true,
92
- diff: after.diff,
91
+ diff: after.stdout,
93
92
  snapshot: {
94
93
  status: "verified",
95
94
  identity: {
96
- diffFingerprint: afterFingerprint,
95
+ diffFingerprint: createHash("sha256").update(after.stdout).digest("hex"),
97
96
  baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline),
98
97
  },
99
98
  baseline,
@@ -138,20 +137,15 @@ export function fingerprintReviewWorktreeBaseline(baseline: WorktreeBaseline): s
138
137
  .digest("hex");
139
138
  }
140
139
 
141
- async function captureReviewDiff(
142
- target: ReviewDiffTarget,
143
- cwd: string,
144
- signal: AbortSignal | undefined,
145
- ): Promise<{ readonly ok: true; readonly diff: string } | { readonly ok: false; readonly error: string; readonly failure: DiffCaptureFailure }> {
146
- const captured = await captureDiffTarget(target, {
140
+ function captureReviewDiff(target: ReviewDiffTarget, cwd: string, signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
141
+ return runGit({
142
+ ...reviewDiffCommand(target),
147
143
  cwd,
144
+ label: "diff capture",
148
145
  signal,
149
146
  timeoutMs: REVIEW_SNAPSHOT_TIMEOUT_MS,
150
147
  maxBufferBytes: REVIEW_SNAPSHOT_MAX_BYTES,
151
148
  });
152
- return captured.ok
153
- ? { ok: true, diff: captured.stdout }
154
- : { ok: false, error: captured.error, failure: captured.failure };
155
149
  }
156
150
 
157
151
  async function captureReviewWorktreeBaseline(
@@ -170,29 +164,31 @@ async function resolvePullRequestBaseline(
170
164
  signal: AbortSignal | undefined,
171
165
  ): Promise<WorktreeBaseline> {
172
166
  const number = String(target.number);
173
- const viewed = await runReviewCommand("gh", ["pr", "view", number, "--json", "headRefOid,headRefName,headRepository"], cwd, signal);
167
+ const viewed = await runReviewCommand("gh", ["pr", "view", number, "--json", "headRefOid,headRefName,headRepository,url"], cwd, signal);
174
168
  const details = viewed.ok ? parsePullRequestHead(viewed.stdout) : undefined;
175
169
  if (!details) {
176
- throw new Error(`pull request head could not be resolved: ${viewed.error ?? (viewed.stderr.trim() || "invalid head commit")}`);
170
+ throw new Error(`pull request head could not be resolved: ${viewed.ok ? "invalid head commit" : viewed.error}`);
177
171
  }
178
172
 
179
173
  if (!(await commitExists(details.head, cwd, signal))) {
180
174
  const fetched = await runReviewCommand(
181
175
  "git",
182
- ["fetch", "--no-tags", "--quiet", `https://github.com/${details.repository}.git`, details.branch],
176
+ ["fetch", "--no-tags", "--quiet", `${details.origin}/${details.repository}.git`, details.branch],
183
177
  cwd,
184
178
  signal,
185
179
  );
186
180
  if (!fetched.ok || !(await commitExists(details.head, cwd, signal))) {
187
181
  throw new Error(
188
- `pull request ${number} head ${details.head} is not available locally: ${fetched.error ?? (fetched.stderr.trim() || "fetch did not provide the commit")}`,
182
+ `pull request ${number} head ${details.head} is not available locally: ${fetched.ok ? "fetch did not provide the commit" : fetched.error}`,
189
183
  );
190
184
  }
191
185
  }
192
186
  return { ref: details.head };
193
187
  }
194
188
 
195
- function parsePullRequestHead(value: string): { readonly head: string; readonly branch: string; readonly repository: string } | undefined {
189
+ function parsePullRequestHead(
190
+ value: string,
191
+ ): { readonly head: string; readonly branch: string; readonly repository: string; readonly origin: string } | undefined {
196
192
  let parsed: unknown;
197
193
  try {
198
194
  parsed = JSON.parse(value);
@@ -204,12 +200,17 @@ function parsePullRequestHead(value: string): { readonly head: string; readonly
204
200
  readonly headRefOid?: unknown;
205
201
  readonly headRefName?: unknown;
206
202
  readonly headRepository?: { readonly nameWithOwner?: unknown } | null;
203
+ readonly url?: unknown;
207
204
  };
208
- if (typeof candidate.headRefOid !== "string" || !/^[0-9a-f]{40,64}$/i.test(candidate.headRefOid)) return undefined;
205
+ if (typeof candidate.headRefOid !== "string" || !isGitObjectId(candidate.headRefOid)) return undefined;
209
206
  if (typeof candidate.headRefName !== "string" || candidate.headRefName.length === 0 || candidate.headRefName.includes("\0")) return undefined;
210
207
  const repository = candidate.headRepository?.nameWithOwner;
211
208
  if (typeof repository !== "string" || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) return undefined;
212
- return { head: candidate.headRefOid, branch: candidate.headRefName, repository };
209
+ // The PR URL carries the host gh resolved the PR on, which is not github.com for GitHub Enterprise.
210
+ if (typeof candidate.url !== "string" || !URL.canParse(candidate.url)) return undefined;
211
+ const url = new URL(candidate.url);
212
+ if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
213
+ return { head: candidate.headRefOid, branch: candidate.headRefName, repository, origin: url.origin };
213
214
  }
214
215
 
215
216
  async function resolveGitDiffBaseline(
@@ -236,9 +237,7 @@ async function captureMutableGitBaseline(
236
237
  const head = await tryResolveCommit("HEAD", cwd, signal);
237
238
  if (!head) throw new Error("the reviewed repository has no committed HEAD baseline");
238
239
 
239
- const args = staged
240
- ? ["diff", "--no-ext-diff", "--binary", "--cached", "HEAD"]
241
- : ["diff", "--no-ext-diff", "--binary", "HEAD"];
240
+ const args = ["diff", "--no-ext-diff", ...GIT_DIFF_MACHINE_FORMAT, "--binary", ...(staged ? ["--cached"] : []), "HEAD"];
242
241
  const snapshot = await runReviewCommand("git", args, cwd, signal, REVIEW_SNAPSHOT_MAX_BYTES);
243
242
  if (!snapshot.ok) {
244
243
  throw new Error(`the reviewed working state could not be captured: ${snapshot.error}`);
@@ -255,7 +254,7 @@ async function resolveCommit(ref: string, cwd: string, signal: AbortSignal | und
255
254
  async function tryResolveCommit(ref: string, cwd: string, signal: AbortSignal | undefined): Promise<string | undefined> {
256
255
  const result = await runReviewCommand("git", ["rev-parse", "--verify", `${ref}^{commit}`], cwd, signal);
257
256
  const commit = result.stdout.trim();
258
- return result.ok && /^[0-9a-f]{40,64}$/i.test(commit) ? commit : undefined;
257
+ return result.ok && isGitObjectId(commit) ? commit : undefined;
259
258
  }
260
259
 
261
260
  async function commitExists(ref: string, cwd: string, signal: AbortSignal | undefined): Promise<boolean> {
@@ -270,25 +269,17 @@ async function runReviewCommand(
270
269
  signal: AbortSignal | undefined,
271
270
  maxBufferBytes = REVIEW_COMMAND_MAX_BYTES,
272
271
  ) {
273
- return await runBoundedProcess({
272
+ return await runGit({
274
273
  file,
275
- args,
276
274
  cwd,
277
- env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
275
+ args,
276
+ label: `${file} review snapshot command`,
278
277
  signal,
279
278
  timeoutMs: REVIEW_SNAPSHOT_TIMEOUT_MS,
280
279
  maxBufferBytes,
281
- abortError: `${file} review snapshot command aborted`,
282
- timeoutError: `${file} review snapshot command timed out after ${REVIEW_SNAPSHOT_TIMEOUT_MS}ms`,
283
- maxBufferError: `${file} review snapshot command exceeded output limit`,
284
- exitError: (stderr, code, processSignal) => stderr.trim() || `${file} exited with code ${code ?? `signal ${processSignal ?? "unknown"}`}`,
285
280
  });
286
281
  }
287
282
 
288
283
  function capturedWithoutSnapshot(diff: string, reason: string): CapturedReviewMaterial {
289
284
  return { ok: true, diff, snapshot: { status: "unavailable", reason } };
290
285
  }
291
-
292
- function fingerprintReviewDiff(diff: string): string {
293
- return createHash("sha256").update(diff).digest("hex");
294
- }
@@ -1,6 +1,12 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import { Value } from "typebox/value";
3
3
 
4
+ /**
5
+ * Pins the git diff output that the engine parses or applies, so user config such as
6
+ * diff.noprefix, diff.mnemonicPrefix, diff.srcPrefix or color.ui cannot change it.
7
+ */
8
+ export const GIT_DIFF_MACHINE_FORMAT = ["--no-color", "--src-prefix=a/", "--dst-prefix=b/"] as const;
9
+
4
10
  export type GitDiffBaselineTarget =
5
11
  | { readonly kind: "working-tree" }
6
12
  | { readonly kind: "index" }
@@ -96,15 +102,16 @@ function isAllowedGitDiffFlag(token: string): boolean {
96
102
  return SAFE_GIT_DIFF_FLAGS.has(token) || /^-U\d+$/.test(token) || /^--unified=\d+$/.test(token) || /^--inter-hunk-context=\d+$/.test(token);
97
103
  }
98
104
 
105
+ /** Executable argv. Git targets gain the machine-format pins, which stay out of the persisted and displayed target. */
99
106
  export function reviewDiffCommand(target: ReviewDiffTarget): { readonly file: "git" | "gh"; readonly args: readonly string[] } {
100
107
  return target.kind === "pull-request"
101
108
  ? { file: "gh", args: ["pr", "diff", String(target.number), "--color=never"] }
102
- : { file: "git", args: target.args };
109
+ : { file: "git", args: ["diff", ...GIT_DIFF_MACHINE_FORMAT, ...target.args.slice(1)] };
103
110
  }
104
111
 
105
112
  /** Canonical display form. Never execute this string directly; use reviewDiffCommand(). */
106
113
  export function formatReviewDiffTarget(target: ReviewDiffTarget): string {
107
- const command = reviewDiffCommand(target);
114
+ const command = target.kind === "git" ? { file: "git", args: target.args } : reviewDiffCommand(target);
108
115
  return [command.file, ...command.args].join(" ");
109
116
  }
110
117
 
@@ -115,16 +122,8 @@ export function reviewGitDiffBaseline(target: GitReviewDiffTarget): GitDiffBasel
115
122
  return parsed.baseline;
116
123
  }
117
124
 
118
- /** Validate persisted targets by round-tripping through the single command allowlist parser. */
119
- export function isReviewDiffTarget(value: unknown): value is ReviewDiffTarget {
120
- if (!Value.Check(ReviewDiffTargetSchema, value)) return false;
121
- const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(value));
122
- return !("error" in parsed) && sameReviewDiffTarget(parsed, value);
123
- }
124
-
125
- function sameReviewDiffTarget(left: ReviewDiffTarget, right: ReviewDiffTarget): boolean {
126
- if (left.kind !== right.kind) return false;
127
- if (left.kind === "pull-request") return right.kind === "pull-request" && left.number === right.number;
128
- if (right.kind !== "git" || left.args.length !== right.args.length) return false;
129
- return left.args.every((arg, index) => arg === right.args[index]);
125
+ /** Validate a schema-checked persisted target by round-tripping it through the single command allowlist parser. */
126
+ export function isAllowedReviewDiffTarget(target: ReviewDiffTarget): boolean {
127
+ const parsed = parseAllowedDiffCommand(formatReviewDiffTarget(target));
128
+ return !("error" in parsed) && Value.Equal(parsed, target);
130
129
  }
@@ -0,0 +1,29 @@
1
+ /** Cap text at `limit` characters, ending a cut value with an ellipsis. */
2
+ export function truncateText(value: string, limit: number): string {
3
+ return value.length <= limit ? value : `${value.slice(0, limit - 1)}…`;
4
+ }
5
+
6
+ /** Pretty-printed JSON, or undefined when the value has no JSON form or cannot be serialized (cycles, BigInt). */
7
+ export function prettyJson(value: unknown): string | undefined {
8
+ try {
9
+ return JSON.stringify(value, null, 2);
10
+ } catch {
11
+ return undefined;
12
+ }
13
+ }
14
+
15
+ /** Compact count for display: 999, 1.2k, 12.3k, 123k, 1.2M. */
16
+ export function formatCount(n: number): string {
17
+ if (!Number.isFinite(n)) return "0";
18
+ const sign = n < 0 ? "-" : "";
19
+ const value = Math.abs(n);
20
+ if (value < 1_000) return `${Math.trunc(n)}`;
21
+ if (value < 1_000_000) return `${sign}${formatCompact(value / 1_000)}k`;
22
+ return `${sign}${formatCompact(value / 1_000_000)}M`;
23
+ }
24
+
25
+ function formatCompact(value: number): string {
26
+ if (value >= 100) return `${Math.round(value)}`;
27
+ const rounded = Math.round(value * 10) / 10;
28
+ return Number.isInteger(rounded) ? `${rounded}` : rounded.toFixed(1);
29
+ }
@@ -33,7 +33,8 @@ function isExternalSearchLikeTool(tool: ToolInfo): boolean {
33
33
  }
34
34
 
35
35
  function isSearchLikeTool(tool: ToolInfo): boolean {
36
- if (isMutationLikeToolName(tool.name)) return false;
36
+ // Local code search must not hand repository context to web search tools.
37
+ if (isMutationLikeToolName(tool.name) || isExternalSearchLikeTool(tool)) return false;
37
38
  const name = tool.name.toLowerCase();
38
39
  if (name.includes("grep") || name.includes("find") || name.includes("search") || name === "rg" || name.includes("ripgrep")) return true;
39
40
  return /\b(?:grep|find|search|ripgrep|rg|structural search|code search)\b/.test(tool.description.toLowerCase());
@@ -2,8 +2,15 @@ import { lstat } from "node:fs/promises";
2
2
  import { dirname, isAbsolute, resolve } from "node:path";
3
3
  import { isMissingPathError } from "./filesystem-error.ts";
4
4
  import { hashIdentity } from "./identity-fingerprint.ts";
5
- import { logicalWorkspacePath, portableRelativePath } from "./replay-path-identity.ts";
6
- import { captureTreeFingerprint, isPathWithin, validateTreeFile, type FingerprintCapture } from "./tree-fingerprint.ts";
5
+ import { logicalWorkspacePath } from "./replay-path-identity.ts";
6
+ import {
7
+ captureSourceTreeFingerprint,
8
+ FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
9
+ isPathWithin,
10
+ portableRelativePath,
11
+ validateTreeFile,
12
+ type FingerprintCapture,
13
+ } from "./tree-fingerprint.ts";
7
14
 
8
15
  export interface EffectiveToolSourceInfoLike {
9
16
  readonly path: string;
@@ -32,10 +39,6 @@ export interface ToolSourceIdentityOptions {
32
39
  readonly signal?: AbortSignal;
33
40
  }
34
41
 
35
- const SOURCE_FINGERPRINT_MAX_BYTES = 32 << 20;
36
- const SOURCE_FINGERPRINT_MAX_FILES = 4096;
37
- const SOURCE_FINGERPRINT_EXCLUSIONS = new Set([".git", ".pi/.workflow-runs"]);
38
-
39
42
  export async function captureEffectiveToolSourceIdentity(
40
43
  sourceInfo: EffectiveToolSourceInfoLike,
41
44
  options: ToolSourceIdentityOptions,
@@ -58,26 +61,20 @@ export async function captureEffectiveToolSourceIdentity(
58
61
  };
59
62
  }
60
63
 
61
- const baseDir = declaredBaseDir === undefined ? undefined : resolveDeclaredPath(declaredBaseDir, options.sessionCwd);
64
+ const baseDir = declaredBaseDir === undefined ? undefined : resolve(options.sessionCwd, declaredBaseDir);
62
65
  const sourcePath = await resolveExistingSourcePath(path, baseDir, options.sessionCwd, options.workspaceRoot);
63
66
  const sourceRoot = baseDir && isPathWithin(baseDir, sourcePath) ? baseDir : dirname(sourcePath);
64
67
  const validation = await validateTreeFile({
65
68
  root: sourceRoot,
66
69
  path: sourcePath,
67
- excludedRelativePaths: SOURCE_FINGERPRINT_EXCLUSIONS,
70
+ excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
68
71
  signal: options.signal,
69
72
  });
70
73
  if (validation.kind === "unverifiable") throw new Error(`tool source "${path}" is not fingerprintable: ${validation.reason}`);
71
74
 
72
75
  let capture = options.cache.get(sourceRoot);
73
76
  if (!capture) {
74
- capture = captureTreeFingerprint({
75
- root: sourceRoot,
76
- excludedRelativePaths: SOURCE_FINGERPRINT_EXCLUSIONS,
77
- maxBytes: SOURCE_FINGERPRINT_MAX_BYTES,
78
- maxFiles: SOURCE_FINGERPRINT_MAX_FILES,
79
- signal: options.signal,
80
- });
77
+ capture = captureSourceTreeFingerprint(sourceRoot, options.signal);
81
78
  options.cache.set(sourceRoot, capture);
82
79
  }
83
80
  const fingerprint = await capture;
@@ -103,11 +100,11 @@ async function resolveExistingSourcePath(
103
100
  ): Promise<string> {
104
101
  const candidates = isAbsolute(path)
105
102
  ? [resolve(path)]
106
- : uniquePaths([
103
+ : [...new Set([
107
104
  ...(baseDir ? [resolve(baseDir, path), resolve(baseDir, path.split(/[\\/]/).at(-1) ?? path)] : []),
108
105
  resolve(sessionCwd, path),
109
106
  resolve(workspaceRoot, path),
110
- ]);
107
+ ])];
111
108
 
112
109
  for (const candidate of candidates) {
113
110
  try {
@@ -119,13 +116,8 @@ async function resolveExistingSourcePath(
119
116
  throw new Error(`tool source path does not identify a regular file: ${path}`);
120
117
  }
121
118
 
122
- function resolveDeclaredPath(path: string, sessionCwd: string): string {
123
- return isAbsolute(path) ? resolve(path) : resolve(sessionCwd, path);
124
- }
125
-
126
119
  function normalizeUnresolvedBaseDir(path: string, sessionCwd: string, workspaceRoot: string): string {
127
- const resolvedPath = resolveDeclaredPath(path, sessionCwd);
128
- return logicalWorkspacePath(resolvedPath, { sessionCwd, workspaceRoot }) ?? "source-root:.";
120
+ return logicalWorkspacePath(resolve(sessionCwd, path), { sessionCwd, workspaceRoot }) ?? "source-root:.";
129
121
  }
130
122
 
131
123
  function logicalSourcePath(path: string, sourceRoot: string, sessionCwd: string, workspaceRoot: string): string {
@@ -139,11 +131,7 @@ function isSyntheticSource(path: string, source: string): boolean {
139
131
  return (path.startsWith("<") && path.endsWith(">")) || source === "builtin" || source === "sdk" || /^(?:builtin|sdk):/.test(path);
140
132
  }
141
133
 
142
- function uniquePaths(paths: readonly string[]): readonly string[] {
143
- return [...new Set(paths)];
144
- }
145
-
146
- function nonEmptyString(value: string, label: string): string {
134
+ export function nonEmptyString(value: string, label: string): string {
147
135
  if (typeof value !== "string") throw new Error(`${label} is not a string`);
148
136
  if (value.length === 0) throw new Error(`${label} is empty`);
149
137
  return value;
@@ -6,6 +6,15 @@ import { throwIfAborted } from "./cancellation.ts";
6
6
  import { isMissingPathError } from "./filesystem-error.ts";
7
7
  import { unknownErrorMessage } from "./unknown-error.ts";
8
8
 
9
+ /** Engine-owned paths that are never part of a fingerprinted or snapshotted workspace. */
10
+ export const FINGERPRINT_EXCLUDED_RELATIVE_PATHS: ReadonlySet<string> = new Set([
11
+ ".git",
12
+ ".pi/.workflow-runs",
13
+ ]);
14
+
15
+ const SOURCE_TREE_MAX_BYTES = 32 << 20;
16
+ const SOURCE_TREE_MAX_FILES = 4096;
17
+
9
18
  export type FingerprintCapture =
10
19
  | { readonly kind: "verified"; readonly fingerprint: string }
11
20
  | { readonly kind: "unverifiable"; readonly reason: string };
@@ -38,8 +47,6 @@ export interface DeclaredInputFingerprintOptions {
38
47
  readonly excludedRelativePaths?: ReadonlySet<string>;
39
48
  readonly maxBytes: number;
40
49
  readonly maxEntries: number;
41
- /** Record direct directory identity without recursively walking descendants. */
42
- readonly shallowDirectories?: boolean;
43
50
  readonly signal?: AbortSignal;
44
51
  }
45
52
 
@@ -71,9 +78,9 @@ export class BoundedFingerprint {
71
78
  this.#hash.update("\0");
72
79
  }
73
80
 
81
+ /** File bytes may contain NUL, so they enter the outer hash as a fixed-length digest. */
74
82
  async addFileHandle(label: string, handle: FileHandle, signal?: AbortSignal): Promise<void> {
75
- this.#hash.update(label);
76
- this.#hash.update("\0");
83
+ const content = createHash("sha256");
77
84
  const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 << 10, signal });
78
85
  try {
79
86
  for await (const chunk of stream) {
@@ -84,13 +91,13 @@ export class BoundedFingerprint {
84
91
  stream.destroy();
85
92
  throw new Error(`content fingerprint exceeded ${this.maxBytes} bytes`);
86
93
  }
87
- this.#hash.update(buffer);
94
+ content.update(buffer);
88
95
  }
89
96
  } catch (error) {
90
97
  throwIfAborted(signal);
91
98
  throw error;
92
99
  }
93
- this.#hash.update("\0");
100
+ this.add(label, content.digest("hex"));
94
101
  }
95
102
 
96
103
  digest(): string {
@@ -178,6 +185,20 @@ export async function captureTreeFingerprint(options: TreeFingerprintOptions): P
178
185
  }
179
186
  }
180
187
 
188
+ /**
189
+ * Fingerprint a workflow or tool source tree. Workflow provenance captured at
190
+ * load time is revalidated on resume, so both captures must share these bounds.
191
+ */
192
+ export async function captureSourceTreeFingerprint(root: string, signal?: AbortSignal): Promise<FingerprintCapture> {
193
+ return await captureTreeFingerprint({
194
+ root,
195
+ excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
196
+ maxBytes: SOURCE_TREE_MAX_BYTES,
197
+ maxFiles: SOURCE_TREE_MAX_FILES,
198
+ signal,
199
+ });
200
+ }
201
+
181
202
  /** Fingerprint only explicitly declared files/directories under one shared bound. */
182
203
  export async function captureDeclaredInputFingerprint(
183
204
  options: DeclaredInputFingerprintOptions,
@@ -227,11 +248,6 @@ export async function captureDeclaredInputFingerprint(
227
248
  }
228
249
  if (!info.isDirectory()) throw new Error(`declared input contains an unsupported entry: ${relativePath}`);
229
250
 
230
- if (options.shallowDirectories === true) {
231
- recordEntry(relativePath, "directory", info.mode);
232
- return true;
233
- }
234
-
235
251
  const entries = await readdir(path, { withFileTypes: true });
236
252
  entries.sort((left, right) => left.name.localeCompare(right.name));
237
253
  let hasObservableChild = false;
@@ -341,6 +357,11 @@ export function isPathWithin(root: string, path: string): boolean {
341
357
  return pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`));
342
358
  }
343
359
 
360
+ export function portableRelativePath(root: string, path: string): string {
361
+ const value = relative(root, path).split(sep).join("/");
362
+ return value.length === 0 ? "." : value;
363
+ }
364
+
344
365
  function isExcludedRelativePath(path: string, excluded: ReadonlySet<string> | undefined): boolean {
345
366
  if (!excluded || excluded.size === 0) return false;
346
367
  const normalized = normalizeRelativePath(path);
@@ -361,8 +382,7 @@ function declaredInputPath(root: string, base: string, input: string): string {
361
382
  const path = resolve(base, input);
362
383
  if (!isPathWithin(root, path)) throw new Error(`declared input escapes the repository root: ${input}`);
363
384
  if (!isPathWithin(base, path)) throw new Error(`declared input escapes the workflow cwd: ${input}`);
364
- const relativePath = relative(root, path).split(sep).join("/");
365
- return relativePath.length === 0 ? "." : relativePath;
385
+ return portableRelativePath(root, path);
366
386
  }
367
387
 
368
388
  function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): void {
@@ -370,7 +390,7 @@ function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string>
370
390
  throw new Error(`declared input enters excluded path: ${path}`);
371
391
  }
372
392
 
373
- function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
393
+ export function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
374
394
  if (!excluded) return false;
375
395
  const normalized = normalizeRelativePath(path);
376
396
  for (const candidate of excluded) {
@@ -3,10 +3,14 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { WorkflowBudget } from "./budget.ts";
4
4
  import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
5
5
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
6
+
7
+ /** The host-facing tool this extension registers; subagents also load extensions, so it is withheld from them. */
8
+ export const WORKFLOW_TOOL_NAME = "workflow";
6
9
  import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
7
10
  import type { WorkflowUsageSnapshot } from "./usage.ts";
8
11
  import type { WorktreeBaseline } from "./worktree.ts";
9
12
  import type { WorkflowModelProfileName } from "./model-profiles.ts";
13
+ import type { WorkflowRunState } from "./workflow-run-record.ts";
10
14
 
11
15
  export type { WorkflowLaneItemStatus } from "./progress-types.ts";
12
16
 
@@ -91,6 +95,8 @@ export interface WorkflowRunOptions {
91
95
 
92
96
  export interface WorkflowProgressSource {
93
97
  snapshot(): WorkflowProgressSnapshot;
98
+ /** Durable lifecycle state of the run, so a finished snapshot can tell completed from failed. */
99
+ readonly state?: () => WorkflowRunState;
94
100
  }
95
101
 
96
102
  /** Options for a single `agent()` call. */