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,8 +1,10 @@
1
1
  import { WorkflowPauseError } from "./cancellation.ts";
2
+ import { isRecord } from "./guards.ts";
2
3
  import {
3
4
  WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
4
5
  type ResolvedWorkflowRunOptions,
5
6
  } from "./options.ts";
7
+ import { truncateText } from "./text.ts";
6
8
  import type { WorkflowProviderUsageLimitPause } from "./workflow-run-record.ts";
7
9
 
8
10
  export const WORKFLOW_PROVIDER_USAGE_LIMIT_CODE = "WORKFLOW_PROVIDER_USAGE_LIMIT";
@@ -43,25 +45,33 @@ export class WorkflowProviderUsageLimitError extends WorkflowPauseError {
43
45
  }
44
46
  }
45
47
 
46
- export function providerUsageLimitFromMessages(
47
- messages: readonly unknown[],
48
- now = Date.now(),
49
- ): WorkflowProviderUsageLimitError | undefined {
48
+ /** The last assistant message when it ended its turn with a provider error, its error text ("" if absent), and its non-empty provider metadata. */
49
+ export function lastAssistantError(messages: readonly unknown[]) {
50
50
  const message = messages.findLast(isAssistantMessage);
51
51
  if (!message || message.stopReason !== "error") return undefined;
52
- const providerMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
53
- if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
54
- const reset = parseProviderResetHint(providerMessage, now);
55
52
  const provider = stringDetail(message.provider);
56
53
  const model = stringDetail(message.model);
57
54
  const api = stringDetail(message.api);
55
+ return {
56
+ message,
57
+ errorMessage: typeof message.errorMessage === "string" ? message.errorMessage : "",
58
+ details: { ...(provider ? { provider } : {}), ...(model ? { model } : {}), ...(api ? { api } : {}) },
59
+ };
60
+ }
61
+
62
+ export function providerUsageLimitFromMessages(
63
+ messages: readonly unknown[],
64
+ now = Date.now(),
65
+ ): WorkflowProviderUsageLimitError | undefined {
66
+ const failure = lastAssistantError(messages);
67
+ if (!failure) return undefined;
68
+ const providerMessage = failure.errorMessage.trim();
69
+ if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
58
70
  return new WorkflowProviderUsageLimitError({
59
71
  stopReason: "error",
60
72
  providerMessage,
61
- ...(provider ? { provider } : {}),
62
- ...(model ? { model } : {}),
63
- ...(api ? { api } : {}),
64
- ...reset,
73
+ ...failure.details,
74
+ ...parseProviderResetHint(providerMessage, now),
65
75
  });
66
76
  }
67
77
 
@@ -119,8 +129,9 @@ export function parseProviderResetHint(
119
129
  : { resetHint: boundedHint(iso) };
120
130
  }
121
131
 
132
+ // Units must be whole words on the hint's line ("6m0s" stays compact; "1 month" and "30\nmessage" are not durations).
122
133
  const labelled = message.match(
123
- /(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)?\s*(?:milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\s*)+)/i,
134
+ /(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)?[ \t]*(?:milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)(?![a-z])[ \t]*)+)/i,
124
135
  );
125
136
  if (labelled?.[1]) {
126
137
  const delayMs = parseDurationMs(labelled[1]);
@@ -150,27 +161,24 @@ function isUsageLimitMessage(message: string): boolean {
150
161
  || /\b(?:usage|rate)\s+limit\s+(?:(?:has\s+been|was)\s+)?(?:reached|exceeded)\b/i.test(message);
151
162
  }
152
163
 
164
+ /** Sum a duration already captured by parseProviderResetHint's labelled grammar. */
153
165
  function parseDurationMs(value: string): number | undefined {
154
166
  const unitPattern = /(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)/gi;
155
167
  let total = 0;
156
- let matched = "";
157
168
  for (const part of value.matchAll(unitPattern)) {
158
169
  const amount = Number(part[1]);
159
- const unit = part[2]?.toLowerCase();
160
- if (!Number.isFinite(amount) || !unit) return undefined;
161
- matched += part[0];
170
+ const unit = (part[2] ?? "").toLowerCase();
162
171
  if (unit === "ms" || unit.startsWith("millisecond")) total += amount;
163
172
  else if (unit === "s" || unit.startsWith("sec")) total += amount * 1_000;
164
173
  else if (unit === "m" || unit.startsWith("min")) total += amount * 60_000;
165
174
  else if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) total += amount * 3_600_000;
166
175
  else total += amount * 86_400_000;
167
176
  }
168
- if (!matched || value.replace(/\s+/g, "") !== matched.replace(/\s+/g, "")) return undefined;
169
- return Number.isFinite(total) && total >= 0 ? total : undefined;
177
+ return Number.isFinite(total) ? total : undefined;
170
178
  }
