@mingchuno/agent-workflows 0.2.0 → 0.4.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.
@@ -1,7 +1,167 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
+ import stringWidth from "string-width";
3
4
  import { recoveryUnavailable } from "../recovery.js";
4
5
  import { cells, colorFor, duration, elapsedRun, executionDuration, wrapLines, } from "./format.js";
6
+ // The detail panel contributes four columns of border and padding.
7
+ const detailWideBreakpoint = 136;
8
+ const detailLabelWidth = 19;
9
+ const hangingLabelMarker = "\u{e000}";
10
+ const conciseErrorWidth = 120;
11
+ const detailColumnGap = " │ ";
12
+ function localTimestamp(value) {
13
+ if (!value)
14
+ return "not recorded";
15
+ const date = new Date(value);
16
+ if (!Number.isFinite(date.valueOf()))
17
+ return "unavailable";
18
+ return new Intl.DateTimeFormat(undefined, {
19
+ dateStyle: "medium",
20
+ timeStyle: "medium",
21
+ }).format(date);
22
+ }
23
+ function profileValue(value, key) {
24
+ if (!value || typeof value !== "object")
25
+ return undefined;
26
+ const candidate = value[key];
27
+ return typeof candidate === "string" && candidate ? candidate : undefined;
28
+ }
29
+ function readableProfile(value, fallbackProvider) {
30
+ return [
31
+ profileValue(value, "provider") ??
32
+ fallbackProvider ??
33
+ "provider unavailable",
34
+ profileValue(value, "model") ?? "model unavailable",
35
+ profileValue(value, "reasoningEffort") ?? "reasoning unavailable",
36
+ ].join(" / ");
37
+ }
38
+ function sameProfile(requested, effective, provider) {
39
+ return (readableProfile(requested, provider) ===
40
+ readableProfile(effective, provider));
41
+ }
42
+ function json(value) {
43
+ return JSON.stringify(value) ?? "unavailable";
44
+ }
45
+ function conciseError(run) {
46
+ const error = run.error ?? run.executions?.at(-1)?.error;
47
+ if (!error)
48
+ return run.outcome === "cancelled"
49
+ ? "Run was cancelled."
50
+ : run.outcome === "blocked"
51
+ ? "Run is blocked."
52
+ : "Run requires operator attention.";
53
+ const firstLine = error.split("\n", 1)[0].replace(/^Error:\s*/i, "");
54
+ const summary = cells(firstLine, conciseErrorWidth);
55
+ return cells(firstLine, conciseErrorWidth + 1) === summary
56
+ ? summary
57
+ : `${cells(firstLine, conciseErrorWidth - 1)}…`;
58
+ }
59
+ function label(label, value) {
60
+ return `${hangingLabelMarker}${label.padEnd(detailLabelWidth)}${value}`;
61
+ }
62
+ export function wrapDetailLines(lines, width) {
63
+ return lines.flatMap((line) => {
64
+ if (!line.startsWith(hangingLabelMarker))
65
+ return wrapLines([line], width);
66
+ const content = line.slice(hangingLabelMarker.length);
67
+ const prefix = content.slice(0, detailLabelWidth);
68
+ const value = content.slice(detailLabelWidth);
69
+ const wrappedValue = wrapLines([value], Math.max(1, width - detailLabelWidth));
70
+ return wrappedValue.map((part, index) => index === 0
71
+ ? `${prefix}${part}`
72
+ : `${" ".repeat(detailLabelWidth)}${part}`);
73
+ });
74
+ }
75
+ function validationLines(run, now) {
76
+ return [
77
+ "VALIDATION",
78
+ ...(run.validation?.length
79
+ ? run.validation.flatMap((check) => [
80
+ `${check.command} ${check.args.join(" ")} · exit ${check.exitCode} · ${duration(check.startedAt, check.finishedAt, now)}`,
81
+ ])
82
+ : ["not recorded"]),
83
+ ];
84
+ }
85
+ function validationSummary(run) {
86
+ const checks = run.validation ?? [];
87
+ if (!checks.length)
88
+ return "not recorded";
89
+ const successful = checks.filter((check) => check.exitCode === 0).length;
90
+ const failed = checks.length - successful;
91
+ return [
92
+ `${checks.length} recorded`,
93
+ successful ? `${successful} exit 0` : "",
94
+ failed ? `${failed} nonzero` : "",
95
+ ]
96
+ .filter(Boolean)
97
+ .join(" · ");
98
+ }
99
+ function sessionLines(sessions, now) {
100
+ return [
101
+ "AGENT SESSIONS",
102
+ ...(sessions.length
103
+ ? sessions.flatMap((session) => {
104
+ const effective = readableProfile(session.effective, session.provider);
105
+ const profile = sameProfile(session.requested, session.effective, session.provider)
106
+ ? `Effective ${effective}`
107
+ : `Requested ${readableProfile(session.requested, session.provider)} → Effective ${effective}`;
108
+ return [
109
+ `${session.step} · invocation ${session.attempt} · ${session.outcome} · ${duration(session.startedAt, session.finishedAt, now)}`,
110
+ profile,
111
+ ];
112
+ })
113
+ : ["not recorded"]),
114
+ ];
115
+ }
116
+ function executionLines(run, now) {
117
+ const executions = [...(run.executions ?? [])].reverse();
118
+ if (!executions.length)
119
+ return ["CURRENT EXECUTION", "not started"];
120
+ return executions.flatMap((execution, index) => {
121
+ const executionTime = execution.startedAt
122
+ ? executionDuration(execution, now).replace("—", "unavailable")
123
+ : "not started";
124
+ const queueWait = duration(execution.createdAt, execution.startedAt ?? execution.finishedAt, now);
125
+ const recovery = execution.recoveryOf
126
+ ? [
127
+ label("Recovery", "continued from prior execution"),
128
+ label("Recovery gap", duration(run.executions?.find((candidate) => candidate.id === execution.recoveryOf)?.finishedAt, execution.createdAt, now).replace("—", "unavailable")),
129
+ label("Reused steps", execution.reusedSteps?.length
130
+ ? execution.reusedSteps.join(", ")
131
+ : "none recorded"),
132
+ ]
133
+ : [];
134
+ const lines = index === 0
135
+ ? [
136
+ "CURRENT EXECUTION",
137
+ label("Outcome", execution.outcome),
138
+ label("Phase", execution.phase),
139
+ label("Execution duration", executionTime),
140
+ label("Queue wait", queueWait),
141
+ label("Created", localTimestamp(execution.createdAt)),
142
+ label("Started", localTimestamp(execution.startedAt)),
143
+ label("Finished", localTimestamp(execution.finishedAt)),
144
+ ...recovery,
145
+ ]
146
+ : [
147
+ `PRIOR EXECUTION ${executions.length - index}`,
148
+ `${execution.outcome} · ${execution.phase} · duration ${executionTime} · queue ${queueWait}`,
149
+ `${localTimestamp(execution.startedAt)} → ${localTimestamp(execution.finishedAt)}`,
150
+ ...recovery,
151
+ ];
152
+ return index < executions.length - 1 ? [...lines, ""] : lines;
153
+ });
154
+ }
155
+ function padToWidth(value, width) {
156
+ return `${value}${" ".repeat(Math.max(0, width - stringWidth(value)))}`;
157
+ }
158
+ function composeDetailColumns(left, right, width) {
159
+ const leftWidth = Math.floor((width - detailColumnGap.length) * 0.58);
160
+ const rightWidth = width - leftWidth - detailColumnGap.length;
161
+ const leftLines = wrapDetailLines(left, leftWidth);
162
+ const rightLines = wrapDetailLines(right, rightWidth);
163
+ return Array.from({ length: Math.max(leftLines.length, rightLines.length) }, (_, index) => `${padToWidth(leftLines[index] ?? "", leftWidth)}${detailColumnGap}${rightLines[index] ?? ""}`);
164
+ }
5
165
  function stepText(event) {
6
166
  if (!event)
7
167
  return "No steps recorded";
@@ -38,56 +198,126 @@ export function summaryLines(run, now, event) {
38
198
  ...(run.change ? [`Change request: ${run.change.url}`] : []),
39
199
  ];
40
200
  }
