pi-workflow-engine 0.10.3 → 0.12.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 (88) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
  12. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
  13. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  16. package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
  17. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
  18. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  19. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
  20. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
  21. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
  22. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  23. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
  24. package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
  25. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  26. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  27. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  28. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  29. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
  30. package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
  31. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  32. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  33. package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
  34. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  35. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  36. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  37. package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
  38. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  39. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  40. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  41. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  42. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
  43. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
  52. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
  53. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  54. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
  55. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  56. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  57. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  58. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  59. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
  60. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
  61. package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
  62. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  68. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  69. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  86. package/README.md +82 -207
  87. package/USAGE.md +295 -21
  88. package/package.json +9 -9
@@ -0,0 +1,138 @@
1
+ export type IdentityCanonicalization =
2
+ | { readonly kind: "verified"; readonly value: string }
3
+ | { readonly kind: "unverifiable"; readonly reason: string };
4
+
5
+ export interface IdentityCanonicalizationOptions {
6
+ readonly maxBytes?: number;
7
+ readonly maxDepth?: number;
8
+ readonly maxNodes?: number;
9
+ }
10
+
11
+ const DEFAULT_MAX_BYTES = 1 << 20;
12
+ const DEFAULT_MAX_DEPTH = 64;
13
+ const DEFAULT_MAX_NODES = 16_384;
14
+
15
+ /**
16
+ * Produce a deterministic, bounded identity for JSON-like data.
17
+ *
18
+ * Replay inputs are not trusted: schemas and tool metadata can contain cycles,
19
+ * accessors, proxies, or values JSON cannot represent. Those inputs disable
20
+ * replay instead of throwing or being silently collapsed to the same key.
21
+ */
22
+ export function canonicalizeIdentity(
23
+ value: unknown,
24
+ options: IdentityCanonicalizationOptions = {},
25
+ ): IdentityCanonicalization {
26
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
27
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
28
+ const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
29
+ const active = new WeakSet<object>();
30
+ const chunks: string[] = [];
31
+ let bytes = 0;
32
+ let nodes = 0;
33
+
34
+ const append = (chunk: string): void => {
35
+ bytes += Buffer.byteLength(chunk);
36
+ if (bytes > maxBytes) throw new IdentityCanonicalizationError(`identity exceeded ${maxBytes} bytes`);
37
+ chunks.push(chunk);
38
+ };
39
+
40
+ const visit = (current: unknown, depth: number): void => {
41
+ nodes += 1;
42
+ if (nodes > maxNodes) throw new IdentityCanonicalizationError(`identity exceeded ${maxNodes} values`);
43
+ if (depth > maxDepth) throw new IdentityCanonicalizationError(`identity exceeded ${maxDepth} levels`);
44
+
45
+ if (current === null) {
46
+ append("n;");
47
+ return;
48
+ }
49
+ switch (typeof current) {
50
+ case "undefined":
51
+ append("u;");
52
+ return;
53
+ case "boolean":
54
+ append(current ? "b1;" : "b0;");
55
+ return;
56
+ case "number":
57
+ if (!Number.isFinite(current)) throw new IdentityCanonicalizationError("identity contains a non-finite number");
58
+ append(`d${Object.is(current, -0) ? "-0" : String(current)};`);
59
+ return;
60
+ case "string":
61
+ append(`s${Buffer.byteLength(current)}:${current};`);
62
+ return;
63
+ case "bigint":
64
+ case "function":
65
+ case "symbol":
66
+ throw new IdentityCanonicalizationError(`identity contains unsupported ${typeof current} data`);
67
+ case "object":
68
+ break;
69
+ }
70
+
71
+ if (active.has(current)) throw new IdentityCanonicalizationError("identity contains a cycle");
72
+ active.add(current);
73
+ try {
74
+ const prototype = Object.getPrototypeOf(current);
75
+ if (Array.isArray(current)) {
76
+ if (prototype !== Array.prototype) throw new IdentityCanonicalizationError("identity contains an array with a custom prototype");
77
+ const descriptors = Object.getOwnPropertyDescriptors(current);
78
+ const ownKeys = Reflect.ownKeys(current);
79
+ if (ownKeys.some((key) => typeof key === "symbol")) {
80
+ throw new IdentityCanonicalizationError("identity contains symbol keys");
81
+ }
82
+ if (ownKeys.some((key) => typeof key === "string" && key !== "length" && !isArrayIndex(key, current.length))) {
83
+ throw new IdentityCanonicalizationError("identity array contains custom properties");
84
+ }
85
+ append(`a${current.length}[`);
86
+ for (let index = 0; index < current.length; index++) {
87
+ const descriptor = descriptors[String(index)];
88
+ if (!descriptor) {
89
+ append("h;");
90
+ continue;
91
+ }
92
+ if (!("value" in descriptor)) throw new IdentityCanonicalizationError("identity contains an array accessor");
93
+ visit(descriptor.value, depth + 1);
94
+ }
95
+ append("];");
96
+ return;
97
+ }
98
+
99
+ if (prototype !== Object.prototype && prototype !== null) {
100
+ throw new IdentityCanonicalizationError("identity contains an object with a custom prototype");
101
+ }
102
+ const descriptors = Object.getOwnPropertyDescriptors(current);
103
+ const ownKeys = Reflect.ownKeys(current);
104
+ if (ownKeys.some((key) => typeof key === "symbol")) {
105
+ throw new IdentityCanonicalizationError("identity contains symbol keys");
106
+ }
107
+ const keys = Object.keys(descriptors).sort();
108
+ append(`o${keys.length}{`);
109
+ for (const key of keys) {
110
+ const descriptor = descriptors[key]!;
111
+ if (!("value" in descriptor)) throw new IdentityCanonicalizationError(`identity property ${key} is an accessor`);
112
+ append(`k${Buffer.byteLength(key)}:${key};`);
113
+ append(`p${descriptor.enumerable ? 1 : 0}${descriptor.configurable ? 1 : 0}${descriptor.writable ? 1 : 0};`);
114
+ visit(descriptor.value, depth + 1);
115
+ }
116
+ append("};");
117
+ } finally {
118
+ active.delete(current);
119
+ }
120
+ };
121
+
122
+ try {
123
+ visit(value, 0);
124
+ return { kind: "verified", value: chunks.join("") };
125
+ } catch (error) {
126
+ return {
127
+ kind: "unverifiable",
128
+ reason: error instanceof IdentityCanonicalizationError ? error.message : "identity inspection failed",
129
+ };
130
+ }
131
+ }
132
+
133
+ class IdentityCanonicalizationError extends Error {}
134
+
135
+ function isArrayIndex(key: string, length: number): boolean {
136
+ const index = Number(key);
137
+ return Number.isInteger(index) && index >= 0 && index < length && String(index) === key;
138
+ }
@@ -0,0 +1,15 @@
1
+ import { createHash } from "node:crypto";
2
+ import { canonicalizeIdentity } from "./identity-canonicalization.ts";
3
+
4
+ export function hashIdentity(value: unknown): string {
5
+ const canonical = canonicalizeIdentity(value);
6
+ if (canonical.kind === "unverifiable") throw new Error(canonical.reason);
7
+ return `sha256:${createHash("sha256").update(canonical.value).digest("hex")}`;
8
+ }
9
+
10
+ export function inspectableFunctionSource(value: unknown, label: string): string {
11
+ if (typeof value !== "function") throw new Error(`${label} is not a function`);
12
+ const source = Function.prototype.toString.call(value);
13
+ if (source.length === 0 || source.includes("[native code]")) throw new Error(`${label} has no inspectable source`);
14
+ return source;
15
+ }
@@ -1,5 +1,7 @@
1
1
  import { Type } from "typebox";
