From dc741e8d11136c3d61ac731445b6b31737970158 Mon Sep 17 00:00:00 2001 From: crisis Date: Sat, 29 Aug 2026 00:03:09 -0300 Subject: [PATCH 1/4] fix(cli): render the provision command as a YAML block scalar facility-crew.yml and facility-codex.yml were unparseable on any repository where init detected no provision command, so neither agent trigger ran: facility-crew.yml:196:27: could not parse as YAML: mapping values are not allowed in this context [syntax-check] The command is interpolated into a bare scalar, and the no-provision fallback text contains "facility: ". Any provision command containing ": " breaks the same way, e.g. 'docker compose up -d && echo "db: ready"'. Rendered into a block scalar, matching what checksRun already does for the checks list. Message text is unchanged; only its YAML context is. Co-Authored-By: Claude Opus 5 (1M context) --- packages/cli/src/init.mjs | 8 ++++++++ packages/cli/templates/workflows/facility-codex.yml | 3 ++- packages/cli/templates/workflows/facility-crew.yml | 3 ++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/init.mjs b/packages/cli/src/init.mjs index 33eb010a..cc6f1a78 100644 --- a/packages/cli/src/init.mjs +++ b/packages/cli/src/init.mjs @@ -125,6 +125,13 @@ function checksList(checks) { return lines.join("\n"); } +// The provision command is interpolated into a YAML block scalar, not a bare +// scalar: a command containing ": " (and the no-provision fallback below does) +// otherwise renders a workflow GitHub cannot parse. +function provisionRun(command) { + return command.split("\n").map((line) => ` ${line}`).join("\n"); +} + function checksRun(checks) { if (checks.length) return checks.map((command) => ` ${command}`).join("\n"); return [ @@ -406,6 +413,7 @@ export async function init(flags, pkgRoot, version) { CODEX_ARCHITECT_REPO_LANE: "true", CODEX_BUILDER_REPO_LANE: "true", PROVISION_CMD: provisionCmd, + PROVISION_RUN: provisionRun(provisionCmd), CHECKS_INLINE: checksInline, CHECKS_RUN: checksRun(checks), CHECKS_LIST: checksList(checks), diff --git a/packages/cli/templates/workflows/facility-codex.yml b/packages/cli/templates/workflows/facility-codex.yml index 643a170b..61666b2b 100644 --- a/packages/cli/templates/workflows/facility-codex.yml +++ b/packages/cli/templates/workflows/facility-codex.yml @@ -155,7 +155,8 @@ jobs: {{TOOLCHAIN_STEPS}} - name: Provision environment if: steps.resolve.outputs.run == 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Install pinned Codex CLI if: steps.resolve.outputs.run == 'true' diff --git a/packages/cli/templates/workflows/facility-crew.yml b/packages/cli/templates/workflows/facility-crew.yml index 55bc954c..8bf52b84 100644 --- a/packages/cli/templates/workflows/facility-crew.yml +++ b/packages/cli/templates/workflows/facility-crew.yml @@ -186,7 +186,8 @@ jobs: # cannot verify will defer; a provisioned one finishes. - name: Provision environment if: steps.requested-agent.outputs.run == 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Capture /builder delivery baseline id: builder-start From e077788c5ec712eef5e2cc6d06cf669329e6b480 Mon Sep 17 00:00:00 2001 From: Javier Toledo Date: Mon, 31 Aug 2026 21:37:03 +0100 Subject: [PATCH 2/4] fix(cli): render the provision command as a block scalar in every workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block scalar landed in facility-crew.yml and facility-codex.yml, but init renders the same interpolation into three more workflows, which kept the bare scalar and so kept the bug: facility-review.yml:51 facility-address-review.yml:64 facility-doctor.yml:112 A default `facility init --yes` on a repository where no provision command is detected still produced three workflows GitHub cannot parse — the fallback text contains "facility: ", and the colon-space ends the scalar: FAIL facility-address-review.yml: mapping values are not allowed here FAIL facility-doctor.yml: mapping values are not allowed here FAIL facility-review.yml: mapping values are not allowed here All three sites indent `run:` by eight spaces exactly like the two already fixed, so PROVISION_RUN applies unchanged. Addresses: review finding — packages/cli/templates/workflows/*.yml Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyXT6m6npUH46VBuY1mPGM --- packages/cli/templates/workflows/facility-address-review.yml | 3 ++- packages/cli/templates/workflows/facility-doctor.yml | 3 ++- packages/cli/templates/workflows/facility-review.yml | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/templates/workflows/facility-address-review.yml b/packages/cli/templates/workflows/facility-address-review.yml index a2d083c0..bb8ea44c 100644 --- a/packages/cli/templates/workflows/facility-address-review.yml +++ b/packages/cli/templates/workflows/facility-address-review.yml @@ -61,7 +61,8 @@ jobs: # Provisioned job site, so the agent can verify before pushing. - name: Provision environment if: steps.workflow-change.outputs.changed != 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} {{ANTHROPIC_AUTH_SETUP_CONDITIONAL}} diff --git a/packages/cli/templates/workflows/facility-doctor.yml b/packages/cli/templates/workflows/facility-doctor.yml index 471a6b55..a6737d87 100644 --- a/packages/cli/templates/workflows/facility-doctor.yml +++ b/packages/cli/templates/workflows/facility-doctor.yml @@ -109,7 +109,8 @@ jobs: # The doctor verifies before pushing, so it gets the same provisioned # world as the crew. - name: Provision environment - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} {{ANTHROPIC_AUTH_SETUP}} diff --git a/packages/cli/templates/workflows/facility-review.yml b/packages/cli/templates/workflows/facility-review.yml index d09ca58b..606030d2 100644 --- a/packages/cli/templates/workflows/facility-review.yml +++ b/packages/cli/templates/workflows/facility-review.yml @@ -48,7 +48,8 @@ jobs: {{TOOLCHAIN_STEPS_CONDITIONAL}} - name: Provision environment if: steps.workflow-change.outputs.changed != 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Capture review baseline id: review-start From fa69db5b083326d1fe1ea9d2a4b7a788bd2ab75f Mon Sep 17 00:00:00 2001 From: Javier Toledo Date: Mon, 31 Aug 2026 21:40:29 +0100 Subject: [PATCH 3/4] test(cli): parse every rendered workflow across provision command shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRIBUTING requires that a change to generated YAML be covered by a test that parses and exercises the rendered workflow rather than checking text alone. Nothing failed when the block scalar was reverted, so the fix was unprotected: the suite stayed at 94 pass / 3 pre-existing fail either way. The test runs the real installer into a temporary repository for three provision shapes — none detected (the ": "-bearing fallback), a command containing ": ", and an ordinary command — parses all eight rendered workflows, and asserts the parsed Provision step's `run` round-trips the command it was given. Reverting the block scalar fails it with "facility-address-review.yml must parse as YAML". Asserting the parsed value, not just that parsing succeeded, is what stops a future rendering change from escaping or mangling the command quietly. `yaml` is added as a root devDependency. It was already in the lockfile as a transitive dependency at the same version, so no new package enters the tree, and being a devDependency it leaves the CLI's no-runtime-dependency rule for what it vendors into adopter repositories untouched. Addresses: review finding — packages/cli/test/init.test.mjs (absent coverage) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyXT6m6npUH46VBuY1mPGM --- package.json | 3 +- packages/cli/test/init.test.mjs | 66 ++++++++++++++++++++++++++++++++- pnpm-lock.yaml | 3 ++ 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index c498e89b..0b29d05a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "turbo": "^2.5.8", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.9.0" } } diff --git a/packages/cli/test/init.test.mjs b/packages/cli/test/init.test.mjs index b33e245b..d5f5c4ed 100644 --- a/packages/cli/test/init.test.mjs +++ b/packages/cli/test/init.test.mjs @@ -1,12 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { lstatSync, mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { lstatSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const cli = join(pkgRoot, "bin", "facility.mjs"); @@ -583,3 +584,66 @@ test("local commands reject unknown, valueless, and conflicting flags", () => { "--local cannot be combined with platform target options", ); }); + +test("every rendered workflow parses whatever the provision command contains", async (t) => { + // A bare `run:` scalar ends at its first ": ", so the no-provision fallback + // text — and any command containing ": " — renders a workflow GitHub cannot + // parse, and the trigger that workflow carries never runs. + const scenarios = [ + { label: "no provision command detected", setup: false, flags: [], run: /^echo "facility: / }, + { + label: "provision command containing \": \"", + setup: true, + flags: ['--provision=docker compose up -d && echo "db: ready"'], + run: 'docker compose up -d && echo "db: ready"', + }, + { label: "ordinary provision command", setup: true, flags: ["--provision=npm run setup"], run: "npm run setup" }, + ]; + + for (const scenario of scenarios) { + const dir = mkdtempSync(join(tmpdir(), "facility-provision-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + { + name: "demo-app", + private: true, + // `setup` is what detection proposes as the provision command, so + // omitting it is what exercises the fallback. + scripts: scenario.setup ? { test: "vitest run", setup: "docker compose up -d" } : { test: "vitest run" }, + }, + null, + 2 + ) + "\n" + ); + writeFileSync(join(dir, "package-lock.json"), "{}\n"); + + const result = runCli(["init", "--yes", `--dir=${dir}`, ...scenario.flags], dir); + assert.equal(result.status, 0, `${scenario.label}: ${result.stdout}${result.stderr}`); + + let provisionSteps = 0; + for (const entry of readdirSync(join(dir, ".github/workflows"))) { + const source = readFileSync(join(dir, ".github/workflows", entry), "utf8"); + let document; + assert.doesNotThrow(() => { + document = parseYaml(source); + }, `${scenario.label}: ${entry} must parse as YAML`); + + for (const job of Object.values(document.jobs ?? {})) { + for (const step of job.steps ?? []) { + if (step.name !== "Provision environment") continue; + provisionSteps += 1; + const run = step.run.trimEnd(); + if (scenario.run instanceof RegExp) { + assert.match(run, scenario.run, `${scenario.label}: ${entry} provision command`); + } else { + assert.equal(run, scenario.run, `${scenario.label}: ${entry} provision command`); + } + } + } + } + assert.equal(provisionSteps, 5, `${scenario.label}: every workflow with a provision step must be covered`); + } +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10db54b1..b053b488 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 apps/docs: dependencies: From f46e46e7cfd5e7265f3d42e0d6be536a2bc18898 Mon Sep 17 00:00:00 2001 From: Javier Toledo Date: Tue, 1 Sep 2026 20:19:59 +0100 Subject: [PATCH 4/4] fix(core): substitute the provision block scalar in the platform renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/core/src/render.ts renders the same templates as the CLI from its own variable map, and that map carried PROVISION_CMD but not PROVISION_RUN. Since the templates now interpolate the provision command through a block scalar, core emitted a literal "{{PROVISION_RUN}}" where the CLI emits the command, so the two renderers disagreed and every workflow core produced carried an unsubstituted placeholder. The byte-for-byte parity test caught it, which is what it is for. It was failing before this branch added anything — at 4890d90 on facility-codex.yml, and after the remaining three templates were converted on facility-address-review.yml, which merely sorts earlier. It went unseen because CI does not run on a fork pull request until a maintainer approves it. provisionRun mirrors the CLI's implementation exactly, alongside the copy of checksRun already kept in step the same way. The parity test now runs three provision shapes instead of one: an ordinary command, a command containing ": ", and no command at all so both sides take the ": "-bearing fallback. The old single shape could only catch a raw placeholder; the new ones also catch a future divergence in how either renderer escapes or indents the command. All three fail if PROVISION_RUN is removed again. Addresses: CI minimum-node and verify on #220 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01LyXT6m6npUH46VBuY1mPGM --- packages/core/src/render.ts | 12 +++ packages/core/test/core.test.ts | 127 ++++++++++++++++++-------------- 2 files changed, 84 insertions(+), 55 deletions(-) diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 24f2c498..4538b8a9 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -251,6 +251,17 @@ function checksList(checks: string[]): string { return lines.join("\n"); } +// The provision command is interpolated into a YAML block scalar, not a bare +// scalar: a command containing ": " (and the no-provision fallback does) +// otherwise renders a workflow GitHub cannot parse. Mirrors provisionRun in +// packages/cli/src/init.mjs — the byte-for-byte parity test holds them equal. +function provisionRun(command: string): string { + return command + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + function checksRun(checks: string[]): string { if (checks.length) return checks.map((command) => ` ${command}`).join("\n"); return [ @@ -453,6 +464,7 @@ export async function renderFacilityInit( ? "false" : "true", PROVISION_CMD: provision, + PROVISION_RUN: provisionRun(provision), CHECKS_INLINE: checks.length ? checks.join(" ; ") : "the checks configured in STANDARD.md", CHECKS_LIST: checksList(checks), CHECKS_RUN: checksRun(checks), diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 69eb10dd..882b45cc 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -280,63 +280,80 @@ describe("fingerprints", () => { }); describe("render", () => { - it("matches the real CLI init output byte-for-byte", async () => { - const dir = mkdtempSync(join(tmpdir(), "facility-core-render-")); - writeFileSync( - join(dir, "package.json"), - `${JSON.stringify( - { - scripts: { - setup: "node setup.mjs", - typecheck: "tsc --noEmit", - test: "vitest run", + // The provision command reaches the workflows through a YAML block scalar, + // so a command containing ": " renders differently from an ordinary one and + // the no-provision fallback contains ": " itself. Both renderers have to + // agree on all three shapes, not just the easy one. + const provisionShapes = [ + { name: "an ordinary provision command", provision: "pnpm run setup", setupScript: true }, + { + name: 'a provision command containing ": "', + provision: 'docker compose up -d && echo "db: ready"', + setupScript: true, + }, + { name: "no provision command, so both fall back", provision: undefined, setupScript: false }, + ]; + + for (const shape of provisionShapes) { + it(`matches the real CLI init output byte-for-byte with ${shape.name}`, async () => { + const dir = mkdtempSync(join(tmpdir(), "facility-core-render-")); + writeFileSync( + join(dir, "package.json"), + `${JSON.stringify( + { + scripts: { + // `setup` is what CLI detection proposes as the provision + // command, so omitting it is what exercises the fallback. + ...(shape.setupScript ? { setup: "node setup.mjs" } : {}), + typecheck: "tsc --noEmit", + test: "vitest run", + }, }, - }, - null, - 2, - )}\n`, - ); - writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); - mkdirSync(join(dir, "migrations"), { recursive: true }); - writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n"); - execFileSync("node", [ - join(process.cwd(), "../cli/bin/facility.mjs"), - "init", - "--yes", - "--dir", - dir, - "--branch", - "main", - "--provision", - "pnpm run setup", - "--checks", - "pnpm run typecheck, pnpm run test", - "--modules", - "database", - ]); - const cliFiles = collect(dir); - const rendered = await renderFacilityInit({ - defaultBranch: "main", - provisionCmd: "pnpm run setup", - checkCmds: ["pnpm run typecheck", "pnpm run test"], - modules: ["database"], - packageManager: "pnpm", - workflowNames: [], + null, + 2, + )}\n`, + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + mkdirSync(join(dir, "migrations"), { recursive: true }); + writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n"); + execFileSync("node", [ + join(process.cwd(), "../cli/bin/facility.mjs"), + "init", + "--yes", + "--dir", + dir, + "--branch", + "main", + ...(shape.provision ? ["--provision", shape.provision] : []), + "--checks", + "pnpm run typecheck, pnpm run test", + "--modules", + "database", + ]); + const cliFiles = collect(dir); + const rendered = await renderFacilityInit({ + defaultBranch: "main", + provisionCmd: shape.provision, + checkCmds: ["pnpm run typecheck", "pnpm run test"], + modules: ["database"], + packageManager: "pnpm", + workflowNames: [], + }); + const coreFiles = new Map( + rendered.files.map((file) => [ + file.path, + { + mode: file.mode ?? (file.executable ? "100755" : "100644"), + content: file.content, + }, + ]), + ); + expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort()); + for (const [path, cliFile] of cliFiles) { + expect(coreFiles.get(path), path).toEqual(cliFile); + } }); - const coreFiles = new Map( - rendered.files.map((file) => [ - file.path, - { - mode: file.mode ?? (file.executable ? "100755" : "100644"), - content: file.content, - }, - ]), - ); - expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort()); - for (const [path, cliFile] of cliFiles) { - expect(coreFiles.get(path), path).toEqual(cliFile); - } - }); + } it("keeps platform-lane slash commands out of repo workflows", async () => { const rendered = await renderFacilityInit({