@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,244 @@
1
+ /**
2
+ * `yolo plan get <planId>` — read a Plan from the substrate.
3
+ *
4
+ * The thinnest of the three workflow commands. No file I/O, no
5
+ * lockfile mutation, no canonicalization. Mints a session-bound
6
+ * delegated token (work.get_plan scope) and prints the plan.
7
+ *
8
+ * Three output modes:
9
+ * - default (`--summary`): one-line plan header + per-step rows
10
+ * in declared order, with stepId, mode, and dependency
11
+ * stepIds. Good for an operator's "is the import what I
12
+ * expected" sanity check.
13
+ * - `--waves`: same header, but steps grouped by topological
14
+ * wave (computed from dependency gates). A wave is the set of
15
+ * steps whose dependencies are all satisfied by earlier waves;
16
+ * a step's wave is `1 + max(wave of any dependency)`. Good for
17
+ * "what runs in parallel at each stage" reading.
18
+ * - `--json`: pretty-printed `work.get_plan` response. Useful in
19
+ * pipelines / for `jq` consumers.
20
+ *
21
+ * Errors map to exit codes via the CLI wrapper:
22
+ * - 0 = success
23
+ * - 1 = http (plan not found, etc.)
24
+ * - 64 = usage error (bad planId, missing env trio,
25
+ * --workspace mismatch)
26
+ */
27
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
28
+ import { resolveSubstrateContext } from './auth-context.js';
29
+ import { planDagUrl } from './webapp-url.js';
30
+ // ─── Public entry ─────────────────────────────────────────────────────────
31
+ const PLAN_ID_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/;
32
+ export async function runPlanGet(options) {
33
+ const env = options.env ?? process.env;
34
+ const format = options.outputFormat ?? 'summary';
35
+ // 1) planId shape (matches substrate's PLAN_ID_REGEX)
36
+ if (!PLAN_ID_REGEX.test(options.planId)) {
37
+ return fail('usage', `invalid planId '${options.planId}': must match /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}$/`);
38
+ }
39
+ // 2) Substrate context
40
+ const auth = resolveSubstrateContext(env);
41
+ if (!auth.ok)
42
+ return fail('auth', auth.message);
43
+ const { sessionId, commonApiUrl, userToken } = auth.context;
44
+ // 3) Mint token
45
+ let mint;
46
+ try {
47
+ mint = await mintSubstrateToken({
48
+ commonApiUrl,
49
+ userToken,
50
+ sessionId,
51
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
52
+ fetchImpl: options.fetchImpl,
53
+ });
54
+ }
55
+ catch (err) {
56
+ if (err instanceof WorkClientError) {
57
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
58
+ }
59
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
60
+ }
61
+ // 4) --workspace assertion
62
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
63
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
64
+ }
65
+ // 5) work.get_plan
66
+ const ctx = {
67
+ commonApiUrl,
68
+ delegatedToken: mint.token,
69
+ fetchImpl: options.fetchImpl,
70
+ };
71
+ const response = await authenticatedRequest(ctx, `/workspaces/${mint.workspaceId}/plans/${options.planId}`, { method: 'GET' });
72
+ if (!response.ok) {
73
+ const text = await safeReadText(response);
74
+ return fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status });
75
+ }
76
+ const json = (await response.json());
77
+ if (!json.plan) {
78
+ return fail('http', 'work.get_plan response missing `plan` field');
79
+ }
80
+ let output;
81
+ if (format === 'json')
82
+ output = formatJson(json.plan);
83
+ else if (format === 'waves')
84
+ output = formatWaves(json.plan, mint.workspaceId, env);
85
+ else
86
+ output = formatSummary(json.plan, mint.workspaceId, env);
87
+ return {
88
+ ok: true,
89
+ output,
90
+ plan: json.plan,
91
+ workspaceId: mint.workspaceId,
92
+ };
93
+ }
94
+ // ─── Output formatting ────────────────────────────────────────────────────
95
+ /**
96
+ * One-line header + per-step rows. Each row: `<stepId> (<mode>)`
97
+ * with `← deps: <predId1>, <predId2>` if the step has dependency
98
+ * gates. Matches the substrate's actual gate shape (single
99
+ * `config.stepId` per dependency gate, per gate-readiness.ts:180);
100
+ * one row per step lists ALL its dependency-gate stepIds.
101
+ */
102
+ export function formatSummary(plan, workspaceId, env = process.env) {
103
+ const lines = [];
104
+ lines.push(`Plan '${plan.planId}' (workspace ${workspaceId}, version ${plan.version}, ${plan.state})`);
105
+ const dagUrl = planDagUrl(env, workspaceId, plan.planId);
106
+ if (dagUrl)
107
+ lines.push(` view: ${dagUrl}`);
108
+ if (plan.failurePolicy)
109
+ lines.push(` failurePolicy: ${plan.failurePolicy}`);
110
+ if (typeof plan.autoRetryCap === 'number')
111
+ lines.push(` autoRetryCap: ${plan.autoRetryCap}`);
112
+ const inputCount = Array.isArray(plan.inputs) ? plan.inputs.length : 0;
113
+ lines.push(` inputs: ${inputCount}`);
114
+ lines.push(` steps: ${plan.steps.length}`);
115
+ for (const step of plan.steps) {
116
+ const deps = depStepIds(step);
117
+ const depTag = deps.length > 0 ? ` ← deps: ${deps.join(', ')}` : '';
118
+ lines.push(` - ${step.stepId} (${step.mode ?? '?'})${depTag}`);
119
+ }
120
+ return lines.join('\n');
121
+ }
122
+ /**
123
+ * Wave-grouped renderer. Same header as formatSummary, then steps
124
+ * grouped by their topological wave. Within a wave, step order
125
+ * matches the declared order in `plan.steps` so output is stable
126
+ * across runs.
127
+ *
128
+ * Defensive on bad shapes the substrate would normally reject:
129
+ * unknown predecessor stepIds collapse to wave 1, cycles short-circuit
130
+ * to wave 1 for the visiting node (so we never recurse forever).
131
+ */
132
+ export function formatWaves(plan, workspaceId, env = process.env) {
133
+ const lines = [];
134
+ lines.push(`Plan '${plan.planId}' (workspace ${workspaceId}, version ${plan.version}, ${plan.state})`);
135
+ const dagUrl = planDagUrl(env, workspaceId, plan.planId);
136
+ if (dagUrl)
137
+ lines.push(` view: ${dagUrl}`);
138
+ if (plan.failurePolicy)
139
+ lines.push(` failurePolicy: ${plan.failurePolicy}`);
140
+ if (typeof plan.autoRetryCap === 'number')
141
+ lines.push(` autoRetryCap: ${plan.autoRetryCap}`);
142
+ const inputCount = Array.isArray(plan.inputs) ? plan.inputs.length : 0;
143
+ lines.push(` inputs: ${inputCount}`);
144
+ const waves = computeWaves(plan.steps);
145
+ const waveCount = waves.length;
146
+ const stepWord = plan.steps.length === 1 ? 'step' : 'steps';
147
+ lines.push(` steps: ${plan.steps.length} ${stepWord} in ${waveCount} ${waveCount === 1 ? 'wave' : 'waves'}`);
148
+ lines.push('');
149
+ for (let i = 0; i < waves.length; i++) {
150
+ const stepsInWave = waves[i];
151
+ const count = stepsInWave.length;
152
+ const parallelTag = count > 1 ? ', parallel' : '';
153
+ lines.push(` Wave ${i + 1} (${count} ${count === 1 ? 'step' : 'steps'}${parallelTag}):`);
154
+ for (const step of stepsInWave) {
155
+ const deps = depStepIds(step);
156
+ const depTag = deps.length > 0 ? ` ← deps: ${deps.join(', ')}` : '';
157
+ lines.push(` - ${step.stepId} (${step.mode ?? '?'})${depTag}`);
158
+ }
159
+ }
160
+ return lines.join('\n');
161
+ }
162
+ /**
163
+ * Topological wave grouping. Returns an array of arrays — index 0 is
164
+ * wave 1, index 1 is wave 2, etc. Within each wave, steps appear in
165
+ * the order they were declared in `plan.steps`.
166
+ */
167
+ function computeWaves(steps) {
168
+ const stepById = new Map();
169
+ for (const s of steps)
170
+ stepById.set(s.stepId, s);
171
+ const waveOfStep = new Map();
172
+ const waveOf = (stepId, visiting) => {
173
+ const cached = waveOfStep.get(stepId);
174
+ if (cached !== undefined)
175
+ return cached;
176
+ const step = stepById.get(stepId);
177
+ if (!step)
178
+ return 1; // unknown predecessor — substrate rejects on import
179
+ visiting.add(stepId);
180
+ let max = 0;
181
+ for (const dep of depStepIds(step)) {
182
+ if (!stepById.has(dep))
183
+ continue; // unknown predecessor → ignore
184
+ if (visiting.has(dep))
185
+ continue; // cycle: ignore the back-edge
186
+ const w = waveOf(dep, visiting);
187
+ if (w > max)
188
+ max = w;
189
+ }
190
+ visiting.delete(stepId);
191
+ const wave = max + 1;
192
+ waveOfStep.set(stepId, wave);
193
+ return wave;
194
+ };
195
+ for (const s of steps)
196
+ waveOf(s.stepId, new Set());
197
+ const grouped = [];
198
+ for (const s of steps) {
199
+ const w = waveOfStep.get(s.stepId) ?? 1;
200
+ while (grouped.length < w)
201
+ grouped.push([]);
202
+ grouped[w - 1].push(s);
203
+ }
204
+ return grouped;
205
+ }
206
+ function depStepIds(step) {
207
+ const out = [];
208
+ for (const gate of step.gates ?? []) {
209
+ if (gate.type === 'dependency') {
210
+ const predId = gate.config?.stepId;
211
+ if (typeof predId === 'string')
212
+ out.push(predId);
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+ function formatJson(plan) {
218
+ return JSON.stringify(plan, null, 2);
219
+ }
220
+ // ─── Helpers ──────────────────────────────────────────────────────────────
221
+ function fail(kind, message, detail) {
222
+ return { ok: false, kind, message, detail };
223
+ }
224
+ function describeError(err) {
225
+ return err instanceof Error ? err.message : String(err);
226
+ }
227
+ async function safeReadText(response) {
228
+ try {
229
+ return await response.text();
230
+ }
231
+ catch {
232
+ return '<no body>';
233
+ }
234
+ }
235
+ export function exitCodeForFailure(kind) {
236
+ switch (kind) {
237
+ case 'usage':
238
+ case 'auth':
239
+ case 'workspace_mismatch':
240
+ return 64;
241
+ case 'http':
242
+ return 1;
243
+ }
244
+ }
@@ -0,0 +1,420 @@
1
+ /**
2
+ * `yolo plan import <file>` orchestration (Phase 8c.3).
3
+ *
4
+ * Composes everything from earlier slices:
5
+ * - `plan-validate` (8c.1) — parse + schema + canonical + no-coercion
6
+ * - `revision` (8c.2) — sha256 over canonical bytes
7
+ * - `lockfile` (8c.2) — `(workspaceId, planId) → entry` storage
8
+ * - `work-client` (8c.2) — `mintSubstrateToken` + `authenticatedRequest`
9
+ * - `plan-diff` (8c.3) — DB plan vs file plan → mutation list
10
+ *
11
+ * High-level flow:
12
+ *
13
+ * 1. Validate file (offline: parse + schema + canonical + no-coercion).
14
+ * 2. Enforce planId equals the file stem (semantic check, design F8.1).
15
+ * 3. Resolve substrate context (env trio); error otherwise.
16
+ * 4. Mint a session-bound delegated token; the response carries the
17
+ * authoritative `workspaceId` (Phase 8 design: workspace binding
18
+ * is derived from session, not env/args).
19
+ * 5. If `--workspace` was passed, assert it matches the minted
20
+ * workspaceId — otherwise the user's mental model and the
21
+ * session's binding diverge silently.
22
+ * 6. Read the lockfile (gitignored default unless `--env` is set).
23
+ * 7. Decide the action:
24
+ * - **NO_CHANGE** — file revision == lockfile.lastImportedRevision.
25
+ * Refresh the timestamp on the lockfile entry,
26
+ * do not touch the DB.
27
+ * - **CREATE** — no lockfile entry. Call `work.create_plan`.
28
+ * On 409 (plan already exists in DB), refuse
29
+ * unless `--force`.
30
+ * - **UPDATE** — lockfile entry exists, file revision differs.
31
+ * GET the DB plan, check `version` against
32
+ * `lastImportedVersion` to detect drift, then
33
+ * `work.update_plan` with computed mutations.
34
+ * - **DIVERGED** — lockfile entry exists but DB version >
35
+ * lastImportedVersion. Refuse unless `--force`.
36
+ * 8. On success, write the new lockfile entry
37
+ * (`{ revision, version, importedAt }`).
38
+ *
39
+ * Errors map to exit codes via the CLI wrapper:
40
+ * - 0 = success (CREATE / UPDATE / NO_CHANGE all success)
41
+ * - 1 = validation failure / refused divergence / DB error
42
+ * - 64 = usage error (missing file, planId/stem mismatch, --workspace
43
+ * mismatch, missing env trio)
44
+ *
45
+ * Out of scope (deferred to a later slice if real demand emerges):
46
+ * - Step REORDER detection. Substrate executor reads
47
+ * `gate.config.requires`, not array order; reordering is a
48
+ * semantic no-op. New steps land at the end via `add-step`.
49
+ * - Outside-container login flow. v1 errors out without `SESSION_ID`
50
+ * (substrate CLI is container-only — design Round 5).
51
+ * - Concurrent import safety. The lockfile read-modify-write is not
52
+ * atomic across processes; multi-developer concurrent imports to
53
+ * the same workspace are rare enough to defer.
54
+ */
55
+ import { readFileSync } from 'node:fs';
56
+ import path from 'node:path';
57
+ import { canonicalizePlanFile } from './canonicalizer.js';
58
+ import { computeMutations } from './plan-diff.js';
59
+ import { validatePlanText, formatErrors } from './plan-validate.js';
60
+ import { computeRevisionHash } from './revision.js';
61
+ import { LockfileError, getEntry, readLockfile, resolveLockfilePath, setEntry, writeLockfile, } from './lockfile.js';
62
+ import { SUBSTRATE_CLI_PLAN_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
63
+ import { resolveSubstrateContext } from './auth-context.js';
64
+ import yaml from 'js-yaml';
65
+ import { planDagUrl } from './webapp-url.js';
66
+ // ─── Public entry ─────────────────────────────────────────────────────────
67
+ export async function runPlanImport(options) {
68
+ const env = options.env ?? process.env;
69
+ const now = options.now ?? (() => new Date().toISOString());
70
+ // 1) Validate file
71
+ let fileText;
72
+ try {
73
+ fileText = readFileSync(options.filePath, 'utf8');
74
+ }
75
+ catch (err) {
76
+ return fail('usage', `cannot read plan file '${options.filePath}': ${describeError(err)}`);
77
+ }
78
+ const validation = validatePlanText(fileText);
79
+ if (!validation.ok) {
80
+ return fail('validation', `plan-file validation failed:\n${formatErrors(validation.errors)}`);
81
+ }
82
+ // Parse the file once we know it's valid+canonical so we can hand
83
+ // both the frontmatter and body to the diff.
84
+ let frontmatter;
85
+ let body;
86
+ try {
87
+ const parsed = parsePlanFile(fileText);
88
+ frontmatter = parsed.frontmatter;
89
+ body = parsed.body;
90
+ }
91
+ catch (err) {
92
+ // validatePlanText already ruled this path out; defense-in-depth.
93
+ return fail('validation', `plan-file parse failed: ${describeError(err)}`);
94
+ }
95
+ const filePlan = makeFilePlan(frontmatter, body);
96
+ // 2) planId vs file stem (design F8.1)
97
+ const stem = path.basename(options.filePath, path.extname(options.filePath));
98
+ if (filePlan.planId !== stem) {
99
+ return fail('planid_stem_mismatch', `frontmatter planId '${filePlan.planId}' must equal the file stem '${stem}' (file '${options.filePath}')`);
100
+ }
101
+ // 3) Substrate context
102
+ const auth = resolveSubstrateContext(env);
103
+ if (!auth.ok)
104
+ return fail('auth', auth.message);
105
+ const { sessionId, commonApiUrl, userToken } = auth.context;
106
+ // 4) Mint token
107
+ let mint;
108
+ try {
109
+ mint = await mintSubstrateToken({
110
+ commonApiUrl,
111
+ userToken,
112
+ sessionId,
113
+ scopes: SUBSTRATE_CLI_PLAN_SCOPES,
114
+ fetchImpl: options.fetchImpl,
115
+ });
116
+ }
117
+ catch (err) {
118
+ if (err instanceof WorkClientError) {
119
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
120
+ }
121
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
122
+ }
123
+ // 5) --workspace assertion
124
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
125
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}). ` +
126
+ `Either omit the flag (it's optional in containers) or open a session for the right workspace.`);
127
+ }
128
+ // 6) Lockfile
129
+ const plansDir = options.plansDir ?? path.dirname(options.filePath);
130
+ let lockfilePath;
131
+ let lockfile;
132
+ try {
133
+ lockfilePath = resolveLockfilePath(plansDir, options.lockfileFlag);
134
+ lockfile = readLockfile(lockfilePath);
135
+ }
136
+ catch (err) {
137
+ if (err instanceof LockfileError) {
138
+ return fail('lockfile', err.message, { code: err.code });
139
+ }
140
+ return fail('lockfile', describeError(err));
141
+ }
142
+ // 7) Decide action
143
+ const canonicalBytes = canonicalizePlanFile(frontmatter, body);
144
+ const fileRevision = computeRevisionHash(canonicalBytes);
145
+ const existingEntry = getEntry(lockfile, mint.workspaceId, filePlan.planId);
146
+ const requestCtx = {
147
+ commonApiUrl,
148
+ delegatedToken: mint.token,
149
+ fetchImpl: options.fetchImpl,
150
+ };
151
+ let resultAction;
152
+ let resultVersion;
153
+ if (!existingEntry) {
154
+ // CREATE path
155
+ const created = await tryCreate(requestCtx, mint.workspaceId, filePlan);
156
+ if (created.ok) {
157
+ resultAction = 'created';
158
+ resultVersion = created.version;
159
+ }
160
+ else if (created.conflict409) {
161
+ if (!options.force) {
162
+ return fail('conflict_no_force', `plan '${filePlan.planId}' already exists in workspace ${mint.workspaceId} but the lockfile has no entry. ` +
163
+ `Run with --force to fast-forward import (will rewrite the DB plan with the file's contents), or ` +
164
+ `run \`yolo plan export ${filePlan.planId}\` first to capture the DB shape.`);
165
+ }
166
+ // --force on 409: pull the DB plan, run the UPDATE path with whatever's there as the base.
167
+ const fetched = await tryGet(requestCtx, mint.workspaceId, filePlan.planId);
168
+ if (!fetched.ok)
169
+ return fetched.error;
170
+ const updated = await tryUpdate(requestCtx, mint.workspaceId, filePlan, fetched.plan);
171
+ if (!updated.ok)
172
+ return updated.error;
173
+ resultAction = 'updated';
174
+ resultVersion = updated.version;
175
+ }
176
+ else {
177
+ return created.error;
178
+ }
179
+ }
180
+ else if (existingEntry.lastImportedRevision === fileRevision) {
181
+ // NO_CHANGE path: refresh timestamp, write lockfile, exit.
182
+ resultAction = 'no-change';
183
+ resultVersion = existingEntry.lastImportedVersion;
184
+ }
185
+ else {
186
+ // UPDATE path: GET DB, check drift, compute mutations, PATCH.
187
+ const fetched = await tryGet(requestCtx, mint.workspaceId, filePlan.planId);
188
+ if (!fetched.ok)
189
+ return fetched.error;
190
+ if (fetched.plan.version !== existingEntry.lastImportedVersion) {
191
+ if (!options.force) {
192
+ return fail('diverged', `DB has plan '${filePlan.planId}' at version ${fetched.plan.version}, but lockfile expected version ${existingEntry.lastImportedVersion}. ` +
193
+ `Run with --force to fast-forward (overwrites DB changes), or \`yolo plan export\` first to capture the DB shape.`, { dbVersion: fetched.plan.version, expectedVersion: existingEntry.lastImportedVersion });
194
+ }
195
+ }
196
+ const updated = await tryUpdate(requestCtx, mint.workspaceId, filePlan, fetched.plan);
197
+ if (!updated.ok)
198
+ return updated.error;
199
+ resultAction = updated.action;
200
+ resultVersion = updated.version;
201
+ }
202
+ // 8) Update lockfile (idempotent — same input → same bytes).
203
+ const importedAt = now();
204
+ const newEntry = {
205
+ lastImportedRevision: fileRevision,
206
+ lastImportedVersion: resultVersion,
207
+ lastImportedAt: importedAt,
208
+ };
209
+ setEntry(lockfile, mint.workspaceId, filePlan.planId, newEntry);
210
+ try {
211
+ writeLockfile(lockfilePath, lockfile);
212
+ }
213
+ catch (err) {
214
+ if (err instanceof LockfileError) {
215
+ return fail('lockfile', err.message, { code: err.code });
216
+ }
217
+ return fail('lockfile', describeError(err));
218
+ }
219
+ return {
220
+ ok: true,
221
+ action: resultAction,
222
+ planId: filePlan.planId,
223
+ workspaceId: mint.workspaceId,
224
+ version: resultVersion,
225
+ revision: fileRevision,
226
+ importedAt,
227
+ lockfilePath,
228
+ };
229
+ }
230
+ async function tryCreate(ctx, workspaceId, filePlan) {
231
+ // `filePlan.description` is the SEPARATOR-NORMALIZED body
232
+ // (`makeFilePlan` strips the leading `\n` that the regex captures
233
+ // along with the body). Sending it raw was the Codex 8c.3 R1 bug —
234
+ // canonical files emit `---\n\n<body>` so the captured `body`
235
+ // starts with `\n`, which would make imported descriptions begin
236
+ // with a phantom blank line and break round-trips.
237
+ const body = {
238
+ planId: filePlan.planId,
239
+ name: filePlan.name,
240
+ description: filePlan.description,
241
+ inputs: filePlan.inputs ?? [],
242
+ failurePolicy: filePlan.failurePolicy ?? 'pause-and-wait',
243
+ autoRetryCap: filePlan.autoRetryCap,
244
+ autoRetryFallback: filePlan.autoRetryFallback,
245
+ integrationPolicy: filePlan.integrationPolicy,
246
+ steps: filePlan.steps,
247
+ // Send the file's state so a Plan authored as 'active' lands at
248
+ // 'active' instead of silently downgrading to 'draft'. Surfaced
249
+ // in Phase 8d.2 verification: the substrate's create_plan didn't
250
+ // accept the field until this slice (validatePlanCreateFields now
251
+ // normalizes it via PLAN_STATES). Omitting the field keeps the
252
+ // substrate's draft-first default for hand-authored callers that
253
+ // don't supply one.
254
+ //
255
+ // Renamed from `authoringState` 2026-05-09 (item 17 of
256
+ // `docs/SUBSTRATE_IMPROVEMENTS.md`).
257
+ state: filePlan.state,
258
+ };
259
+ const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans`, {
260
+ method: 'POST',
261
+ jsonBody: body,
262
+ });
263
+ if (response.ok) {
264
+ const json = (await response.json());
265
+ if (typeof json.version !== 'number') {
266
+ return { ok: false, error: fail('http', 'create_plan response missing version field') };
267
+ }
268
+ return { ok: true, version: json.version };
269
+ }
270
+ if (response.status === 409) {
271
+ return { ok: false, conflict409: true };
272
+ }
273
+ const text = await safeReadText(response);
274
+ return {
275
+ ok: false,
276
+ error: fail('http', `work.create_plan failed: HTTP ${response.status} — ${text}`, {
277
+ status: response.status,
278
+ }),
279
+ };
280
+ }
281
+ async function tryGet(ctx, workspaceId, planId) {
282
+ const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans/${planId}`, {
283
+ method: 'GET',
284
+ });
285
+ if (!response.ok) {
286
+ const text = await safeReadText(response);
287
+ return {
288
+ ok: false,
289
+ error: fail('http', `work.get_plan failed: HTTP ${response.status} — ${text}`, { status: response.status }),
290
+ };
291
+ }
292
+ const json = (await response.json());
293
+ if (!json.plan) {
294
+ return { ok: false, error: fail('http', 'work.get_plan response missing `plan` field') };
295
+ }
296
+ return { ok: true, plan: json.plan };
297
+ }
298
+ async function tryUpdate(ctx, workspaceId, filePlan, dbPlan) {
299
+ const mutations = computeMutations(dbPlan, filePlan);
300
+ if (mutations.length === 0) {
301
+ // Lockfile knew the file was different (otherwise we'd be on the
302
+ // NO_CHANGE path), but the diff says nothing to change. Likely:
303
+ // the file's revision changed cosmetically (e.g., the
304
+ // canonicalizer's output changed across versions). Treat as
305
+ // no-change, and the caller will refresh the lockfile entry.
306
+ return { ok: true, action: 'no-change', version: dbPlan.version };
307
+ }
308
+ const response = await authenticatedRequest(ctx, `/workspaces/${workspaceId}/plans/${filePlan.planId}`, {
309
+ method: 'PATCH',
310
+ jsonBody: { baseVersion: dbPlan.version, mutations: mutations },
311
+ });
312
+ if (!response.ok) {
313
+ const text = await safeReadText(response);
314
+ return {
315
+ ok: false,
316
+ error: fail('http', `work.update_plan failed: HTTP ${response.status} — ${text}`, { status: response.status }),
317
+ };
318
+ }
319
+ const json = (await response.json());
320
+ if (typeof json.version !== 'number') {
321
+ return { ok: false, error: fail('http', 'work.update_plan response missing version field') };
322
+ }
323
+ return { ok: true, action: 'updated', version: json.version };
324
+ }
325
+ // ─── Helpers ──────────────────────────────────────────────────────────────
326
+ const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/;
327
+ /**
328
+ * Re-split the file into frontmatter object + body string. Mirrors
329
+ * the regex used by validatePlanText so that a successful validate
330
+ * implies a successful parse here. Throws on failure (defense-in-depth
331
+ * against future drift between the two parsers).
332
+ */
333
+ function parsePlanFile(text) {
334
+ const match = text.match(FRONTMATTER_RE);
335
+ if (!match)
336
+ throw new Error('frontmatter delimiters missing');
337
+ const fm = yaml.load(match[1]);
338
+ return { frontmatter: fm, body: match[2] };
339
+ }
340
+ function makeFilePlan(frontmatter, body) {
341
+ // Body is Plan.description (Phase 8b design). The frontmatter→body
342
+ // separator (the blank line after the closing `---`) is OWNED by
343
+ // the canonicalizer (`canonicalizePlanFile` builds `---\n\n<body>`
344
+ // and strips leading `\n+` from the captured body before re-emit).
345
+ // Mirror that normalization here so `description` we send to
346
+ // `work.create_plan` / `work.update_plan` is exactly what
347
+ // round-tripping back through the canonicalizer would produce —
348
+ // otherwise an imported description gets a phantom leading blank
349
+ // line, and 8c.4 export round-trips churn (Codex 8c.3 R1 Medium).
350
+ const description = body
351
+ .replace(/\r\n/g, '\n') // CRLF → LF (validate already rejects CRLF; defense in depth)
352
+ .replace(/^\n+/, '') // strip separator-owned leading newline(s)
353
+ .replace(/\s+$/, '') + // collapse trailing whitespace…
354
+ '\n'; // …and ensure exactly one trailing newline.
355
+ return {
356
+ planId: String(frontmatter.planId ?? ''),
357
+ name: String(frontmatter.name ?? ''),
358
+ description,
359
+ state: frontmatter.state ?? 'draft',
360
+ failurePolicy: frontmatter.failurePolicy,
361
+ autoRetryCap: frontmatter.autoRetryCap,
362
+ autoRetryFallback: frontmatter.autoRetryFallback,
363
+ inputs: frontmatter.inputs ?? [],
364
+ integrationPolicy: frontmatter.integrationPolicy,
365
+ steps: (frontmatter.steps ?? []),
366
+ };
367
+ }
368
+ function fail(kind, message, detail) {
369
+ return { ok: false, kind, message, detail };
370
+ }
371
+ function describeError(err) {
372
+ return err instanceof Error ? err.message : String(err);
373
+ }
374
+ async function safeReadText(response) {
375
+ try {
376
+ return await response.text();
377
+ }
378
+ catch {
379
+ return '<no body>';
380
+ }
381
+ }
382
+ /**
383
+ * Map an `ImportFailure.kind` to a CLI exit code. Used by cli.ts and
384
+ * exported for tests.
385
+ */
386
+ export function exitCodeForFailure(kind) {
387
+ switch (kind) {
388
+ case 'usage':
389
+ case 'auth':
390
+ case 'planid_stem_mismatch':
391
+ case 'workspace_mismatch':
392
+ return 64; // EX_USAGE
393
+ case 'validation':
394
+ case 'lockfile':
395
+ case 'diverged':
396
+ case 'conflict_no_force':
397
+ case 'http':
398
+ return 1;
399
+ }
400
+ }
401
+ /**
402
+ * Render an `ImportSuccess` as a CLI summary. The first line is the
403
+ * existing one-liner; if a webapp URL can be derived from the env, a
404
+ * second line prints the Plan-DAG link so operators can click straight
405
+ * through to the visual topology view. Caller appends `\n` if needed.
406
+ */
407
+ export function formatSuccess(result, env = process.env) {
408
+ const headline = (() => {
409
+ switch (result.action) {
410
+ case 'created':
411
+ return `OK: created plan '${result.planId}' in workspace ${result.workspaceId} at version ${result.version} (revision ${result.revision.slice(0, 14)}…)`;
412
+ case 'updated':
413
+ return `OK: updated plan '${result.planId}' in workspace ${result.workspaceId} to version ${result.version} (revision ${result.revision.slice(0, 14)}…)`;
414
+ case 'no-change':
415
+ return `OK: plan '${result.planId}' already at file revision (workspace ${result.workspaceId}, version ${result.version})`;
416
+ }
417
+ })();
418
+ const dagUrl = planDagUrl(env, result.workspaceId, result.planId);
419
+ return dagUrl ? `${headline}\n view: ${dagUrl}` : headline;
420
+ }