@mingchuno/agent-workflows 0.2.0 → 0.3.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
 
@@ -29,7 +35,7 @@ commit together; failure preserves the blocked state.
29
35
  | `prepare()` | Require clean Git state; fetch base and create unique branch |
30
36
  | `implement()` | Fresh implementation session; reject unexpected commits or branch changes |
31
37
  | `validate()` | Run commands and verify unchanged diff; false means no change |
32
- | `writePublication()` | Fresh writer session; schema-check and persist generated text |
38
+ | `writePublication()` | Validate text; finalize persisted run/co-author trailers from retained changes |
33
39
  | `commit()` / `push()` | Separate reconciled Git effects using the verified change set |
34
40
  | `publish()` | Find existing request by branch before creating a draft |
35
41
  | `review()` | Fresh read-only reviewer; exact published diff, head and validation evidence |
@@ -44,7 +50,7 @@ checks. Stage overrides replace only `defaultPrompt`. Custom stages have no
44
50
  inferred built-in default; resolve file-based custom stages before invocation
45
51
  with `resolveStagePrompt(stage, defaultPrompt, baseDirectory)` if they must be
46
52
  frozen alongside startup configuration. The runner resolves built-in prompt files
47
- at construction using `promptBaseDirectory`.
53
+ at construction using `promptBaseDirectory ?? pathBaseDirectory ?? process.cwd()`.
48
54
 
49
55
  ```ts
50
56
  await operations.invoke("report", stage, {
@@ -135,7 +141,7 @@ Caveats:
135
141
 
136
142
  ## Extension contracts
137
143
 
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.
144
+ `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
145
 
140
146
  `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
147
 
@@ -154,6 +160,11 @@ only. This callback must not mutate Store records. Operator tools should use
154
160
 
155
161
  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
162
 
163
+ Writable invocation before/after evidence is retained on the run. Publication
164
+ finalization persists contributing provider identities in first-contribution
165
+ order, independent of custom stage names. Recovery reuses that provenance and
166
+ the finalized publication message.
167
+
157
168
  `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
169
 
159
170
  ## Publication recovery
@@ -1,29 +1,46 @@
1
1
  # Configuration
2
2
 
3
- The CLI reads `agent-workflows.json`, or `--config PATH`. Unknown properties are rejected by Zod. Paths are local filesystem paths; checkout paths are canonicalized before ownership is acquired. Use absolute paths when launching from different working directories.
3
+ The CLI reads `agent-workflows.json`, or `--config PATH`. Both paths resolve from
4
+ the launch directory. Unknown properties are rejected by Zod. Relative
5
+ configuration-owned paths resolve from the directory containing the resolved
6
+ configuration file, independently of where the CLI is launched. Use
7
+ `--config-base-directory DIRECTORY` to select another base; a relative option
8
+ value resolves from the launch directory. The base must be an existing directory
9
+ and is canonicalized before startup.
4
10
 
5
11
  | Runner field | Default / meaning |
6
12
  | ---------------- | --------------------------------------------------------------------------------------------------------- |
7
13
  | `id` | Required stable letters/digits/underscore/hyphen identity; scopes records and queues |
8
14
  | `databaseUrlEnv` | `AGENT_WORKFLOWS_DATABASE_URL`; environment variable containing a PostgreSQL connection URL with username |
9
- | `stateDirectory` | `.agent-workflows`; must resolve outside every managed checkout |
15
+ | `stateDirectory` | `.agent-workflows`; relative to the configuration base and outside every managed checkout |
10
16
  | `projects` | Nonempty array; duplicate IDs or canonical checkout roots are rejected |
11
17
 
12
18
  | Project field | Default / meaning |
13
19
  | ---------------------- | --------------------------------------------------------------------------------------------------- |
14
- | `id`, `checkout` | Required stable identity and existing Git repository root |
20
+ | `id`, `checkout` | Required stable identity and existing Git repository root; relative to the configuration base |
15
21
  | `hosting` | `provider` (`github`/`gitlab`), web `origin`, `repository`, and `tokenEnv`; no serialized tokens |
16
22
  | `labels` | `['ready-for-agent']`; all labels must match |
17
23
  | `baseBranch`, `remote` | `main`, `origin`; Git remote is independent of hosting API origin |
18
24
  | `branchTemplate` | `agent/{issue}-{attempt}`; `{issue}` required; `{attempt}` and `{run}` supported |