2
- import type { WorkflowApi, WorkflowMeta, WorkflowModule } from "./types.ts";
2
+ import { createHash } from "node:crypto";
3
+ import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
4
+ import { unknownErrorMessage } from "./unknown-error.ts";
3
5
  import { parseWorkflowMeta } from "./workflow-module.ts";
4
6
 
5
7
  /**
@@ -28,7 +30,7 @@ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecuto
28
30
 
29
31
  const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
30
32
 
31
- export function compileInlineWorkflow(source: string): WorkflowModule {
33
+ export function compileInlineWorkflow(source: string): LoadedWorkflow {
32
34
  rejectForbiddenModuleSyntax(source);
33
35
  const metaLiteral = extractMetaLiteral(source);
34
36
  const parsedMeta = parseWorkflowMeta(metaLiteral.value);
@@ -36,7 +38,11 @@ export function compileInlineWorkflow(source: string): WorkflowModule {
36
38
 
37
39
  const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
38
40
  const executor = compileExecutor(defaultExpression);
39
- return { meta: parsedMeta.meta, default: (api) => executor(api, Type) };
41
+ return {
42
+ meta: parsedMeta.meta,
43
+ default: (api) => executor(api, Type),
44
+ source: { kind: "fingerprint", fingerprint: createHash("sha256").update(source).digest("hex") },
45
+ };
40
46
  }
41
47
 
42
48
  export function extractMetaLiteral(source: string): InlineMetaLiteral {
@@ -133,8 +139,7 @@ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
133
139
  try {
134
140
  return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
135
141
  } catch (error) {
136
- const message = error instanceof Error ? error.message : String(error);
137
- throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
142
+ throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
138
143
  }
139
144
  }
140
145
 
@@ -2,21 +2,54 @@ import { createHash, randomUUID } from "node:crypto";
2
2
  import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
4
  import type { AgentOptions } from "./types.ts";
5
+ import type { WorktreeBaseline } from "./worktree.ts";
6
+ import { canonicalizeIdentity } from "./identity-canonicalization.ts";
7
+ import { unknownErrorMessage } from "./unknown-error.ts";
8
+ import {
9
+ isAgentResumeContext,
10
+ resumeContextMismatchReason,
11
+ type AgentResumeContext,
12
+ type ResumeContextComparisonOptions,
13
+ } from "./resume-context.ts";
5
14
 
6
15
  export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
7
16
  export const WORKFLOW_JOURNAL_KEEP = 50;
8
17
 
9
- export interface JournalEntry {
18
+ export interface JournalEntryV2 {
19
+ readonly version: 2;
20
+ readonly key: string;
21
+ readonly result: unknown;
22
+ readonly identity: AgentResumeContext;
23
+ }
24
+
25
+ /** Parsed so journals written before effective-session identity can fail closed without aborting resume. */
26
+ export interface LegacyJournalEntryV2 {
27
+ readonly version: 2;
28
+ readonly key: string;
29
+ readonly result: unknown;
30
+ readonly identity: unknown;
31
+ }
32
+
33
+ /** Parsed only so resume can explain why an older entry is not replayed. */
34
+ export interface LegacyJournalEntryV1 {
35
+ readonly version?: 1;
10
36
  readonly key: string;
11
37
  readonly value: unknown;
38
+ readonly context?: unknown;
12
39
  }