171
179
 
172
180
  function boundedHint(value: string): string {
173
- return value.length <= MAX_RESET_HINT_LENGTH ? value : `${value.slice(0, MAX_RESET_HINT_LENGTH - 1)}…`;
181
+ return truncateText(value, MAX_RESET_HINT_LENGTH);
174
182
  }
175
183
 
176
184
  function isAssistantMessage(value: unknown): value is {
@@ -181,7 +189,7 @@ function isAssistantMessage(value: unknown): value is {
181
189
  readonly model?: unknown;
182
190
  readonly api?: unknown;
183
191
  } {
184
- return typeof value === "object" && value !== null && "role" in value && value.role === "assistant";
192
+ return isRecord(value) && value.role === "assistant";
185
193
  }
186
194
 
187
195
  function stringDetail(value: unknown): string | undefined {
@@ -1,5 +1,5 @@
1
- import { relative, resolve, sep } from "node:path";
2
- import { isPathWithin } from "./tree-fingerprint.ts";
1
+ import { resolve } from "node:path";
2
+ import { isPathWithin, portableRelativePath } from "./tree-fingerprint.ts";
3
3
 
4
4
  export interface ReplayWorkspaceRoots {
5
5
  readonly sessionCwd: string;
@@ -20,11 +20,6 @@ export function normalizeWorkspaceReferences(value: string, roots: ReplayWorkspa
20
20
  return paths.reduce((normalized, path) => replacePathPrefix(normalized, path, "<workspace>"), value);
21
21
  }
22
22
 
23
- export function portableRelativePath(root: string, path: string): string {
24
- const value = relative(root, path).split(sep).join("/");
25
- return value.length === 0 ? "." : value;
26
- }
27
-
28
23
  function resolvedWorkspaceRoots(roots: ReplayWorkspaceRoots): readonly string[] {
29
24
  return [...new Set([resolve(roots.sessionCwd), resolve(roots.workspaceRoot)])];
30
25
  }
@@ -39,7 +39,6 @@ export const ResearchEvidenceSchema = Type.Object({
39
39
  export type ResearchEvidence = Static<typeof ResearchEvidenceSchema>;
40
40
 
41
41
  export const ResearchLaneResultSchema = Type.Object({
42
- laneId: Type.String({ minLength: 1 }),
43
42
  evidence: Type.Array(ResearchEvidenceSchema, { maxItems: MAX_LANE_EVIDENCE }),
44
43
  gaps: Type.Array(Type.String()),
45
44
  });
@@ -1,5 +1,4 @@
1
1
  import {
2
- MAX_RESEARCH_LANES,
3
2
  MAX_VERIFICATION_CLAIMS,
4
3
  type ResearchClaimCandidate,
5
4
  type ResearchEvidence,
@@ -22,31 +21,21 @@ export function normalizeResearchLanes(plan: ResearchPlan): ResearchLane[] {
22
21
  const id = normalizedText(lane.id);
23
22
  const title = normalizedText(lane.title);
24
23
  const objective = normalizedText(lane.objective);
25
- const queries = dedupeText(lane.queries).slice(0, 4);
24
+ const queries = dedupeText(lane.queries);
26
25
  if (!id || !title || !objective || queries.length === 0 || seen.has(id.toLowerCase())) continue;
27
26
  seen.add(id.toLowerCase());
28
27
  lanes.push({ id, title, objective, queries });
29
- if (lanes.length >= MAX_RESEARCH_LANES) break;
30
28
  }
31
29
  return lanes;
32
30
  }
33
31
 
34
- export function sanitizeLaneResults(results: readonly ResearchLaneResult[]): ResearchLaneResult[] {
35
- return results.map((result) => ({
36
- laneId: normalizedText(result.laneId),
37
- gaps: dedupeText(result.gaps),
38
- evidence: dedupeEvidence(result.evidence.map(sanitizeEvidence).filter(isDefined)),
39
- }));
40
- }
41
-
42
32
  export function buildClaimCandidates(results: readonly ResearchLaneResult[]): ResearchClaimCandidate[] {
43
33
  const byClaim = new Map<string, { claim: string; importance: ResearchEvidence["importance"]; evidence: ResearchEvidence[] }>();
44
- for (const item of results.flatMap((result) => result.evidence)) {
45
- const key = normalizedText(item.claim).toLowerCase();
46
- if (!key) continue;
34
+ for (const item of results.flatMap((result) => result.evidence.map(sanitizeEvidence).filter(isDefined))) {
35
+ const key = item.claim.toLowerCase();
47
36
  const current = byClaim.get(key);
48
37
  if (!current) {
49
- byClaim.set(key, { claim: normalizedText(item.claim), importance: item.importance, evidence: [item] });
38
+ byClaim.set(key, { claim: item.claim, importance: item.importance, evidence: [item] });
50
39
  continue;
51
40
  }
52
41
  if (IMPORTANCE_RANK[item.importance] < IMPORTANCE_RANK[current.importance]) current.importance = item.importance;
@@ -142,10 +131,7 @@ function verificationSources(
142
131
  return byClaim;
143
132
  }
144
133
 
145
- export function fallbackResearchReport(
146
- verifications: readonly ResearchVerification[],
147
- limitation: string,
148
- ): ResearchReport {
134
+ export function fallbackResearchReport(verifications: readonly ResearchVerification[]): ResearchReport {
149
135
  const entry = (verification: ResearchVerification): ResearchReportEntry => ({
150
136
  claim: verification.claim,
151
137
  explanation: verification.explanation,
@@ -158,22 +144,32 @@ export function fallbackResearchReport(
158
144
  uncertainties: verifications.filter((item) => item.verdict === "UNCERTAIN").map(entry),
159
145
  inferences: verifications.filter((item) => item.verdict === "INFERENCE").map(entry),
160
146
  sources: dedupeSources(verifications.flatMap((verification) => verification.sources)),
161
- limitations: [limitation],
147
+ limitations: ["The synthesis stage failed; verified claims are listed without a narrative answer."],
162
148
  nextSteps: ["Review the verified claim groups and rerun synthesis if a narrative answer is required."],
163
149
  };
164
150
  }
165
151
 
166
- export function unavailableResearchReport(reason: "empty-question" | "missing-capability" | "no-evidence"): ResearchReport {
167
- const message = reason === "empty-question"
168
- ? "No research question was provided."
169
- : reason === "missing-capability"
170
- ? "Research could not start because pi exposed no installed external web-search or URL-extraction tool."
171
- : "Research completed without enough direct-page evidence to support an answer.";
172
- const nextStep = reason === "empty-question"
173
- ? "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments."
174
- : reason === "missing-capability"
175
- ? "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow."
176
- : "Check the installed external-search tool, narrow the question, or provide preferred source domains.";
152
+ const UNAVAILABLE_RESEARCH = {
153
+ "empty-question": {
154
+ message: "No research question was provided.",
155
+ nextStep: "Run `/workflow research <question>` and include any source, date, or geography constraints in the arguments.",
156
+ },
157
+ "missing-capability": {
158
+ message: "Research could not start because pi exposed no installed external web-search or URL-extraction tool.",
159
+ nextStep: "Install or enable a pi tool that can search the web or extract HTTP(S) pages, then rerun the workflow.",
160
+ },
161
+ "lanes-failed": {
162
+ message: "Research could not gather direct-page evidence because one or more research lanes failed.",
163
+ nextStep: "Check the installed external-search tool and its provider, then rerun the workflow.",
164
+ },
165
+ "no-evidence": {
166
+ message: "Research completed without enough direct-page evidence to support an answer.",
167
+ nextStep: "Check the installed external-search tool, narrow the question, or provide preferred source domains.",
168
+ },
169
+ } as const;
170
+
171
+ export function unavailableResearchReport(reason: keyof typeof UNAVAILABLE_RESEARCH): ResearchReport {
172
+ const { message, nextStep } = UNAVAILABLE_RESEARCH[reason];
177
173
  return {
178
174
  answer: message,
179
175
  supportedClaims: [],
@@ -3,15 +3,19 @@ import { lstat, stat } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { throwIfAborted } from "./cancellation.ts";
5
5
  import { isMissingPathError } from "./filesystem-error.ts";
6
+ import { isGitObjectId, parseGitTopLevel, runGit } from "./git.ts";
7
+ import { isRecord } from "./guards.ts";
6
8
  import type {
7
9
  EffectiveAgentSessionIdentity,
8
10
  EffectiveToolIdentity,
9
11
  } from "./agent-session-identity.ts";
10
- import { runBoundedProcess, type BoundedProcessResult } from "./process-runner.ts";
12
+ import type { BoundedProcessResult } from "./process-runner.ts";
11
13
  import {
12
14
  BoundedFingerprint,
13
15
  captureDeclaredInputFingerprint,
14
- captureTreeFingerprint,
16
+ captureSourceTreeFingerprint,
17
+ FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
18
+ isExcludedDeclaredInput,
15
19
  isPathWithin,
16
20
  resolveDeclaredInputPaths,
17
21
  validateTreeFile,
@@ -58,11 +62,8 @@ export interface AgentResumeBaseContext {
58
62
  readonly workflow: WorkflowResumeContext;
59
63
  }
60
64
 
61
- export interface VerifiedAgentResumeBaseContext {
65
+ export interface AgentResumeContext {
62
66
  readonly workflow: VerifiedWorkflowResumeContext;
63
- }
64
-
65
- export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
66
67
  readonly repository: VerifiedRepositoryResumeContext;
67
68
  readonly session: EffectiveAgentSessionIdentity;
68
69
  readonly skills: readonly ResolvedSkillIdentity[];
@@ -86,7 +87,6 @@ type BoundedProcessFailureResult = Extract<BoundedProcessResult, { readonly ok:
86
87
  const GIT_CONTEXT_TIMEOUT_MS = 15_000;
87
88
  const GIT_CONTEXT_MAX_BYTES = 32 << 20;
88
89
  const CONTENT_FINGERPRINT_MAX_BYTES = 32 << 20;
89
- const SOURCE_TREE_MAX_FILES = 4096;
90
90
  const REPOSITORY_INPUT_MAX_ENTRIES = 4096;
91
91
  const GIT_UNTRACKED_MAX_ENTRIES = 32_768;
92
92
  const GIT_VISIBLE_PATHS = [
@@ -94,14 +94,6 @@ const GIT_VISIBLE_PATHS = [
94
94
  ":(exclude).pi/.workflow-runs/**",
95
95
  ":(glob,exclude)**/.pi/.workflow-runs/**",
96
96
  ] as const;
97
- export const FINGERPRINT_EXCLUDED_RELATIVE_PATHS = new Set([
98
- ".git",
99
- ".pi/.workflow-runs",
100
- ]);
101
-
102
- export function unverifiableRepositoryResumeContext(reason: string): RepositoryResumeContext {
103
- return { kind: "unverifiable", reason };
104
- }
105
97
 
106
98
  export function unverifiableWorkflowResumeContext(name: string, reason: string): WorkflowResumeContext {
107
99
  return { kind: "unverifiable", name, reason };
@@ -152,9 +144,9 @@ export async function captureIsolatedRepositoryContext(
152
144
  return { kind: "unverifiable", reason: "isolated baseline is not a full commit object ID" };
153
145
  }
154
146
  const [commit, tree, entries] = await Promise.all([
155
- runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{commit}`], signal),
156
- runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{tree}`], signal),
157
- runGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", baselineOid], signal),
147
+ runContextGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{commit}`], signal),
148
+ runContextGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{tree}`], signal),
149
+ runContextGit(cwd, ["ls-tree", "-r", "-z", "--full-tree", baselineOid], signal),
158
150
  ]);
159
151
  throwIfAborted(signal);
160
152
  if (!commit.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline commit probe", commit) };
@@ -189,7 +181,7 @@ async function captureNonGitRepositoryContext(
189
181
  const capture = await captureRepositoryInputs(cwd, inputs, signal);
190
182
  return capture.kind === "verified"
191
183
  ? { kind: "verified", state: "non-git", workingTreeFingerprint: capture.fingerprint }
192
- : unverifiableRepositoryResumeContext(capture.reason);
184
+ : capture;
193
185
  }
194
186
 
195
187
  async function captureRepositoryInputs(
@@ -240,7 +232,7 @@ async function inspectRepository(
240
232
  signal: AbortSignal | undefined,
241
233
  ): Promise<RepositoryInspection> {
242
234
  throwIfAborted(signal);
243
- const probe = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"], signal);
235
+ const probe = await runContextGit(cwd, ["rev-parse", "--is-inside-work-tree"], signal);
244
236
  throwIfAborted(signal);
245
237
  if (!probe.ok) {
246
238
  if (probe.failure.kind === "exit") {
@@ -258,8 +250,8 @@ async function inspectRepository(
258
250
  }
259
251
 
260
252
  const [rootResult, headResult] = await Promise.all([
261
- runGit(cwd, ["rev-parse", "--show-toplevel"], signal),
262
- runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], signal),
253
+ runContextGit(cwd, ["rev-parse", "--show-toplevel"], signal),
254
+ runContextGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD"], signal),
263
255
  ]);
264
256
  throwIfAborted(signal);
265
257
  if (!rootResult.ok) {
@@ -283,57 +275,50 @@ async function captureGitVisibleState(
283
275
  signal: AbortSignal | undefined,
284
276
  ): Promise<FingerprintCapture> {
285
277
  try {
286
- return await captureGitVisibleStateUnchecked(cwd, signal);
287
- } catch (error) {
278
+ const diffFlags = [
279
+ "--binary",
280
+ "--full-index",
281
+ "--no-ext-diff",
282
+ "--no-textconv",
283
+ "--no-color",
284
+ "--ignore-submodules=none",
285
+ ] as const;
286
+ const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
287
+ runContextGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
288
+ runContextGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
289
+ runContextGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
290
+ runContextGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
291
+ runContextGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
292
+ runContextGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
293
+ ]);
288
294
  throwIfAborted(signal);
289
- return { kind: "unverifiable", reason: unknownErrorMessage(error) };
290
- }
291
- }
292
-
293
- async function captureGitVisibleStateUnchecked(
294
- cwd: string,
295
- signal: AbortSignal | undefined,
296
- ): Promise<FingerprintCapture> {
297
- const diffFlags = [
298
- "--binary",
299
- "--full-index",
300
- "--no-ext-diff",
301
- "--no-textconv",
302
- "--no-color",
303
- "--ignore-submodules=none",
304
- ] as const;
305
- const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
306
- runGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
307
- runGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
308
- runGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
309
- runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
310
- runGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
311
- runGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
312
- ]);
313
- throwIfAborted(signal);
314
295
 
315
- const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
316
- if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
296
+ const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
297
+ if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
317
298
 
318
- const untracked = await captureDeclaredInputFingerprint({
319
- root: cwd,
320
- inputs: validated.untrackedPaths.filter((path) => !isFingerprintExcludedPath(path)),
321
- excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
322
- maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
323
- maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
324
- signal,
325
- });
326
- if (untracked.kind === "unverifiable") return untracked;
299
+ const untracked = await captureDeclaredInputFingerprint({
300
+ root: cwd,
301
+ inputs: validated.untrackedPaths.filter((path) => !isExcludedDeclaredInput(path, FINGERPRINT_EXCLUDED_RELATIVE_PATHS)),
302
+ excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
303
+ maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
304
+ maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
305
+ signal,
306
+ });
307
+ if (untracked.kind === "unverifiable") return untracked;
327
308
 
328
- return {
329
- kind: "verified",
330
- fingerprint: combineFingerprints([
331
- ["unstaged", validated.unstaged],
332
- ["staged", validated.staged],
333
- ["index", validated.indexEntries],
334
- ["untracked", untracked.fingerprint],
335
- ]),
336
- };
309
+ return {
310
+ kind: "verified",
311
+ fingerprint: combineFingerprints([
312
+ ["unstaged", validated.unstaged],
313
+ ["staged", validated.staged],
314
+ ["index", validated.indexEntries],
315
+ ["untracked", untracked.fingerprint],
316
+ ]),
317
+ };
318
+ } catch (error) {
319
+ throwIfAborted(signal);
320
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
321
+ }
337
322
  }
338
323
 
339
324
  interface GitVisibleProcessOutputs {
@@ -474,13 +459,7 @@ export async function captureWorkflowResumeContext(
474
459
  return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source file is not part of its source tree: ${validation.reason}`);
475
460
  }
476
461
 
477
- const current = await captureTreeFingerprint({
478
- root: sourceRoot,
479
- excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
480
- maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
481
- maxFiles: SOURCE_TREE_MAX_FILES,
482
- signal,
483
- });
462
+ const current = await captureSourceTreeFingerprint(sourceRoot, signal);
484
463
  if (current.kind === "unverifiable") {
485
464
  return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source tree could not be verified: ${current.reason}`);
486
465
  }
@@ -490,18 +469,20 @@ export async function captureWorkflowResumeContext(
490
469
  return { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint };
491
470
  }
492
471
 
493
- export function createAgentResumeContext(
494
- base: VerifiedAgentResumeBaseContext,
495
- repository: VerifiedRepositoryResumeContext,
496
- session: EffectiveAgentSessionIdentity,
497
- skills: readonly ResolvedSkillIdentity[],
498
- ): AgentResumeContext {
499
- return {
500
- ...base,
501
- repository,
502
- session,
503
- skills: [...skills],
504
- };
472
+ export function repositoryMismatchReason(
473
+ stored: VerifiedRepositoryResumeContext,
474
+ current: VerifiedRepositoryResumeContext,
475
+ ): string | undefined {
476
+ if (stored.state !== current.state) return "repository state changed";
477
+ if (
478
+ (stored.state === "git" || stored.state === "isolated") &&
479
+ (current.state === "git" || current.state === "isolated") &&
480
+ stored.head !== current.head
481
+ ) {
482
+ return stored.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
483
+ }
484
+ if (stored.workingTreeFingerprint !== current.workingTreeFingerprint) return "working tree contents changed";
485
+ return undefined;
505
486
  }
506
487
 
507
488
  export function resumeContextMismatchReason(
@@ -509,17 +490,8 @@ export function resumeContextMismatchReason(
509
490
  current: AgentResumeContext,
510
491
  options: ResumeContextComparisonOptions = {},
511
492
  ): string | undefined {
512
- if (stored.repository.state !== current.repository.state) return "repository state changed";
513
- if (
514
- (stored.repository.state === "git" || stored.repository.state === "isolated") &&
515
- (current.repository.state === "git" || current.repository.state === "isolated") &&
516
- stored.repository.head !== current.repository.head
517
- ) {
518
- return stored.repository.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
519
- }
520
- if (stored.repository.workingTreeFingerprint !== current.repository.workingTreeFingerprint) {
521
- return "working tree contents changed";
522
- }
493
+ const repository = repositoryMismatchReason(stored.repository, current.repository);
494
+ if (repository) return repository;
523
495
  if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
524
496
  if (
525
497
  !options.allowWorkflowSourceMismatch
@@ -560,19 +532,14 @@ export function isAgentResumeContext(value: unknown): value is AgentResumeContex
560
532
  );
561
533
  }
562
534
 
563
- async function runGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
564
- return await runBoundedProcess({
565
- file: "git",
566
- args,
535
+ async function runContextGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
536
+ return await runGit({
567
537
  cwd,
568
- env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
538
+ args,
539
+ label: "git resume-context capture",
569
540
  signal,
570
541
  timeoutMs: GIT_CONTEXT_TIMEOUT_MS,
571
542
  maxBufferBytes: GIT_CONTEXT_MAX_BYTES,
572
- abortError: "git resume-context capture aborted",
573
- timeoutError: `git resume-context capture timed out after ${GIT_CONTEXT_TIMEOUT_MS}ms`,
574
- maxBufferError: `git resume-context capture exceeded ${GIT_CONTEXT_MAX_BYTES} bytes`,
575
- exitError: (stderr, code, exitSignal) => stderr.trim() || `git exited with code ${code ?? `signal ${exitSignal ?? "unknown"}`}`,
576
543
  });
577
544
  }
578
545
 
@@ -715,37 +682,6 @@ function parseNullTerminatedRecords(output: string, operation: string): string[]
715
682
  return output.slice(0, -1).split("\0");
716
683
  }
717
684
 
718
- function parseGitTopLevel(output: string, cwd: string): string | undefined {
719
- const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
720
- const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
721
- if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
722
- const root = resolve(cwd, value);
723
- return isPathWithin(root, cwd) ? root : undefined;
724
- }
725
-
726
- function isGitObjectId(value: string): boolean {
727
- return /^[0-9a-f]{40,64}$/i.test(value);
728
- }
729
-
730
- function isFingerprintExcludedPath(path: string): boolean {
731
- const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
732
- for (const excluded of FINGERPRINT_EXCLUDED_RELATIVE_PATHS) {
733
- if (
734
- normalized === excluded ||
735
- normalized.startsWith(`${excluded}/`) ||
736
- normalized.endsWith(`/${excluded}`) ||
737
- normalized.includes(`/${excluded}/`)
738
- ) {
739
- return true;
740
- }
741
- }
742
- return false;
743
- }
744
-
745
- function isRecord(value: unknown): value is Record<string, unknown> {
746
- return typeof value === "object" && value !== null;
747
- }
748
-
749
685
  function sanitizeProcessMessage(message: string): string {
750
686
  return message.trim().replace(/\s+/g, " ").slice(0, 500);
751
687
  }