19
25
  | `pollIntervalMs` | 30000; minimum 100 |
20
- | `gitIdentity` | Required `name` and `email`; used by application commits |
26
+ | `includeAgentCoAuthors` | `true`; append co-author trailers for providers whose writable invocations produced retained changes |
21
27
  | `validation` | Array of `{command,args,timeoutMs}`; no shell expansion; timeout defaults to 300000 ms |
22
28
  | `agent` | Required default profile |
23
29
  | `stages` | `implementation`, `publication`, `review`; each has optional `profile`, `prompt`, `promptFile`, `timeoutMs` |
24
30
 
25
31
  Issues are selected in ascending issue-number order within each intake scan. Deduplication persists across restarts. An explicit retry is a new numbered attempt linked through `retryOf`.
26
32
 
33
+ Absolute configuration paths remain absolute. Effective state and checkout
34
+ paths are normalized once during startup before safety and ownership checks, so
35
+ a later working-directory change cannot redirect a running process. Validation
36
+ commands still execute in the canonical checkout; command names and arguments
37
+ are passed unchanged and are not rebased to the configuration directory.
38
+
39
+ `init` writes relative checkout and external sibling-state paths from the
40
+ effective configuration base. It still refuses to overwrite an existing file.
41
+ When the configuration is in the checkout root, the checkout is `.` and state
42
+ is the relative sibling `<checkout-name>.agent-workflows`.
43
+
27
44
  ## CLI environment files
28
45
 
29
46
  Select one file explicitly for any CLI command:
@@ -128,11 +145,12 @@ in inspection explicitly; do not present an incomplete review as a clean review.
128
145
  These exact defaults are checked against the runtime source.
129
146
 
130
147
  Use either nonblank literal `prompt` text or a `promptFile` path, never both.
131
- Files must contain nonblank UTF-8 text. Relative paths resolve from the CLI
132
- configuration file's directory, independently of the launch directory; absolute
133
- paths are allowed. Files load once when the runner is constructed. Restart to
134
- apply edits. No templating, interpolation, or includes are supported. SDK callers
135
- supply `promptBaseDirectory` for relative paths.
148
+ Files must contain nonblank UTF-8 text. Relative paths use the same configuration
149
+ base as state and checkout paths; absolute paths are allowed. Files load once
150
+ when the runner is constructed. Restart to apply edits. No templating,
151
+ interpolation, or includes are supported. SDK callers use `pathBaseDirectory` as
152
+ the general base. `promptBaseDirectory`, when supplied, overrides it for prompt
153
+ files only.
136
154
 
137
155
  ```json
138
156
  "stages": {
@@ -146,6 +164,29 @@ supply `promptBaseDirectory` for relative paths.
146
164
  without aliases. Configure skills in the selected agent runtime and request them
147
165
  in task text. Old invocation records and skill snapshots remain readable.
148
166
 
167
+ ## Git identity and agent attribution
168
+
169
+ Commits use Git's native author and committer selection. The application does
170
+ not configure, validate, snapshot, or override identity. Repository, worktree,
171
+ global, system, and identity environment settings therefore behave as they do
172
+ for `git commit`, including distinct author and committer identities. Missing or
173
+ invalid identity fails at the commit operation with Git's error. The removed
174
+ `gitIdentity` property is rejected as unknown input.
175
+
176
+ Agent assistance is represented separately. With `includeAgentCoAuthors: true`,
177
+ the finalized commit message includes each provider once, ordered by its first
178
+ successful writable invocation that produced a retained change:
179
+
180
+ - `Codex <noreply@openai.com>` (OpenAI's published implementation convention)
181
+ - `Copilot <223556219+Copilot@users.noreply.github.com>` (GitHub's current
182
+ first-party convention, not a stable product API guarantee)
183
+
184
+ Read-only publication/review invocations and writable invocations with no
185
+ accepted retained change are not attributed. Existing trailers are preserved,
186
+ matching agent trailers are deduplicated, and exactly one
187
+ `Agent-Workflows-Run` trailer remains. Set `includeAgentCoAuthors: false` per
188
+ project to disable injection; existing publication trailers are not removed.
189
+
149
190
  Stage timeout defaults to 30 minutes, including profile validation and at most
150
191
  one format-correction attempt. Correction uses a fresh inspection-only session
151
192
  and only the remaining deadline. Provider failures, cancellation, timeout and