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
@@ -0,0 +1,75 @@
1
+ import { Type, type Static } from "typebox";
2
+ import { collectAdvisoryStage, type AdvisoryStageCoverage } from "./advisory-evidence.ts";
3
+ import { DEFAULT_ADVISORY_TOOLS, DEFAULT_ADVISORY_TOOL_HINTS, type AdvisoryVerified } from "./workflow-advisory-utils.ts";
4
+ import type { WorkflowApi } from "./types.ts";
5
+
6
+ const ChallengeSchema = Type.Object({
7
+ outcome: Type.Union([Type.Literal("counterexample"), Type.Literal("alternative-explanation"), Type.Literal("supports-original"), Type.Literal("no-counterexample")]),
8
+ evidence: Type.Array(Type.String()),
9
+ experiment: Type.String({ description: "Smallest distinguishing experiment, with observed result if actually run." }),
10
+ });
11
+ const AdjudicationSchema = Type.Object({
12
+ outcome: Type.Union([Type.Literal("upheld"), Type.Literal("refuted"), Type.Literal("unresolved")]),
13
+ evidence: Type.Array(Type.String()),
14
+ reason: Type.String(),
15
+ });
16
+ export type ChallengeRecord =
17
+ | { status: "complete"; challenge: Static<typeof ChallengeSchema>; adjudication: Static<typeof AdjudicationSchema> }
18
+ | { status: "failed"; challenge?: Static<typeof ChallengeSchema> };
19
+ export interface AdvisoryChallengeOptions {
20
+ maxChallenges: number;
21
+ shouldChallenge?: (finding: AdvisoryVerified) => boolean;
22
+ }
23
+
24
+ /** Opt in with --challenge or --challenge=N (hard bound of 10). */
25
+ export function parseChallengeArgs(args: string): { args: string; options: AdvisoryChallengeOptions } {
26
+ let maxChallenges = 0;
27
+ const remaining = args.replace(/(?:^|\s)--challenge(?:=([^\s]*))?(?=\s|$)/g, (_match, limit: string | undefined) => {
28
+ if (limit !== undefined && !/^\d+$/.test(limit)) {
29
+ throw new Error(`Invalid --challenge value "${limit}". Use --challenge or --challenge=N with a non-negative integer; zero disables challenges.`);
30
+ }
31
+ maxChallenges = Math.min(10, limit === undefined ? 3 : Number(limit));
32
+ return " ";
33
+ });
34
+ return { args: remaining.trim(), options: { maxChallenges } };
35
+ }
36
+
37
+ export function needsChallenge(finding: AdvisoryVerified): boolean {
38
+ return finding.verdict === "PLAUSIBLE" || finding.verdict === "NOT_SUBSTANTIATED" || finding.evidence.length === 0 ||
39
+ /\b(security|concurren\w*|race|persist\w*|cancel\w*|retr(?:y|ies)|data loss|corrupt\w*|high impact)\b/i.test(`${finding.category} ${finding.summary} ${finding.impact}`);
40
+ }
41
+
42
+ /** A bounded recipe using existing agent/parallel calls, not a new runtime primitive. */
43
+ export async function challengeFindings<T extends AdvisoryVerified>(
44
+ api: Pick<WorkflowApi, "agent" | "parallel">,
45
+ findings: T[],
46
+ context: string,
47
+ options: AdvisoryChallengeOptions,
48
+ coverage: AdvisoryStageCoverage[],
49
+ ): Promise<T[]> {
50
+ const limit = Number.isFinite(options.maxChallenges) ? Math.max(0, Math.min(10, Math.trunc(options.maxChallenges))) : 0;
51
+ if (limit === 0) return findings;
52
+ const selected = findings.filter((finding) => finding.verdict !== "REFUTED" && (options.shouldChallenge ?? needsChallenge)(finding)).slice(0, limit);
53
+ const replacements = new Map<string, T>();
54
+ await collectAdvisoryStage(api, "Challenge", selected.map((finding) => ({ id: finding.candidateId, run: async () => {
55
+ // Preserve the candidate as unresolved if either independent stage fails.
56
+ replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed" } });
57
+ const challenge = await api.agent(
58
+ `Assume this finding is a false positive. Try to DISPROVE it. Find the strongest concrete counterexample or alternative root cause. Inspect callers, invariants, tests and control flow. For a repair, seek an input, race or error path that still fails. State the smallest experiment distinguishing explanations. Do not edit files or claim tests you did not run. No counterexample found is not proof.\n\nExact review context:\n${context}\n\nCandidate and verifier evidence:\n${JSON.stringify(finding)}`,
59
+ { label: `challenge:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: DEFAULT_ADVISORY_TOOLS, toolHints: DEFAULT_ADVISORY_TOOL_HINTS, schema: ChallengeSchema },
60
+ );
61
+ replacements.set(finding.candidateId, { ...finding, verdict: "NOT_SUBSTANTIATED", challenge: { status: "failed", challenge } });
62
+ const adjudication = await api.agent(
63
+ `Adjudicate the original finding, independent verifier evidence and falsification attempt below. Preserve unresolved conflict; do not force consensus. A missing counterexample alone cannot upgrade a plausible claim. Cite concrete evidence and observed test results; never invent experiments.\nContext:\n${context}\nOriginal and verifier:\n${JSON.stringify(finding)}\nChallenger:\n${JSON.stringify(challenge)}`,
64
+ { label: `adjudicate:${finding.candidateId}`, phase: "Challenge", profile: "medium", tools: [], skills: [], schema: AdjudicationSchema },
65
+ );
66
+ replacements.set(finding.candidateId, {
67
+ ...finding,
68
+ verdict: adjudication.outcome === "refuted" ? "REFUTED" : adjudication.outcome === "unresolved" ? "NOT_SUBSTANTIATED" : finding.verdict,
69
+ evidence: [...finding.evidence, ...challenge.evidence, ...(challenge.experiment ? [challenge.experiment] : []), ...adjudication.evidence, adjudication.reason],
70
+ challenge: { status: "complete", challenge, adjudication },
71
+ });
72
+ return finding.candidateId;
73
+ } })), coverage);
74
+ return findings.map((finding) => replacements.get(finding.candidateId) ?? finding);
75
+ }
@@ -0,0 +1,90 @@
1
+ import { createHash } from "node:crypto";
2
+ import { Type, type Static } from "typebox";
3
+ import { AdvisorySeveritySchema, type IdentifiedAdvisoryCandidate, type AdvisoryCandidate, type AdvisoryReport } from "./advisory-schema.ts";
4
+ import type { WorkflowApi } from "./types.ts";
5
+
6
+ export type AdvisoryStageCoverage = NonNullable<AdvisoryReport["coverage"]>[number];
7
+
8
+ /** Settled results are required here: a missing branch is not a negative finding. */
9
+ export async function collectAdvisoryStage<T>(
10
+ api: Pick<WorkflowApi, "parallel">,
11
+ stage: string,
12
+ branches: { id: string; candidate?: AdvisoryCandidate; run(): Promise<T> }[],
13
+ coverage: AdvisoryStageCoverage[],
14
+ ): Promise<T[]> {
15
+ const results = await api.parallel(branches.map((branch) => branch.run), { settled: true });
16
+ const failures = results.flatMap((result, index) => result.ok ? [] : [{ branch: branches[index]!.id, reason: result.error.message, ...(branches[index]!.candidate ? { candidate: branches[index]!.candidate } : {}) }]);
17
+ coverage.push({ stage, expected: branches.length, completed: results.length - failures.length, failed: failures.length, failures });
18
+ return results.flatMap((result) => result.ok ? [result.value] : []);
19
+ }
20
+
21
+ export function identifyCandidates(candidates: readonly AdvisoryCandidate[], lens: string): IdentifiedAdvisoryCandidate[] {
22
+ return candidates.map((candidate, index) => {
23
+ const candidateId = createHash("sha256").update(JSON.stringify([lens, index, candidate])).digest("hex").slice(0, 20);
24
+ // Identities are assigned by the workflow, never accepted from a finder.
25
+ return { ...candidate, candidateId, sourceCandidateIds: [candidateId] };
26
+ });
27
+ }
28
+
29
+ function normalizedSummary(summary: string): string {
30
+ return summary.toLowerCase().replace(/\s+/g, " ").trim().replace(/[.!?]+$/, "");
31
+ }
32
+
33
+ /** Repo-relative readings of a reported path, most literal first: without a stray `./`, then also without a git `a/`/`b/` prefix. */
34
+ export function repoPathCandidates(path: string): [literal: string, unprefixed: string] {
35
+ const literal = path.replace(/^\.\//, "");
36
+ return [literal, literal.replace(/^[ab]\//, "")];
37
+ }
38
+
39
+ function candidateDedupKey(candidate: AdvisoryCandidate): string {
40
+ const location = candidate.locations[0];
41
+ return JSON.stringify([candidate.category, location && repoPathCandidates(location.file)[1],
42
+ location?.symbol ?? location?.line ?? null, normalizedSummary(candidate.summary)]);
43
+ }
44
+
45
+ /** Collapse only equivalent claims at the same anchor; keep every discovery ID and location. */
46
+ export function dedupeCandidates<T extends IdentifiedAdvisoryCandidate>(candidates: readonly T[]): T[] {
47
+ const seen = new Map<string, T>();
48
+ for (const candidate of candidates) {
49
+ const key = candidateDedupKey(candidate);
50
+ const previous = seen.get(key);
51
+ if (!previous) seen.set(key, { ...candidate });
52
+ else {
53
+ previous.sourceCandidateIds = [...new Set([...previous.sourceCandidateIds, ...candidate.sourceCandidateIds])];
54
+ previous.locations = uniqueLocations([...previous.locations, ...candidate.locations]);
55
+ previous.impact = [...new Set([previous.impact, candidate.impact])].join("\n");
56
+ previous.discoveryEvidence = [...new Set([...(previous.discoveryEvidence ?? []), ...(candidate.discoveryEvidence ?? [])])];
57
+ }
58
+ }
59
+ return [...seen.values()];
60
+ }
61
+
62
+ export function uniqueLocations(locations: AdvisoryCandidate["locations"]): AdvisoryCandidate["locations"] {
63
+ return [...new Map(locations.map((location) => [JSON.stringify(location), location])).values()];
64
+ }
65
+
66
+ // Synthesis selects evidence records. It has no fields with which to replace their evidence.
67
+ export const AdvisorySynthesisSchema = Type.Object({
68
+ summary: Type.String(),
69
+ findings: Type.Array(Type.Object({
70
+ sourceCandidateIds: Type.Array(Type.String(), { minItems: 1 }),
71
+ severity: AdvisorySeveritySchema,
72
+ recommendation: Type.String(),
73
+ })),
74
+ nextSteps: Type.Array(Type.String()),
75
+ });
76
+ export type AdvisorySynthesis = Static<typeof AdvisorySynthesisSchema>;
77
+ export const SYNTHESIS_ID_INSTRUCTIONS = "Select or merge findings only by sourceCandidateIds shown below. Return severity and an advisory recommendation per selection. Never select an ID absent from these verified records. ";
78
+
79
+ export function withAdvisoryCoverage<T extends AdvisoryReport>(report: T, coverage: AdvisoryStageCoverage[]) {
80
+ const incomplete = coverage.some((stage) => stage.failed > 0);
81
+ const gaps = coverage.flatMap((stage) => stage.failures.map((failure) => `${stage.stage}/${failure.branch}: ${failure.reason}`));
82
+ return {
83
+ ...report,
84
+ status: incomplete ? "incomplete" as const : "complete" as const,
85
+ summary: incomplete ? `Incomplete review: ${gaps.length} branch(es) failed. ${report.findings.length} finding(s) available; no clean conclusion is possible.` : report.summary,
86
+ nextSteps: incomplete ? ["Inspect failed branches and rerun missing work.", ...(report.findings.length > 0 ? report.nextSteps : [])] : report.nextSteps,
87
+ coverage,
88
+ gaps,
89
+ };
90
+ }
@@ -9,7 +9,7 @@ export const AdvisoryConfidenceSchema = Type.Union([Type.Literal("low"), Type.Li
9
9
  description: "Confidence that the finding is actionable.",
10
10
  });
11
11
 
12
- export const AdvisoryVerifierVerdictSchema = Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED")], {
12
+ export const AdvisoryVerifierVerdictSchema = Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE"), Type.Literal("REFUTED"), Type.Literal("NOT_SUBSTANTIATED")], {
13
13
  description: "Verifier judgment for a candidate finding.",
14
14
  });
15
15
 
@@ -20,6 +20,8 @@ export const AdvisoryLocationSchema = Type.Object({
20
20
  });
21
21
 
22
22
  export const AdvisoryCandidateSchema = Type.Object({
23
+ discoveryEvidence: Type.Optional(Type.Array(Type.String())),
24
+ reviewAnchor: Type.Optional(AdvisoryLocationSchema),
23
25
  summary: Type.String({ description: "One-line candidate finding or hypothesis." }),
24
26
  category: Type.String({ description: "Workflow-specific category such as bug, duplication, root-cause, or io." }),
25
27
  locations: Type.Array(AdvisoryLocationSchema, { description: "Relevant code or configuration locations." }),
@@ -34,10 +36,12 @@ export const AdvisoryCandidatesSchema = Type.Object({
34
36
  export const AdvisoryVerdictSchema = Type.Object({
35
37
  verdict: AdvisoryVerifierVerdictSchema,
36
38
  evidence: Type.Array(Type.String({ description: "Quoted or cited evidence supporting the verdict." })),
37
- confidence: Type.Optional(AdvisoryConfidenceSchema),
38
39
  });
39
40
 
40
41
  export const AdvisoryFindingSchema = Type.Object({
42
+ verdict: Type.Optional(AdvisoryVerifierVerdictSchema),
43
+ sourceCandidateIds: Type.Optional(Type.Array(Type.String())),
44
+ reviewAnchor: Type.Optional(AdvisoryLocationSchema),
41
45
  summary: Type.String({ description: "One-line final finding." }),
42
46
  category: Type.String({ description: "Workflow-specific category for grouping and display." }),
43
47
  severity: AdvisorySeveritySchema,
@@ -49,6 +53,12 @@ export const AdvisoryFindingSchema = Type.Object({
49
53
  });
50
54
 
51
55
  export const AdvisoryReportSchema = Type.Object({
56
+ status: Type.Optional(Type.Union([Type.Literal("complete"), Type.Literal("incomplete")])),
57
+ gaps: Type.Optional(Type.Array(Type.String())),
58
+ coverage: Type.Optional(Type.Array(Type.Object({
59
+ stage: Type.String(), expected: Type.Number(), completed: Type.Number(), failed: Type.Number(),
60
+ failures: Type.Array(Type.Object({ branch: Type.String(), reason: Type.String(), candidate: Type.Optional(AdvisoryCandidateSchema) })),
61
+ }))),
52
62
  summary: Type.String({ description: "Short overall advisory summary." }),
53
63
  findings: Type.Array(AdvisoryFindingSchema, { description: "Verified and ranked advisory findings." }),
54
64
  nextSteps: Type.Array(Type.String({ description: "Concrete follow-up commands, inspections, or decisions." })),
@@ -61,6 +71,7 @@ export const AdvisoryReportWithStatsSchema = Type.Object({
61
71
 
62
72
  export type AdvisoryLocation = Static<typeof AdvisoryLocationSchema>;
63
73
  export type AdvisoryCandidate = Static<typeof AdvisoryCandidateSchema>;
74
+ export type IdentifiedAdvisoryCandidate = AdvisoryCandidate & { candidateId: string; sourceCandidateIds: string[] };
64
75
  export type AdvisoryVerdict = Static<typeof AdvisoryVerdictSchema>;
65
76
  export type AdvisoryFinding = Static<typeof AdvisoryFindingSchema>;
66
77
  export type AdvisoryReport = Static<typeof AdvisoryReportSchema>;
@@ -69,3 +80,10 @@ export type AdvisoryReportWithStats = Static<typeof AdvisoryReportWithStatsSchem
69
80
  export function isAdvisoryReport(value: unknown): value is AdvisoryReportWithStats {
70
81
  return Value.Check(AdvisoryReportWithStatsSchema, value);
71
82
  }
83
+
84
+ /** `file:line (symbol)`, omitting whichever of line and symbol is unknown. */
85
+ export function formatAdvisoryLocation(location: AdvisoryLocation): string {
86
+ const line = location.line != null ? `:${location.line}` : "";
87
+ const symbol = location.symbol ? ` (${location.symbol})` : "";
88
+ return `${location.file}${line}${symbol}`;
89
+ }
@@ -2,28 +2,23 @@ import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import { assertWorkflowBudgetAvailable } from "./budget.ts";
3
3
  import {
4
4
  type AgentExecutionOptions,
5
- type AgentRunTags,
6
5
  type RunContext,
7
6
  } from "./agent-runner-types.ts";
8
7
  import {
9
8
  captureReplayIdentity,
10
9
  captureIsolatedRepositoryAfterSetup,
11
10
  captureSharedRepositoryBeforeSetup,
12
- isReplayEnabled,
13
11
  lookupReplayResult,
14
- validateReplayEvidence,
12
+ validateMutationGuard,
15
13
  validateReplayIdentity,
16
14
  type AgentAttemptResult,
17
- type AgentReplayEvidence,
15
+ type AgentReplayContract,
18
16
  type AgentReplayPlan,
17
+ type ArmedReplayPlan,
19
18
  } from "./agent-replay.ts";
20
19
  import { openAgentSession, promptAgentSession, type AgentSessionHandle } from "./agent-session.ts";
21
- import {
22
- createAgentWorkspace,
23
- disposeAgentWorkspace,
24
- type AgentWorkspace,
25
- } from "./agent-workspace.ts";
26
- import type { AgentResumeBaseContext, AgentResumeContext, RepositoryResumeContext } from "./resume-context.ts";
20
+ import { createAgentWorkspace, type AgentWorkspace } from "./agent-workspace.ts";
21
+ import type { AgentResumeBaseContext, RepositoryResumeContext } from "./resume-context.ts";
27
22
  import { captureRepositoryMutationGuard } from "./resume-context.ts";
28
23
 
29
24
  /** Execute one fully bracketed workspace/session attempt. Cleanup always precedes settlement. */
@@ -36,38 +31,33 @@ export async function executeAgentAttempt(input: {
36
31
  readonly replay: AgentReplayPlan;
37
32
  readonly label: string;
38
33
  readonly rowId: number;
39
- readonly tags: AgentRunTags;
34
+ readonly phase: string;
40
35
  readonly admitLiveAgent: () => void;
41
36
  }): Promise<AgentAttemptResult> {
42
- const { rc, prompt, opts, resumeBaseContext, model, replay, label, rowId, tags, admitLiveAgent } = input;
37
+ const { rc, prompt, opts, resumeBaseContext, model, replay, label, rowId, phase, admitLiveAgent } = input;
38
+ let armed: ArmedReplayPlan | undefined;
43
39
  let repositoryBefore: RepositoryResumeContext | undefined;
44
- let evidence: AgentReplayEvidence | undefined;
45
- if (isReplayEnabled(replay)) {
46
- if (replay.kind === "isolated") {
47
- const mutationGuard = await captureRepositoryMutationGuard(rc.cwd, rc.signal);
48
- if (mutationGuard.kind === "unverifiable") {
49
- repositoryBefore = mutationGuard;
50
- } else {
51
- evidence = { kind: "isolated", mutationGuard: mutationGuard.fingerprint };
52
- }
53
- } else {
54
- repositoryBefore = await captureSharedRepositoryBeforeSetup(rc, prompt, opts, replay);
55
- evidence = { kind: "shared" };
56
- }
40
+ if (replay.kind === "isolated") {
41
+ const mutationGuard = await captureRepositoryMutationGuard(rc.cwd, rc.signal);
42
+ if (mutationGuard.kind === "verified") armed = { ...replay, mutationGuard: mutationGuard.fingerprint };
43
+ else rc.progress.log(`${label}: resume disabled for this call (${mutationGuard.reason})`);
44
+ } else if (replay.kind === "shared") {
45
+ armed = replay;
46
+ repositoryBefore = await captureSharedRepositoryBeforeSetup(rc, prompt, opts, replay);
57
47
  }
58
48
  let workspace: AgentWorkspace | undefined;
59
49
  let handle: AgentSessionHandle | undefined;
60
50
 
61
51
  try {
62
52
  workspace = await createAgentWorkspace(rc, opts, label);
63
- if (replay.kind === "isolated" && evidence?.kind === "isolated") {
53
+ if (armed?.kind === "isolated") {
64
54
  if (workspace.kind !== "isolated") throw new Error("Isolated replay created a shared workspace.");
65
55
  repositoryBefore = await captureIsolatedRepositoryAfterSetup(rc, prompt, opts, workspace);
66
56
  }
67
- handle = await openAgentSession({ rc, prompt, opts, cwd: workspace.cwd, model, label, tags });
57
+ handle = await openAgentSession({ rc, prompt, opts, cwd: workspace.cwd, model, label });
68
58
 
69
- let identity: AgentResumeContext | undefined;
70
- if (isReplayEnabled(replay) && repositoryBefore && evidence) {
59
+ let contract: AgentReplayContract | undefined;
60
+ if (armed && repositoryBefore) {
71
61
  const capture = await captureReplayIdentity({
72
62
  rc,
73
63
  base: resumeBaseContext,
@@ -79,23 +69,12 @@ export async function executeAgentAttempt(input: {
79
69
  if (capture.kind === "unverifiable") {
80
70
  rc.progress.log(`${label}: resume disabled for this call (${capture.reason})`);
81
71
  } else {
82
- identity = capture.identity;
83
- const cached = await lookupReplayResult({ rc, key: replay.key, identity, opts, workspace });
72
+ contract = { ...armed, identity: capture.identity };
73
+ const cached = await lookupReplayResult({ rc, key: contract.key, identity: contract.identity, opts, workspace });
84
74
  if (cached.hit) {
85
- const contract = await validateReplayIdentity({
86
- rc,
87
- identity,
88
- selectedSkills: handle.selectedSkills,
89
- session: handle.session,
90
- sessionCwd: workspace.cwd,
91
- replay,
92
- workspace,
93
- });
94
- const validation = contract.ok
95
- ? await validateReplayEvidence(rc, evidence)
96
- : contract;
75
+ const validation = await validateReplayContract(rc, contract, handle, workspace);
97
76
  if (validation.ok) {
98
- return { kind: "cache-hit", result: cached.result, identity, evidence };
77
+ return { kind: "cache-hit", result: cached.result, contract };
99
78
  }
100
79
  rc.progress.log(`${label}: cached result invalidated (${validation.reason})`);
101
80
  } else if (cached.reason) {
@@ -113,35 +92,42 @@ export async function executeAgentAttempt(input: {
113
92
  opts,
114
93
  label,
115
94
  rowId,
116
- tags,
95
+ phase,
117
96
  });
118
97
  const result = await workspace.wrapResult(rawResult);
119
- if (!identity) return { kind: "live-unrecordable", result };
120
- if (!isReplayEnabled(replay) || !evidence) throw new Error("Replay identity produced without complete replay evidence.");
98
+ if (!contract) return { kind: "live-unrecordable", result };
121
99
 
122
- const contract = await validateReplayIdentity({
123
- rc,
124
- identity,
125
- selectedSkills: handle.selectedSkills,
126
- session: handle.session,
127
- sessionCwd: workspace.cwd,
128
- replay,
129
- workspace,
130
- });
131
- const validation = contract.ok
132
- ? await validateReplayEvidence(rc, evidence)
133
- : contract;
100
+ const validation = await validateReplayContract(rc, contract, handle, workspace);
134
101
  if (validation.ok) {
135
- return { kind: "live-recordable", result, identity, evidence };
102
+ return { kind: "live-recordable", result, contract };
136
103
  }
137
104
  rc.progress.log(`${label}: read-only resume contract was not recorded (${validation.reason})`);
138
105
  return { kind: "live-unrecordable", result };
139
106
  } finally {
140
107
  try {
141
108
  const session = handle?.session;
142
- if (session) rc.perf.timeSync("agent.dispose_ms", () => session.dispose(), tags);
109
+ if (session) rc.perf.timeSync("agent.dispose_ms", () => session.dispose());
143
110
  } finally {
144
- await disposeAgentWorkspace(rc, label, workspace);
111
+ await workspace?.dispose();
145
112
  }
146
113
  }
147
114
  }
115
+
116
+ /** Re-check a captured replay identity and its repository evidence against the session and workspace as they are now. */
117
+ async function validateReplayContract(
118
+ rc: RunContext,
119
+ contract: AgentReplayContract,
120
+ handle: AgentSessionHandle,
121
+ workspace: AgentWorkspace,
122
+ ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
123
+ const identity = await validateReplayIdentity({
124
+ rc,
125
+ contract,
126
+ selectedSkills: handle.selectedSkills,
127
+ session: handle.session,
128
+ sessionCwd: workspace.cwd,
129
+ workspace,
130
+ });
131
+ if (!identity.ok || contract.kind === "shared") return identity;
132
+ return await validateMutationGuard(rc, contract.mutationGuard);
133
+ }
@@ -1,8 +1,9 @@
1
1
  import type { Skill } from "@earendil-works/pi-coding-agent";
2
2
  import { Value } from "typebox/value";
3
3
  import { captureAgentSkillIdentities, extractSkillSelectorsFromText } from "./agent-skills.ts";
4
- import type { AgentExecutionOptions, AgentRunnerSession, AgentRunTags, RunContext } from "./agent-runner-types.ts";
4
+ import type { AgentExecutionOptions, AgentRunnerSession, RunContext } from "./agent-runner-types.ts";
5
5
  import { captureEffectiveAgentSessionIdentity } from "./agent-session-identity.ts";
6
+ import { isRecord } from "./guards.ts";
6
7
  import { FINAL_TOOL } from "./agent-session.ts";
7
8
  import type { AgentWorkspace, IsolatedAgentWorkspace } from "./agent-workspace.ts";
8
9
  import { captureAgentJournalKey } from "./journal.ts";
@@ -11,7 +12,7 @@ import {
11
12
  captureRepositoryMutationGuard,
12
13
  captureIsolatedRepositoryContext,
13
14
  captureRepositoryResumeContext,
14
- createAgentResumeContext,
15
+ repositoryMismatchReason,
15
16
  resumeContextMismatchReason,
16
17
  type AgentResumeBaseContext,
17
18
  type AgentResumeContext,
@@ -26,13 +27,17 @@ export type AgentReplayPlan =
26
27
  | { readonly kind: "shared"; readonly key: string; readonly additionalInputs: readonly string[] }
27
28
  | { readonly kind: "isolated"; readonly key: string };
28
29
 
29
- export type AgentReplayEvidence =
30
- | { readonly kind: "shared" }
31
- | { readonly kind: "isolated"; readonly mutationGuard: string };
30
+ /** An enabled replay plan with its main-workspace evidence: isolated replays carry the mutation guard taken before setup. */
31
+ export type ArmedReplayPlan =
32
+ | Extract<AgentReplayPlan, { readonly kind: "shared" }>
33
+ | (Extract<AgentReplayPlan, { readonly kind: "isolated" }> & { readonly mutationGuard: string });
34
+
35
+ /** An armed replay plan plus the resume identity captured for one attempt. */
36
+ export type AgentReplayContract = ArmedReplayPlan & { readonly identity: AgentResumeContext };
32
37
 
33
38
  export type AgentAttemptResult =
34
- | { readonly kind: "cache-hit"; readonly result: unknown; readonly identity: AgentResumeContext; readonly evidence: AgentReplayEvidence }
35
- | { readonly kind: "live-recordable"; readonly result: unknown; readonly identity: AgentResumeContext; readonly evidence: AgentReplayEvidence }
39
+ | { readonly kind: "cache-hit"; readonly result: unknown; readonly contract: AgentReplayContract }
40
+ | { readonly kind: "live-recordable"; readonly result: unknown; readonly contract: AgentReplayContract }
36
41
  | { readonly kind: "live-unrecordable"; readonly result: unknown };
37
42
 
38
43
  export type AgentAttemptSettlement =
@@ -129,28 +134,28 @@ export async function lookupReplayResult(input: {
129
134
 
130
135
  export async function validateReplayIdentity(input: {
131
136
  readonly rc: RunContext;
132
- readonly identity: AgentResumeContext;
137
+ readonly contract: AgentReplayContract;
133
138
  readonly selectedSkills: readonly Skill[];
134
139
  readonly session: AgentRunnerSession;
135
140
  readonly sessionCwd: string;
136
- readonly replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>;
137
141
  readonly workspace: AgentWorkspace;
138
142
  }): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
139
- const repository = input.identity.repository.state === "inaccessible"
143
+ const { identity } = input.contract;
144
+ const repository = identity.repository.state === "inaccessible"
140
145
  ? INACCESSIBLE_REPOSITORY_CONTEXT
141
- : await captureCurrentRepository(input.rc, input.replay, input.workspace);
146
+ : await captureCurrentRepository(input.rc, input.contract, input.workspace);
142
147
  if (repository.kind === "unverifiable") return { ok: false, reason: repository.reason };
143
148
 
144
149
  const current = await captureVerifiedReplayIdentity({
145
150
  rc: input.rc,
146
151
  repository,
147
- workflow: input.identity.workflow,
152
+ workflow: identity.workflow,
148
153
  selectedSkills: input.selectedSkills,
149
154
  session: input.session,
150
155
  sessionCwd: input.sessionCwd,
151
156
  });
152
157
  if (current.kind === "unverifiable") return { ok: false, reason: current.reason };
153
- const mismatch = resumeContextMismatchReason(input.identity, current.identity, {
158
+ const mismatch = resumeContextMismatchReason(identity, current.identity, {
154
159
  allowWorkflowSourceMismatch: input.rc.resumeEditedWorkflow,
155
160
  });
156
161
  return mismatch ? { ok: false, reason: mismatch } : { ok: true };
@@ -159,23 +164,16 @@ export async function validateReplayIdentity(input: {
159
164
  export async function settleAgentAttempt(input: {
160
165
  readonly rc: RunContext;
161
166
  readonly label: string;
162
- readonly tags: AgentRunTags;
163
- readonly replay: AgentReplayPlan;
164
167
  readonly outcome: AgentAttemptResult;
165
168
  }): Promise<AgentAttemptSettlement> {
166
- const { rc, label, replay, outcome } = input;
169
+ const { rc, label, outcome } = input;
167
170
  if (outcome.kind === "live-unrecordable") {
168
171
  recordAgentResultSource(rc, "live");
169
172
  return { kind: "done", result: outcome.result };
170
173
  }
171
- if (!isReplayEnabled(replay)) throw new Error("Replayable agent outcome produced without an enabled replay plan.");
172
174
 
173
- const repository = await validateRepositoryAfterCleanup(
174
- rc,
175
- outcome.identity,
176
- replay,
177
- outcome.evidence,
178
- );
175
+ const { contract } = outcome;
176
+ const repository = await validateRepositoryAfterCleanup(rc, contract);
179
177
  if (!repository.ok) {
180
178
  if (outcome.kind === "cache-hit") {
181
179
  rc.progress.log(`${label}: cached result invalidated after cleanup (${repository.reason})`);
@@ -185,10 +183,10 @@ export async function settleAgentAttempt(input: {
185
183
  return { kind: "done", result: outcome.result };
186
184
  }
187
185
 
188
- await recordJournalResult(rc, label, replay.key, outcome.result, outcome.identity);
186
+ await recordJournalResult(rc, label, contract.key, outcome.result, contract.identity);
189
187
  if (outcome.kind === "cache-hit") {
190
188
  rc.progress.log(`${label}: using cached result from workflow journal`);
191
- rc.perf.counter("agent.cache_hit", 1, input.tags);
189
+ rc.perf.counter("agent.cache_hit");
192
190
  recordAgentResultSource(rc, "cached");
193
191
  } else {
194
192
  recordAgentResultSource(rc, "live");
@@ -236,12 +234,12 @@ async function captureVerifiedReplayIdentity(input: {
236
234
  }
237
235
  return {
238
236
  kind: "verified",
239
- identity: createAgentResumeContext(
240
- { workflow: input.workflow },
241
- input.repository,
242
- effectiveSession.identity,
243
- skills.skills,
244
- ),
237
+ identity: {
238
+ workflow: input.workflow,
239
+ repository: input.repository,
240
+ session: effectiveSession.identity,
241
+ skills: skills.skills,
242
+ },
245
243
  };
246
244
  }
247
245
 
@@ -259,7 +257,7 @@ async function validateCachedResult(
259
257
  if (!patch.ok) {
260
258
  return {
261
259
  ok: false,
262
- reason: `cached isolated patch does not apply to the current baseline (${(patch.error ?? patch.stderr.trim()) || "unknown error"})`,
260
+ reason: `cached isolated patch does not apply to the current baseline (${patch.error})`,
263
261
  };
264
262
  }
265
263
  result = isolated.wrapper.result;
@@ -271,7 +269,6 @@ async function validateCachedResult(
271
269
  ? { ok: true }
272
270
  : { ok: false, reason: "cached text result is not a string" };
273
271
  }
274
- if (result === null) return { ok: true };
275
272
  try {
276
273
  return Value.Check(opts.schema, result)
277
274
  ? { ok: true }
@@ -287,63 +284,45 @@ function isolatedCachedResult(value: unknown):
287
284
  readonly wrapper: { readonly result: unknown; readonly patch: string; readonly changed: boolean };
288
285
  }
289
286
  | { readonly ok: false; readonly reason: string } {
290
- if (typeof value !== "object" || value === null) {
287
+ if (!isRecord(value)) {
291
288
  return { ok: false, reason: "cached isolated result is not an object" };
292
289
  }
293
- const candidate = value as { readonly result?: unknown; readonly patch?: unknown; readonly changed?: unknown };
294
- if (!("result" in candidate) || typeof candidate.patch !== "string" || typeof candidate.changed !== "boolean") {
290
+ if (!("result" in value) || typeof value.patch !== "string" || typeof value.changed !== "boolean") {
295
291
  return { ok: false, reason: "cached isolated result has an invalid wrapper" };
296
292
  }
297
- const wrapper = { result: candidate.result, patch: candidate.patch, changed: candidate.changed };
293
+ const wrapper = { result: value.result, patch: value.patch, changed: value.changed };
298
294
  return { ok: true, wrapper };
299
295
  }
300
296
 
301
297
  async function validateRepositoryAfterCleanup(
302
298
  rc: RunContext,
303
- identity: AgentResumeContext,
304
- replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>,
305
- evidence: AgentReplayEvidence,
299
+ contract: AgentReplayContract,
306
300
  ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
307
- if (replay.kind === "isolated") {
308
- return evidence.kind === "isolated"
309
- ? await validateReplayEvidence(rc, evidence)
310
- : { ok: false, reason: "isolated replay omitted its main-workspace mutation guard" };
311
- }
301
+ if (contract.kind === "isolated") return await validateMutationGuard(rc, contract.mutationGuard);
312
302
 
313
- const repository = identity.repository.state === "inaccessible"
303
+ const repository = contract.identity.repository.state === "inaccessible"
314
304
  ? INACCESSIBLE_REPOSITORY_CONTEXT
315
- : await captureRepositoryResumeContext(rc.cwd, replay.additionalInputs, rc.signal);
305
+ : await captureRepositoryResumeContext(rc.cwd, contract.additionalInputs, rc.signal);
316
306
  if (repository.kind === "unverifiable") return { ok: false, reason: repository.reason };
317
- const mismatch = resumeContextMismatchReason(identity, { ...identity, repository });
318
- if (mismatch) return { ok: false, reason: mismatch };
319
- return evidence.kind === "shared"
320
- ? { ok: true }
321
- : { ok: false, reason: "shared replay carried isolated mutation evidence" };
322
- }
323
-
324
- export async function validateReplayEvidence(
325
- rc: RunContext,
326
- evidence: AgentReplayEvidence,
327
- ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
328
- return evidence.kind === "shared"
329
- ? { ok: true }
330
- : await validateRepositoryMutationGuard(rc, evidence.mutationGuard);
307
+ const mismatch = repositoryMismatchReason(contract.identity.repository, repository);
308
+ return mismatch ? { ok: false, reason: mismatch } : { ok: true };
331
309
  }
332
310
 
333
- export async function validateRepositoryMutationGuard(
311
+ /** Check that the main workspace is unchanged since an isolated replay took its mutation guard. */
312
+ export async function validateMutationGuard(
334
313
  rc: RunContext,
335
- expected: string,
314
+ mutationGuard: string,
336
315
  ): Promise<{ readonly ok: true } | { readonly ok: false; readonly reason: string }> {
337
316
  const current = await captureRepositoryMutationGuard(rc.cwd, rc.signal);
338
317
  if (current.kind === "unverifiable") return { ok: false, reason: current.reason };
339
- return current.fingerprint === expected
318
+ return current.fingerprint === mutationGuard
340
319
  ? { ok: true }
341
320
  : { ok: false, reason: "main workspace changed outside the isolated worktree" };
342
321
  }
343
322
 
344
323
  async function captureCurrentRepository(
345
324
  rc: RunContext,
346
- replay: Extract<AgentReplayPlan, { readonly kind: "shared" | "isolated" }>,
325
+ replay: ArmedReplayPlan,
347
326
  workspace: AgentWorkspace,
348
327
  ): Promise<RepositoryResumeContext> {
349
328
  if (replay.kind === "shared") return await captureRepositoryResumeContext(rc.cwd, replay.additionalInputs, rc.signal);
@@ -375,8 +354,8 @@ function declaresNoWorkspaceCapability(prompt: string, opts: AgentExecutionOptio
375
354
  opts.tools !== undefined &&
376
355
  opts.tools.length === 0 &&
377
356
  (opts.toolHints?.length ?? 0) === 0 &&
378
- (opts.skills?.length ?? 0) === 0 &&
379
- extractSkillSelectorsFromText(prompt).length === 0
357
+ // Explicit skills suppress prompt inference, matching resolveAgentSkillRequest.
358
+ (opts.skills !== undefined ? opts.skills.length === 0 : extractSkillSelectorsFromText(prompt).length === 0)
380
359
  );
381
360
  }
382
361