13
40
 
14
- export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false };
41
+ export type JournalEntry = LegacyJournalEntryV2 | LegacyJournalEntryV1;
42
+
43
+ export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false; readonly reason?: string };
15
44
  export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
16
45
 
46
+ export type AgentJournalKeyCapture =
47
+ | { readonly kind: "verified"; readonly key: string }
48
+ | { readonly kind: "unverifiable"; readonly reason: string };
49
+
17
50
  export interface WorkflowJournal {
18
- lookup(key: string): JournalLookup;
19
- record(key: string, value: unknown): Promise<JournalRecordResult>;
51
+ lookup(key: string, identity: AgentResumeContext, options?: ResumeContextComparisonOptions): JournalLookup;
52
+ record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
20
53
  }
21
54
 
22
55
  export class WorkflowJournalLoadError extends Error {
@@ -28,31 +61,75 @@ export function createWorkflowRunId(): string {
28
61
  }
29
62
 
30
63
  export function workflowJournalPath(cwd: string, runId: string): string {
31
- return join(cwd, WORKFLOW_RUNS_DIR, `${validateRunId(runId)}.jsonl`);
64
+ return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
65
+ }
66
+
67
+ export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
68
+ const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
69
+ return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
32
70
  }
33
71
 
34
- export function agentJournalKey(prompt: string, opts: AgentOptions = {}): string {
35
- const behaviorHash = hashAgentCall(prompt, opts);
36
- const cacheKey = opts.cacheKey?.trim();
37
- if (!cacheKey) return `agent:${behaviorHash}`;
72
+ export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
73
+ const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
74
+ return capture.kind === "verified"
75
+ ? capture.hash
76
+ : createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
77
+ }
78
+
79
+ /** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
80
+ export function captureAgentJournalKey(
81
+ prompt: string,
82
+ opts: AgentOptions = {},
83
+ worktreeBaseline?: WorktreeBaseline,
84
+ ): AgentJournalKeyCapture {
85
+ const behavior = captureAgentCallHash(prompt, opts, worktreeBaseline);
86
+ if (behavior.kind === "unverifiable") return behavior;
87
+ let cacheKey: string | undefined;
88
+ try {
89
+ cacheKey = opts.cacheKey?.trim();
90
+ } catch {
91
+ return { kind: "unverifiable", reason: "agent cache key could not be inspected" };
92
+ }
93
+ if (!cacheKey) return { kind: "verified", key: `agent:${behavior.hash}` };
38
94
  const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
39
- return `agent:${cacheKeyHash}:${behaviorHash}`;
40
- }
41
-
42
- export function hashAgentCall(prompt: string, opts: AgentOptions = {}): string {
43
- const behavior = {
44
- prompt,
45
- opts: {
46
- model: opts.model,
47
- thinkingLevel: opts.thinkingLevel,
48
- tools: sortedArray(opts.tools),
49
- toolHints: sortedArray(opts.toolHints),
50
- skills: sortedArray(opts.skills),
51
- schema: opts.schema,
52
- isolation: opts.isolation,
53
- },
54
- };
55
- return createHash("sha256").update(stableStringify(behavior)).digest("hex");
95
+ return { kind: "verified", key: `agent:${cacheKeyHash}:${behavior.hash}` };
96
+ }
97
+
98
+ type AgentCallHashCapture =
99
+ | { readonly kind: "verified"; readonly hash: string }
100
+ | { readonly kind: "unverifiable"; readonly reason: string };
101
+
102
+ function captureAgentCallHash(
103
+ prompt: string,
104
+ opts: AgentOptions,
105
+ worktreeBaseline: WorktreeBaseline | undefined,
106
+ ): AgentCallHashCapture {
107
+ try {
108
+ const behavior = {
109
+ prompt,
110
+ opts: {
111
+ thinkingLevel: opts.thinkingLevel,
112
+ tools: opts.tools,
113
+ toolHints: opts.toolHints,
114
+ requireToolHints: opts.requireToolHints === true ? true : undefined,
115
+ skills: opts.skills,
116
+ schema: opts.schema,
117
+ isolation: opts.isolation,
118
+ worktreeBaseline: worktreeBaseline
119
+ ? {
120
+ ref: worktreeBaseline.ref,
121
+ patchFingerprint: createHash("sha256").update(worktreeBaseline.patch ?? "").digest("hex"),
122
+ }
123
+ : undefined,
124
+ },
125
+ };
126
+ const canonical = canonicalizeIdentity(behavior);
127
+ return canonical.kind === "verified"
128
+ ? { kind: "verified", hash: createHash("sha256").update(canonical.value).digest("hex") }
129
+ : canonical;
130
+ } catch {
131
+ return { kind: "unverifiable", reason: "agent replay inputs could not be inspected" };
132
+ }
56
133
  }
57
134
 
58
135
  export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
@@ -61,7 +138,7 @@ export async function loadJournalEntries(path: string, options: { readonly requi
61
138
  content = await readFile(path, "utf8");
62
139
  } catch (error) {
63
140
  if (options.required) {
64
- throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${formatError(error)}`);
141
+ throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${unknownErrorMessage(error)}`);
65
142
  }
66
143
  return [];
67
144
  }
@@ -95,29 +172,50 @@ export async function createWorkflowJournal(options: {
95
172
  readonly writePath: string;
96
173
  }): Promise<WorkflowJournal> {
97
174
  const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
175
+ await mkdir(dirname(options.writePath), { recursive: true });
176
+ await appendFile(options.writePath, "", "utf8");
98
177
  return createMemoryBackedJournal(priorEntries, options.writePath);
99
178
  }
100
179
 
101
180
  export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
102
- const priorByKey = new Map<string, JournalEntry | "ambiguous">();
181
+ const priorByKey = new Map<string, JournalEntry[]>();
103
182
  for (const entry of priorEntries) {
104
- priorByKey.set(entry.key, priorByKey.has(entry.key) ? "ambiguous" : entry);
183
+ const entries = priorByKey.get(entry.key) ?? [];
184
+ entries.push(entry);
185
+ priorByKey.set(entry.key, entries);
105
186
  }
106
187
 
107
188
  return {
108
- lookup(key) {
109
- const entry = priorByKey.get(key);
110
- if (!entry || entry === "ambiguous") return { hit: false };
111
- return { hit: true, value: entry.value };
189
+ lookup(key, identity, options) {
190
+ const entries = priorByKey.get(key);
191
+ if (!entries || entries.length === 0) return { hit: false };
192
+
193
+ const current = entries.filter(
194
+ (entry): entry is JournalEntryV2 => entry.version === 2 && isAgentResumeContext(entry.identity),
195
+ );
196
+ if (current.length === 0) {
197
+ return {
198
+ hit: false,
199
+ reason: entries.some((entry) => entry.version === 2)
200
+ ? "journal entry predates effective replay identity"
201
+ : "journal entry predates replay contract v2",
202
+ };
203
+ }
204
+ const matches = current.filter(
205
+ (entry) => resumeContextMismatchReason(entry.identity, identity, options) === undefined,
206
+ );
207
+ if (matches.length === 1) return { hit: true, value: matches[0]!.result };
208
+ if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
209
+ return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity, options) };
112
210
  },
113
- async record(key, value) {
114
- const entry = { key, value };
211
+ async record(key, result, identity) {
212
+ const entry: JournalEntryV2 = { version: 2, key, result, identity };
115
213
  if (writePath) {
116
214
  try {
117
215
  await mkdir(dirname(writePath), { recursive: true });
118
216
  await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
119
217
  } catch (error) {
120
- return { ok: false, error: formatError(error) };
218
+ return { ok: false, error: unknownErrorMessage(error) };
121
219
  }
122
220
  }
123
221
  return { ok: true };
@@ -156,7 +254,7 @@ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL
156
254
  await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
157
255
  }
158
256
 
159
- function validateRunId(runId: string): string {
257
+ export function validateWorkflowRunId(runId: string): string {
160
258
  const trimmed = runId.trim();
161
259
  if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
162
260
  throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
@@ -164,37 +262,29 @@ function validateRunId(runId: string): string {
164
262
  return trimmed;
165
263
  }
166
264
 
167
- function sortedArray(values: readonly string[] | undefined): readonly string[] | undefined {
168
- return values ? [...values].sort() : undefined;
169
- }
170
-
171
- function stableStringify(value: unknown): string {
172
- return JSON.stringify(normalizeStable(value));
173
- }
174
-
175
- function normalizeStable(value: unknown): unknown {
176
- if (value === undefined) return undefined;
177
- if (value === null) return null;
178
- if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
179
- if (Array.isArray(value)) return value.map(normalizeStable);
180
- if (typeof value === "object") {
181
- const record = value as Record<string, unknown>;
182
- const out: Record<string, unknown> = {};
183
- for (const key of Object.keys(record).sort()) {
184
- const normalized = normalizeStable(record[key]);
185
- if (normalized !== undefined) out[key] = normalized;
186
- }
187
- return out;
188
- }
189
- return String(value);
190
- }
191
-
192
265
  function isJournalEntry(value: unknown): value is JournalEntry {
193
266
  if (typeof value !== "object" || value === null) return false;
194
- const entry = value as { readonly key?: unknown; readonly value?: unknown };
195
- return typeof entry.key === "string" && "value" in entry;
267
+ const entry = value as {
268
+ readonly version?: unknown;
269
+ readonly key?: unknown;
270
+ readonly result?: unknown;
271
+ readonly identity?: unknown;
272
+ readonly value?: unknown;
273
+ readonly context?: unknown;
274
+ };
275
+ if (entry.version === 2) {
276
+ return typeof entry.key === "string" && "result" in entry && "identity" in entry;
277
+ }
278
+ return (
279
+ (entry.version === undefined || entry.version === 1) &&
280
+ typeof entry.key === "string" &&
281
+ "value" in entry &&
282
+ (entry.context === undefined || isLegacyAgentResumeContext(entry.context))
283
+ );
196
284
  }
197
285
 
198
- function formatError(error: unknown): string {
199
- return error instanceof Error ? error.message : String(error);
286
+ function isLegacyAgentResumeContext(value: unknown): boolean {
287
+ if (typeof value !== "object" || value === null) return false;
288
+ const context = value as { readonly repository?: unknown; readonly workflow?: unknown; readonly model?: unknown };
289
+ return "repository" in context && "workflow" in context && "model" in context;
200
290
  }
@@ -0,0 +1,167 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AutocompleteItem } from "@earendil-works/pi-tui";
3
+ import {
4
+ clearWorkflowModelProfile,
5
+ isWorkflowModelProfileName,
6
+ isWorkflowThinkingLevel,
7
+ resolveWorkflowModelProfiles,
8
+ setWorkflowModelProfile,
9
+ workflowModelProfilePaths,
10
+ WORKFLOW_MODEL_PROFILE_NAMES,
11
+ WORKFLOW_THINKING_LEVELS,
12
+ type ResolvedWorkflowModelProfiles,
13
+ type WorkflowModelProfilePaths,
14
+ type WorkflowModelProfileName,
15
+ } from "./model-profiles.ts";
16
+ import { unknownErrorMessage } from "./unknown-error.ts";
17
+ import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
18
+
19
+ type ConfigScope = "user" | "project";
20
+
21
+ type WorkflowModelsCommand =
22
+ | { readonly kind: "status" }
23
+ | {
24
+ readonly kind: "set";
25
+ readonly scope: ConfigScope;
26
+ readonly profile: WorkflowModelProfileName;
27
+ readonly model: string;
28
+ readonly thinkingLevel?: Parameters<typeof setWorkflowModelProfile>[0]["profile"]["thinkingLevel"];
29
+ }
30
+ | { readonly kind: "clear"; readonly scope: ConfigScope; readonly profile: WorkflowModelProfileName }
31
+ | { readonly kind: "error"; readonly message: string };
32
+
33
+ const USAGE =
34
+ "Usage: /workflow:models [status | set <small|medium|big> <provider/model> [thinkingLevel] [--user|--project] | clear <small|medium|big> [--user|--project]]";
35
+ const MODEL_COMMAND_ACTIONS = [
36
+ { value: "status", description: "Show resolved workflow model profiles" },
37
+ { value: "set", description: "Configure an exact model for a profile" },
38
+ { value: "clear", description: "Remove a configured profile" },
39
+ ] as const;
40
+ const MODEL_COMMAND_SCOPES = [
41
+ { value: "--user", description: "Write the user-level workflow model config" },
42
+ { value: "--project", description: "Write the project-level workflow model config" },
43
+ ] as const;
44
+
45
+ export function registerWorkflowModelProfileCommand(pi: ExtensionAPI): void {
46
+ pi.registerCommand("workflow:models", {
47
+ description: "Inspect or configure exact small, medium, and big workflow model profiles",
48
+ getArgumentCompletions: workflowModelProfileArgumentCompletions,
49
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
50
+ const command = parseWorkflowModelsCommand(args);
51
+ if (command.kind === "error") {
52
+ ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
53
+ return;
54
+ }
55
+
56
+ try {
57
+ const paths = workflowModelProfilePaths(ctx.cwd);
58
+ if (command.kind === "set") {
59
+ setWorkflowModelProfile({
60
+ configPath: paths[command.scope],
61
+ name: command.profile,
62
+ profile: {
63
+ model: command.model,
64
+ ...(command.thinkingLevel === undefined ? {} : { thinkingLevel: command.thinkingLevel }),
65
+ },
66
+ modelRegistry: ctx.modelRegistry,
67
+ });
68
+ } else if (command.kind === "clear") {
69
+ clearWorkflowModelProfile(paths[command.scope], command.profile);
70
+ }
71
+
72
+ const profiles = resolveWorkflowModelProfiles({
73
+ cwd: ctx.cwd,
74
+ modelRegistry: ctx.modelRegistry,
75
+ hostModel: ctx.model,
76
+ paths,
77
+ });
78
+ const prefix = command.kind === "status"
79
+ ? "Workflow model profiles"
80
+ : `${command.profile} ${command.kind === "set" ? "updated" : "cleared"} in ${command.scope} config`;
81
+ ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths)}`, "info");
82
+ } catch (error) {
83
+ ctx.ui.notify(unknownErrorMessage(error), "error");
84
+ }
85
+ },
86
+ });
87
+ }
88
+
89
+ export function workflowModelProfileArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
90
+ const parts = splitArgumentPrefix(argumentPrefix);
91
+ const completedWithoutScopes = parts.completed.filter((token) => token !== "--user" && token !== "--project");
92
+ const action = completedWithoutScopes[0];
93
+ const scopes = parts.completed.some((token) => token === "--user" || token === "--project")
94
+ ? []
95
+ : MODEL_COMMAND_SCOPES;
96
+
97
+ if (completedWithoutScopes.length === 0) {
98
+ return completeCurrentArgument(argumentPrefix, MODEL_COMMAND_ACTIONS);
99
+ }
100
+ if (action === "status") return null;
101
+ if (completedWithoutScopes.length === 1) {
102
+ return completeCurrentArgument(
103
+ argumentPrefix,
104
+ WORKFLOW_MODEL_PROFILE_NAMES.map((value) => ({ value, description: `${value} workflow model profile` })),
105
+ );
106
+ }
107
+ if (action === "clear") return completeCurrentArgument(argumentPrefix, scopes);
108
+ if (action !== "set") return null;
109
+
110
+ if (completedWithoutScopes.length === 2) {
111
+ return null;
112
+ }
113
+ if (completedWithoutScopes.length === 3) {
114
+ return completeCurrentArgument(argumentPrefix, [
115
+ ...WORKFLOW_THINKING_LEVELS.map((value) => ({ value, description: "Thinking level" })),
116
+ ...scopes,
117
+ ]);
118
+ }
119
+ return completeCurrentArgument(argumentPrefix, scopes);
120
+ }
121
+
122
+ export function parseWorkflowModelsCommand(args: string): WorkflowModelsCommand {
123
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
124
+ if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "status")) return { kind: "status" };
125
+
126
+ const userScope = tokens.includes("--user");
127
+ const projectScope = tokens.includes("--project");
128
+ if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
129
+ const scope: ConfigScope = projectScope ? "project" : "user";
130
+ const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
131
+ const [action, profile, model, thinkingLevel, ...extra] = positional;
132
+ if (extra.length > 0) return { kind: "error", message: "Too many /workflow:models arguments." };
133
+ if (!profile || !isWorkflowModelProfileName(profile)) {
134
+ return { kind: "error", message: "Profile must be small, medium, or big." };
135
+ }
136
+
137
+ if (action === "clear") {
138
+ if (model !== undefined) return { kind: "error", message: "The clear action accepts only a profile and optional scope." };
139
+ return { kind: "clear", scope, profile };
140
+ }
141
+ if (action !== "set") return { kind: "error", message: `Unknown /workflow:models action "${action ?? ""}".` };
142
+ if (!model) return { kind: "error", message: "The set action requires an exact provider/model identity." };
143
+ if (thinkingLevel !== undefined && !isWorkflowThinkingLevel(thinkingLevel)) {
144
+ return { kind: "error", message: "Thinking level must be off, minimal, low, medium, high, xhigh, or max." };
145
+ }
146
+ return {
147
+ kind: "set",
148
+ scope,
149
+ profile,
150
+ model,
151
+ ...(thinkingLevel === undefined ? {} : { thinkingLevel }),
152
+ };
153
+ }
154
+
155
+ function formatWorkflowModelProfiles(
156
+ profiles: ResolvedWorkflowModelProfiles,
157
+ paths: WorkflowModelProfilePaths,
158
+ ): string {
159
+ const lines = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
160
+ const profile = profiles[name];
161
+ const model = profile.model ? `${profile.model.provider}/${profile.model.id}` : "(no host model)";
162
+ const thinking = profile.thinkingLevel ?? "host";
163
+ const source = profile.source === "host" ? "host fallback" : `${profile.source}: ${profile.configPath}`;
164
+ return `${name}: ${model} · thinking ${thinking} · ${source}`;
165
+ });
166
+ return [...lines, `user: ${paths.user}`, `project: ${paths.project} (overrides user)`].join("\n");
167
+ }