@yolo-labs/yolo-cli 0.23.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.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Plan diff → mutations (Phase 8c.3 helper).
3
+ *
4
+ * Given the DB's current Plan and the parsed-from-file Plan,
5
+ * compute the mutation list that `work.update_plan` needs to bring
6
+ * the DB into the file's state. Pure — no I/O, no Mongo, no fetch.
7
+ *
8
+ * Mutation shapes match `common-api/src/services/work/plan-mutations.ts`:
9
+ *
10
+ * { op: 'set-plan-fields', fields: { name?, description?, inputs?,
11
+ * failurePolicy?, autoRetryCap?, autoRetryFallback?, integrationPolicy? } }
12
+ * { op: 'add-step', step: StepDefinition }
13
+ * { op: 'update-step', stepId: string, fields: <full new step shape, used as patch> }
14
+ * { op: 'remove-step', stepId: string }
15
+ * { op: 'set-state', state: 'draft' | 'active' | 'archived' }
16
+ *
17
+ * Re-import strategy notes:
18
+ * - Top-level fields: emit `set-plan-fields` with only the fields
19
+ * that differ. Compared via canonical JSON for inputs /
20
+ * integrationPolicy (free-form objects); primitive equality for
21
+ * scalars. The substrate validator at the Worker tier ignores any
22
+ * field absent from the patch, so this is safe to send selectively.
23
+ * - Steps: emit add/remove/update by stepId. `update-step.fields` is
24
+ * the FULL new step shape used as a patch — the substrate's
25
+ * `applyAndValidateStepPatch` re-validates after applying, so
26
+ * sending the whole shape is equivalent to a per-step replace.
27
+ * - Step ORDER inside the file is NOT enforced as a diff target in
28
+ * v1: the substrate's executor reads `gate.config.requires`, not
29
+ * array order, so reordering is semantically a no-op. New steps
30
+ * from `add-step` land at the end of the array. (A later phase
31
+ * can add `reorder-steps` if a UI reason emerges.)
32
+ * - Description: the design says body IS Plan.description (no
33
+ * frontmatter description field). The caller passes the body as
34
+ * `filePlan.description`; the diff treats it as a regular
35
+ * top-level string field.
36
+ * - state: emit `set-state` only if the file's state differs from
37
+ * the DB. Substrate-side validates the transition; this layer
38
+ * just produces the request. (Renamed from `authoringState` /
39
+ * `set-authoring-state` 2026-05-09 — item 17.)
40
+ *
41
+ * Input shape:
42
+ * - `dbPlan` = the response from `work.get_plan` (already validated
43
+ * by common-api).
44
+ * - `filePlan` = the validated frontmatter object plus the body
45
+ * string used as `description`.
46
+ */
47
+ /**
48
+ * Top-level fields that round-trip through `set-plan-fields`. Order
49
+ * matters for nothing here — we compare by name and emit only deltas.
50
+ */
51
+ const TOP_LEVEL_DIFF_FIELDS = [
52
+ 'name',
53
+ 'description',
54
+ 'failurePolicy',
55
+ 'autoRetryCap',
56
+ 'autoRetryFallback',
57
+ 'inputs',
58
+ 'integrationPolicy',
59
+ ];
60
+ /**
61
+ * Compute the mutation list required to bring `dbPlan` to match
62
+ * `filePlan`. Empty array means "nothing to do". The caller is
63
+ * responsible for guarding on `filePlan.planId === dbPlan.planId`
64
+ * before calling this — diff doesn't cross identity.
65
+ */
66
+ export function computeMutations(dbPlan, filePlan) {
67
+ const mutations = [];
68
+ // 1) Top-level fields (set-plan-fields)
69
+ const fieldDelta = {};
70
+ for (const field of TOP_LEVEL_DIFF_FIELDS) {
71
+ const fileValue = filePlan[field];
72
+ const dbValue = dbPlan[field];
73
+ if (!equalDeep(fileValue, dbValue)) {
74
+ // Server validator coerces null → undefined for description; we
75
+ // mirror that here so the produced mutation is what the route
76
+ // already accepts.
77
+ fieldDelta[field] = fileValue ?? undefined;
78
+ }
79
+ }
80
+ if (Object.keys(fieldDelta).length > 0) {
81
+ mutations.push({ op: 'set-plan-fields', fields: fieldDelta });
82
+ }
83
+ // 2) Steps diff (add / update / remove)
84
+ const dbStepIndex = new Map();
85
+ for (const step of dbPlan.steps) {
86
+ const id = step.stepId;
87
+ if (typeof id === 'string')
88
+ dbStepIndex.set(id, step);
89
+ }
90
+ const fileStepIds = new Set();
91
+ for (const step of filePlan.steps) {
92
+ const id = step.stepId;
93
+ if (typeof id !== 'string')
94
+ continue;
95
+ fileStepIds.add(id);
96
+ const dbStep = dbStepIndex.get(id);
97
+ if (!dbStep) {
98
+ mutations.push({ op: 'add-step', step });
99
+ }
100
+ else if (!equalDeep(step, dbStep)) {
101
+ mutations.push({ op: 'update-step', stepId: id, fields: step });
102
+ }
103
+ }
104
+ for (const id of dbStepIndex.keys()) {
105
+ if (!fileStepIds.has(id)) {
106
+ mutations.push({ op: 'remove-step', stepId: id });
107
+ }
108
+ }
109
+ // 3) Plan state (set-state)
110
+ if (filePlan.state !== dbPlan.state) {
111
+ mutations.push({ op: 'set-state', state: filePlan.state });
112
+ }
113
+ return mutations;
114
+ }
115
+ // ─── Internals ────────────────────────────────────────────────────────────
116
+ /**
117
+ * Structural equality for the value shapes that show up in plan
118
+ * frontmatter: strings / numbers / booleans / null / undefined / arrays
119
+ * of same / plain objects of same. NOT a general-purpose deep equality;
120
+ * doesn't handle Date, RegExp, Map, Set, class instances. None of those
121
+ * appear in the file → DB diff path because we're comparing JSON-shaped
122
+ * data on both sides.
123
+ *
124
+ * Treats `undefined` and missing keys identically, so a file that omits
125
+ * `autoRetryCap` matches a DB where `autoRetryCap: undefined`. Treats
126
+ * `null` and `undefined` as equal too — the substrate validator
127
+ * normalizes both ways.
128
+ */
129
+ function equalDeep(a, b) {
130
+ if (a === b)
131
+ return true;
132
+ if (a == null && b == null)
133
+ return true; // null/undefined normalize
134
+ if (a == null || b == null)
135
+ return false;
136
+ if (typeof a !== typeof b)
137
+ return false;
138
+ if (typeof a !== 'object')
139
+ return false;
140
+ if (Array.isArray(a) !== Array.isArray(b))
141
+ return false;
142
+ if (Array.isArray(a) && Array.isArray(b)) {
143
+ if (a.length !== b.length)
144
+ return false;
145
+ for (let i = 0; i < a.length; i++)
146
+ if (!equalDeep(a[i], b[i]))
147
+ return false;
148
+ return true;
149
+ }
150
+ const ao = a;
151
+ const bo = b;
152
+ // Ignore keys whose values are undefined — they're equivalent to absent.
153
+ const aKeys = Object.keys(ao).filter((k) => ao[k] !== undefined);
154
+ const bKeys = Object.keys(bo).filter((k) => bo[k] !== undefined);
155
+ if (aKeys.length !== bKeys.length)
156
+ return false;
157
+ for (const k of aKeys) {
158
+ if (!equalDeep(ao[k], bo[k]))
159
+ return false;
160
+ }
161
+ return true;
162
+ }
@@ -0,0 +1,261 @@
1
+ /**
2
+ * `yolo plan export <planId>` orchestration (Phase 8c.4).
3
+ *
4
+ * Inverse of `yolo plan import`: DB → file. Composes everything from
5
+ * earlier slices:
6
+ * - `work-client` (8c.2) — mint + authenticatedRequest for
7
+ * `work.get_plan`.
8
+ * - `canonicalizer` (8b) — pure object → canonical YAML+body.
9
+ * - `revision` (8c.2) — sha256 of the bytes we just wrote.
10
+ * - `lockfile` (8c.2) — refresh `(workspaceId, planId)`
11
+ * entry so a follow-up `import` is
12
+ * NO_CHANGE.
13
+ *
14
+ * High-level flow:
15
+ *
16
+ * 1. Resolve substrate context (env trio); error if missing.
17
+ * 2. Mint a session-bound delegated token; the response is the
18
+ * authoritative `workspaceId`.
19
+ * 3. If `--workspace` was passed, assert it matches the minted
20
+ * workspaceId (same ergonomics as import).
21
+ * 4. Call `work.get_plan(workspaceId, planId)`; serialize to the
22
+ * canonical file form.
23
+ * 5. Write the canonical bytes to `-o <file>` (default
24
+ * `<plansDir>/<planId>.md`). Errors out if the destination
25
+ * directory doesn't exist UNLESS `--force` is set, in which
26
+ * case it `mkdir -p`s.
27
+ * 6. Refresh the lockfile entry so `import` of the freshly
28
+ * written file is detected as NO_CHANGE on the next run.
29
+ *
30
+ * Out of scope (deferred):
31
+ * - `--diff` / dry-run output. Add when there's real demand.
32
+ * - File-watch / auto-export daemon. Manual export only per the
33
+ * Phase 8 design ("Bidirectional file ↔ DB sync... out of scope").
34
+ * - Outside-container login. v1 errors out without `SESSION_ID`
35
+ * (substrate CLI is container-only — design Round 5).
36
+ *
37
+ * Errors map to exit codes via the CLI wrapper:
38
+ * - 0 = success
39
+ * - 1 = HTTP / file-write / lockfile failure
40
+ * - 64 = usage error (missing planId, workspace mismatch, missing env trio)
41
+ */
42
+ import { mkdirSync, writeFileSync } from 'node:fs';
43
+ import path from 'node:path';
44
+ import { canonicalizePlanFile } from './canonicalizer.js';
45
+ import { computeRevisionHash } from './revision.js';
46
+ import { LockfileError, readLockfile, resolveLockfilePath, setEntry, writeLockfile, } from './lockfile.js';
47
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
48
+ import { resolveSubstrateContext } from './auth-context.js';
49
+ // ─── Public entry ─────────────────────────────────────────────────────────
50
+ const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
51
+ export async function runPlanExport(options) {
52
+ const env = options.env ?? process.env;
53
+ const now = options.now ?? (() => new Date().toISOString());
54
+ // 1) planId shape (matches substrate's PLAN_ID_REGEX)
55
+ if (!PLAN_ID_REGEX.test(options.planId)) {
56
+ return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
57
+ }
58
+ // 1b) -o basename stem must equal planId so the exported file
59
+ // round-trips through `yolo plan import`, which enforces
60
+ // `frontmatter.planId === file-stem` (plan-import.ts:172).
61
+ // Otherwise `yolo plan export foo -o custom-name.md` produces
62
+ // a file the import command refuses (Codex 8c.4 R1 Medium).
63
+ // -o is for changing the DIRECTORY, not the filename.
64
+ if (options.outputFlag !== undefined) {
65
+ const outStem = path.basename(options.outputFlag, path.extname(options.outputFlag));
66
+ if (outStem !== options.planId) {
67
+ return fail('usage', `-o basename stem '${outStem}' must equal planId '${options.planId}' so the exported file round-trips through \`yolo plan import\`. ` +
68
+ `Either rename the output to '${options.planId}.md' (with any directory you like) or drop -o to use the default <plansDir>/${options.planId}.md.`);
69
+ }
70
+ }
71
+ // 2) Substrate context
72
+ const auth = resolveSubstrateContext(env);
73
+ if (!auth.ok)
74
+ return fail('auth', auth.message);
75
+ const { sessionId, commonApiUrl, userToken } = auth.context;
76
+ // 3) Mint token
77
+ let mint;
78
+ try {
79
+ mint = await mintSubstrateToken({
80
+ commonApiUrl,
81
+ userToken,
82
+ sessionId,
83
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
84
+ fetchImpl: options.fetchImpl,
85
+ });
86
+ }
87
+ catch (err) {
88
+ if (err instanceof WorkClientError) {
89
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
90
+ }
91
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
92
+ }
93
+ // 4) --workspace assertion
94
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
95
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
96
+ }
97
+ // 5) work.get_plan
98
+ const plansDir = options.plansDir ?? path.resolve('.yolo', 'plans');
99
+ const requestCtx = {
100
+ commonApiUrl,
101
+ delegatedToken: mint.token,
102
+ fetchImpl: options.fetchImpl,
103
+ };
104
+ const fetched = await tryGet(requestCtx, mint.workspaceId, options.planId);
105
+ if (!fetched.ok)
106
+ return fetched.error;
107
+ // 6) Convert to canonical file bytes
108
+ const dbPlan = fetched.plan;
109
+ const { frontmatter, body } = dbPlanToFile(dbPlan);
110
+ const canonicalBytes = canonicalizePlanFile(frontmatter, body);
111
+ // 7) Resolve output path + ensure dir
112
+ const outputPath = options.outputFlag
113
+ ? path.resolve(options.outputFlag)
114
+ : path.join(plansDir, `${dbPlan.planId}.md`);
115
+ try {
116
+ mkdirSync(path.dirname(outputPath), { recursive: true });
117
+ writeFileSync(outputPath, canonicalBytes, 'utf8');
118
+ }
119
+ catch (err) {
120
+ return fail('write', `could not write '${outputPath}': ${describeError(err)}`);
121
+ }
122
+ // 8) Refresh lockfile so a subsequent `yolo plan import` of this
123
+ // file is a NO_CHANGE (file revision == lockfile entry).
124
+ const revision = computeRevisionHash(canonicalBytes);
125
+ const exportedAt = now();
126
+ let lockfilePath;
127
+ let lockfile;
128
+ try {
129
+ lockfilePath = resolveLockfilePath(plansDir, options.lockfileFlag);
130
+ lockfile = readLockfile(lockfilePath);
131
+ }
132
+ catch (err) {
133
+ if (err instanceof LockfileError) {
134
+ return fail('lockfile', err.message, { code: err.code });
135
+ }
136
+ return fail('lockfile', describeError(err));
137
+ }
138
+ const newEntry = {
139
+ lastImportedRevision: revision,
140
+ lastImportedVersion: dbPlan.version,
141
+ lastImportedAt: exportedAt,
142
+ };
143
+ setEntry(lockfile, mint.workspaceId, dbPlan.planId, newEntry);
144
+ try {
145
+ writeLockfile(lockfilePath, lockfile);
146
+ }
147
+ catch (err) {
148
+ if (err instanceof LockfileError) {
149
+ return fail('lockfile', err.message, { code: err.code });
150
+ }
151
+ return fail('lockfile', describeError(err));
152
+ }
153
+ return {
154
+ ok: true,
155
+ planId: dbPlan.planId,
156
+ workspaceId: mint.workspaceId,
157
+ version: dbPlan.version,
158
+ revision,
159
+ outputPath,
160
+ exportedAt,
161
+ lockfilePath,
162
+ };
163
+ }
164
+ /**
165
+ * Convert a `work.get_plan` response (the `serializePlan` shape from
166
+ * `common-api/src/routes/work.ts`) to the canonical file form
167
+ * (frontmatter object + body string). Pure, no I/O.
168
+ *
169
+ * Strips DB-only fields (`version`, `latestRunId`, `createdAt`,
170
+ * `updatedAt`) per the Phase 8 design — those don't round-trip.
171
+ * Converts nulls back to `undefined` so the canonicalizer drops them
172
+ * via `reorderObject`'s "skip undefined" rule.
173
+ *
174
+ * Empty `inputs: []` is preserved in the frontmatter so a follow-up
175
+ * `import` produces a stable diff (round-trip equivalence). Empty
176
+ * `integrationPolicy: {}` is dropped since the substrate validator
177
+ * normalizes it to `undefined` anyway.
178
+ */
179
+ export function dbPlanToFile(dbPlan) {
180
+ const frontmatter = {
181
+ planId: dbPlan.planId,
182
+ name: dbPlan.name,
183
+ state: dbPlan.state,
184
+ failurePolicy: dbPlan.failurePolicy ?? 'pause-and-wait',
185
+ steps: dbPlan.steps,
186
+ };
187
+ if (dbPlan.autoRetryCap !== undefined && dbPlan.autoRetryCap !== null) {
188
+ frontmatter.autoRetryCap = dbPlan.autoRetryCap;
189
+ }
190
+ if (dbPlan.autoRetryFallback !== undefined && dbPlan.autoRetryFallback !== null) {
191
+ frontmatter.autoRetryFallback = dbPlan.autoRetryFallback;
192
+ }
193
+ if (Array.isArray(dbPlan.inputs)) {
194
+ frontmatter.inputs = dbPlan.inputs;
195
+ }
196
+ if (dbPlan.integrationPolicy !== undefined &&
197
+ dbPlan.integrationPolicy !== null &&
198
+ Object.keys(dbPlan.integrationPolicy).length > 0) {
199
+ frontmatter.integrationPolicy = dbPlan.integrationPolicy;
200
+ }
201
+ // Body = description. canonicalizePlanFile's body-side normalization
202
+ // (CRLF→LF, strip leading `\n+`, exactly one trailing newline)
203
+ // handles whatever comes out of the DB. Treat `null`/`undefined` as
204
+ // empty string — canonical form will be `---\n…\n---\n\n\n`, which
205
+ // is an empty body plus the mandatory trailing newline.
206
+ const body = typeof dbPlan.description === 'string' ? dbPlan.description : '';
207
+ return { frontmatter, body };
208
+ }
209
+ async function tryGet(ctx, workspaceId, planId) {
210
+ const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans/${planId}`, { method: 'GET' });
211
+ if (!response.ok) {
212
+ const text = await safeReadText(response);
213
+ return {
214
+ ok: false,
215
+ error: fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status }),
216
+ };
217
+ }
218
+ const json = (await response.json());
219
+ if (!json.plan) {
220
+ return { ok: false, error: fail('http', 'work.get_plan response missing `plan` field') };
221
+ }
222
+ return { ok: true, plan: json.plan };
223
+ }
224
+ // ─── Helpers + CLI surface ────────────────────────────────────────────────
225
+ function fail(kind, message, detail) {
226
+ return { ok: false, kind, message, detail };
227
+ }
228
+ function describeError(err) {
229
+ return err instanceof Error ? err.message : String(err);
230
+ }
231
+ async function safeReadText(response) {
232
+ try {
233
+ return await response.text();
234
+ }
235
+ catch {
236
+ return '<no body>';
237
+ }
238
+ }
239
+ /**
240
+ * Map an `ExportFailure.kind` to a CLI exit code. Used by cli.ts and
241
+ * exported for tests.
242
+ */
243
+ export function exitCodeForFailure(kind) {
244
+ switch (kind) {
245
+ case 'usage':
246
+ case 'auth':
247
+ case 'workspace_mismatch':
248
+ return 64; // EX_USAGE
249
+ case 'http':
250
+ case 'write':
251
+ case 'lockfile':
252
+ return 1;
253
+ }
254
+ }
255
+ /**
256
+ * Render an `ExportSuccess` as a single-line CLI summary. Caller
257
+ * appends `\n` if needed.
258
+ */
259
+ export function formatSuccess(result) {
260
+ return `OK: exported plan '${result.planId}' (workspace ${result.workspaceId}, version ${result.version}) to ${result.outputPath} (revision ${result.revision.slice(0, 14)}…)`;
261
+ }
@@ -0,0 +1,184 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://yolo.studio/schemas/plan-frontmatter.json",
4
+ "title": "YOLO Studio Plan Frontmatter",
5
+ "description": "Schema for the YAML frontmatter of a `.yolo/plans/<slug>.md` file. Mirrors the WorkPlan TypeScript type in common-api/src/types/work.ts. Lossless round-trip with the canonicalizer at packages/yolo-cli/src/canonicalizer.ts.",
6
+ "type": "object",
7
+ "required": [
8
+ "planId",
9
+ "name",
10
+ "state",
11
+ "failurePolicy",
12
+ "steps"
13
+ ],
14
+ "additionalProperties": false,
15
+ "properties": {
16
+ "planId": {
17
+ "type": "string",
18
+ "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$",
19
+ "description": "Workspace-scoped slug. Must equal the file stem (e.g., `final-launch.md` → `planId: final-launch`). Substrate-side regex enforced at /internal/work create_plan."
20
+ },
21
+ "name": {
22
+ "type": "string",
23
+ "minLength": 1,
24
+ "maxLength": 80,
25
+ "description": "Human-readable Plan name. Tighter character-class rules (Unicode-aware) live in semantic validation, not in this schema (Draft-07 `pattern` runs through validators that don't all support `\\p{L}`/`\\p{N}` consistently)."
26
+ },
27
+ "state": {
28
+ "type": "string",
29
+ "enum": ["draft", "active", "archived"]
30
+ },
31
+ "failurePolicy": {
32
+ "type": "string",
33
+ "enum": ["pause-and-wait", "abort-run", "proceed-on-non-blocked", "auto-retry"]
34
+ },
35
+ "autoRetryCap": {
36
+ "type": "integer",
37
+ "minimum": 0,
38
+ "description": "Required when failurePolicy is auto-retry; ignored otherwise."
39
+ },
40
+ "autoRetryFallback": {
41
+ "type": "string",
42
+ "enum": ["pause-and-wait", "abort-run", "proceed-on-non-blocked"],
43
+ "description": "Fallback when auto-retry exhausts the cap."
44
+ },
45
+ "inputs": {
46
+ "type": "array",
47
+ "items": { "$ref": "#/definitions/PlanInputDecl" }
48
+ },
49
+ "integrationPolicy": {
50
+ "type": "object",
51
+ "additionalProperties": true,
52
+ "description": "Free-form per-Plan integration knobs (base branch, merge strategy, etc.). Canonicalizer sorts keys lexicographically."
53
+ },
54
+ "steps": {
55
+ "type": "array",
56
+ "items": { "$ref": "#/definitions/StepDefinition" }
57
+ }
58
+ },
59
+ "definitions": {
60
+ "PlanInputDecl": {
61
+ "type": "object",
62
+ "required": ["name", "required"],
63
+ "additionalProperties": false,
64
+ "properties": {
65
+ "name": { "type": "string", "minLength": 1 },
66
+ "required": { "type": "boolean" },
67
+ "default": { "type": "string", "description": "Default value (string-typed; substrate inputs are always string per R5)." },
68
+ "description": { "type": "string" }
69
+ }
70
+ },
71
+ "StepDefinition": {
72
+ "type": "object",
73
+ "required": ["stepId", "name", "mode", "gates"],
74
+ "additionalProperties": false,
75
+ "properties": {
76
+ "stepId": { "type": "string", "minLength": 1 },
77
+ "name": { "type": "string", "minLength": 1 },
78
+ "description": { "type": "string" },
79
+ "mode": {
80
+ "type": "string",
81
+ "enum": ["workstream", "test", "critic", "manual", "preview", "automation", "decision"]
82
+ },
83
+ "runner": { "type": "string" },
84
+ "gates": {
85
+ "type": "array",
86
+ "items": { "$ref": "#/definitions/GateDefinition" }
87
+ },
88
+ "declaredOutputs": {
89
+ "type": "array",
90
+ "items": { "$ref": "#/definitions/StepDeclaredOutput" }
91
+ },
92
+ "failurePolicy": {
93
+ "type": "string",
94
+ "enum": ["pause-and-wait", "abort-run", "proceed-on-non-blocked", "auto-retry"]
95
+ },
96
+ "autoRetryCap": { "type": "integer", "minimum": 0 },
97
+ "autoRetryFallback": {
98
+ "type": "string",
99
+ "enum": ["pause-and-wait", "abort-run", "proceed-on-non-blocked"]
100
+ },
101
+ "template": { "$ref": "#/definitions/StepTemplate" }
102
+ }
103
+ },
104
+ "GateDefinition": {
105
+ "type": "object",
106
+ "required": ["gateId", "type", "config"],
107
+ "additionalProperties": false,
108
+ "properties": {
109
+ "gateId": { "type": "string", "minLength": 1 },
110
+ "type": {
111
+ "type": "string",
112
+ "enum": ["dependency", "dependency-failed", "dependency-any-of", "dependency-quorum", "acceptance", "artifact-presence", "test-result", "approval", "critique", "merge"]
113
+ },
114
+ "config": {
115
+ "type": "object",
116
+ "additionalProperties": true,
117
+ "description": "Free-form per-gate-type config. Canonicalizer sorts keys lexicographically. Conventional shapes: dependency.requires (string[]); artifact-presence.{name,producerStepId}; test-result.{minPassRate,requiredSuites,retryFlakes}; critique.{rubric,minScore,reviewers}; merge.{baseBranch,allowedStrategies,requireGreenCi,blockOnConflict}."
118
+ }
119
+ }
120
+ },
121
+ "StepDeclaredOutput": {
122
+ "type": "object",
123
+ "required": ["name", "artifactType"],
124
+ "additionalProperties": false,
125
+ "properties": {
126
+ "name": { "type": "string", "minLength": 1 },
127
+ "artifactType": { "type": "string", "minLength": 1 },
128
+ "required": { "type": "boolean" }
129
+ }
130
+ },
131
+ "StepTemplate": {
132
+ "type": "object",
133
+ "additionalProperties": false,
134
+ "properties": {
135
+ "todoContent": { "type": "string" },
136
+ "todoFile": { "type": "string" },
137
+ "branch": { "type": "string" },
138
+ "metadata": {
139
+ "type": "object",
140
+ "additionalProperties": { "type": "string" },
141
+ "description": "Free-form key-value metadata (string-valued). Canonicalizer sorts keys lexicographically."
142
+ },
143
+ "agentType": {
144
+ "type": "string",
145
+ "enum": ["claude", "codex", "opencode-yolo", "yolo", "yolo-code"],
146
+ "description": "Item 16 follow-up — per-Step agent label (workstream-mode only). Forwarded to container-api's /lanes/:name/spawn-agent so the lane runner picks the matching adapter, AND used by the dispatcher to append the agent to the Plan Run's operators[] on Step Run spawn (addedReason 'step-spawn') so the agent fan grows to match actual execution. Must be in the substrate's container-spawnable set; the alias 'yolo-code' canonicalizes to 'yolo' (the canonical id token-minting expects) before persisted on the binding. test / critic / preview / automation modes don't use this field. Enum mirrors the server-side check in common-api/src/services/work/plan-validators.ts; keep them in sync."
147
+ },
148
+ "decision": {
149
+ "type": "object",
150
+ "additionalProperties": false,
151
+ "required": ["prompt"],
152
+ "properties": {
153
+ "prompt": { "type": "string", "minLength": 1, "maxLength": 2000 },
154
+ "options": {
155
+ "type": "array",
156
+ "items": {
157
+ "type": "object",
158
+ "additionalProperties": false,
159
+ "required": ["id", "label"],
160
+ "properties": {
161
+ "id": { "type": "string", "minLength": 1 },
162
+ "label": { "type": "string", "minLength": 1 },
163
+ "description": { "type": "string" }
164
+ }
165
+ }
166
+ }
167
+ },
168
+ "description": "Decision-mode config (mode: 'decision'). A human-in-the-loop choice gate: the Run PAUSES (pauseReasonStructured.kind 'awaiting-decision') and the operator resolves via work.resolve_decision, which records the choice, completes the Step, and resumes the Run. Requires `prompt`; `options` are author-supplied or, when omitted, derived from the Step's dependency / dependency-any-of candidate gates (the bake-off 'operator picks the winner' shape). Only valid on mode='decision' Steps. Mirrors the server-side check in common-api/src/services/work/plan-validators.ts."
169
+ },
170
+ "uiBehavior": {
171
+ "type": "object",
172
+ "additionalProperties": false,
173
+ "properties": {
174
+ "keepFullSizeOnTerminal": {
175
+ "type": "boolean",
176
+ "description": "SUBSTRATE_IMPROVEMENTS #31 addendum (2026-05-11) — keep this Step's tile at full chrome on the Run desktop after the Step Run reaches terminal success / skip / cancelled. Use sparingly: workstream / critic / test / automation Steps whose generated artifact (rendered dashboard, demo page, etc.) the operator wants to keep looking at after the agent finishes. Default false / undefined. Preview-mode and manual-mode Steps are exempt from compact-on-terminal regardless of this field — those modes' tiles always stay full."
177
+ }
178
+ },
179
+ "description": "Plan-author UI hints. Currently the only field is `keepFullSizeOnTerminal`; future hints land here as additional properties (each closed-schema)."
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }