pi-workflow-engine 0.12.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 +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 +20 -2
  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 +18 -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 +37 -86
  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 +1 -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 +128 -142
  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 +119 -0
  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 +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  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 +9 -14
  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 +9 -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 -126
  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 +178 -188
  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 +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -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 -49
  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: [],
@@ -1,16 +1,21 @@
1
- import { lstat } from "node:fs/promises";
1
+ import type { Stats } from "node:fs";
2
+ import { lstat, stat } from "node:fs/promises";
2
3
  import { dirname, join, resolve } from "node:path";
3
4
  import { throwIfAborted } from "./cancellation.ts";
4
5
  import { isMissingPathError } from "./filesystem-error.ts";
6
+ import { isGitObjectId, parseGitTopLevel, runGit } from "./git.ts";
7
+ import { isRecord } from "./guards.ts";
5
8
  import type {
6
9
  EffectiveAgentSessionIdentity,
7
10
  EffectiveToolIdentity,
8
11
  } from "./agent-session-identity.ts";
9
- import { runBoundedProcess, type BoundedProcessResult } from "./process-runner.ts";
12
+ import type { BoundedProcessResult } from "./process-runner.ts";
10
13
  import {
11
14
  BoundedFingerprint,
12
15
  captureDeclaredInputFingerprint,
13
- captureTreeFingerprint,
16
+ captureSourceTreeFingerprint,
17
+ FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
18
+ isExcludedDeclaredInput,
14
19
  isPathWithin,
15
20
  resolveDeclaredInputPaths,
16
21
  validateTreeFile,
@@ -57,11 +62,8 @@ export interface AgentResumeBaseContext {
57
62
  readonly workflow: WorkflowResumeContext;
58
63
  }
59
64
 
60
- export interface VerifiedAgentResumeBaseContext {
65
+ export interface AgentResumeContext {
61
66
  readonly workflow: VerifiedWorkflowResumeContext;
62
- }
63
-
64
- export interface AgentResumeContext extends VerifiedAgentResumeBaseContext {
65
67
  readonly repository: VerifiedRepositoryResumeContext;
66
68
  readonly session: EffectiveAgentSessionIdentity;
67
69
  readonly skills: readonly ResolvedSkillIdentity[];
@@ -85,7 +87,6 @@ type BoundedProcessFailureResult = Extract<BoundedProcessResult, { readonly ok:
85
87
  const GIT_CONTEXT_TIMEOUT_MS = 15_000;
86
88
  const GIT_CONTEXT_MAX_BYTES = 32 << 20;
87
89
  const CONTENT_FINGERPRINT_MAX_BYTES = 32 << 20;
88
- const SOURCE_TREE_MAX_FILES = 4096;
89
90
  const REPOSITORY_INPUT_MAX_ENTRIES = 4096;
90
91
  const GIT_UNTRACKED_MAX_ENTRIES = 32_768;
91
92
  const GIT_VISIBLE_PATHS = [
@@ -93,14 +94,6 @@ const GIT_VISIBLE_PATHS = [
93
94
  ":(exclude).pi/.workflow-runs/**",
94
95
  ":(glob,exclude)**/.pi/.workflow-runs/**",
95
96
  ] as const;
96
- export const FINGERPRINT_EXCLUDED_RELATIVE_PATHS = new Set([
97
- ".git",
98
- ".pi/.workflow-runs",
99
- ]);
100
-
101
- export function unverifiableRepositoryResumeContext(reason: string): RepositoryResumeContext {
102
- return { kind: "unverifiable", reason };
103
- }
104
97
 
105
98
  export function unverifiableWorkflowResumeContext(name: string, reason: string): WorkflowResumeContext {
106
99
  return { kind: "unverifiable", name, reason };
@@ -151,9 +144,9 @@ export async function captureIsolatedRepositoryContext(
151
144
  return { kind: "unverifiable", reason: "isolated baseline is not a full commit object ID" };
152
145
  }
153
146
  const [commit, tree, entries] = await Promise.all([
154
- runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{commit}`], signal),
155
- runGit(cwd, ["rev-parse", "--verify", `${baselineOid}^{tree}`], signal),
156
- 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),
157
150
  ]);
158
151
  throwIfAborted(signal);
159
152
  if (!commit.ok) return { kind: "unverifiable", reason: processFailureReason("isolated baseline commit probe", commit) };
@@ -188,7 +181,7 @@ async function captureNonGitRepositoryContext(
188
181
  const capture = await captureRepositoryInputs(cwd, inputs, signal);
189
182
  return capture.kind === "verified"
190
183
  ? { kind: "verified", state: "non-git", workingTreeFingerprint: capture.fingerprint }
191
- : unverifiableRepositoryResumeContext(capture.reason);
184
+ : capture;
192
185
  }
193
186
 
194
187
  async function captureRepositoryInputs(
@@ -239,7 +232,7 @@ async function inspectRepository(
239
232
  signal: AbortSignal | undefined,
240
233
  ): Promise<RepositoryInspection> {
241
234
  throwIfAborted(signal);
242
- 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);
243
236
  throwIfAborted(signal);
244
237
  if (!probe.ok) {
245
238
  if (probe.failure.kind === "exit") {
@@ -257,8 +250,8 @@ async function inspectRepository(
257
250
  }
258
251
 
259
252
  const [rootResult, headResult] = await Promise.all([
260
- runGit(cwd, ["rev-parse", "--show-toplevel"], signal),
261
- 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),
262
255
  ]);
263
256
  throwIfAborted(signal);
264
257
  if (!rootResult.ok) {
@@ -282,57 +275,50 @@ async function captureGitVisibleState(
282
275
  signal: AbortSignal | undefined,
283
276
  ): Promise<FingerprintCapture> {
284
277
  try {
285
- return await captureGitVisibleStateUnchecked(cwd, signal);
286
- } 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
+ ]);
287
294
  throwIfAborted(signal);
288
- return { kind: "unverifiable", reason: unknownErrorMessage(error) };
289
- }
290
- }
291
295
 
292
- async function captureGitVisibleStateUnchecked(
293
- cwd: string,
294
- signal: AbortSignal | undefined,
295
- ): Promise<FingerprintCapture> {
296
- const diffFlags = [
297
- "--binary",
298
- "--full-index",
299
- "--no-ext-diff",
300
- "--no-textconv",
301
- "--no-color",
302
- "--ignore-submodules=none",
303
- ] as const;
304
- const [unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags] = await Promise.all([
305
- runGit(cwd, ["diff", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
306
- runGit(cwd, ["diff", "--raw", "-z", "--no-abbrev", "--no-renames", ...diffFlags.slice(2), "--", ...GIT_VISIBLE_PATHS], signal),
307
- runGit(cwd, ["diff", "--cached", ...diffFlags, "--", ...GIT_VISIBLE_PATHS], signal),
308
- runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...GIT_VISIBLE_PATHS], signal),
309
- runGit(cwd, ["ls-files", "--stage", "--full-name", "-z"], signal),
310
- runGit(cwd, ["ls-files", "-v", "--full-name", "-z"], signal),
311
- ]);
312
- throwIfAborted(signal);
296
+ const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
297
+ if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
313
298
 
314
- const validated = validateGitVisibleOutputs({ unstaged, unstagedRaw, staged, untrackedPaths, indexEntries, trackedFlags });
315
- if (!validated.ok) return { kind: "unverifiable", reason: validated.reason };
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;
316
308
 
317
- const untracked = await captureDeclaredInputFingerprint({
318
- root: cwd,
319
- inputs: validated.untrackedPaths.filter((path) => !isFingerprintExcludedPath(path)),
320
- excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
321
- maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
322
- maxEntries: GIT_UNTRACKED_MAX_ENTRIES,
323
- signal,
324
- });
325
- if (untracked.kind === "unverifiable") return untracked;
326
-
327
- return {
328
- kind: "verified",
329
- fingerprint: combineFingerprints([
330
- ["unstaged", validated.unstaged],
331
- ["staged", validated.staged],
332
- ["index", validated.indexEntries],
333
- ["untracked", untracked.fingerprint],
334
- ]),
335
- };
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
+ }
336
322
  }
337
323
 
338
324
  interface GitVisibleProcessOutputs {
@@ -473,13 +459,7 @@ export async function captureWorkflowResumeContext(
473
459
  return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source file is not part of its source tree: ${validation.reason}`);
474
460
  }
475
461
 
476
- const current = await captureTreeFingerprint({
477
- root: sourceRoot,
478
- excludedRelativePaths: FINGERPRINT_EXCLUDED_RELATIVE_PATHS,
479
- maxBytes: CONTENT_FINGERPRINT_MAX_BYTES,
480
- maxFiles: SOURCE_TREE_MAX_FILES,
481
- signal,
482
- });
462
+ const current = await captureSourceTreeFingerprint(sourceRoot, signal);
483
463
  if (current.kind === "unverifiable") {
484
464
  return unverifiableWorkflowResumeContext(mod.meta.name, `workflow source tree could not be verified: ${current.reason}`);
485
465
  }
@@ -489,18 +469,20 @@ export async function captureWorkflowResumeContext(
489
469
  return { kind: "verified", name: mod.meta.name, sourceFingerprint: mod.source.fingerprint };
490
470
  }
491
471
 
492
- export function createAgentResumeContext(
493
- base: VerifiedAgentResumeBaseContext,
494
- repository: VerifiedRepositoryResumeContext,
495
- session: EffectiveAgentSessionIdentity,
496
- skills: readonly ResolvedSkillIdentity[],
497
- ): AgentResumeContext {
498
- return {
499
- ...base,
500
- repository,
501
- session,
502
- skills: [...skills],
503
- };
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;
504
486
  }
505
487
 
506
488
  export function resumeContextMismatchReason(
@@ -508,17 +490,8 @@ export function resumeContextMismatchReason(
508
490
  current: AgentResumeContext,
509
491
  options: ResumeContextComparisonOptions = {},
510
492
  ): string | undefined {
511
- if (stored.repository.state !== current.repository.state) return "repository state changed";
512
- if (
513
- (stored.repository.state === "git" || stored.repository.state === "isolated") &&
514
- (current.repository.state === "git" || current.repository.state === "isolated") &&
515
- stored.repository.head !== current.repository.head
516
- ) {
517
- return stored.repository.state === "git" ? "repository HEAD changed" : "isolated baseline changed";
518
- }
519
- if (stored.repository.workingTreeFingerprint !== current.repository.workingTreeFingerprint) {
520
- return "working tree contents changed";
521
- }
493
+ const repository = repositoryMismatchReason(stored.repository, current.repository);
494
+ if (repository) return repository;
522
495
  if (stored.workflow.name !== current.workflow.name) return "workflow name changed";
523
496
  if (
524
497
  !options.allowWorkflowSourceMismatch
@@ -559,22 +532,65 @@ export function isAgentResumeContext(value: unknown): value is AgentResumeContex
559
532
  );
560
533
  }
561
534
 
562
- async function runGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
563
- return await runBoundedProcess({
564
- file: "git",
565
- args,
535
+ async function runContextGit(cwd: string, args: readonly string[], signal: AbortSignal | undefined): Promise<BoundedProcessResult> {
536
+ return await runGit({
566
537
  cwd,
567
- env: { ...process.env, GIT_EXTERNAL_DIFF: "", GIT_DIFF_OPTS: "" },
538
+ args,
539
+ label: "git resume-context capture",
568
540
  signal,
569
541
  timeoutMs: GIT_CONTEXT_TIMEOUT_MS,
570
542
  maxBufferBytes: GIT_CONTEXT_MAX_BYTES,
571
- abortError: "git resume-context capture aborted",
572
- timeoutError: `git resume-context capture timed out after ${GIT_CONTEXT_TIMEOUT_MS}ms`,
573
- maxBufferError: `git resume-context capture exceeded ${GIT_CONTEXT_MAX_BYTES} bytes`,
574
- exitError: (stderr, code, exitSignal) => stderr.trim() || `git exited with code ${code ?? `signal ${exitSignal ?? "unknown"}`}`,
575
543
  });
576
544
  }
577
545
 
546
+ /**
547
+ * Decide whether a `.git` entry found during the ancestor walk marks a repository
548
+ * that git may have failed to open, or a stray entry that git's own discovery
549
+ * also skips.
550
+ *
551
+ * The rule mirrors git's discovery loop so that "absent" means git itself would
552
+ * have walked past every `.git` entry and its "not a repository" answer is
553
+ * authoritative:
554
+ *
555
+ * - A regular file is a gitfile. Git halts on any regular `.git` file, even when
556
+ * its content is malformed or its `gitdir:` target is missing, so the entry is
557
+ * a marker regardless of content.
558
+ * - A directory is a repository only when `HEAD`, `objects/`, and `refs/` all
559
+ * exist. Git skips a directory that lacks any of them (an empty `.git` is the
560
+ * common case), so such a directory is not a marker.
561
+ * - A symbolic link is resolved first, as git uses `stat`. A dangling link is
562
+ * skipped by git and is therefore not a marker.
563
+ * - Any other entry type (socket, FIFO, device) is skipped by git.
564
+ *
565
+ * A missing path (`ENOENT`) is a negative answer, not a failure. Any other
566
+ * filesystem error propagates so the caller reports the probe as "unknown".
567
+ */
568
+ async function isRepositoryMarker(entryPath: string, entry: Stats): Promise<boolean> {
569
+ let target = entry;
570
+ if (entry.isSymbolicLink()) {
571
+ const resolved = await statIfPresent(entryPath);
572
+ if (!resolved) return false;
573
+ target = resolved;
574
+ }
575
+ if (target.isFile()) return true;
576
+ if (!target.isDirectory()) return false;
577
+ for (const name of GIT_DIRECTORY_REQUIRED_ENTRIES) {
578
+ if (!(await statIfPresent(join(entryPath, name)))) return false;
579
+ }
580
+ return true;
581
+ }
582
+
583
+ const GIT_DIRECTORY_REQUIRED_ENTRIES = ["HEAD", "objects", "refs"] as const;
584
+
585
+ async function statIfPresent(path: string): Promise<Stats | undefined> {
586
+ try {
587
+ return await stat(path);
588
+ } catch (error) {
589
+ if (isMissingPathError(error)) return undefined;
590
+ throw error;
591
+ }
592
+ }
593
+
578
594
  type GitControlPathResult =
579
595
  | { readonly kind: "present" | "absent" }
580
596
  | { readonly kind: "unknown"; readonly reason: string };
@@ -583,9 +599,10 @@ async function findGitControlPath(cwd: string, signal: AbortSignal | undefined):
583
599
  let current = resolve(cwd);
584
600
  while (true) {
585
601
  throwIfAborted(signal);
602
+ const entryPath = join(current, ".git");
586
603
  try {
587
- await lstat(join(current, ".git"));
588
- return { kind: "present" };
604
+ const entry = await lstat(entryPath);
605
+ if (await isRepositoryMarker(entryPath, entry)) return { kind: "present" };
589
606
  } catch (error) {
590
607
  if (!isMissingPathError(error)) {
591
608
  return { kind: "unknown", reason: `git control-path probe failed: ${unknownErrorMessage(error)}` };
@@ -665,37 +682,6 @@ function parseNullTerminatedRecords(output: string, operation: string): string[]
665
682
  return output.slice(0, -1).split("\0");
666
683
  }
667
684
 
668
- function parseGitTopLevel(output: string, cwd: string): string | undefined {
669
- const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
670
- const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
671
- if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
672
- const root = resolve(cwd, value);
673
- return isPathWithin(root, cwd) ? root : undefined;
674
- }
675
-
676
- function isGitObjectId(value: string): boolean {
677
- return /^[0-9a-f]{40,64}$/i.test(value);
678
- }
679
-
680
- function isFingerprintExcludedPath(path: string): boolean {
681
- const normalized = path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
682
- for (const excluded of FINGERPRINT_EXCLUDED_RELATIVE_PATHS) {
683
- if (
684
- normalized === excluded ||
685
- normalized.startsWith(`${excluded}/`) ||
686
- normalized.endsWith(`/${excluded}`) ||
687
- normalized.includes(`/${excluded}/`)
688
- ) {
689
- return true;
690
- }
691
- }
692
- return false;
693
- }
694
-
695
- function isRecord(value: unknown): value is Record<string, unknown> {
696
- return typeof value === "object" && value !== null;
697
- }
698
-
699
685
  function sanitizeProcessMessage(message: string): string {
700
686
  return message.trim().replace(/\s+/g, " ").slice(0, 500);
701
687
  }