@juicesharp/rpiv-workflow 1.14.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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +449 -0
  3. package/api.ts +557 -0
  4. package/audit.ts +217 -0
  5. package/built-ins.ts +65 -0
  6. package/command.ts +137 -0
  7. package/docs/cover.png +0 -0
  8. package/docs/cover.svg +120 -0
  9. package/docs/workflow-authoring.md +629 -0
  10. package/docs/workflow-basics.md +122 -0
  11. package/docs-protocol.ts +106 -0
  12. package/fanout.ts +96 -0
  13. package/host.ts +97 -0
  14. package/index.ts +230 -0
  15. package/internal-utils.ts +69 -0
  16. package/internal.ts +27 -0
  17. package/layers.ts +33 -0
  18. package/lifecycle.ts +274 -0
  19. package/load/cache.test.ts +82 -0
  20. package/load/cache.ts +40 -0
  21. package/load/index.ts +159 -0
  22. package/load/merge.ts +136 -0
  23. package/load/normalize.ts +73 -0
  24. package/load/paths.ts +32 -0
  25. package/load/resolve-default.ts +43 -0
  26. package/load/shape-guards.test.ts +74 -0
  27. package/load/shape-guards.ts +42 -0
  28. package/messages.ts +185 -0
  29. package/outcomes/collectors/directory-path.test.ts +64 -0
  30. package/outcomes/collectors/directory-path.ts +40 -0
  31. package/outcomes/collectors/index.ts +21 -0
  32. package/outcomes/collectors/tool-call.test.ts +110 -0
  33. package/outcomes/collectors/tool-call.ts +63 -0
  34. package/outcomes/collectors/transcript-path.test.ts +70 -0
  35. package/outcomes/collectors/transcript-path.ts +53 -0
  36. package/outcomes/collectors/union.test.ts +59 -0
  37. package/outcomes/collectors/union.ts +55 -0
  38. package/outcomes/collectors/url.test.ts +67 -0
  39. package/outcomes/collectors/url.ts +45 -0
  40. package/outcomes/collectors/workspace-diff.test.ts +107 -0
  41. package/outcomes/collectors/workspace-diff.ts +123 -0
  42. package/outcomes/git-commit.test.ts +194 -0
  43. package/outcomes/git-commit.ts +192 -0
  44. package/outcomes/index.ts +22 -0
  45. package/outcomes/parsers/index.ts +11 -0
  46. package/outcomes/parsers/json-body.test.ts +80 -0
  47. package/outcomes/parsers/json-body.ts +50 -0
  48. package/outcomes/side-effect.ts +26 -0
  49. package/output-spec.ts +170 -0
  50. package/output.ts +98 -0
  51. package/package.json +83 -0
  52. package/preview.ts +120 -0
  53. package/routing.ts +79 -0
  54. package/runner/chain-advance.ts +185 -0
  55. package/runner/index.ts +7 -0
  56. package/runner/runner.ts +356 -0
  57. package/runner/script-stage.ts +240 -0
  58. package/runner/stage-lifecycle.ts +447 -0
  59. package/sessions/extraction.ts +297 -0
  60. package/sessions/index.ts +7 -0
  61. package/sessions/sessions.ts +269 -0
  62. package/sessions/spawn.ts +135 -0
  63. package/state/index.ts +27 -0
  64. package/state/paths.ts +46 -0
  65. package/state/reads.ts +190 -0
  66. package/state/state.ts +115 -0
  67. package/state/writes.ts +58 -0
  68. package/transcript.ts +156 -0
  69. package/triggers.ts +27 -0
  70. package/typebox-adapter.ts +48 -0
  71. package/types.ts +237 -0
  72. package/validate-output.ts +120 -0
  73. package/validate-workflow.ts +491 -0
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Output production + validation retry loop. Sits between the
3
+ * post-session classifier (which decides "stage finished cleanly?") and
4
+ * the persistence helpers ("record this stage").
5
+ *
6
+ * Public entry: `produceAndValidateOutput`. Returns a tagged outcome
7
+ * — `ok` with the output, `fatal` (halt with a wording the
8
+ * collector/parser supplied), or `validation-exhausted` (halt after the
9
+ * retry budget tripped without a passing schema).
10
+ *
11
+ * The two-step contract:
12
+ * 1. `outcome.collector.collect(ctx)` — enumerate artifacts.
13
+ * 2. `outcome.parser?.parse(ctx)` — shape the typed data channel
14
+ * (default: data = artifacts,
15
+ * kind = "artifacts").
16
+ */
17
+
18
+ import type { StageDef, StageSchema } from "../api.js";
19
+ import { nowIso } from "../audit.js";
20
+ import type { Artifact } from "../handle.js";
21
+ import { assertNever, withTimeout } from "../internal-utils.js";
22
+ import { buildLifecycleContext, skillStageRef } from "../lifecycle.js";
23
+ import { ERR_SCHEMA_TIMEOUT, MSG_VALIDATION_RETRY, MSG_VALIDATION_RETRY_PROMPT } from "../messages.js";
24
+ import { sideEffectOutcome } from "../outcomes/index.js";
25
+ import { finalizeOutput, type Output } from "../output.js";
26
+ import type { CollectCtx, OutputSpec } from "../output-spec.js";
27
+ import { type BranchEntry, readBranch } from "../transcript.js";
28
+ import type { RunnerCtx, StageSession } from "../types.js";
29
+ import {
30
+ DEFAULT_VALIDATION_RETRIES,
31
+ DEFAULT_VALIDATION_RETRY_TIMEOUT_MS,
32
+ MAX_VALIDATION_RETRIES,
33
+ MAX_VALIDATION_RETRY_TIMEOUT_MS,
34
+ MIN_VALIDATION_RETRIES,
35
+ MIN_VALIDATION_RETRY_TIMEOUT_MS,
36
+ type SchemaValidationFailure,
37
+ type ValidationResult,
38
+ validateOutputData,
39
+ } from "../validate-output.js";
40
+ import { handlerFor } from "./spawn.js";
41
+
42
+ export type OutputProduction =
43
+ | { kind: "ok"; output: Output }
44
+ | { kind: "fatal"; message: string }
45
+ | { kind: "validation-exhausted"; failureSummary: string };
46
+
47
+ /** Retry loop re-produces against the latest branch after each fix request. */
48
+ export async function produceAndValidateOutput(
49
+ ctx: RunnerCtx,
50
+ s: StageSession,
51
+ branch: BranchEntry[],
52
+ branchOffset: number | undefined,
53
+ ): Promise<OutputProduction> {
54
+ const outcome = resolveOutcome(s.stage, s.skill);
55
+ const collectCtx = buildCollectCtx(s, branch, branchOffset);
56
+ const finalize = (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => wrapOutput(s, parts);
57
+
58
+ const first = await runOutcome(outcome, collectCtx, finalize);
59
+ if (first.kind === "fatal") return first;
60
+ const initialOutput = enforceCompletionContract(s.stage, s.skill, first.output);
61
+ if (initialOutput.kind === "fatal") return initialOutput;
62
+
63
+ if (!shouldValidateOutput(s.stage, initialOutput.output)) return initialOutput;
64
+
65
+ return retryUntilValid(ctx, s, { outcome, collectCtx, finalize }, initialOutput.output);
66
+ }
67
+
68
+ /**
69
+ * Explicit `stage.outcome` wins. Defaults:
70
+ * - `side-effect` → `sideEffectOutcome` (universal — emits empty artifacts).
71
+ * - `produces` → throws. There is no framework-wide default; the
72
+ * `.rpiv/artifacts/<bucket>/<file>.md` layout is an rpiv-pi convention
73
+ * and lives in that package. `validate-workflow.ts` rejects this at
74
+ * load time; the runtime throw is defense-in-depth for programmatic
75
+ * embedders that bypassed validation.
76
+ */
77
+ function resolveOutcome(stage: StageDef, skill: string): OutputSpec {
78
+ if (stage.outcome) return stage.outcome;
79
+ switch (stage.kind) {
80
+ case "side-effect":
81
+ return sideEffectOutcome;
82
+ case "produces":
83
+ throw new Error(
84
+ `runStage: stage "${skill}" has kind "produces" but no \`outcome\` — ` +
85
+ "there is no framework default for produces stages (the `.rpiv/artifacts/` layout is " +
86
+ "an rpiv-pi convention). Either wire `outcome: rpivArtifactMdOutcome` (from @juicesharp/rpiv-pi) " +
87
+ "or supply your own `{ collector, parser? }`.",
88
+ );
89
+ default:
90
+ return assertNever(stage.kind);
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Contract: `branch` is always the FULL unsliced branch and
96
+ * `branchOffset` is always the policy-derived offset (continue → the
97
+ * stage's captured offset; fresh → undefined). Collectors slice on
98
+ * demand via the `branchOffset` field. Initial production and retry
99
+ * production use the same offset value.
100
+ */
101
+ function buildCollectCtx(s: StageSession, branch: BranchEntry[], branchOffset: number | undefined): CollectCtx {
102
+ return {
103
+ cwd: s.cwd,
104
+ runId: s.runId,
105
+ stageIndex: s.stageIndex,
106
+ state: s.state,
107
+ branch,
108
+ branchOffset,
109
+ snapshot: s.snapshot,
110
+ skill: s.skill,
111
+ };
112
+ }
113
+
114
+ function wrapOutput(s: StageSession, parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }): Output {
115
+ return finalizeOutput(parts, {
116
+ stage: s.stageName,
117
+ skill: s.skill,
118
+ stageNumber: s.state.lastAllocatedStageNumber + 1,
119
+ ts: nowIso(),
120
+ runId: s.runId,
121
+ });
122
+ }
123
+
124
+ type RunOutcomeResult = { kind: "ok"; output: Output } | { kind: "fatal"; message: string };
125
+
126
+ /**
127
+ * The collector → parser pipeline. When `parser` is omitted, the
128
+ * output emits `kind: "artifacts"` with `data = artifacts` — a stage
129
+ * that only needs to enumerate doesn't have to write a parser.
130
+ */
131
+ async function runOutcome(
132
+ outcome: OutputSpec,
133
+ ctx: CollectCtx,
134
+ finalize: (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => Output,
135
+ ): Promise<RunOutcomeResult> {
136
+ const collected = await outcome.collector.collect(ctx);
137
+ if (collected.kind === "fatal") return collected;
138
+
139
+ if (!outcome.parser) {
140
+ return {
141
+ kind: "ok",
142
+ output: finalize({ kind: "artifacts", artifacts: collected.artifacts, data: collected.artifacts }),
143
+ };
144
+ }
145
+
146
+ const parsed = await outcome.parser.parse({ ...ctx, artifacts: collected.artifacts });
147
+ if (parsed.kind === "fatal") return parsed;
148
+ return {
149
+ kind: "ok",
150
+ output: finalize({
151
+ kind: parsed.payload.kind,
152
+ artifacts: collected.artifacts,
153
+ data: parsed.payload.data,
154
+ }),
155
+ };
156
+ }
157
+
158
+ /**
159
+ * Contract check: `produces` stages MUST emit at least one
160
+ * artifact. The collector/parser pair can succeed structurally
161
+ * (kind: "ok") with zero artifacts — that's a chain halt for
162
+ * `produces` (the stage promised an output and didn't deliver)
163
+ * but a normal pass-through for `side-effect`.
164
+ */
165
+ function enforceCompletionContract(
166
+ stage: StageDef,
167
+ skill: string,
168
+ output: Output,
169
+ ): { kind: "ok"; output: Output } | { kind: "fatal"; message: string } {
170
+ if (stage.kind === "produces" && output.artifacts.length === 0) {
171
+ return {
172
+ kind: "fatal",
173
+ message: `${skill} finished without producing any artifact (collector returned an empty list)`,
174
+ };
175
+ }
176
+ return { kind: "ok", output };
177
+ }
178
+
179
+ function shouldValidateOutput(stage: StageDef, output: Output): boolean {
180
+ return !!(stage.outputSchema && output.data !== undefined);
181
+ }
182
+
183
+ interface RetryDeps {
184
+ outcome: OutputSpec;
185
+ collectCtx: CollectCtx;
186
+ finalize: (parts: { kind: string; artifacts: readonly Artifact[]; data: unknown }) => Output;
187
+ }
188
+
189
+ async function retryUntilValid(
190
+ ctx: RunnerCtx,
191
+ s: StageSession,
192
+ deps: RetryDeps,
193
+ initial: Output,
194
+ ): Promise<OutputProduction> {
195
+ const schema = s.stage.outputSchema!;
196
+ const maxRetries = Math.max(
197
+ MIN_VALIDATION_RETRIES,
198
+ Math.min(s.stage.maxRetries ?? DEFAULT_VALIDATION_RETRIES, MAX_VALIDATION_RETRIES),
199
+ );
200
+ const timeoutMs = Math.max(
201
+ MIN_VALIDATION_RETRY_TIMEOUT_MS,
202
+ Math.min(s.stage.validateTimeoutMs ?? DEFAULT_VALIDATION_RETRY_TIMEOUT_MS, MAX_VALIDATION_RETRY_TIMEOUT_MS),
203
+ );
204
+
205
+ let output = initial;
206
+ const initialValidation = await validateOrFatal(schema, output.data, s.skill, timeoutMs);
207
+ if (initialValidation.kind === "fatal") return initialValidation;
208
+ let result = initialValidation.result;
209
+ let attempts = 0;
210
+
211
+ while (!result.valid && attempts < maxRetries && s.stage.onInvalid !== "halt") {
212
+ attempts++;
213
+ // onStageRetry fires before the agent is re-prompted; `attempt` is 1-based.
214
+ await s.lifecycle.fire(
215
+ ctx,
216
+ "onStageRetry",
217
+ skillStageRef(s.stageName, s.stageIndex + 1, s.skill),
218
+ attempts,
219
+ buildLifecycleContext({
220
+ cwd: s.cwd,
221
+ runId: s.runId,
222
+ workflow: s.runIdentity.workflow,
223
+ totalStages: s.runIdentity.totalStages,
224
+ trigger: s.runIdentity.trigger,
225
+ state: s.state,
226
+ }),
227
+ );
228
+ try {
229
+ await askAgentToFix(ctx, s, attempts, result.failures, timeoutMs);
230
+ } catch (e) {
231
+ const msg = e instanceof Error ? e.message : String(e);
232
+ return { kind: "fatal", message: msg };
233
+ }
234
+
235
+ const retryBranch = readBranch(ctx);
236
+ const retryCtx: CollectCtx = { ...deps.collectCtx, branch: retryBranch };
237
+ const reRun = await runOutcome(deps.outcome, retryCtx, deps.finalize);
238
+ if (reRun.kind === "fatal") return reRun;
239
+ const contract = enforceCompletionContract(s.stage, s.skill, reRun.output);
240
+ if (contract.kind === "fatal") return contract;
241
+
242
+ output = contract.output;
243
+ const reValidation = await validateOrFatal(schema, output.data, s.skill, timeoutMs);
244
+ if (reValidation.kind === "fatal") return reValidation;
245
+ result = reValidation.result;
246
+ }
247
+
248
+ if (!result.valid) return validationExhausted(result.failures);
249
+ return { kind: "ok", output };
250
+ }
251
+
252
+ /**
253
+ * Translate a thrown `validateOutputData` (user-authored schemas may throw
254
+ * synchronously or reject their Promise) into the canonical fatal-extraction
255
+ * outcome. Async schemas are guarded by `timeoutMs` — the same
256
+ * `validateTimeoutMs` budget that bounds the agent-settle step on a
257
+ * retry.
258
+ */
259
+ async function validateOrFatal(
260
+ schema: StageSchema,
261
+ data: unknown,
262
+ skill: string,
263
+ timeoutMs: number,
264
+ ): Promise<{ kind: "ok"; result: ValidationResult } | { kind: "fatal"; message: string }> {
265
+ try {
266
+ const result = await withTimeout(
267
+ Promise.resolve(validateOutputData(schema, data)),
268
+ timeoutMs,
269
+ ERR_SCHEMA_TIMEOUT("outputSchema", timeoutMs),
270
+ );
271
+ return { kind: "ok", result };
272
+ } catch (e) {
273
+ const reason = e instanceof Error ? e.message : String(e);
274
+ return { kind: "fatal", message: `${skill}: ${reason}` };
275
+ }
276
+ }
277
+
278
+ async function askAgentToFix(
279
+ ctx: RunnerCtx,
280
+ s: StageSession,
281
+ attempt: number,
282
+ failures: SchemaValidationFailure[],
283
+ timeoutMs: number,
284
+ ): Promise<void> {
285
+ ctx.ui.notify(MSG_VALIDATION_RETRY(s.skill, attempt), "warning");
286
+ const errorLines = failures.map((f) => ` • ${f.path} — ${f.message}`).join("\n");
287
+ await withTimeout(
288
+ handlerFor(s.stage.sessionPolicy).send(ctx, MSG_VALIDATION_RETRY_PROMPT(s.skill, errorLines), s.continueHost),
289
+ timeoutMs,
290
+ `${s.skill}: validation retry attempt ${attempt} exceeded ${timeoutMs}ms — agent did not settle`,
291
+ );
292
+ }
293
+
294
+ function validationExhausted(failures: SchemaValidationFailure[]): OutputProduction {
295
+ const failureSummary = failures.map((f) => `${f.path}: ${f.message}`).join("; ");
296
+ return { kind: "validation-exhausted", failureSummary };
297
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Session execution public surface. The runner is internally split into
3
+ * three files (see `sessions.ts`'s header for the module map); this
4
+ * barrel re-exports only the symbols the rest of the package consumes.
5
+ */
6
+
7
+ export { runFanoutSession, runStageSession } from "./sessions.js";
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Session execution — one Pi session per workflow stage / fanout unit.
3
+ * `runStageSession` and `runFanoutSession` are the two public entries.
4
+ *
5
+ * The fresh-vs-continue policy split is owned by `SessionPolicyHandler`
6
+ * (see `spawn.ts`): `FRESH_HANDLER` and `CONTINUE_HANDLER` implement
7
+ * the three policy-specific decisions. Everything in this file —
8
+ * post-processing, halt routing, success persistence, outcome reading
9
+ * — is policy-agnostic.
10
+ *
11
+ * Companion modules:
12
+ * - extraction.ts — produceAndValidateOutput + retry loop +
13
+ * outcome helpers (collector → parser pipeline).
14
+ * - spawn.ts — SessionPolicyHandler + FRESH/CONTINUE handlers +
15
+ * handlerFor.
16
+ */
17
+
18
+ import {
19
+ type AuditCtx,
20
+ fanoutRowStage,
21
+ nowIso,
22
+ recordCancellation,
23
+ recordStage,
24
+ recordStopFailure,
25
+ recordTerminalFailure,
26
+ } from "../audit.js";
27
+ import { resolvePublishName } from "../internal-utils.js";
28
+ import { buildLifecycleContext, skillStageRef } from "../lifecycle.js";
29
+ import {
30
+ ERR_AUDIT_WRITE_FAILED,
31
+ ERR_VALIDATION_FAILED,
32
+ MSG_AUDIT_WRITE_FAILED,
33
+ MSG_STAGE_COMPLETE,
34
+ MSG_STAGE_FAILED,
35
+ MSG_VALIDATION_EXHAUSTED,
36
+ } from "../messages.js";
37
+ import type { Output } from "../output.js";
38
+ import { type BranchEntry, classifyStop, readBranch, type StopSignal } from "../transcript.js";
39
+ import type { FanoutSession, RunnerCtx, SessionContext, StageSession } from "../types.js";
40
+ import { produceAndValidateOutput } from "./extraction.js";
41
+ import { FRESH_HANDLER, handlerFor } from "./spawn.js";
42
+
43
+ // ===========================================================================
44
+ // PUBLIC ENTRIES — what the orchestrator calls
45
+ // ===========================================================================
46
+
47
+ /** Execute one DAG stage in its own session. */
48
+ export async function runStageSession(ctx: RunnerCtx, s: StageSession): Promise<void> {
49
+ const handler = handlerFor(s.stage.sessionPolicy);
50
+ const { cancelled } = await handler.spawn(ctx, s.prompt, (sessionCtx) => postStage(sessionCtx, s), s.continueHost);
51
+ if (cancelled) await recordCancellation(ctx, auditFor(s));
52
+ }
53
+
54
+ /** Execute one fanout-unit iteration. Always fresh. */
55
+ export async function runFanoutSession(ctx: RunnerCtx, s: FanoutSession): Promise<void> {
56
+ const { cancelled } = await FRESH_HANDLER.spawn(ctx, s.prompt, (sessionCtx) => postFanout(sessionCtx, s));
57
+ if (cancelled) await recordCancellation(ctx, auditFor(s));
58
+ }
59
+
60
+ // ===========================================================================
61
+ // POST-PROCESSING — runs after the agent loop settles
62
+ // ===========================================================================
63
+
64
+ /** Stage post-processing: classify outcome → produce & validate output → persist → chain. */
65
+ async function postStage(ctx: RunnerCtx, s: StageSession): Promise<void> {
66
+ const handler = handlerFor(s.stage.sessionPolicy);
67
+ const offset = handler.branchOffset(s.branchOffset);
68
+ const outcome = readSessionOutcome(ctx, offset);
69
+ if (outcome.stop !== "stop") return haltStage(ctx, s, outcome.stop);
70
+
71
+ const result = await produceAndValidateOutput(ctx, s, outcome.branch, offset);
72
+ if (result.kind === "fatal") return haltStageWithExtractionError(ctx, s, result.message);
73
+ if (result.kind === "validation-exhausted") return haltStageWithValidationFailure(ctx, s, result.failureSummary);
74
+
75
+ if (!(await recordStageSuccess(ctx, s, result.output))) return;
76
+ await s.onSuccess(ctx, result.output.artifacts[0]);
77
+ }
78
+
79
+ /** Fanout-unit post-processing: classify outcome → persist bare row → chain. */
80
+ async function postFanout(ctx: RunnerCtx, s: FanoutSession): Promise<void> {
81
+ const outcome = readSessionOutcome(ctx, undefined);
82
+ if (outcome.stop !== "stop") return haltFanout(ctx, s, outcome.stop);
83
+
84
+ if (!(await recordFanoutSuccess(ctx, s))) return;
85
+ await s.onSuccess(ctx);
86
+ }
87
+
88
+ // ===========================================================================
89
+ // HALT HELPERS — turn a halt reason into the right audit-layer call
90
+ // ===========================================================================
91
+
92
+ async function haltStage(ctx: RunnerCtx, s: StageSession, stop: Exclude<StopSignal, "stop">): Promise<void> {
93
+ await recordStopFailure(ctx, auditFor(s), stop, `${s.skill} failed`, s.onFailure);
94
+ }
95
+
96
+ async function haltStageWithExtractionError(ctx: RunnerCtx, s: StageSession, message: string): Promise<void> {
97
+ await recordTerminalFailure(
98
+ ctx,
99
+ auditFor(s),
100
+ { status: "failed", notifyMsg: MSG_STAGE_FAILED(s.skill), notifyLevel: "error", errMsg: message },
101
+ s.onFailure,
102
+ );
103
+ }
104
+
105
+ async function haltStageWithValidationFailure(ctx: RunnerCtx, s: StageSession, failureSummary: string): Promise<void> {
106
+ await recordTerminalFailure(
107
+ ctx,
108
+ auditFor(s),
109
+ {
110
+ status: "failed",
111
+ notifyMsg: MSG_VALIDATION_EXHAUSTED(s.skill),
112
+ notifyLevel: "error",
113
+ errMsg: ERR_VALIDATION_FAILED(s.skill, failureSummary),
114
+ },
115
+ s.onFailure,
116
+ );
117
+ }
118
+
119
+ async function haltFanout(ctx: RunnerCtx, s: FanoutSession, stop: Exclude<StopSignal, "stop">): Promise<void> {
120
+ await recordStopFailure(ctx, auditFor(s), stop, `${s.skill} unit ${s.unitIndex} (${s.label}) failed`);
121
+ }
122
+
123
+ // ===========================================================================
124
+ // SUCCESS-PERSISTENCE HELPERS
125
+ // ===========================================================================
126
+
127
+ /**
128
+ * Write + counter-increment guard shared by `recordStageSuccess` and
129
+ * `recordFanoutSuccess`. Returns `true` iff the JSONL row landed.
130
+ * Output assignment lives here so callers get the same "output is
131
+ * set iff the row that carried it landed" invariant.
132
+ */
133
+ function tryRecordStage(s: SessionContext, row: { stage: string; skill?: string; output?: Output }): boolean {
134
+ const assigned = recordStage(
135
+ s.cwd,
136
+ s.runId,
137
+ {
138
+ stage: row.stage,
139
+ skill: row.skill,
140
+ status: "completed",
141
+ ts: nowIso(),
142
+ output: row.output,
143
+ },
144
+ s.state,
145
+ );
146
+ if (assigned === undefined) return false;
147
+ if (row.output) s.state.output = row.output;
148
+ s.state.stagesCompleted++;
149
+ return true;
150
+ }
151
+
152
+ /**
153
+ * Update the rolling chain-input slot. Three cases:
154
+ * 1. `produces` stages whose collector returned at least one artifact
155
+ * advance the primary (first artifact wins; `role` is user-facing
156
+ * metadata, not a framework gate).
157
+ * 2. `side-effect` stages with `inheritsArtifacts: false` (authored via
158
+ * `terminal()`) CLEAR the slot — they explicitly break the chain
159
+ * so anything after also starts without an inherited artifact.
160
+ * 3. Other `side-effect` stages (commit, implement) leave it in place
161
+ * so a stage after them inherits the upstream chain input.
162
+ */
163
+ function maybeAdvancePrimary(s: StageSession, output: Output): void {
164
+ if (s.stage.kind === "produces") {
165
+ const next = output.artifacts[0];
166
+ if (next) s.state.primaryArtifact = next;
167
+ const key = resolvePublishName(s.stage, s.stageName);
168
+ const slot = s.state.named[key];
169
+ if (slot) slot.push(output);
170
+ else s.state.named[key] = [output];
171
+ return;
172
+ }
173
+ if (s.stage.inheritsArtifacts === false) {
174
+ s.state.primaryArtifact = undefined;
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Returns true on successful write — caller gates `onSuccess` on this so the
180
+ * chain advances only when the audit row landed. On failure, leaves
181
+ * `state.output` / `state.primaryArtifact` at their prior values and sets
182
+ * `state.termination.error` to halt the run.
183
+ */
184
+ async function recordStageSuccess(ctx: RunnerCtx, s: StageSession, output: Output): Promise<boolean> {
185
+ if (tryRecordStage(s, { stage: s.stageName, skill: s.skill, output })) {
186
+ maybeAdvancePrimary(s, output);
187
+ ctx.ui.notify(MSG_STAGE_COMPLETE(s.skill), "info");
188
+ await s.lifecycle.fire(
189
+ ctx,
190
+ "onStageEnd",
191
+ skillStageRef(s.stageName, s.state.lastAllocatedStageNumber, s.skill),
192
+ output,
193
+ lifecycleCtxFromSession(s),
194
+ );
195
+ return true;
196
+ }
197
+ ctx.ui.notify(MSG_AUDIT_WRITE_FAILED(s.skill), "error");
198
+ s.state.termination.error = ERR_AUDIT_WRITE_FAILED(s.skill);
199
+ return false;
200
+ }
201
+
202
+ async function recordFanoutSuccess(ctx: RunnerCtx, s: FanoutSession): Promise<boolean> {
203
+ const stageLabel = fanoutRowStage(s);
204
+ if (tryRecordStage(s, { stage: stageLabel, skill: s.skill })) {
205
+ await s.lifecycle.fire(
206
+ ctx,
207
+ "onFanoutUnitEnd",
208
+ skillStageRef(s.stageName, s.stageIndex + 1, s.skill),
209
+ { prompt: s.prompt, label: s.label, ...(s.id !== undefined && { id: s.id }) },
210
+ s.unitIndex,
211
+ lifecycleCtxFromSession(s),
212
+ );
213
+ return true;
214
+ }
215
+ s.state.termination.error = ERR_AUDIT_WRITE_FAILED(stageLabel);
216
+ return false;
217
+ }
218
+
219
+ /** Build a `LifecycleContext` from any SessionContext-shaped object. */
220
+ function lifecycleCtxFromSession(s: SessionContext) {
221
+ return buildLifecycleContext({
222
+ cwd: s.cwd,
223
+ runId: s.runId,
224
+ workflow: s.runIdentity.workflow,
225
+ totalStages: s.runIdentity.totalStages,
226
+ trigger: s.runIdentity.trigger,
227
+ state: s.state,
228
+ });
229
+ }
230
+
231
+ // ===========================================================================
232
+ // OUTCOME READER
233
+ // ===========================================================================
234
+
235
+ interface SessionOutcome {
236
+ branch: BranchEntry[];
237
+ stop: StopSignal;
238
+ }
239
+
240
+ /**
241
+ * Always reads the full unsliced branch + applies the policy-derived
242
+ * `branchOffset` to `classifyStop` so the prior-stage prefix is
243
+ * skipped in place. The same offset value flows through to
244
+ * `produceAndValidateOutput` (initial == retry).
245
+ *
246
+ * No longer scans the transcript for an artifact path — discovery is
247
+ * the collector's job, not the runner's.
248
+ */
249
+ function readSessionOutcome(ctx: RunnerCtx, branchOffset: number | undefined): SessionOutcome {
250
+ const branch = readBranch(ctx);
251
+ return {
252
+ branch,
253
+ stop: classifyStop(branch, branchOffset),
254
+ };
255
+ }
256
+
257
+ // ===========================================================================
258
+ // Helpers
259
+ // ===========================================================================
260
+
261
+ const auditFor = (s: StageSession | FanoutSession): AuditCtx => ({
262
+ cwd: s.cwd,
263
+ runId: s.runId,
264
+ state: s.state,
265
+ stageName: "unitIndex" in s ? fanoutRowStage(s) : s.stageName,
266
+ skill: s.skill,
267
+ lifecycle: s.lifecycle,
268
+ runIdentity: s.runIdentity,
269
+ });