@unknown-creatives/substrate
v2.2.0
Published
--- purpose: "The substrate CLI: setup, check, generate and upgrade for consumer projects" authority: source audience: [maintainers, consumers] children: skip ---
Readme
purpose: "The substrate CLI: setup, check, generate and upgrade for consumer projects" authority: source audience: [maintainers, consumers] children: skip
@unknown-creatives/substrate
The distribution CLI. substrate setup takes a fresh or stale consumer repo
to a project that checks and generates: it installs the signed engine bundle,
wires the host and each AI tool (skills, instruction file, the opt-in Bloom
agent) across the platform matrix (Claude Code, Cursor, Windsurf, Copilot,
Gemini, Codex, …), installs the check gates, and adds package scripts.
substrate check, generate and migrate run the installed engine, and
substrate doctor reports what is wrong with a project and how to fix it. The CLI
also owns catalog scaffolding through substrate add, deterministic
substrate upgrade engine swaps, and exact-byte engine artifact assembly
through substrate artifact assemble. setup and upgrade cross one delivery
seam: both consume the signed engine bundle and share a single verification
implementation
(distribution architecture v2).
substrate init was removed: calling it exits 1 and names setup.
This README is the command reference. To bring Substrate into a project, follow getting started: it runs every step from the handoff files to an upgrade, and a test runs its commands. The delivery runbook covers the handoff and release operations.
Running the CLI
This package is published to npm. The package is
@unknown-creatives/substrate; a bare npx substrate still installs an
unrelated third-party package — always use the scoped name:
npx @unknown-creatives/substrate@<v> <command>The bare substrate <cmd> spellings below assume that invocation. The
published version always equals the engine release version (single-version
coupling), so npx @unknown-creatives/substrate@<v> means "the CLI released
alongside engine <v>".
The published package is self-contained: it declares and installs its own
dependencies (js-yaml, and esbuild for artifact assemble). The engine
runs from its sealed compiler.mjs and needs nothing installed in the client.
Working on Substrate itself, run the bin from your checkout after npm ci:
node <substrate-checkout>/packages/cli/bin/substrate-init.js setup --helpOne-step setup
Without --plan or --apply, setup plans and applies in one invocation
(getting started §4 shows the
command). It takes --brand and --target (repeatable), --ai <host>
(repeatable), --bloom or --no-bloom, --adopt <folder>, --yes and
--json. It prints the plan and asks before applying; --yes skips the prompt, and a
run that can't prompt must pass it. --json prints the apply result (or
{ "status": "failed", "error", "findings" }) as one JSON document on stdout
for an agent. The exit code is 0 when setup committed or found everything in
place, and 1 when it failed or left a needed edit. The result's skipped lists
every edit setup left to the client with its exact text (blocking: true when
the app can't import Substrate until it is made), and next names the commands
that check and generate the project.
The transaction described below also:
- wires AI hosts. Each detected host, or each
--ai <host>, gets every skill in the engine'sskills/copied into its skills folder and the Substrate section of its instruction file (betweensubstrate:startandsubstrate:endmarkers for append-mode files).--bloomcopiesagents/bloom/;--no-bloomremoves the copy an earlier setup made, and the choice is recorded in.substrate/state.yaml. Links an earlier install made into an engine are removed.aiHostInstallin state.yaml records each folder setup installed, per host, with a content hash. Setup overwrites or removes only those folders, and only while they hold what it installed; a recorded skill the engine no longer ships is removed. A same-named folder it didn't install, or a recorded one the client edited, is a blockingskippeditem: rename or move yours, or pass--adopt <folder>to let setup take it; - adds package scripts.
substrate:checkandsubstrate:generaterun the installedcompiler.mjs, so they need no global CLI. A script the project already uses under either name is kept and listed underskipped; - migrates and checks the content. The bundled engine's config migrations
run over the staged content root, and
substrate checkmust pass, before anything is generated. A finding stops setup with its file, line and fix; a migration that needs a decision stops it with that note; - replaces an engine no delivery record owns, such as a vendored engine an older install left, and removes its links from the content root, so a stale repo comes current with the same command.
Setup planning and transactional apply
substrate setup --plan is the non-mutating front door for self-serve setup. It
inspects a supported Vite + React consumer through client-visible signals
(package.json, one vite.config.*, index.html, the React application entry,
the application tsconfig, and one lockfile family) and prints a canonical JSON
plan to stdout. substrate setup --apply <plan.json> --signing-public-key <key>
then applies that plan.
--dry-run is an alias for --plan. Both modes only read the consumer
repository. Keep a serialized plan outside the consumer repository: the plan
binds the repository's real path and exact file snapshot, so adding the plan
itself inside that tree correctly makes the original snapshot stale.
Apply accepts only that plan and a trusted Ed25519 public key. It rechecks the
repository snapshot, re-derives the complete plan, verifies the retained bundle
bytes against their attestation, checksum, signature, and public key, and stages
the engine plus ownership records before committing any consumer-visible
resource. A stale, edited, or foreign plan fails without mutation. Pre-commit
failure removes staging and leaves the consumer byte-equivalent to its starting
state; an exact rerun reports retained without rewriting files.
Apply also installs the write-time gates, listed in the plan's check-gates
phase. Each runs .substrate/hooks/check-gate.mjs, which calls the engine's
check and reports every finding with its fix:
- a Claude Code
PostToolUsehook onWrite|Edit|MultiEditin.claude/settings.json, when Claude Code is detected; - a git pre-commit gate that blocks a commit with a broken staged config,
added to
.husky/pre-commit, a lefthook config, or thepre-commitfile incore.hooksPathor git's hooks folder; .github/workflows/substrate-check.yml, which runs the check on every push and pull request.
A gate setup can't own, such as a project inside a larger git repository, is
listed under skipped with the line to add by hand. The pre-commit gate judges
the staged text, so a partly staged config is checked as it will be committed.
substrate upgrade rewrites the installed gates to its own version.
Apply is also the reconciliation boundary for an existing declaration. It
copies the current generated root into staging, runs the complete declared
project generation there, hash-validates every generated path declared by the
new manifest.gen.json, and compares that manifest with the previous generated
ownership record. Unmanifested passthrough files are checked separately and
must remain byte-identical. A removed brand, component, or target deletes every
path owned by the previous manifest, including a locally edited copy;
unmanifested files are copied through unchanged, and a newly generated path
that would collide with an unowned file fails the transaction. Generation or
pre-commit failure cannot publish a partial prune or advance ownership.
The apply JSON reports generated artifacts separately from committed
transaction resources. artifacts.targets.css, .swift, and .compose each
list created, updated, removed, and unchanged client-relative paths; the
ownership-manifest report independently identifies whether
manifest.gen.json was created, updated, or unchanged. Repeating the same
declaration or contraction therefore produces an all-unchanged artifact report
and no committed resource changes.
The local engine input is the
substrate-engine-<version>.bundle file emitted by substrate artifact
assemble, together with its .attestation.json, .bundle.sha256, and
.bundle.sig sidecars. Planning reads the bundle's declared member metadata to
enumerate future destinations, but labels those declarations unverified:
provider verification of the retained bundle bytes is mandatory before apply
can extract anything. It never accepts an unpacked directory as a second
delivery format.
The JSON identifies the vite-react host adapter, operational root remaps and
integration points, inferred or existing substrate/project.yaml, detected AI
hosts, and the future acquisition, ownership, projection, host-integration,
generation, and verification phases. Every planned resource includes its future
operation and reason. Generated target projections carry only target-neutral
brand/target selections; engine adapters own their concrete output
topology and the generation phase owns manifest.gen.json.
The client-owned declaration has three keys. Components are not listed: every
folder under substrate/components/ with a config.yaml is generated.
version: 2
brands:
- acme
targets:
- css
- swift
- composeTargets are output selections, not AI hosts. AI-host detection remains a
separate operational concern destined for .substrate/state.yaml; delivery
provenance remains in .substrate/manifest.yaml; generated topology and
freshness remain in substrate/generated/manifest.gen.json.
The installed engine resolves this declaration into one platform-neutral declared project graph before target descriptor construction. CSS, Swift, and Compose consume target selections from the same graph, so brand identity, component dependency closure, system inputs, and provenance resolve once while adapter syntax remains separate.
When substrate/project.yaml already exists, plan mode reads and validates it
instead of accepting selection flags as a second authority; a version 1 file is
planned as the version 2 declaration the engine's migration produces. Unsupported
or ambiguous host shapes fail with a resolution before any edit, except a Vite
config that declares its own top-level resolve: setup leaves it alone, lists
the exact alias edit under skipped, skips the build gate, and reports the run
incomplete until the edit is made. The supported
Vite seam directly imports defineConfig from vite, exports one object
literal through that binding, and declares exactly one top-level plugins
array. Transactional apply verifies and stages the sealed engine, durable
project state, generated CSS projection, and deterministic Vite/TypeScript/
React integration before committing them. It then runs the consumer-owned
scripts.build, whose supported contract performs both the TypeScript check
and Vite production build; setup reports success only when that command
passes. Repeated setup retains the managed integration byte-for-byte.
Unsupported, ambiguous, or symlinked host layouts fail before any client file
is edited.
The plan never schedules the existing client-owned project declaration for
rewrite, and apply preserves its exact bytes. An engine root whose exact file
set and hashes match the unverified bundle declaration is retained only when
its delivery manifest also owns the same engine root, version, and member hash
set. An engine root no delivery record owns is replaced; a recorded one that
differs is rejected with a direction to use substrate upgrade. Verification
is mandatory before apply relies on any case.
Onboarding P0 release gate
Run the complete release-level onboarding acceptance gate from the Substrate repository root:
npm run verify:onboarding:p0The gate requires Node 22, npm registry access for disposable installs, a real
swiftc, and a Java-backed kotlinc. Missing native toolchains are blocking
failures; there is no local or CI skip mode.
The command packs and installs @unknown-creatives/substrate, assembles the allowlisted
engine bundle through that installed public command, and invokes onboarding
only through the documented substrate setup --plan / --apply interface.
Disposable consumers have registry-installed Vite, React, and TypeScript
dependencies and no source-checkout aliases or links. The gate builds the real
Vite application, compiles minimal Swift and Kotlin consumers against the
shipped kernels and projected output, proves an unchanged rerun, and contracts
brands, components, and targets from generated-manifest ownership while
preserving an unmanifested client file. It also proves byte restoration and no
success output for artifact tampering, invalid declaration, generation,
Swift/Compose compilation, and consumer-build failures.
The pull-request checks job and the release workflow run this exact command.
P0 is not green, and a release cannot be cut, unless every black-box contract
passes with all three CSS, Swift, and Compose targets enabled.
Toolchain aliases
Setup writes the supported @substrate/* import aliases for the project's
toolchain (UCS-703). The alias list is sourced from the engine's
src/aliases.ts policy SSOT (UCS-702), so a contract change can't drift from
what setup writes.
- tsconfig: the two public aliases (
@substrate/engine,@substrate/generated/*) go intocompilerOptions.paths, preserving other options and paths.baseUrlis never added: TypeScript 7 removed it (TS5102). - Bundlers:
.substrate/aliases.jsexports the same aliases resolved to absolute paths. Thevite-reactadapter adds them tovite.config.*; any other bundler config is listed underskippedwith the exact edit.
Targets resolve against any roots: remapping in .substrate/state.yaml.
Catalog add
substrate add is the P4·c v0 catalog path. It resolves a path-derived entry id
from a local catalog, validates the author-facing entry.yaml, copies
scaffoldable files into the client content root with provenance headers, records
the fetch in .substrate/manifest.yaml, and runs the caller-provided generate
command. V0 deliberately has no customer permission, tier, or delivery-token
model.
substrate add components/button \
--catalog ./catalog-staging \
--generate-command "npm run substrate:generate"Inside the Substrate repo, the same public command scaffolds entries from
catalog-staging/ into a client-shaped target for dogfooding.
Local engine artifact assembly
substrate artifact assemble is the P0 local engine delivery path. It compiles
the generator and its runtime packages into a standalone Node compiler
entrypoint, independently bundles the browser-safe runtime entrypoint, adds
only sealed engine data from the explicit allowlist, and writes one
deterministic artifact bundle plus verification metadata:
substrate artifact assemble \
--source-root . \
--output-directory dist/local-engine \
--version 0.39.0 \
--signing-private-key env:SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEYFor version 0.39.0, the output set is:
substrate-engine-0.39.0.bundle— the exact bytes delivered and consumedsubstrate-engine-0.39.0.attestation.json— version, byte length, checksum, signature, member hashes, and compiler/browser-runtime input provenancesubstrate-engine-0.39.0.bundle.sha256substrate-engine-0.39.0.bundle.sig
The final-member allowlist contains compiler.mjs, the browser
runtime.mjs plus its public TypeScript declarations, a minimal artifact
package.json, sealed system/property YAML, the type configs a client
component extends (types/action, types/chart, types/feedback,
types/input and types/text), the public alias policy, and static web
cascade CSS. It also contains the exact Swift/Kotlin kernel sources and
SwiftUI/Compose API stubs required to compile generated native projections
during client setup. Those native validation inputs have their own canonical
provenance list and must match bundle members byte-for-byte. The runtime is
compiled separately from the Node compiler so the disposable application
imports one browser-safe public entrypoint rather than engine source internals.
generated/, src/brands/, src/components/, and client-owned
substrate.config.yaml are not members. Compiler source and package inputs are
also enumerated by exact path in checked-in compiler and browser-runtime
allowlists; both exact path sets are recorded in artifact provenance. Assembly
fails if either build graph reaches a newly imported test, demo, generated
registry, source, or package file until that input is explicitly reviewed and
added.
Setup can use the local provider seam without trusting a mutable path:
import {
createLocalEngineArtifactProvider,
inspectEngineArtifact,
} from '@unknown-creatives/substrate/engine-artifact';
const declaration = inspectEngineArtifact({
artifactPath: './incoming/substrate-engine-0.39.0.bundle',
attestationPath: './incoming/substrate-engine-0.39.0.attestation.json',
});
// declaration.verification === 'unverified'
// declaration exposes version, entrypoint, and immutable memberPaths for planning.
const provider = createLocalEngineArtifactProvider({
artifactPath: './incoming/substrate-engine-0.39.0.bundle',
attestationPath: './incoming/substrate-engine-0.39.0.attestation.json',
checksumPath: './incoming/substrate-engine-0.39.0.bundle.sha256',
signaturePath: './incoming/substrate-engine-0.39.0.bundle.sig',
publicKey,
});
const artifact = await provider.acquire();
// artifact.verification === 'verified'
artifact.extractTo(stagingDirectory);inspectEngineArtifact() and provider.inspect() parse only declared,
read-only planning metadata and never extract or confer trust. Setup may use
their immutable member paths to build a plan, but it must not mutate a client
from an unverified declaration. acquire() reads the bundle once, then
verifies the byte length, SHA-256 checksum sidecar, Ed25519 signature sidecar,
matching attestation, version, attested provenance, member set, member hashes,
and safe paths. readBytes() returns a copy of those retained verified bytes and
extractTo() materializes from the retained bundle, so replacing the local
file after verification cannot change what gets staged. Extraction requires an
empty staging directory. The artifact test executes the extracted
compiler.mjs against client-owned brand intent, then asserts that the compiler
plans and emits a generated registry without node_modules or a source checkout.
The release workflow uses this same exact-byte assembler for engine delivery.
Both substrate setup --apply and substrate upgrade acquire the engine
through this one provider, so the artifact that onboards a client is the
artifact that upgrades them (UCS-1118). The existing directory-based catalog
signer remains separate.
Deterministic engine upgrade
substrate upgrade is the P3·d updater v1: no git merge, no semantic merge,
and no silent overwrite of manifest-tracked engine edits.
Upgrade takes the same signed .bundle and sidecars that setup --apply
consumes — one delivery contract across onboarding and updates. Verification
is not reimplemented here: upgrade calls
createLocalEngineArtifactProvider().acquire(), the single implementation
described above, so byte length, checksum, signature, attestation, member
hashes, and safe member paths are checked identically on both paths. The engine
is then extracted from the retained verified bytes, never copied from a mutable
directory. The command runs seven reported steps: verify artifact, codemods,
migrate and check, swap engine, regenerate, verify, report. It:
- Acquires and verifies the signed engine bundle through the shared provider
before touching the current engine, and refuses a bundle whose declared
version disagrees with
--engine-version. It then confirms the currentengine.fileshash set in.substrate/manifest.yamlstill matches the vendored engine on disk. If it does not, the command stops before swapping and requires an explicit three-door decision. - Runs the versioned codemods the new engine's registry selects for this version crossing (e.g. the 1.10 platform-config split) on a staged copy of the project. A codemod that exits 1 needs a person's decision: the upgrade stops, prints what to change, and has changed nothing.
- Migrates the staged configs to the new engine's schema with its own
migrate, then runs itscheck, the waysetup --applydoes. A migration that needs your decision or a check finding stops the upgrade with its fix, and nothing has changed. It also plans the AI-host refresh from theaiHostInstallrecord in.substrate/state.yaml: the skills and Bloom copies setup recorded, and any links an earlier install made. A folder Substrate did not install, or a recorded copy you edited, stops the upgrade until you move it or pass--adopt <folder>. On a project setup never ran on (init'sstate.yaml), it also stages what setup would have wired: the host files, every check gate and thesubstrate:checkandsubstrate:generatepackage scripts, from setup's own code. - Swaps everything at once: the engine root from the verified bytes, the
migrated config files, the AI-host copies and the managed Substrate section
of the instruction file (links become copies, skills the engine dropped go),
the onboarding files from step 3, and the content-root links into the
engine, which the sealed engine does not need.
state.yamlrecords the new install the way setup does, in setup's format, sosubstrate doctorpasses after one upgrade. - Runs the caller-provided regenerate command.
- Runs the caller-provided verify command, then refreshes the manifest engine version and hash set while preserving catalog fetch records, and brings the installed check gates to this CLI's version.
- Writes Markdown and JSON reports under
.substrate/reports/and prints the same renderer's summary to the terminal.
A failure from step 4 on rolls back the engine, the configs, the AI-host
folders and everything else the upgrade touched. A step onboarding couldn't
take, such as a client script under a Substrate script name, prints as
Remaining step: with its fix after the upgrade completes. A runtime stop prints the
failure and its fix; the usage text prints only for a malformed command.
Getting started §10 shows the
command. --engine-version must match the bundle; --regenerate-command and
--verify-command run in steps 5 and 6; --report-id names the report;
--modified-engine picks a door (below); --adopt <folder> hands an edited
AI-host folder to the upgrade.
The .attestation.json, .bundle.sha256, and .bundle.sig sidecars are
resolved next to the bundle, exactly as setup resolves them, so a client
passes the release handoff files unchanged.
Migrating from the directory contract
--engine-artifact no longer accepts an unpacked engine directory; passing one
fails with a migration message before any engine mutation. --artifact-sha256
and --artifact-signature are likewise retired — the bundle carries its own
checksum and signature sidecars, verified against the attestation, so those
values are no longer passed by hand. If you only have a source checkout, produce
a bundle with substrate artifact assemble and pass that.
The signature payload is deterministic and covers artifact kind, target version, and checksum:
substrate-artifact-signature-v1
kind=engine
version=0.37.0
sha256=<sha256>--signing-public-key accepts a PEM string, a file path, or env:NAME. The
release workflow assembles the signed engine bundle through the exact-byte path
above and signs catalog attestations with
SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEY; clients receive the public key through
the engagement handoff and the updater refuses unsigned or tampered artifacts
before the engine swap.
Release signing public key
The current release signing public key is recorded in
docs/release/substrate-release-signing-public-key.pem and distributed with
signed release assets as substrate-release-signing-public-key.pem. Its
SHA-256 fingerprint over the SPKI DER public key is:
b61d2c945cd9fad091d9ec2fbd1c5e4ec1fa6097e294b00803744aa472363a6eRelease handoffs should include the public key, this fingerprint, and the
matching *.attestation.json, *.sha256, and *.sig files for the delivered
engine or catalog artifact. The private key remains only in owner-controlled
storage and the GitHub Actions secret named
SUBSTRATE_RELEASE_SIGNING_PRIVATE_KEY; it must never be committed, attached
to a release, or pasted into handoff notes. If the key is rotated, publish the
new public key and fingerprint before signing the first release with it, then
call out the rotation in release notes and engagement handoff material.
When the current engine has local edits, --modified-engine refuse (the
default) prints the modified paths and the three available doors without
touching disk: discard replaces the edited engine with the new artifact,
freeze exits successfully while staying on the current version, and
engage-uc exits successfully with an upstream/white-glove handoff prompt.
Only discard may overwrite engine edits, and it must be selected explicitly.
Engine artifacts may ship upgrade codemods in
codemods/registry.yaml. This framework is specific to deterministic engine
upgrades and is deliberately separate from the guided src/migration/ domain.
Entries run in target-version order during the post-swap-pre-regenerate phase,
which now runs on the staged copy of the project with the new engine, before
anything in the project changes:
version: 1
codemods:
- id: split-platform-config
from: 1.9.2
to: 1.10.0
phase: post-swap-pre-regenerate
always: true
script: compiler.mjs
args: [codemod, split-platform-config]
command: node "$SUBSTRATE_ENGINE_ROOT/compiler.mjs" codemod split-platform-configAn entry runs when its to version is above the client's previous engine
version and at or below the new one. always: true drops the lower bound: an
idempotent codemod then reruns on every upgrade whose target is at or above
to (CLIs that predate the field run it only on the crossing). script is an engine-relative file run
with the upgrading CLI's own Node and no shell, with args after it; command
is a shell command, required when there is no script and kept alongside it
for older CLIs. Either way the codemod runs from the root of the staged project copy with
SUBSTRATE_PROJECT_ROOT, SUBSTRATE_ENGINE_ROOT, SUBSTRATE_CONTENT_ROOT,
SUBSTRATE_GENERATED_ROOT, and SUBSTRATE_CODEMOD_* environment variables, and
its output is printed under the 2/7 codemods step. Exit 1 means a person has
to decide: the upgrade stops, prints the codemod's output, and changes nothing.
Any other non-zero exit fails the upgrade the same way. The phase field defaults to
post-swap-pre-regenerate; the explicit field is kept so a future
post-regenerate phase can be added without changing the registry shape.
The engine's registry lives in scripts/codemods/registry.yaml and is sealed
into the bundle as codemods/registry.yaml. Engine codemods ship inside
compiler.mjs (node compiler.mjs codemod <id> [--check]), so they run with
nothing but the bundle. split-platform-config shares its implementation with
scripts/split-platform-config.ts (src/kernel/system/config-layer-migration.ts)
and reads the new engine's property schema. The upgrade protects the client's
components/, types/ and brands/ trees and every config file the migration
writes or moves, so codemod and migration edits are restored if regenerate or
verify fails.
The report lists refreshed pristine engine files, modified delivered catalog
files, path-collision counts, and knowledge/reference overlay conflicts. Overlay
conflicts cover same-path overlays plus conservative semantic duplicates:
Markdown/frontmatter titles provide the semantic key, token overlap provides a
deterministic confidence score, and each finding carries a stable id plus a
cleanup recommendation for Bloom. Its JSON sidecar is the durable input for the
later substrate adopt flow (P3·f).
Checking configs
substrate check runs every config rule the engine enforces and reports each
problem with its file, line, key path, message and fix. It writes nothing and
says nothing about generated output.
substrate check # the whole content root
substrate check substrate/components/button # what these paths touch
substrate check components/button/config.yaml # paths may be content-root relative
substrate check --json # the findings as a JSON arrayA finding prints as file:line path [rule], then the message, then fix:.
--json gives each finding file, line, path, rule, severity,
message and fix. The rule is a stable id from the engine's registry
(src/kernel/schema/finding-rules.ts), and every severity is error. A fix
followed as written gives the config its author meant; where Substrate has no
form for what was written, the fix says to report the gap instead. An
absolute path through a linked folder (macOS /tmp is /private/tmp) checks
the file it links to. A path that is missing, outside the content root, or
names no config is a finding too, never skipped. Exit codes: 0 no problems,
1 findings, 2 the check could not run.
substrate generate writes the generated output. substrate generate --check
writes nothing and exits 1 when output is stale or missing; when a config is
invalid it says so and names substrate check. Both run the engine's
compiler.mjs (check, or no command), or scripts/engine-compiler.ts with
the engine's tsx in a source checkout.
Config migrations
substrate migrate runs the engine's config migrations over the project's
content root. They are versioned by each file's schema: key, not by engine
version: a migration rewrites files at one schema version to the next, and a
file without the key is at version 1. Rewrites edit the parsed YAML, so
comments and key order survive.
substrate migrate --check # list files that need migrating; exit 1 if any
substrate migrate # rewrite them
substrate migrate --content-root content # a content root other than the project's
substrate migrate --json # one JSON object: files, changed, decide, errorsMigrate makes every rewrite it can make exactly, including the values
substrate check computes (a CSS length in the brand's units, a quoted zero).
It then reports two kinds of line. changed: lines (stdout) tell you what it
moved or removed, for information. decide: lines (stderr) name what only you
can settle, each with its fix. After a migrate, substrate check reports only
those decisions and gaps in the grammar.
The CLI runs node <engine>/compiler.mjs migrate, or the engine's
scripts/engine-compiler.ts for a source checkout. The registry is
src/kernel/schema/migrations.ts. Exit codes: 0 done or nothing to do (with
or without changed: lines), 1 work left under --check or a decide: line,
2 the command could not run.
Project health
substrate doctor runs a fixed list of checks on a project setup installed and
names the fix for each one that fails. It changes nothing. Run it when setup,
substrate check or generation behaves unexpectedly, and after an upgrade.
substrate doctor # failures first, each with its fix, then what passed
substrate doctor --json # { status, checks: [{ id, status, message, fix }] }| Check | Passes when |
|---|---|
| engine | .substrate/state.yaml was written by setup, the engine has compiler.mjs, and its version matches the CLI's |
| check | substrate check finds no problems; a failure lists the first three findings with their fixes. Leftover files the engine rejects (a client properties.yaml, config.global.yaml, config.<brand>.yaml, components: in project.yaml) fail here |
| generated | substrate generate --check finds the output up to date |
| schema | substrate migrate --check finds nothing to migrate |
| gate-script | .substrate/hooks/check-gate.mjs matches the gate this CLI installs |
| claude-hook | .claude/settings.json has the current check-gate hook (skipped when Claude Code isn't wired) |
| pre-commit | the managed block in the git hook, or the lefthook command, runs the current check (skipped when setup can't own the hooks) |
| ci-workflow | .github/workflows/substrate-check.yml matches what this CLI writes |
| ai-host-skills | every wired host's skill copies match the engine's skills: none missing, edited, older than the engine's, or dropped by the engine |
| bloom | agents/bloom matches the engine's Bloom agent (skipped without --bloom) |
| instructions | each wired host's instruction file holds the current Substrate section |
| package-scripts | package.json has substrate:check and substrate:generate running the installed engine |
| bundler | the host adapter's files (alias manifest, Vite config, tsconfig paths, setup module and entry import) match what setup writes |
A check that doesn't apply is skipped, and a missing engine skips the checks
that need it. Each fix names the command to run, usually substrate setup
again, which repairs everything it installs. check, generate --check and
migrate --check run side by side, so doctor takes about as long as the
slowest of them. In --json, a failed check may also carry details: the
findings or paths behind it. Exit codes: 0 every check passed or was skipped,
1 any failed, 2 doctor could not run.
Telemetry
Telemetry is off, and stays off unless you turn it on. When it's on, Substrate keeps a local log of which check rules your project trips, so you can share it with the Substrate maintainers and help Bloom learn from real projects. Nothing is ever sent for you.
substrate telemetry on # sets telemetry: true in project.yaml
substrate telemetry status # on or off, and how many records there are
substrate telemetry report # writes .substrate/telemetry/report.md and prints an issue link
substrate telemetry off # sets telemetry: false; old records stay until you delete themYou can also set telemetry: true in project.yaml yourself.
While it's on, the check gate (the Claude Code hook and the pre-commit hook)
and substrate doctor each append one line to
.substrate/telemetry/events.jsonl. A line holds:
- the day (no time), and whether the check gate or doctor wrote it
- the engine version, and the version of each skill that declares one
- how many findings there were, counted by rule id
- for doctor, each check id with pass, fail or skip
It never holds config contents, file names or paths, brand or project names,
messages, or anything that identifies you or the repo. An id that doesn't look
like a rule id is counted as other. The CI gate records nothing. The folder
holds a .gitignore, so records are never committed.
To share, run substrate telemetry report and read report.md: it's exactly
what you'd submit. The command prints a link to the
telemetry issue form,
with the report filled in when it fits in a link. Otherwise paste it. Issues
are public. To stop, run substrate telemetry off and delete
.substrate/telemetry/.
Bloom reconciliation
substrate adopt consumes an upgrade report JSON sidecar and turns flagged
findings into stable, per-finding actions. Listing mode never mutates disk:
substrate adopt --report .substrate/reports/upgrade-2026-06-13-client-upgrade.jsonApply mode requires one explicit finding id. The first implementation only performs deterministic file-level moves: file path collisions copy the engine file into the client path, while exact byte-identical knowledge/reference overlays are removed because the engine now carries the same content. Shadow overlays, semantic duplicates, directory collisions, and modified delivered catalog files remain manual/Bloom-reconciliation plans.
substrate adopt \
--report .substrate/reports/upgrade-2026-06-13-client-upgrade.json \
--finding path-collision:0 \
--applyEvery applied finding writes a rollback record under .substrate/adopt/ before
changing files and prints the rollback id:
substrate adopt --rollback adopt-2026-06-13t18.20.00.000z-12345-path-collision-0A wrong merge is any applied finding that fails validation or user review after adoption. The rollback record restores the exact previous file bytes (or absence) for that finding, giving the client a reversible default while larger semantic choices stay in manual Bloom review.
Substrate location detection
detectSubstrate() (bin/lib/detect.js) probes in priority order; the
resolved path is always the directory containing skills/ and agents/:
| Probe | Model | Path |
|---|---|---|
| $SUBSTRATE_PATH set | manual | env var value (must stay inside the project) |
| substrate/engine/skills/substrate-config/ | client | substrate/engine |
| engine/skills/substrate-config/ | client | engine (substrate root remapped onto the repo root) |
| node_modules/@unknown-ui/substrate/skills/substrate-config/ | install (legacy) | node_modules/@unknown-ui/substrate |
| substrate/skills/substrate-config/ | clone-subdir (legacy) | substrate |
| skills/substrate-config/ | clone-root | . |
The client model is the v2 client layout (spec §4): a vendored engine at
{substrate root}/engine/, the sealed unit UC replaces wholesale. It is
probed before every legacy model so a v2 client repo is never misdetected as
clone-subdir and a leftover npm install never shadows the vendored engine.
The npm install model is superseded
The npm-symlink install model (npm install @unknown-ui/substrate +
symlinks into node_modules/) is formally superseded by vendored
delivery — decision D10 of the
2026-06-12 distribution architecture v2
(§10). Detection of node_modules/@unknown-ui/substrate stays so existing
installs keep working, but no new capability targets it.
To be precise about what is and isn't published: what was superseded is
engine-in-node_modules; what is published today is the CLI only
(@unknown-creatives/substrate — the bin/ + assets/ surface of this
package). The engine is never published; it arrives through the signed
bundle (supervised engagement) or a vendored checkout, and P4·h's
engine-packaging question is unchanged by CLI publishing.
The packaging research behind it (npm files manifest, symlink-through-
node_modules resolution, update flow) is preserved in the
2026-05-18 companion spec
and re-enters as the npm-engine packaging option (P4·h) if self-serve
productization wants it. That trigger — not general demand — is what would
revive the model.
