@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.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @yololabs/yolo-cli — YOLO Studio substrate CLI
2
+
3
+ The `yolo` binary owns substrate-tooling subcommands (Plan import/export,
4
+ future workspace and artifact primitives). Distinct from:
5
+
6
+ - **`yolo-code`** — YOLO Studio's built-in coding agent CLI (separate package).
7
+ - **`yolo-router`** — the LLM gateway client (separate package).
8
+
9
+ ## Status
10
+
11
+ Phase 8a Group 9 scaffold. v1 ships only `yolo --version` and
12
+ `yolo context` (resolves and prints the container ambient session
13
+ context). Plan import/export lands in Phase 8c.
14
+
15
+ ## Usage (in-container)
16
+
17
+ ```sh
18
+ yolo --version
19
+ yolo context
20
+ ```
21
+
22
+ Outside a Studio container the CLI exits with `session-required`. A future
23
+ external-login flow is planned but out of scope for Phase 8.
24
+
25
+ ## Auth contract
26
+
27
+ Requires:
28
+
29
+ - `SESSION_ID` (load-bearing — workspace derives from the session record)
30
+ - `YOLO_COMMON_API_URL` (or `YOLO_API_URL`)
31
+ - A credential — resolved by precedence (AUTH_AND_ONBOARDING Slice 0):
32
+ 1. `~/.config/yolo/token` — the rotated **user access JWT**, rewritten
33
+ every ~10 min by container-api's token-refresh service. Preferred;
34
+ reading the file (not the env var) avoids the stale-shell problem.
35
+ 2. `YOLO_API_TOKEN` env — the pod-injected user JWT (≤24h).
36
+ 3. `INTERNAL_API_KEY` env — service master-key fallback, kept for
37
+ lane-runner / service callers. **No longer required in user shells**
38
+ and intentionally excluded from the sandbox env.
39
+
40
+ The CLI mints a short-lived delegated MCP token via the session-bound
41
+ endpoint (`POST /internal/mcp/tokens`) with `agentId: 'substrate-cli'`
42
+ and a capped scope set, authenticating with the user JWT
43
+ (`Authorization: Bearer`) when available, falling back to
44
+ `X-Internal-Auth` otherwise. It then calls `/internal/work/*` REST
45
+ routes with the delegated bearer (which is the capability — the
46
+ service header is no longer required by `requireMcpAuth`).
47
+
48
+ ## Local build
49
+
50
+ ```sh
51
+ npm install
52
+ npm run build
53
+ node dist/cli.js --version
54
+ ```
55
+
56
+ Standalone package — not a workspace member. Container install is an
57
+ explicit `COPY` + `npm install -g` block in `containers/sandboxes/yolo-main/Dockerfile`.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `yolo artifact get <key>` — fetch a single artifact version.
3
+ *
4
+ * Returns the latest version by default; `--version <n>` pins to a
5
+ * specific version. Default summary renders metadata only (key,
6
+ * version, type, producer, fingerprint, contentLength, createdAt).
7
+ * `--content` flag emits ONLY the artifact content body to stdout
8
+ * (suitable for piping). `--json` emits the full record including
9
+ * content.
10
+ *
11
+ * Errors map to exit codes via the CLI wrapper:
12
+ * - 0 = success
13
+ * - 1 = http (artifact not found, etc.)
14
+ * - 64 = usage (bad key, bad --version, missing env trio,
15
+ * --workspace mismatch, --content + --json conflict)
16
+ */
17
+ import { SUBSTRATE_CLI_ARTIFACT_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
18
+ import { resolveSubstrateContext } from './auth-context.js';
19
+ // ─── Public entry ─────────────────────────────────────────────────────────
20
+ const ARTIFACT_KEY_REGEX = /^[a-z0-9][a-z0-9/_.\-]{0,127}$/;
21
+ export async function runArtifactGet(options) {
22
+ const env = options.env ?? process.env;
23
+ const format = options.outputFormat ?? 'summary';
24
+ if (!ARTIFACT_KEY_REGEX.test(options.key)) {
25
+ return fail('usage', `invalid artifact key '${options.key}': must match /^[a-z0-9][a-z0-9/_.\\-]{0,127}$/`);
26
+ }
27
+ if (options.version !== undefined) {
28
+ if (!Number.isInteger(options.version) || options.version < 1) {
29
+ return fail('usage', `invalid --version '${options.version}': must be a positive integer`);
30
+ }
31
+ }
32
+ const auth = resolveSubstrateContext(env);
33
+ if (!auth.ok)
34
+ return fail('auth', auth.message);
35
+ const { sessionId, commonApiUrl, userToken } = auth.context;
36
+ let mint;
37
+ try {
38
+ mint = await mintSubstrateToken({
39
+ commonApiUrl,
40
+ userToken,
41
+ sessionId,
42
+ scopes: SUBSTRATE_CLI_ARTIFACT_SCOPES,
43
+ fetchImpl: options.fetchImpl,
44
+ });
45
+ }
46
+ catch (err) {
47
+ if (err instanceof WorkClientError) {
48
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
49
+ }
50
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
51
+ }
52
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
53
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
54
+ }
55
+ const ctx = {
56
+ commonApiUrl,
57
+ delegatedToken: mint.token,
58
+ fetchImpl: options.fetchImpl,
59
+ };
60
+ const path = options.version !== undefined
61
+ ? `/workspaces/${mint.workspaceId}/artifacts/${encodeURIComponent(options.key)}?version=${options.version}`
62
+ : `/workspaces/${mint.workspaceId}/artifacts/${encodeURIComponent(options.key)}`;
63
+ const response = await authenticatedRequest(ctx, path, { method: 'GET' });
64
+ if (!response.ok) {
65
+ const text = await safeReadText(response);
66
+ return fail('http', `work.get_artifact failed: HTTP ${response.status} — ${text}`, { status: response.status });
67
+ }
68
+ const artifact = (await response.json());
69
+ if (typeof artifact.key !== 'string' || typeof artifact.version !== 'number') {
70
+ return fail('http', 'work.get_artifact response missing required fields');
71
+ }
72
+ let output;
73
+ if (format === 'json')
74
+ output = formatJson(artifact);
75
+ else if (format === 'content')
76
+ output = artifact.content;
77
+ else
78
+ output = formatSummary(artifact, mint.workspaceId);
79
+ return { ok: true, output, artifact, workspaceId: mint.workspaceId };
80
+ }
81
+ // ─── Output formatting ────────────────────────────────────────────────────
82
+ /**
83
+ * Header with metadata — no content body. The producer block tells
84
+ * the operator who emitted the artifact (agent + tile) and provenance
85
+ * fingerprint for cross-run identity.
86
+ */
87
+ export function formatSummary(artifact, workspaceId) {
88
+ const lines = [];
89
+ lines.push(`Artifact ${artifact.key} v${artifact.version} (workspace ${workspaceId})`);
90
+ lines.push(` type: ${artifact.artifactType}`);
91
+ lines.push(` contentLength: ${artifact.contentLength}`);
92
+ lines.push(` producedBy: ${artifact.producedByAgentId}${artifact.producedByTileId ? ` (tile ${artifact.producedByTileId})` : ''}`);
93
+ if (artifact.producerType)
94
+ lines.push(` producerType: ${artifact.producerType}`);
95
+ if (artifact.producerId)
96
+ lines.push(` producerId: ${artifact.producerId}`);
97
+ if (artifact.fingerprint)
98
+ lines.push(` fingerprint: ${artifact.fingerprint}`);
99
+ if (artifact.refPath)
100
+ lines.push(` refPath: ${artifact.refPath}`);
101
+ lines.push(` createdAt: ${artifact.createdAt}`);
102
+ return lines.join('\n');
103
+ }
104
+ function formatJson(artifact) {
105
+ return JSON.stringify(artifact, null, 2);
106
+ }
107
+ // ─── Helpers ──────────────────────────────────────────────────────────────
108
+ function fail(kind, message, detail) {
109
+ return { ok: false, kind, message, detail };
110
+ }
111
+ function describeError(err) {
112
+ return err instanceof Error ? err.message : String(err);
113
+ }
114
+ async function safeReadText(response) {
115
+ try {
116
+ return await response.text();
117
+ }
118
+ catch {
119
+ return '<no body>';
120
+ }
121
+ }
122
+ export function exitCodeForFailure(kind) {
123
+ switch (kind) {
124
+ case 'usage':
125
+ case 'auth':
126
+ case 'workspace_mismatch':
127
+ return 64;
128
+ case 'http':
129
+ return 1;
130
+ }
131
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `yolo artifact list` — enumerate workspace artifacts (newest-first).
3
+ *
4
+ * Default summary is a table per artifact: key, latest version,
5
+ * artifactType, latestTimestamp. `--prefix <prefix>` server-side
6
+ * filters by key prefix; `--limit <n>` caps result count (1-500,
7
+ * default 100). `--json` emits the raw `artifacts[]` array.
8
+ *
9
+ * Exit codes:
10
+ * - 0 = success (zero artifacts is also success — empty list,
11
+ * not an error)
12
+ * - 1 = http (server error, etc.)
13
+ * - 64 = usage (bad --limit, missing env, --workspace mismatch)
14
+ */
15
+ import { SUBSTRATE_CLI_ARTIFACT_SCOPES, WorkClientError, authenticatedRequest, mintSubstrateToken, } from './work-client.js';
16
+ import { resolveSubstrateContext } from './auth-context.js';
17
+ // ─── Public entry ─────────────────────────────────────────────────────────
18
+ export async function runArtifactList(options) {
19
+ const env = options.env ?? process.env;
20
+ const format = options.outputFormat ?? 'summary';
21
+ if (options.limit !== undefined) {
22
+ if (!Number.isInteger(options.limit) || options.limit < 1 || options.limit > 500) {
23
+ return fail('usage', `invalid --limit '${options.limit}': must be an integer between 1 and 500`);
24
+ }
25
+ }
26
+ const auth = resolveSubstrateContext(env);
27
+ if (!auth.ok)
28
+ return fail('auth', auth.message);
29
+ const { sessionId, commonApiUrl, userToken } = auth.context;
30
+ let mint;
31
+ try {
32
+ mint = await mintSubstrateToken({
33
+ commonApiUrl,
34
+ userToken,
35
+ sessionId,
36
+ scopes: SUBSTRATE_CLI_ARTIFACT_SCOPES,
37
+ fetchImpl: options.fetchImpl,
38
+ });
39
+ }
40
+ catch (err) {
41
+ if (err instanceof WorkClientError) {
42
+ return fail('auth', `failed to mint substrate token: ${err.message}`, { status: err.status, code: err.code });
43
+ }
44
+ return fail('auth', `failed to mint substrate token: ${describeError(err)}`);
45
+ }
46
+ if (options.workspaceFlag && options.workspaceFlag !== mint.workspaceId) {
47
+ return fail('workspace_mismatch', `--workspace ${options.workspaceFlag} does not match the workspace bound to this session (${mint.workspaceId}).`);
48
+ }
49
+ const ctx = {
50
+ commonApiUrl,
51
+ delegatedToken: mint.token,
52
+ fetchImpl: options.fetchImpl,
53
+ };
54
+ const params = [];
55
+ if (options.prefix)
56
+ params.push(`prefix=${encodeURIComponent(options.prefix)}`);
57
+ if (options.limit !== undefined)
58
+ params.push(`limit=${options.limit}`);
59
+ const query = params.length > 0 ? `?${params.join('&')}` : '';
60
+ const path = `/workspaces/${mint.workspaceId}/artifacts${query}`;
61
+ const response = await authenticatedRequest(ctx, path, { method: 'GET' });
62
+ if (!response.ok) {
63
+ const text = await safeReadText(response);
64
+ return fail('http', `work.list_artifacts failed: HTTP ${response.status} — ${text}`, {
65
+ status: response.status,
66
+ });
67
+ }
68
+ const json = (await response.json());
69
+ if (!Array.isArray(json.artifacts)) {
70
+ return fail('http', 'work.list_artifacts response missing `artifacts` array');
71
+ }
72
+ return {
73
+ ok: true,
74
+ output: format === 'json'
75
+ ? formatJson(json.artifacts)
76
+ : formatSummary(json.artifacts, mint.workspaceId, options.prefix),
77
+ artifacts: json.artifacts,
78
+ workspaceId: mint.workspaceId,
79
+ };
80
+ }
81
+ // ─── Output formatting ────────────────────────────────────────────────────
82
+ /**
83
+ * Header + table row per artifact. Columns auto-pad to longest key
84
+ * and artifactType. Empty workspace renders "(no artifacts)" rather
85
+ * than just a header.
86
+ */
87
+ export function formatSummary(artifacts, workspaceId, prefix) {
88
+ const lines = [];
89
+ const filterTag = prefix ? ` (prefix: ${prefix})` : '';
90
+ lines.push(`Artifacts in workspace ${workspaceId}${filterTag}: ${artifacts.length}`);
91
+ if (artifacts.length === 0) {
92
+ lines.push(' (no artifacts)');
93
+ return lines.join('\n');
94
+ }
95
+ const keyCol = Math.max(3, ...artifacts.map((a) => a.key.length));
96
+ const typeCol = Math.max(4, ...artifacts.map((a) => a.artifactType.length));
97
+ lines.push(` ${'key'.padEnd(keyCol)} v# ${'type'.padEnd(typeCol)} latestTimestamp`);
98
+ for (const a of artifacts) {
99
+ const key = a.key.padEnd(keyCol);
100
+ const v = `v${a.latestVersion}`.padEnd(5);
101
+ const type = a.artifactType.padEnd(typeCol);
102
+ lines.push(` ${key} ${v} ${type} ${a.latestTimestamp}`);
103
+ }
104
+ return lines.join('\n');
105
+ }
106
+ function formatJson(artifacts) {
107
+ return JSON.stringify(artifacts, null, 2);
108
+ }
109
+ // ─── Helpers ──────────────────────────────────────────────────────────────
110
+ function fail(kind, message, detail) {
111
+ return { ok: false, kind, message, detail };
112
+ }
113
+ function describeError(err) {
114
+ return err instanceof Error ? err.message : String(err);
115
+ }
116
+ async function safeReadText(response) {
117
+ try {
118
+ return await response.text();
119
+ }
120
+ catch {
121
+ return '<no body>';
122
+ }
123
+ }
124
+ export function exitCodeForFailure(kind) {
125
+ switch (kind) {
126
+ case 'usage':
127
+ case 'auth':
128
+ case 'workspace_mismatch':
129
+ return 64;
130
+ case 'http':
131
+ return 1;
132
+ }
133
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Substrate CLI auth-context resolution (AUTH_AND_ONBOARDING Slice 0).
3
+ *
4
+ * The CLI used to accept `INTERNAL_API_KEY` — the service-to-service master
5
+ * key — from the shell env as a fallback. That master key should never be the
6
+ * credential a user-controlled shell wields, and the JWT path has fully
7
+ * replaced it (the mint + work routes accept the user JWT, ownership-checked).
8
+ * The fallback is now REMOVED: the CLI is user-JWT-only.
9
+ *
10
+ * Token precedence:
11
+ * 1. `~/.config/yolo/token` — the rotated user JWT, rewritten every
12
+ * ~10 min by container-api's yolo-token-refresh. Reading the FILE
13
+ * (not the `YOLO_API_TOKEN` env var) sidesteps the stale-shell
14
+ * problem: a long-lived shell holds whatever token was in scope at
15
+ * its own launch, but the file is always fresh.
16
+ * 2. `YOLO_API_TOKEN` env — the pod-injected user JWT (≤24h TTL).
17
+ *
18
+ * Auth requires SESSION_ID + a common-api URL + a user access JWT.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ export function resolveSubstrateContext(env, readFileImpl = defaultReadFile) {
24
+ const sessionId = env.SESSION_ID;
25
+ const commonApiUrl = env.YOLO_COMMON_API_URL || env.YOLO_API_URL;
26
+ if (!sessionId) {
27
+ return { ok: false, message: 'SESSION_ID env var is required (substrate CLI is container-only in v1)' };
28
+ }
29
+ if (!commonApiUrl) {
30
+ return { ok: false, message: 'YOLO_COMMON_API_URL (or YOLO_API_URL) env var is required' };
31
+ }
32
+ const userToken = resolveUserToken(env, readFileImpl);
33
+ if (!userToken) {
34
+ return {
35
+ ok: false,
36
+ message: 'no user token available: expected a user access JWT in ~/.config/yolo/token or the YOLO_API_TOKEN env var. ' +
37
+ 'The substrate CLI is user-JWT-only — the INTERNAL_API_KEY fallback was removed.',
38
+ };
39
+ }
40
+ return { ok: true, context: { sessionId, commonApiUrl, userToken } };
41
+ }
42
+ export function resolveUserToken(env, readFileImpl = defaultReadFile) {
43
+ const home = env.HOME || os.homedir();
44
+ const tokenFile = path.join(home, '.config', 'yolo', 'token');
45
+ const fromFile = readFileImpl(tokenFile);
46
+ if (fromFile && fromFile.trim())
47
+ return fromFile.trim();
48
+ const fromEnv = env.YOLO_API_TOKEN;
49
+ if (fromEnv && fromEnv.trim())
50
+ return fromEnv.trim();
51
+ return undefined;
52
+ }
53
+ export function defaultReadFile(filePath) {
54
+ try {
55
+ return fs.readFileSync(filePath, 'utf-8');
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Plan-file canonicalizer (Phase 8b).
3
+ *
4
+ * Pure function: a parsed plan-frontmatter object → canonical YAML
5
+ * string. The canonical form is what `import` writes to the file
6
+ * after parsing/normalizing user input AND what `export` emits when
7
+ * snapshotting the live DB plan back to disk. Both paths share this
8
+ * one engine so a round-trip through import → export produces a
9
+ * byte-identical file.
10
+ *
11
+ * Round 5 stance #7 + Round 2 stance answers locked these rules:
12
+ * - Stable key order at the schema-defined level (planId, name,
13
+ * state, …, steps[]). Top-level field order is fixed.
14
+ * - Step-level + gate-level + input-level + declared-output-level +
15
+ * template-level orders are also schema-defined.
16
+ * - Nested arbitrary-keyed objects (gate.config,
17
+ * integrationPolicy, template.metadata) sort recursively
18
+ * lexicographically. The schema says nothing about their key
19
+ * order, so alphabetical is the only stable rule.
20
+ * - Unknown top-level keys (forward-compat) trail the known schema
21
+ * fields in lexicographic order.
22
+ * - Output uses `\n` line endings, single trailing newline, 2-space
23
+ * indent, plain scalars where unambiguous (double-quoted when a
24
+ * string would otherwise parse as a non-string YAML value), and
25
+ * no boolean/numeric coercion at parse time (caller's
26
+ * responsibility — js-yaml's default load is happy to coerce, so
27
+ * callers should run their own validator that doesn't).
28
+ *
29
+ * NOT enforced here (caller's job):
30
+ * - Schema validation (frontmatter shape).
31
+ * - Stripping DB-only fields (`_id`, `workspaceId`, `version`,
32
+ * `latestRunId`, `nextRunNumber`, `createdAt`, `updatedAt`).
33
+ * - Verifying `planId` matches the file stem.
34
+ * - Asserting stepId uniqueness, gate `requires` resolves to
35
+ * siblings, etc.
36
+ */
37
+ import yaml from 'js-yaml';
38
+ // ─── Schema-defined field orders ──────────────────────────────────────────
39
+ // NB: no `description` at the plan level. Per the Phase 8 design
40
+ // (`docs/_ORCHESTRATION_PHASE8_PLAN_FILES_DESIGN.md` §"body is
41
+ // Plan.description"), the markdown body IS `Plan.description` —
42
+ // frontmatter has no description field, by design, to avoid dual
43
+ // sources. Step- and input-level `description` fields are unrelated
44
+ // and remain in their respective `*_FIELD_ORDER`s below.
45
+ const PLAN_FIELD_ORDER = [
46
+ 'planId',
47
+ 'name',
48
+ 'state',
49
+ 'failurePolicy',
50
+ 'autoRetryCap',
51
+ 'autoRetryFallback',
52
+ 'inputs',
53
+ 'integrationPolicy',
54
+ 'steps',
55
+ ];
56
+ const STEP_FIELD_ORDER = [
57
+ 'stepId',
58
+ 'name',
59
+ 'description',
60
+ 'mode',
61
+ 'runner',
62
+ 'gates',
63
+ 'declaredOutputs',
64
+ 'failurePolicy',
65
+ 'autoRetryCap',
66
+ 'autoRetryFallback',
67
+ 'template',
68
+ ];
69
+ const GATE_FIELD_ORDER = ['gateId', 'type', 'config'];
70
+ const INPUT_FIELD_ORDER = ['name', 'required', 'default', 'description'];
71
+ const DECLARED_OUTPUT_FIELD_ORDER = ['name', 'artifactType', 'required'];
72
+ const TEMPLATE_FIELD_ORDER = ['todoContent', 'todoFile', 'branch', 'metadata'];
73
+ // ─── Helpers ──────────────────────────────────────────────────────────────
74
+ /**
75
+ * Reorder an object's keys: known schema keys first (in the given
76
+ * order), then any unknown keys lexicographically. Missing schema
77
+ * keys are skipped (we never emit `null` for unset optional fields).
78
+ */
79
+ function reorderObject(obj, order) {
80
+ const out = {};
81
+ for (const key of order) {
82
+ if (key in obj && obj[key] !== undefined) {
83
+ out[key] = obj[key];
84
+ }
85
+ }
86
+ const known = new Set(order);
87
+ const extras = Object.keys(obj)
88
+ .filter((k) => !known.has(k) && obj[k] !== undefined)
89
+ .sort();
90
+ for (const k of extras) {
91
+ out[k] = obj[k];
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Recursively sort the keys of arbitrary-keyed nested objects
97
+ * lexicographically. Arrays preserve their declared order; primitives
98
+ * pass through unchanged.
99
+ *
100
+ * Used for `gate.config`, `integrationPolicy`, `template.metadata`,
101
+ * and any future free-form nested map. The schema doesn't dictate
102
+ * key order for these, so alphabetical is the only deterministic
103
+ * choice.
104
+ */
105
+ function sortNestedKeys(value) {
106
+ if (Array.isArray(value)) {
107
+ return value.map(sortNestedKeys);
108
+ }
109
+ if (value !== null && typeof value === 'object') {
110
+ const obj = value;
111
+ const out = {};
112
+ for (const key of Object.keys(obj).sort()) {
113
+ out[key] = sortNestedKeys(obj[key]);
114
+ }
115
+ return out;
116
+ }
117
+ return value;
118
+ }
119
+ /**
120
+ * Reorder a parsed plan frontmatter to canonical shape. Pure — does
121
+ * not mutate the input.
122
+ */
123
+ export function canonicalizeFrontmatterObject(plan) {
124
+ const ordered = reorderObject(plan, PLAN_FIELD_ORDER);
125
+ if (Array.isArray(ordered.inputs)) {
126
+ ordered.inputs = ordered.inputs.map((input) => reorderObject(input, INPUT_FIELD_ORDER));
127
+ }
128
+ if (ordered.integrationPolicy && typeof ordered.integrationPolicy === 'object') {
129
+ ordered.integrationPolicy = sortNestedKeys(ordered.integrationPolicy);
130
+ }
131
+ if (Array.isArray(ordered.steps)) {
132
+ ordered.steps = ordered.steps.map((step) => {
133
+ const orderedStep = reorderObject(step, STEP_FIELD_ORDER);
134
+ if (Array.isArray(orderedStep.gates)) {
135
+ orderedStep.gates = orderedStep.gates.map((gate) => {
136
+ const og = reorderObject(gate, GATE_FIELD_ORDER);
137
+ if (og.config && typeof og.config === 'object') {
138
+ og.config = sortNestedKeys(og.config);
139
+ }
140
+ return og;
141
+ });
142
+ }
143
+ if (Array.isArray(orderedStep.declaredOutputs)) {
144
+ orderedStep.declaredOutputs = orderedStep.declaredOutputs.map((d) => reorderObject(d, DECLARED_OUTPUT_FIELD_ORDER));
145
+ }
146
+ if (orderedStep.template && typeof orderedStep.template === 'object') {
147
+ const ot = reorderObject(orderedStep.template, TEMPLATE_FIELD_ORDER);
148
+ if (ot.metadata && typeof ot.metadata === 'object') {
149
+ ot.metadata = sortNestedKeys(ot.metadata);
150
+ }
151
+ orderedStep.template = ot;
152
+ }
153
+ return orderedStep;
154
+ });
155
+ }
156
+ return ordered;
157
+ }
158
+ // ─── YAML emission ────────────────────────────────────────────────────────
159
+ /**
160
+ * Emit a canonical YAML string for the given (already-reordered)
161
+ * frontmatter object. Always ends with a single `\n`.
162
+ */
163
+ export function emitCanonicalYaml(ordered) {
164
+ return yaml.dump(ordered, {
165
+ noRefs: true, // no anchors/aliases
166
+ sortKeys: false, // we control order ourselves; js-yaml leaves it alone
167
+ lineWidth: -1, // never wrap long scalars
168
+ quotingType: '"', // double-quotes when quoting is needed
169
+ forceQuotes: false, // only quote when ambiguous
170
+ indent: 2,
171
+ flowLevel: -1, // always block style for objects/arrays (except where the YAML grammar requires flow)
172
+ });
173
+ }
174
+ // ─── Public entry points ──────────────────────────────────────────────────
175
+ /**
176
+ * Canonicalize a parsed plan frontmatter object to its YAML string.
177
+ * Pure: same input → same byte-identical output.
178
+ */
179
+ export function canonicalizeFrontmatter(plan) {
180
+ const ordered = canonicalizeFrontmatterObject(plan);
181
+ return emitCanonicalYaml(ordered);
182
+ }
183
+ /**
184
+ * Canonicalize a full plan file (frontmatter + body). Output shape:
185
+ *
186
+ * ---
187
+ * <frontmatter>
188
+ * ---
189
+ *
190
+ * <body>
191
+ *
192
+ * Body is normalized to LF-only, exactly one trailing newline, no
193
+ * trailing whitespace beyond that. Frontmatter and body are
194
+ * separated by `---` plus one blank line (markdown-friendly).
195
+ */
196
+ export function canonicalizePlanFile(plan, body) {
197
+ const fm = canonicalizeFrontmatter(plan);
198
+ // js-yaml's dump always ends with a single \n; collapse any
199
+ // accidental trailing blanks just in case.
200
+ const fmTrimmed = fm.replace(/\n+$/, '\n');
201
+ // Body: LF-only, strip CR; strip any leading newlines (the
202
+ // frontmatter-body separator is owned by this function, not the
203
+ // caller's body string — a parsed file's body capture often starts
204
+ // with the blank line after the closing `---`); trim trailing
205
+ // whitespace; ensure exactly one trailing newline.
206
+ const bodyTrimmed = body.replace(/\r\n/g, '\n').replace(/^\n+/, '').replace(/\s+$/, '');
207
+ return `---\n${fmTrimmed}---\n\n${bodyTrimmed}\n`;
208
+ }