41
- export function detailLines(run, sessions, now, recoveryReason = recoveryUnavailable(run)) {
42
- return [
43
- `#${run.issue.number} ${run.issue.title}`,
44
- `${run.outcome} · ${run.phase} · attempt ${run.attempt}`,
45
- `Run: ${run.id}`,
46
- `Branch: ${run.branch}`,
47
- `Issue: ${run.issue.url}`,
48
- `Total elapsed: ${elapsedRun(run, now)}`,
49
- ...(run.executions ?? []).flatMap((execution, index) => [
201
+ export function detailLines(run, sessions, now, recoveryReason = recoveryUnavailable(run), width = 76, available = {
202
+ stop: false,
203
+ retry: false,
204
+ recover: false,
205
+ }) {
206
+ const validation = validationSummary(run);
207
+ const recovery = run.outcome === "failed"
208
+ ? recoveryReason
209
+ ? "restricted"
210
+ : "eligible"
211
+ : "not applicable";
212
+ const attention = ["failed", "blocked", "cancelled"].includes(run.outcome)
213
+ ? [
214
+ "",
215
+ "ATTENTION",
216
+ label("Problem", conciseError(run)),
217
+ label("Recovery", recoveryReason ?? "Eligible; runner checks still apply"),
218
+ label("Action", [
219
+ available.recover ? "c recover" : "",
220
+ available.retry ? "r retry" : "",
221
+ available.stop ? "s stop" : "",
222
+ ]
223
+ .filter(Boolean)
224
+ .join(" · ") || "none available"),
225
+ ]
226
+ : [];
227
+ const executions = executionLines(run, now);
228
+ const evidence = [
229
+ ...validationLines(run, now),
230
+ "",
231
+ ...sessionLines(sessions, now),
232
+ ];
233
+ const evidenceArea = width >= detailWideBreakpoint
234
+ ? composeDetailColumns(executions, evidence, width)
235
+ : [...executions, "", ...evidence];
236
+ const executionsByRecency = [...(run.executions ?? [])].reverse();
237
+ const executionErrors = executionsByRecency.filter((execution) => execution.error);
238
+ const diagnostics = run.error || executionErrors.length
239
+ ? [
240
+ "",
241
+ "DIAGNOSTICS",
242
+ ...(run.error ? [label("Run error", run.error)] : []),
243
+ ...executionErrors.map((execution, index) => label(index === 0 ? "Current error" : "Prior error", execution.error)),
244
+ ]
245
+ : [];
246
+ const technical = [
247
+ "",
248
+ "TECHNICAL DETAILS",
249
+ label("Run ID", run.id),
250
+ label("Issue URL", run.issue.url || "unavailable"),
251
+ label("Checkout", run.checkout || "unavailable"),
252
+ label("Task key", run.taskKey),
253
+ label("Created ISO", run.createdAt),
254
+ label("Updated ISO", run.updatedAt),
255
+ ...(run.retryOf ? [label("Retry of", run.retryOf)] : []),
256
+ ...(run.base ? [label("Base revision", run.base)] : []),
257
+ ...(run.head ? [label("Head revision", run.head)] : []),
258
+ ...(run.change ? [label("Change URL", run.change.url)] : []),
259
+ ...executionsByRecency.flatMap((execution, index) => [
50
260
  "",
51
- `EXECUTION ${index + 1}: ${execution.id}`,
52
- `${execution.outcome} · ${execution.phase} · duration ${executionDuration(execution, now)}`,
53
- `Queue wait: ${duration(execution.createdAt, execution.startedAt ?? execution.finishedAt, now)}`,
54
- `Started: ${execution.startedAt ?? "Not started"} · Finished: ${execution.finishedAt ?? "—"}`,
261
+ `Execution ${executionsByRecency.length - index}`,
262
+ label("Execution ID", execution.id),
263
+ label("Created ISO", execution.createdAt),
264
+ label("Started ISO", execution.startedAt ?? "not recorded"),
265
+ label("Finished ISO", execution.finishedAt ?? "not recorded"),
55
266
  ...(execution.recoveryOf
56
- ? [
57
- `Recovered from: ${execution.recoveryOf}`,
58
- `Reused: ${execution.reusedSteps?.join(", ") ?? "—"}`,
59
- ]
267
+ ? [label("Recovery source", execution.recoveryOf)]
60
268
  : []),
61
269
  ]),
62
- "",
63
- "RECOVERY",
64
- recoveryReason ?? "Eligible for admission; runner checks still required",
65
- "",
66
- "ERROR",
67
- run.error ?? "None",
68
- "",
69
- "VALIDATION",
70
- ...(run.validation?.flatMap((check) => [
71
- `${check.command} ${check.args.join(" ")} · exit ${check.exitCode} · ${duration(check.startedAt, check.finishedAt, now)}`,
72
- `Log: ${check.log}`,
73
- ]) ?? ["No checks recorded"]),
74
- "",
75
- "AGENT SESSIONS",
76
- ...sessions.flatMap((session) => [
77
- `${session.step} · invocation ${session.attempt} · ${session.outcome}`,
78
- `Session: ${session.sessionId ?? session.sessionState}`,
79
- `Duration: ${duration(session.startedAt, session.finishedAt, now)}`,
80
- `Requested: ${JSON.stringify(session.requested)}`,
81
- `Effective: ${JSON.stringify(session.effective)}`,
82
- `Log: ${session.log}`,
270
+ ...(run.validation?.flatMap((check, index) => [
83
271
  "",
272
+ `Validation ${index + 1}`,
273
+ label("Log path", check.log),
274
+ label("Started ISO", check.startedAt),
275
+ label("Finished ISO", check.finishedAt),
276
+ ]) ?? []),
277
+ ...sessions.flatMap((session, index) => [
278
+ "",
279
+ `Agent session ${index + 1}`,
280
+ label("Invocation ID", session.id),
281
+ label("Session ID", session.sessionId ?? session.sessionState),
282
+ label("Provider", session.provider),
283
+ label("Log path", session.log),
284
+ label("Started ISO", session.startedAt),
285
+ label("Finished ISO", session.finishedAt ?? "not recorded"),
286
+ label("Requested profile", json(session.requested)),
287
+ label("Effective profile", json(session.effective)),
84
288
  ]),
85
289
  ];
290
+ return [
291
+ `#${run.issue.number} ${run.issue.title}`,
292
+ label("Outcome", run.outcome),
293
+ label("Phase", run.phase),
294
+ label("Attempt", String(run.attempt)),
295
+ label("Total elapsed", elapsedRun(run, now).replace("—", "unavailable")),
296
+ label("Branch", run.branch || "unavailable"),
297
+ label("Health", `Error ${run.error || run.executions?.some((execution) => execution.error) ? "recorded" : "none"} · Validation ${validation} · Recovery ${recovery}`),
298
+ ...attention,
299
+ "",
300
+ ...evidenceArea,
301
+ ...diagnostics,
302
+ ...technical,
303
+ ];
86
304
  }
87
- export function Lines({ lines, width, height, offset = 0, }) {
88
- const wrapped = wrapLines(lines, width);
305
+ export function Lines({ lines, width, height, offset = 0, outcome, }) {
306
+ const wrapped = wrapDetailLines(lines, width);
89
307
  const start = Math.min(offset, Math.max(0, wrapped.length - height));
90
- return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: wrapped.slice(start, start + height).map((line, index) => (_jsx(Box, { height: 1, flexShrink: 0, children: _jsx(Text, { wrap: "truncate", children: line || " " }) }, `${start + index}`))) }));
308
+ const summaryOutcomeIndex = wrapped.findIndex((line) => line.startsWith("Outcome"));
309
+ return (_jsx(Box, { flexDirection: "column", width: width, height: height, overflow: "hidden", children: wrapped.slice(start, start + height).map((line, index) => {
310
+ const absoluteIndex = start + index;
311
+ const divider = line.indexOf(detailColumnGap);
312
+ const left = divider >= 0 ? line.slice(0, divider) : line;
313
+ const right = divider >= 0
314
+ ? line.slice(divider + detailColumnGap.length)
315
+ : undefined;
316
+ const isHeading = (value) => /^[A-Z][A-Z0-9 ]+$/.test(value.trim());
317
+ return (_jsx(Box, { height: 1, flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: absoluteIndex === summaryOutcomeIndex
318
+ ? colorFor(outcome ?? "")
319
+ : undefined, children: [_jsx(Text, { bold: isHeading(left), children: left || " " }), right !== undefined ? (_jsxs(_Fragment, { children: [_jsx(Text, { children: detailColumnGap }), _jsx(Text, { bold: isHeading(right), children: right })] })) : null] }) }, `${absoluteIndex}`));
320
+ }) }));
91
321
  }
92
322
  export function RunList({ runs, selected, width, height, now, }) {
93
323
  const count = Math.max(1, Math.floor(height / 3));
@@ -3,6 +3,12 @@ import { access, lstat, mkdir, readFile, realpath } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { BlockedError, } from "./domain.js";
5
5
  import { command } from "./runtime/process.js";
6
+ function fileStateDigest(content, mode) {
7
+ return createHash("sha256")
8
+ .update(content)
9
+ .update(String(mode))
10
+ .digest("hex");
11
+ }
6
12
  export class ExistingCheckout {
7
13
  processDirectories = new Map();
8
14
  processDirectory(project) {
@@ -97,7 +103,7 @@ export class ExistingCheckout {
97
103
  if (stat.isSymbolicLink())
98
104
  throw new BlockedError(`Changed symlink requires manual handling: ${path}`);
99
105
  const content = await readFile(absolute);
100
- files[path] = createHash("sha256").update(content).digest("hex");
106
+ files[path] = fileStateDigest(content, stat.mode);
101
107
  hash.update(path).update(content).update(String(stat.mode));
102
108
  if (untracked.split("\0").includes(path))
103
109
  fullDiff += `\n--- /dev/null\n+++ b/${path}\n${content.toString()}`;
@@ -125,12 +131,19 @@ export class ExistingCheckout {
125
131
  }
126
132
  async commit(project, expected, publication, runId, signal) {
127
133
  signal?.throwIfAborted();
134
+ const runMarkers = publication.commitMessage
135
+ .split(/\r?\n/)
136
+ .map((line) => line.trim())
137
+ .filter((line) => line.startsWith("Agent-Workflows-Run:"));
138
+ if (runMarkers.length !== 1 ||
139
+ runMarkers[0] !== `Agent-Workflows-Run: ${runId}`)
140
+ throw new BlockedError("Commit message must contain exactly one matching workflow run marker");
128
141
  const current = await this.inspect(project);
129
142
  if (current.head !== expected.head) {
130
143
  const message = await this.git(project, "log", "-1", "--format=%B");
131
144
  const parent = await this.git(project, "rev-parse", "HEAD^");
132
145
  if (parent === expected.head &&
133
- message.includes(`Agent-Workflows-Run: ${runId}`) &&
146
+ message.trimEnd() === publication.commitMessage.trimEnd() &&
134
147
  current.paths.length === 0) {
135
148
  const changed = (await this.git(project, "diff", "HEAD^", "HEAD", "--no-renames", "--name-only", "-z"))
136
149
  .split("\0")
@@ -139,7 +152,11 @@ export class ExistingCheckout {
139
152
  if (JSON.stringify(changed) !== JSON.stringify(expected.paths))
140
153
  throw new BlockedError("Reconciled commit has an unexpected change set");
141
154
  for (const [path, digest] of Object.entries(expected.files)) {
142
- const actual = await readFile(join(project.checkout, path)).then((content) => createHash("sha256").update(content).digest("hex"), (error) => {
155
+ const absolute = join(project.checkout, path);
156
+ const actual = await Promise.all([
157
+ readFile(absolute),
158
+ lstat(absolute),
159
+ ]).then(([content, stat]) => fileStateDigest(content, stat.mode), (error) => {
143
160
  if (error.code === "ENOENT")
144
161
  return null;
145
162
  throw error;
@@ -156,15 +173,11 @@ export class ExistingCheckout {
156
173
  throw new BlockedError("No changes to commit");
157
174
  await this.runGit(project, ["add", "--", ...expected.paths], { signal });
158
175
  await this.runGit(project, [
159
- "-c",
160
- `user.name=${project.gitIdentity.name}`,
161
- "-c",
162
- `user.email=${project.gitIdentity.email}`,
163
176
  "-c",
164
177
  "core.hooksPath=/dev/null",
165
178
  "commit",
166
179
  "-m",
167
- `${publication.commitMessage}\n\nAgent-Workflows-Run: ${runId}`,
180
+ publication.commitMessage,
168
181
  ], { signal });
169
182
  return this.git(project, "rev-parse", "HEAD");
170
183
  }
package/docs/api.md CHANGED
@@ -4,7 +4,13 @@ Exports are in `src/index.ts`; the built package resolves to `dist/src/index.js`
4
4
 
5
5
  ## Runner and controls
6
6
 
7
- `new Runner({config,databaseUrl,hosting,agents,workspace?,workflow?,workflowVersion?,promptBaseDirectory?})` injects hosting/agent adapters and optionally a workspace strategy or workflow. `hosting(project)` returns a host-qualified adapter. `agents` maps provider names to adapters. The default workspace uses the existing checkout. One DBOS runtime runs per Node process; one runner owns each configuration and checkout.
7
+ `new Runner({config,databaseUrl,hosting,agents,workspace?,workflow?,workflowVersion?,pathBaseDirectory?,promptBaseDirectory?})` injects hosting/agent adapters and optionally a workspace strategy or workflow. `hosting(project)` returns a host-qualified adapter. `agents` maps provider names to adapters. The default workspace uses the existing checkout. One DBOS runtime runs per Node process; one runner owns each configuration and checkout.
8
+
9
+ `pathBaseDirectory` must identify an existing directory. It resolves relative
10
+ state, checkout, and prompt-file paths once when the runner is constructed.
11
+ Omitting it preserves current-working-directory behavior for SDK callers.
12
+ `promptBaseDirectory` retains its narrower role and, when both are supplied,
13
+ overrides only relative prompt files.
8
14
 
9
15
  `start()` validates registration, acquires ownership, launches DBOS, registers concurrency-one project queues, and starts polling. `poll(projectId?)` performs an immediate scan. `pause(projectId)` stops new starts while active work continues. `resume(projectId)` refuses blocked checkouts. `stop(runId)` waits for the active invocation/process to end, or cancels queued work. `retry(runId)` requires a terminal failed/blocked/cancelled run and a clean checkout, then returns a new linked run ID. `recover(runId)` returns a new execution ID for publication recovery of the same run. `shutdown()` stops intake, cancels and awaits active work, closes DBOS and releases ownership.
10
16
 
@@ -17,7 +23,9 @@ the next attempt; another request for the same task fails while that retry is
17
23
  queued or running. Replaying the same command ID returns its existing retry,
18
24
  without rechecking the checkout or emitting events. A command ID cannot identify
19
25
  retries of different runs. Retry creation, project unblocking and their events
20
- commit together; failure preserves the blocked state.
26
+ commit together; failure preserves the blocked state. The rationale for separate
27
+ run and execution identities is in
28
+ [ADR 0003](adr/0003-run-and-execution-identity.md).
21
29
 
22
30
  ## Durable operations
23
31
 
@@ -29,7 +37,7 @@ commit together; failure preserves the blocked state.
29
37
  | `prepare()` | Require clean Git state; fetch base and create unique branch |
30
38
  | `implement()` | Fresh implementation session; reject unexpected commits or branch changes |
31
39
  | `validate()` | Run commands and verify unchanged diff; false means no change |
32
- | `writePublication()` | Fresh writer session; schema-check and persist generated text |
40
+ | `writePublication()` | Validate text; finalize persisted run/co-author trailers from retained changes |
33
41
  | `commit()` / `push()` | Separate reconciled Git effects using the verified change set |
34
42
  | `publish()` | Find existing request by branch before creating a draft |
35
43
  | `review()` | Fresh read-only reviewer; exact published diff, head and validation evidence |
@@ -44,7 +52,7 @@ checks. Stage overrides replace only `defaultPrompt`. Custom stages have no
44
52
  inferred built-in default; resolve file-based custom stages before invocation
45
53
  with `resolveStagePrompt(stage, defaultPrompt, baseDirectory)` if they must be
46
54
  frozen alongside startup configuration. The runner resolves built-in prompt files
47
- at construction using `promptBaseDirectory`.
55
+ at construction using `promptBaseDirectory ?? pathBaseDirectory ?? process.cwd()`.
48
56
 
49
57
  ```ts
50
58
  await operations.invoke("report", stage, {
@@ -135,7 +143,7 @@ Caveats:
135
143
 
136
144
  ## Extension contracts
137
145
 
138
- `Workspace` separates `check`, `prepare`, `inspect`, `verify`, `commit`, `push` and `release`. `Snapshot` contains branch/head, changed paths, diff and a content fingerprint. Never implement release by discarding files. A future isolated workspace implementation can replace this interface without changing workflow composition.
146
+ `Workspace` separates `check`, `prepare`, `inspect`, `verify`, `commit`, `push` and `release`. `Snapshot` contains branch/head, changed paths, diff and a content fingerprint. `commit` receives the already-finalized message, including attribution and run marker, and must preserve native Git identity selection. Never implement release by discarding files. A future isolated workspace implementation can replace this interface without changing workflow composition.
139
147
 
140
148
  `prepare`, `commit` and `push` receive an optional final `AbortSignal`. Custom workspaces must stop their subprocesses before settling a cancelled operation. The existing-checkout strategy journals Git processes under the Git directory; ownership acquisition rejects surviving process groups after a runner crash.
141
149
 
@@ -150,10 +158,16 @@ Caveats:
150
158
  `Store.admitRetry` owns persisted retry admission. Runner supplies its checkout
151
159
  and process safety check, which runs under the project lock for new admissions
152
160
  only. This callback must not mutate Store records. Operator tools should use
153
- `retry` commands or `Runner.retry`, preserving those safety checks.
161
+ `retry` commands or `Runner.retry`, preserving those safety checks. The locking
162
+ boundary is recorded in [ADR 0005](adr/0005-postgresql-persistence-boundary.md).
154
163
 
155
164
  Invocation records include project/run IDs, stable DBOS step ID and name, invocation ID, attempt, timestamps, requested/effective profile, provider, effective task prompt/source/hash, output-contract and evidence identities, artifact path and session state (`pending`, `available`, `unavailable`). Repeated custom steps retain separate invocations. A retry has a separate run record linked to its predecessor.
156
165
 
166
+ Writable invocation before/after evidence is retained on the run. Publication
167
+ finalization persists contributing provider identities in first-contribution
168
+ order, independent of custom stage names. Recovery reuses that provenance and
169
+ the finalized publication message.
170
+
157
171
  `request(kind,target)` queues the same `pause`, `resume`, `stop`, `retry`, or `recover` commands used by the CLI/TUI; `commands()` reports pending/success/failure. A runner must be active to execute them. `finishCommand` and record-writing methods support adapters and custom workflows; operator tools should prefer commands over direct mutation.
158
172
 
159
173
  ## Publication recovery
@@ -171,7 +185,8 @@ markers. `Store.recoveryPlan(runId)` reports persisted eligibility and its reaso
171
185
  live safety checks happen at admission and execution. Runs without execution
172
186
  metadata remain readable and retryable, but cannot be recovered.
173
187
 
174
- Recovery uses DBOS forks, retaining the original workflow input and checkpoint
175
- prefix. The accepted command ID is the fork ID: dispatch adopts an existing fork
176
- after an uncertain response or crash. Copied start gates do not replace live
177
- checks in the first non-replayed operation. Successful prefixes cannot rerun.
188
+ Recovery preserves the original workflow input and completed checkpoint prefix.
189
+ The accepted command ID identifies the new execution so uncertain dispatch can
190
+ be reconciled after a crash. Live checks still run in the first non-replayed
191
+ operation. See [ADR 0003](adr/0003-run-and-execution-identity.md) for the complete
192
+ identity and recovery decision.